- 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',
|
||||
]);
|
||||
|
||||
136
tests/Unit/AdminDashboardWidgetsTest.php
Normal file
136
tests/Unit/AdminDashboardWidgetsTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
68
tests/Unit/TenantCreditTest.php
Normal file
68
tests/Unit/TenantCreditTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
72
tests/Unit/TenantPolicyTest.php
Normal file
72
tests/Unit/TenantPolicyTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
33
tests/e2e/checkout-hero-cta.test.ts
Normal file
33
tests/e2e/checkout-hero-cta.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
191
tests/e2e/checkout-payment.test.ts
Normal file
191
tests/e2e/checkout-payment.test.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user