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

View File

@@ -0,0 +1,136 @@
<?php
namespace Tests\Unit;
use App\Filament\Widgets\CreditAlertsWidget;
use App\Filament\Widgets\RevenueTrendWidget;
use App\Models\PurchaseHistory;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use ReflectionClass;
use Tests\TestCase;
class AdminDashboardWidgetsTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
parent::tearDown();
Carbon::setTestNow();
}
public function test_credit_alerts_widget_cards_reflect_metrics(): void
{
$lowBalanceTenant = Tenant::factory()->create([
'event_credits_balance' => 2,
'is_active' => true,
'subscription_expires_at' => now()->addMonths(2),
]);
Tenant::factory()->create([
'event_credits_balance' => 20,
'is_active' => true,
'subscription_expires_at' => now()->addMonths(1),
]);
Tenant::factory()->create([
'event_credits_balance' => 1,
'is_active' => false,
'subscription_expires_at' => now()->subDay(),
]);
PurchaseHistory::create([
'id' => 'ph-1',
'tenant_id' => $lowBalanceTenant->id,
'package_id' => 'starter_pack',
'credits_added' => 5,
'price' => 149.90,
'currency' => 'EUR',
'platform' => 'web',
'transaction_id' => 'txn-1',
'purchased_at' => now()->startOfMonth()->addDay(),
'created_at' => now(),
]);
$widget = new CreditAlertsWidget();
$cards = $this->invokeProtectedMethod($widget, 'getCards');
$this->assertCount(3, $cards);
$this->assertSame(
__('admin.widgets.credit_alerts.low_balance_label'),
$cards[0]->getLabel()
);
$this->assertSame(1, $cards[0]->getValue());
$this->assertSame(
2,
$cards[2]->getValue()
);
$this->assertStringContainsString('149.9', (string) $cards[1]->getValue());
}
public function test_revenue_trend_widget_compiles_monthly_totals(): void
{
Carbon::setTestNow(Carbon::create(2025, 10, 20, 12));
$tenant = Tenant::factory()->create();
PurchaseHistory::create([
'id' => 'cur-1',
'tenant_id' => $tenant->id,
'package_id' => 'pro_pack',
'credits_added' => 10,
'price' => 299.99,
'currency' => 'EUR',
'platform' => 'web',
'transaction_id' => 'txn-cur',
'purchased_at' => now()->copy()->startOfMonth()->addDays(2),
'created_at' => now(),
]);
PurchaseHistory::create([
'id' => 'prev-1',
'tenant_id' => $tenant->id,
'package_id' => 'starter_pack',
'credits_added' => 5,
'price' => 149.90,
'currency' => 'EUR',
'platform' => 'web',
'transaction_id' => 'txn-prev',
'purchased_at' => now()->copy()->subMonth()->startOfMonth()->addDays(4),
'created_at' => now()->subMonth(),
]);
$widget = new RevenueTrendWidget();
$data = $this->invokeProtectedMethod($widget, 'getData');
$this->assertArrayHasKey('datasets', $data);
$this->assertArrayHasKey('labels', $data);
$this->assertCount(12, $data['labels']);
$this->assertSame(12, count($data['datasets'][0]['data']));
$lastValue = end($data['datasets'][0]['data']);
$prevValue = $data['datasets'][0]['data'][count($data['datasets'][0]['data']) - 2];
$this->assertEquals(299.99, $lastValue);
$this->assertEquals(149.90, $prevValue);
}
/**
* @template T
*
* @param object $object
* @param string $method
* @return mixed
*/
private function invokeProtectedMethod(object $object, string $method)
{
$reflection = new ReflectionClass($object);
$reflectedMethod = $reflection->getMethod($method);
$reflectedMethod->setAccessible(true);
return $reflectedMethod->invoke($object);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Tests\Unit;
use App\Models\Package;
use App\Models\Tenant;
use App\Models\TenantPackage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TenantCreditTest extends TestCase
{
use RefreshDatabase;
public function test_consume_event_allowance_uses_reseller_package(): void
{
$package = Package::factory()
->reseller()
->create([
'max_events_per_year' => 5,
]);
$tenant = Tenant::factory()->create([
'event_credits_balance' => 0,
]);
TenantPackage::factory()->for($tenant)->for($package)->create([
'used_events' => 1,
'active' => true,
]);
$this->assertTrue($tenant->consumeEventAllowance());
$updatedPackage = $tenant->getActiveResellerPackage();
$this->assertNotNull($updatedPackage);
$this->assertSame(2, $updatedPackage->used_events);
}
public function test_consume_event_allowance_decrements_credits_when_no_package(): void
{
$tenant = Tenant::factory()->create([
'event_credits_balance' => 2,
]);
$this->assertTrue($tenant->consumeEventAllowance(1, 'event.create', 'Event #1 created'));
$tenant->refresh();
$this->assertSame(1, $tenant->event_credits_balance);
$this->assertDatabaseHas('event_credits_ledger', [
'tenant_id' => $tenant->id,
'delta' => -1,
'reason' => 'event.create',
'note' => 'Event #1 created',
]);
}
public function test_consume_event_allowance_returns_false_without_package_or_credits(): void
{
$tenant = Tenant::factory()->create([
'event_credits_balance' => 0,
]);
$this->assertFalse($tenant->consumeEventAllowance());
$this->assertDatabaseCount('event_credits_ledger', 0);
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace Tests\Unit;
use App\Models\Tenant;
use App\Models\User;
use App\Policies\TenantPolicy;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TenantPolicyTest extends TestCase
{
use RefreshDatabase;
protected TenantPolicy $policy;
protected function setUp(): void
{
parent::setUp();
$this->policy = new TenantPolicy();
}
public function test_super_admin_can_adjust_credits(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'role' => 'super_admin',
]);
$this->assertTrue($this->policy->adjustCredits($user, $tenant));
}
public function test_tenant_admin_cannot_adjust_credits(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'role' => 'tenant_admin',
]);
$user->forceFill(['tenant_id' => $tenant->id])->save();
$this->assertFalse($this->policy->adjustCredits($user, $tenant));
}
public function test_tenant_admin_can_view_own_tenant(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'role' => 'tenant_admin',
]);
$user->forceFill(['tenant_id' => $tenant->id])->save();
$this->assertTrue($this->policy->view($user, $tenant));
}
public function test_tenant_admin_cannot_view_other_tenant(): void
{
$tenant = Tenant::factory()->create();
$otherTenant = Tenant::factory()->create();
$user = User::factory()->create([
'role' => 'tenant_admin',
]);
$user->forceFill(['tenant_id' => $tenant->id])->save();
$this->assertFalse($this->policy->view($user, $otherTenant));
}
}

View File

@@ -0,0 +1,33 @@
import { test, expect } from '@playwright/test';
test.describe('Marketing hero CTA smoke', () => {
test('home hero CTA navigates to packages', async ({ page, baseURL }) => {
test.skip(!baseURL, 'baseURL is required to run marketing smoke tests');
await page.goto('/');
const cta = page.getByRole('link', {
name: /Pakete entdecken|Jetzt loslegen|Discover Packages|Get started now/i,
});
await expect(cta).toBeVisible();
await cta.click();
await expect(page).toHaveURL(/\/packages/);
});
test('packages hero CTA jumps to endcustomer section', async ({ page, baseURL }) => {
test.skip(!baseURL, 'baseURL is required to run marketing smoke tests');
await page.goto('/packages');
const cta = page.getByRole('link', {
name: /Pakete entdecken|Lieblingspaket sichern|Discover Packages|Explore top packages/i,
});
await expect(cta).toBeVisible();
await cta.click();
await expect(page.locator('#endcustomer')).toBeVisible();
});
});

View File

@@ -0,0 +1,191 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
const LOGIN_EMAIL = 'checkout-e2e@example.com';
const LOGIN_PASSWORD = 'Password123!';
test.describe('Checkout Payment Step Stripe & PayPal states', () => {
test.beforeAll(async () => {
execSync(
`php artisan tenant:add-dummy --email=${LOGIN_EMAIL} --password=${LOGIN_PASSWORD} --first_name=Checkout --last_name=Tester --address="Playwrightstr. 1" --phone="+4912345678"`
);
execSync(
`php artisan tinker --execute="App\\\\Models\\\\User::where('email', '${LOGIN_EMAIL}')->update(['email_verified_at' => now()]);"`
);
});
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', LOGIN_EMAIL);
await page.fill('input[name="password"]', LOGIN_PASSWORD);
await page.getByRole('button', { name: /Anmelden|Login/ }).click();
await expect(page).toHaveURL(/dashboard/);
});
test('Stripe payment intent error surfaces descriptive status', async ({ page }) => {
await page.route('**/stripe/create-payment-intent', async (route) => {
await route.fulfill({
status: 422,
contentType: 'application/json',
body: JSON.stringify({ error: 'Test payment intent failure' }),
});
});
await openCheckoutPaymentStep(page);
await expect(
page.locator('text=/Fehler beim Laden der Zahlungsdaten|Error loading payment data/')
).toBeVisible();
await expect(
page.locator('text=/Zahlungsformular bereit|Payment form ready/')
).not.toBeVisible();
});
test('Stripe payment intent ready state renders when backend responds', async ({ page }) => {
await page.route('**/stripe/create-payment-intent', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ client_secret: 'pi_test_secret' }),
});
});
await openCheckoutPaymentStep(page);
await expect(
page.locator('text=/Zahlungsformular bereit\\. Bitte gib deine Daten ein\\.|Payment form ready\\./')
).toBeVisible();
});
test('PayPal approval success updates status', async ({ page }) => {
await stubPayPalSdk(page);
await page.route('**/paypal/create-order', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'ORDER_TEST', status: 'CREATED' }),
});
});
await page.route('**/paypal/capture-order', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'captured' }),
});
});
await openCheckoutPaymentStep(page);
await selectPayPalMethod(page);
await page.waitForFunction(() => window.__paypalButtonsConfig !== undefined);
await page.evaluate(async () => {
const config = window.__paypalButtonsConfig;
if (!config) return;
await config.createOrder();
await config.onApprove({ orderID: 'ORDER_TEST' });
});
await expect(
page.locator('text=/Zahlung bestätigt\\. Bestellung wird abgeschlossen|Payment confirmed/')
).toBeVisible();
});
test('PayPal capture failure notifies user', async ({ page }) => {
await stubPayPalSdk(page);
await page.route('**/paypal/create-order', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'ORDER_FAIL', status: 'CREATED' }),
});
});
await page.route('**/paypal/capture-order', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'capture_failed' }),
});
});
await openCheckoutPaymentStep(page);
await selectPayPalMethod(page);
await page.waitForFunction(() => window.__paypalButtonsConfig !== undefined);
await page.evaluate(async () => {
const config = window.__paypalButtonsConfig;
if (!config) return;
await config.createOrder();
await config.onApprove({ orderID: 'ORDER_FAIL' });
});
await expect(
page.locator('text=/PayPal capture failed|PayPal-Abbuchung fehlgeschlagen|PayPal capture error/')
).toBeVisible();
});
});
async function openCheckoutPaymentStep(page: import('@playwright/test').Page) {
await page.goto('/packages');
const checkoutLink = page.locator('a[href^="/checkout/"]').first();
const href = await checkoutLink.getAttribute('href');
if (!href) {
throw new Error('No checkout link found on packages page.');
}
await page.goto(href);
const nextButton = page.getByRole('button', {
name: /Weiter zum Zahlungsschritt|Continue to Payment/,
});
if (await nextButton.isVisible()) {
await nextButton.click();
}
await page.waitForSelector('text=/Zahlung|Payment/');
}
async function selectPayPalMethod(page: import('@playwright/test').Page) {
const paypalButton = page.getByRole('button', { name: /PayPal/ });
if (await paypalButton.isVisible()) {
await paypalButton.click();
}
}
async function stubPayPalSdk(page: import('@playwright/test').Page) {
await page.route('https://www.paypal.com/sdk/js**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/javascript',
body: `
window.paypal = {
Buttons: function (config) {
window.__paypalButtonsConfig = config;
return {
render: function () {
// noop
},
};
},
};
`,
});
});
}
declare global {
interface Window {
__paypalButtonsConfig?: {
createOrder: () => Promise<string>;
onApprove: (data: { orderID: string }) => Promise<void>;
onError?: (error: unknown) => void;
};
}
}