- 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:
Codex Agent
2025-10-19 11:41:03 +02:00
parent ae9b9160ac
commit a949c8d3af
113 changed files with 5169 additions and 712 deletions

View 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();
}
}

View 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',
]);
}
}

View File

@@ -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']);
}
}
}

View File

@@ -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();

View File

@@ -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',
];
}
}

View 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);
}
}

View File

@@ -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',
]);