- Wired the checkout wizard for Google “comfort login”: added Socialite controller + dependency, new Google env
hooks in config/services.php/.env.example, and updated wizard steps/controllers to store session payloads, attach packages, and surface localized success/error states. - Retooled payment handling for both Stripe and PayPal, adding richer status management in CheckoutController/ PayPalController, fallback flows in the wizard’s PaymentStep.tsx, and fresh feature tests for intent creation, webhooks, and the wizard CTA. - Introduced a consent-aware Matomo analytics stack: new consent context, cookie-banner UI, useAnalytics/ useCtaExperiment hooks, and MatomoTracker component, then instrumented marketing pages (Home, Packages, Checkout) with localized copy and experiment tracking. - Polished package presentation across marketing UIs by centralizing formatting in PresentsPackages, surfacing localized description tables/placeholders, tuning badges/layouts, and syncing guest/marketing translations. - Expanded docs & reference material (docs/prp/*, TODOs, public gallery overview) and added a Playwright smoke test for the hero CTA while reconciling outstanding checklist items.
This commit is contained in:
103
tests/Feature/CheckoutGoogleControllerTest.php
Normal file
103
tests/Feature/CheckoutGoogleControllerTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
|
||||
use Laravel\Socialite\Contracts\Provider as SocialiteProvider;
|
||||
use Laravel\Socialite\Contracts\User as SocialiteUserContract;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CheckoutGoogleControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_redirect_persists_package_context_and_delegates_to_google(): void
|
||||
{
|
||||
$package = Package::factory()->create();
|
||||
|
||||
$provider = Mockery::mock(SocialiteProvider::class);
|
||||
$provider->shouldReceive('scopes')->andReturnSelf();
|
||||
$provider->shouldReceive('with')->andReturnSelf();
|
||||
$provider->shouldReceive('redirect')->once()->andReturn(redirect('/google/auth'));
|
||||
|
||||
$this->mock(SocialiteFactory::class, function ($mock) use ($provider) {
|
||||
$mock->shouldReceive('driver')->with('google')->andReturn($provider);
|
||||
});
|
||||
|
||||
$response = $this->get('/checkout/auth/google?package_id=' . $package->id . '&locale=de');
|
||||
|
||||
$response->assertRedirect('/google/auth');
|
||||
$this->assertSame($package->id, session('checkout_google_payload.package_id'));
|
||||
}
|
||||
|
||||
public function test_callback_creates_user_and_logs_in(): void
|
||||
{
|
||||
$package = Package::factory()->create(['price' => 0]);
|
||||
|
||||
$googleUser = Mockery::mock(SocialiteUserContract::class);
|
||||
$googleUser->shouldReceive('getEmail')->andReturn('checkout-google@example.com');
|
||||
$googleUser->shouldReceive('getName')->andReturn('Checkout Google');
|
||||
|
||||
$provider = Mockery::mock(SocialiteProvider::class);
|
||||
$provider->shouldReceive('user')->andReturn($googleUser);
|
||||
|
||||
$this->mock(SocialiteFactory::class, function ($mock) use ($provider) {
|
||||
$mock->shouldReceive('driver')->with('google')->andReturn($provider);
|
||||
});
|
||||
|
||||
$response = $this
|
||||
->withSession([
|
||||
'checkout_google_payload' => ['package_id' => $package->id, 'locale' => 'de'],
|
||||
])
|
||||
->get('/checkout/auth/google/callback');
|
||||
|
||||
$response->assertRedirect(route('purchase.wizard', ['package' => $package->id]));
|
||||
|
||||
$this->assertAuthenticated();
|
||||
|
||||
$user = auth()->user();
|
||||
$this->assertSame('checkout-google@example.com', $user->email);
|
||||
$this->assertTrue($user->pending_purchase);
|
||||
$this->assertNotNull($user->tenant);
|
||||
$this->assertDatabaseHas('tenant_packages', [
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_callback_with_missing_email_flashes_error(): void
|
||||
{
|
||||
$package = Package::factory()->create();
|
||||
|
||||
$googleUser = Mockery::mock(SocialiteUserContract::class);
|
||||
$googleUser->shouldReceive('getEmail')->andReturn(null);
|
||||
$googleUser->shouldReceive('getName')->andReturn('No Email');
|
||||
|
||||
$provider = Mockery::mock(SocialiteProvider::class);
|
||||
$provider->shouldReceive('user')->andReturn($googleUser);
|
||||
|
||||
$this->mock(SocialiteFactory::class, function ($mock) use ($provider) {
|
||||
$mock->shouldReceive('driver')->with('google')->andReturn($provider);
|
||||
});
|
||||
|
||||
$response = $this
|
||||
->withSession([
|
||||
'checkout_google_payload' => ['package_id' => $package->id, 'locale' => 'en'],
|
||||
])
|
||||
->get('/checkout/auth/google/callback');
|
||||
|
||||
$response->assertRedirect(route('purchase.wizard', ['package' => $package->id]));
|
||||
$response->assertSessionHas('checkout_google_error');
|
||||
$this->assertGuest();
|
||||
}
|
||||
}
|
||||
132
tests/Feature/CheckoutPaymentIntentTest.php
Normal file
132
tests/Feature/CheckoutPaymentIntentTest.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
|
||||
|
||||
#[RunTestsInSeparateProcesses]
|
||||
class CheckoutPaymentIntentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function actingAsTenantUser(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
Tenant::factory()->create(['user_id' => $user->id]);
|
||||
Auth::login($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function test_returns_null_client_secret_for_free_package(): void
|
||||
{
|
||||
$this->actingAsTenantUser();
|
||||
$package = Package::factory()->create([
|
||||
'price' => 0,
|
||||
]);
|
||||
|
||||
if (Schema::hasColumn('packages', 'is_free')) {
|
||||
\DB::table('packages')->where('id', $package->id)->update(['is_free' => true]);
|
||||
}
|
||||
|
||||
$response = $this->postJson('/stripe/create-payment-intent', [
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
if (Schema::hasColumn('packages', 'is_free')) {
|
||||
$response->assertJson([
|
||||
'client_secret' => null,
|
||||
'free_package' => true,
|
||||
]);
|
||||
} else {
|
||||
$response->assertJson([
|
||||
'client_secret' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function mockStripePaymentIntent(object $payload): void
|
||||
{
|
||||
if (class_exists(\Stripe\PaymentIntent::class, false)) {
|
||||
$this->fail('Stripe\\PaymentIntent already loaded; unable to mock static methods.');
|
||||
}
|
||||
|
||||
$mock = Mockery::mock('alias:Stripe\PaymentIntent');
|
||||
$mock->shouldReceive('create')
|
||||
->once()
|
||||
->andReturn($payload);
|
||||
}
|
||||
|
||||
private function mockStripePaymentIntentFailure(\Throwable $exception): void
|
||||
{
|
||||
if (class_exists(\Stripe\PaymentIntent::class, false)) {
|
||||
$this->fail('Stripe\\PaymentIntent already loaded; unable to mock static methods.');
|
||||
}
|
||||
|
||||
$mock = Mockery::mock('alias:Stripe\PaymentIntent');
|
||||
$mock->shouldReceive('create')
|
||||
->once()
|
||||
->andThrow($exception);
|
||||
}
|
||||
|
||||
public function test_creates_payment_intent_and_returns_client_secret(): void
|
||||
{
|
||||
config(['services.stripe.secret' => 'sk_test_dummy']);
|
||||
|
||||
$this->actingAsTenantUser();
|
||||
$package = Package::factory()->create([
|
||||
'price' => 129,
|
||||
]);
|
||||
|
||||
$this->mockStripePaymentIntent((object) [
|
||||
'id' => 'pi_test_123',
|
||||
'client_secret' => 'secret_test_456',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/stripe/create-payment-intent', [
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson([
|
||||
'client_secret' => 'secret_test_456',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_returns_error_when_payment_intent_creation_fails(): void
|
||||
{
|
||||
config(['services.stripe.secret' => 'sk_test_dummy']);
|
||||
|
||||
$this->actingAsTenantUser();
|
||||
$package = Package::factory()->create([
|
||||
'price' => 59,
|
||||
]);
|
||||
|
||||
$this->mockStripePaymentIntentFailure(new \RuntimeException('Stripe failure'));
|
||||
|
||||
$response = $this->postJson('/stripe/create-payment-intent', [
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
$response->assertStatus(500)
|
||||
->assertJson([
|
||||
'error' => 'Fehler beim Erstellen der Zahlungsdaten: Stripe failure',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ use App\Models\TenantPackage;
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class EventControllerTest extends TestCase
|
||||
{
|
||||
@@ -107,8 +110,7 @@ class EventControllerTest extends TestCase
|
||||
public function test_upload_exceeds_package_limit_fails(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$event = Event::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$event = Event::factory()->create(['tenant_id' => $tenant->id, 'status' => 'published']);
|
||||
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 0]); // Limit 0
|
||||
EventPackage::factory()->create([
|
||||
'event_id' => $event->id,
|
||||
@@ -116,12 +118,15 @@ class EventControllerTest extends TestCase
|
||||
'used_photos' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson("/api/v1/events/{$event->slug}/photos", [
|
||||
'photo' => 'test-photo.jpg',
|
||||
Storage::fake('public');
|
||||
$token = app(EventJoinTokenService::class)->createToken($event);
|
||||
|
||||
$response = $this->withHeader('X-Device-Id', 'limit-test')
|
||||
->post("/api/v1/events/{$token->token}/upload", [
|
||||
'photo' => UploadedFile::fake()->image('limit.jpg'),
|
||||
]);
|
||||
|
||||
$response->assertStatus(402)
|
||||
->assertJson(['error' => 'Upload limit reached for this event']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,16 @@ class GuestJoinTokenFlowTest extends TestCase
|
||||
->assertJsonPath('error.code', 'token_expired');
|
||||
}
|
||||
|
||||
public function test_slug_access_is_rejected(): void
|
||||
{
|
||||
$event = $this->createPublishedEvent();
|
||||
|
||||
$response = $this->getJson("/api/v1/events/{$event->slug}");
|
||||
|
||||
$response->assertStatus(404)
|
||||
->assertJsonPath('error.code', 'invalid_token');
|
||||
}
|
||||
|
||||
public function test_guest_cannot_access_event_with_revoked_token(): void
|
||||
{
|
||||
$event = $this->createPublishedEvent();
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Tests\Feature;
|
||||
use App\Models\OAuthClient;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -60,8 +60,22 @@ KEY;
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
file_put_contents(storage_path('app/public.key'), self::PUBLIC_KEY);
|
||||
file_put_contents(storage_path('app/private.key'), self::PRIVATE_KEY);
|
||||
config()->set('oauth.keys.current_kid', 'test-kid');
|
||||
config()->set('oauth.keys.storage_path', storage_path('app/oauth-keys-tests'));
|
||||
|
||||
$paths = $this->keyPaths('test-kid');
|
||||
|
||||
File::ensureDirectoryExists($paths['directory']);
|
||||
File::put($paths['public'], self::PUBLIC_KEY);
|
||||
File::put($paths['private'], self::PRIVATE_KEY);
|
||||
File::chmod($paths['private'], 0600);
|
||||
File::chmod($paths['public'], 0644);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
File::deleteDirectory(storage_path('app/oauth-keys-tests'));
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_authorization_code_flow_and_refresh(): void
|
||||
@@ -150,5 +164,121 @@ KEY;
|
||||
'error' => 'Refresh token cannot be used from this IP address',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_refresh_token_ip_binding_can_be_disabled(): void
|
||||
{
|
||||
config()->set('oauth.refresh_tokens.enforce_ip_binding', false);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'slug' => 'ip-free',
|
||||
]);
|
||||
|
||||
OAuthClient::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'tenant_id' => $tenant->id,
|
||||
'redirect_uris' => ['http://localhost/callback'],
|
||||
'scopes' => ['tenant:read'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$codeVerifier = 'unit-test-code-verifier-abcdef';
|
||||
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$codeResponse = $this->get('/api/v1/oauth/authorize?' . http_build_query([
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'response_type' => 'code',
|
||||
'scope' => 'tenant:read',
|
||||
'state' => 'state',
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
]));
|
||||
|
||||
$location = $codeResponse->headers->get('Location');
|
||||
parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query);
|
||||
$code = $query['code'];
|
||||
|
||||
$tokenResponse = $this->post('/api/v1/oauth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'code_verifier' => $codeVerifier,
|
||||
]);
|
||||
|
||||
$token = $tokenResponse->json('refresh_token');
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.33'])
|
||||
->post('/api/v1/oauth/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $token,
|
||||
'client_id' => 'tenant-admin-app',
|
||||
])
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_refresh_token_allows_same_subnet_when_enabled(): void
|
||||
{
|
||||
config()->set('oauth.refresh_tokens.allow_subnet_match', true);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'slug' => 'subnet-tenant',
|
||||
]);
|
||||
|
||||
OAuthClient::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'tenant_id' => $tenant->id,
|
||||
'redirect_uris' => ['http://localhost/callback'],
|
||||
'scopes' => ['tenant:read'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$codeVerifier = 'unit-test-code-verifier-subnet';
|
||||
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$codeResponse = $this->get('/api/v1/oauth/authorize?' . http_build_query([
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'response_type' => 'code',
|
||||
'scope' => 'tenant:read',
|
||||
'state' => 'state',
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
]));
|
||||
|
||||
$location = $codeResponse->headers->get('Location');
|
||||
parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query);
|
||||
$code = $query['code'];
|
||||
|
||||
$tokenResponse = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.24'])->post('/api/v1/oauth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'code_verifier' => $codeVerifier,
|
||||
]);
|
||||
|
||||
$token = $tokenResponse->json('refresh_token');
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '198.51.100.55'])
|
||||
->post('/api/v1/oauth/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $token,
|
||||
'client_id' => 'tenant-admin-app',
|
||||
])
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
private function keyPaths(string $kid): array
|
||||
{
|
||||
$base = storage_path('app/oauth-keys-tests');
|
||||
|
||||
return [
|
||||
'directory' => $base . DIRECTORY_SEPARATOR . $kid,
|
||||
'public' => $base . DIRECTORY_SEPARATOR . $kid . DIRECTORY_SEPARATOR . 'public.key',
|
||||
'private' => $base . DIRECTORY_SEPARATOR . $kid . DIRECTORY_SEPARATOR . 'private.key',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
75
tests/Feature/PayPalWebhookControllerTest.php
Normal file
75
tests/Feature/PayPalWebhookControllerTest.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PayPalWebhookControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_subscription_activation_marks_tenant_active(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create(['subscription_status' => 'free']);
|
||||
$package = Package::factory()->reseller()->create();
|
||||
|
||||
$payload = [
|
||||
'webhook_id' => 'WH-activation',
|
||||
'webhook_event' => [
|
||||
'event_type' => 'BILLING.SUBSCRIPTION.ACTIVATED',
|
||||
'resource' => [
|
||||
'id' => 'I-123456',
|
||||
'custom_id' => json_encode([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
]),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/paypal/webhook', $payload);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['status' => 'SUCCESS']);
|
||||
|
||||
$this->assertEquals('active', $tenant->fresh()->subscription_status);
|
||||
}
|
||||
|
||||
public function test_subscription_cancellation_deactivates_tenant_package(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create(['subscription_status' => 'active']);
|
||||
$package = Package::factory()->reseller()->create();
|
||||
|
||||
TenantPackage::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'webhook_id' => 'WH-cancel',
|
||||
'webhook_event' => [
|
||||
'event_type' => 'BILLING.SUBSCRIPTION.CANCELLED',
|
||||
'resource' => [
|
||||
'id' => 'I-123456',
|
||||
'custom_id' => json_encode([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
]),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/paypal/webhook', $payload);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['status' => 'SUCCESS']);
|
||||
|
||||
$this->assertEquals('expired', $tenant->fresh()->subscription_status);
|
||||
$this->assertFalse($tenant->tenantPackages()->first()->fresh()->active);
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,6 @@ class PurchaseTest extends TestCase
|
||||
$this->app->instance(PaypalClientFactory::class, $factory);
|
||||
|
||||
$response = $this->postJson('/paypal/create-order', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
@@ -172,7 +171,6 @@ class PurchaseTest extends TestCase
|
||||
$this->app->instance(PaypalClientFactory::class, $factory);
|
||||
|
||||
$response = $this->postJson('/paypal/create-subscription', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'plan_id' => 'PLAN-123',
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user