rework of the e2e test suites
This commit is contained in:
68
.github/workflows/tests.yml
vendored
68
.github/workflows/tests.yml
vendored
@@ -48,3 +48,71 @@ jobs:
|
||||
|
||||
- name: Tests
|
||||
run: ./vendor/bin/phpunit
|
||||
|
||||
ui:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
APP_URL: http://127.0.0.1:8000
|
||||
E2E_GUEST_BASE_URL: http://127.0.0.1:8000
|
||||
E2E_GUEST_EVENT_SLUG: pwa-demo-event
|
||||
E2E_TENANT_EMAIL: hello@lumen-moments.demo
|
||||
E2E_TENANT_PASSWORD: Demo1234!
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
tools: composer:v2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Node Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Composer Dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Build Assets
|
||||
run: npm run build
|
||||
|
||||
- name: Prepare Environment
|
||||
run: |
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
php artisan migrate:fresh --seed --force
|
||||
php artisan tenant:add-dummy --email=$E2E_TENANT_EMAIL --password=$E2E_TENANT_PASSWORD --first_name=E2E --last_name=Tenant --address="CI Street 1" --phone="+491234567" --no-interaction
|
||||
|
||||
- name: Start Laravel Server
|
||||
run: php artisan serve --host=127.0.0.1 --port=8000 > storage/logs/ci-server.log 2>&1 &
|
||||
|
||||
- name: Wait for Server
|
||||
run: npx --yes wait-on@7 http://127.0.0.1:8000
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Provision Guest Demo Event
|
||||
run: |
|
||||
curl --fail -X POST http://127.0.0.1:8000/api/_testing/guest-events \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"slug":"pwa-demo-event","name":"CI Guest Demo"}'
|
||||
|
||||
- name: UI Purchase Suite
|
||||
run: npm run test:ui:purchase -- --reporter=dot
|
||||
|
||||
- name: UI Auth Suite
|
||||
run: npm run test:ui:auth -- --reporter=dot
|
||||
|
||||
- name: UI Admin Suite
|
||||
run: npm run test:ui:admin -- --reporter=dot
|
||||
|
||||
- name: UI Guest Suite
|
||||
run: npm run test:ui:guest -- --reporter=dot
|
||||
|
||||
109
app/Http/Controllers/Testing/TestCheckoutController.php
Normal file
109
app/Http/Controllers/Testing/TestCheckoutController.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Testing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Services\Checkout\CheckoutWebhookService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TestCheckoutController extends Controller
|
||||
{
|
||||
public function latest(Request $request): JsonResponse
|
||||
{
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => ['nullable', 'string', 'email'],
|
||||
'tenant_id' => ['nullable', 'integer'],
|
||||
'status' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$query = CheckoutSession::query()->latest();
|
||||
|
||||
if ($validated['email'] ?? null) {
|
||||
$query->whereHas('user', fn ($q) => $q->where('email', $validated['email']));
|
||||
}
|
||||
|
||||
if ($validated['tenant_id'] ?? null) {
|
||||
$query->where('tenant_id', $validated['tenant_id']);
|
||||
}
|
||||
|
||||
if ($validated['status'] ?? null) {
|
||||
$query->where('status', $validated['status']);
|
||||
}
|
||||
|
||||
$session = $query->first();
|
||||
|
||||
if (! $session) {
|
||||
return response()->json([
|
||||
'data' => null,
|
||||
], 404);
|
||||
}
|
||||
|
||||
$session->loadMissing(['user', 'tenant', 'package']);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'id' => $session->id,
|
||||
'status' => $session->status,
|
||||
'provider' => $session->provider,
|
||||
'tenant_id' => $session->tenant_id,
|
||||
'package_id' => $session->package_id,
|
||||
'user_email' => $session->user?->email,
|
||||
'coupon_id' => $session->coupon_id,
|
||||
'amount_subtotal' => $session->amount_subtotal,
|
||||
'amount_total' => $session->amount_total,
|
||||
'created_at' => $session->created_at?->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function simulatePaddle(
|
||||
Request $request,
|
||||
CheckoutWebhookService $webhooks,
|
||||
CheckoutSession $session
|
||||
): JsonResponse {
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'event_type' => ['nullable', 'string'],
|
||||
'transaction_id' => ['nullable', 'string'],
|
||||
'status' => ['nullable', 'string'],
|
||||
'checkout_id' => ['nullable', 'string'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$eventType = $validated['event_type'] ?? 'transaction.completed';
|
||||
$metadata = array_merge([
|
||||
'tenant_id' => $session->tenant_id,
|
||||
'package_id' => $session->package_id,
|
||||
'checkout_session_id' => $session->id,
|
||||
], $validated['metadata'] ?? []);
|
||||
|
||||
$payload = [
|
||||
'event_type' => $eventType,
|
||||
'data' => array_filter([
|
||||
'id' => $validated['transaction_id'] ?? ('txn_'.Str::uuid()),
|
||||
'status' => $validated['status'] ?? 'completed',
|
||||
'metadata' => $metadata,
|
||||
'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['paddle_checkout_id'] ?? 'chk_'.Str::uuid(),
|
||||
]),
|
||||
];
|
||||
|
||||
$handled = $webhooks->handlePaddleEvent($payload);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'handled' => $handled,
|
||||
'session' => [
|
||||
'id' => $session->id,
|
||||
'status' => $session->fresh()->status,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
150
app/Http/Controllers/Testing/TestCouponController.php
Normal file
150
app/Http/Controllers/Testing/TestCouponController.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Testing;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Coupon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TestCouponController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
|
||||
$payload = $request->input('coupons');
|
||||
$definitions = collect(is_array($payload) ? $payload : [])
|
||||
->map(fn ($definition) => $this->normalizeDefinition($definition))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
if ($definitions->isEmpty()) {
|
||||
$definitions = collect($this->defaultDefinitions());
|
||||
}
|
||||
|
||||
$created = $definitions->map(function (array $definition) {
|
||||
$coupon = Coupon::updateOrCreate(
|
||||
['code' => strtoupper($definition['code'])],
|
||||
[
|
||||
'name' => $definition['name'] ?? $definition['code'],
|
||||
'type' => CouponType::from($definition['type']),
|
||||
'status' => CouponStatus::from($definition['status'] ?? CouponStatus::ACTIVE->value),
|
||||
'amount' => $definition['amount'],
|
||||
'currency' => $definition['currency'],
|
||||
'description' => $definition['description'] ?? null,
|
||||
'enabled_for_checkout' => $definition['enabled_for_checkout'],
|
||||
'auto_apply' => $definition['auto_apply'],
|
||||
'is_stackable' => $definition['is_stackable'],
|
||||
'usage_limit' => $definition['usage_limit'],
|
||||
'per_customer_limit' => $definition['per_customer_limit'],
|
||||
'starts_at' => $definition['starts_at'],
|
||||
'ends_at' => $definition['ends_at'],
|
||||
]
|
||||
);
|
||||
|
||||
if (array_key_exists('packages', $definition)) {
|
||||
$coupon->packages()->sync($definition['packages']);
|
||||
}
|
||||
|
||||
return $coupon->fresh(['packages']);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $created->map(fn (Coupon $coupon) => [
|
||||
'id' => $coupon->id,
|
||||
'code' => $coupon->code,
|
||||
'type' => $coupon->type->value,
|
||||
'status' => $coupon->status->value,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function normalizeDefinition(array $definition): array
|
||||
{
|
||||
$code = strtoupper((string) ($definition['code'] ?? ''));
|
||||
$type = $definition['type'] ?? CouponType::PERCENTAGE->value;
|
||||
|
||||
if ($code === '') {
|
||||
throw ValidationException::withMessages(['code' => 'Coupon code is required.']);
|
||||
}
|
||||
|
||||
$amount = (float) ($definition['amount'] ?? 0);
|
||||
if ($amount <= 0) {
|
||||
throw ValidationException::withMessages(['amount' => 'Coupon amount must be greater than zero.']);
|
||||
}
|
||||
|
||||
$currency = $definition['currency'] ?? null;
|
||||
if ($type !== CouponType::PERCENTAGE->value && ! $currency) {
|
||||
$currency = 'EUR';
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => $code,
|
||||
'name' => $definition['name'] ?? null,
|
||||
'type' => $type,
|
||||
'status' => $definition['status'] ?? CouponStatus::ACTIVE->value,
|
||||
'amount' => $amount,
|
||||
'currency' => $currency ? strtoupper((string) $currency) : null,
|
||||
'description' => $definition['description'] ?? null,
|
||||
'enabled_for_checkout' => Arr::get($definition, 'enabled_for_checkout', true),
|
||||
'auto_apply' => Arr::get($definition, 'auto_apply', false),
|
||||
'is_stackable' => Arr::get($definition, 'is_stackable', false),
|
||||
'usage_limit' => Arr::get($definition, 'usage_limit'),
|
||||
'per_customer_limit' => Arr::get($definition, 'per_customer_limit'),
|
||||
'starts_at' => $this->parseDate($definition['starts_at'] ?? null),
|
||||
'ends_at' => $this->parseDate($definition['ends_at'] ?? null),
|
||||
'packages' => Arr::get($definition, 'packages'),
|
||||
];
|
||||
}
|
||||
|
||||
private function parseDate(mixed $value): ?Carbon
|
||||
{
|
||||
if (! $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($value instanceof Carbon) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return Carbon::parse($value);
|
||||
}
|
||||
|
||||
private function defaultDefinitions(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'code' => 'PERCENT10',
|
||||
'name' => '10% off',
|
||||
'type' => CouponType::PERCENTAGE->value,
|
||||
'amount' => 10,
|
||||
'description' => '10% discount for package flows',
|
||||
'usage_limit' => 500,
|
||||
],
|
||||
[
|
||||
'code' => 'FLAT50',
|
||||
'name' => '50 EUR off',
|
||||
'type' => CouponType::FLAT->value,
|
||||
'amount' => 50,
|
||||
'currency' => 'EUR',
|
||||
'description' => '50€ discount on qualifying packages',
|
||||
'usage_limit' => 200,
|
||||
],
|
||||
[
|
||||
'code' => 'EXPIRED25',
|
||||
'name' => 'Expired 25%',
|
||||
'type' => CouponType::PERCENTAGE->value,
|
||||
'amount' => 25,
|
||||
'description' => 'Expired coupon for error handling tests',
|
||||
'starts_at' => now()->subWeeks(2),
|
||||
'ends_at' => now()->subDays(2),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
91
app/Http/Controllers/Testing/TestEventController.php
Normal file
91
app/Http/Controllers/Testing/TestEventController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Testing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
|
||||
class TestEventController extends Controller
|
||||
{
|
||||
public function joinToken(Request $request, EventJoinTokenService $tokens): JsonResponse
|
||||
{
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'event_id' => ['nullable', 'integer'],
|
||||
'slug' => ['nullable', 'string'],
|
||||
'ensure_active' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$eventId = $validated['event_id'] ?? null;
|
||||
$slug = $validated['slug'] ?? null;
|
||||
|
||||
if (! $eventId && ! $slug) {
|
||||
throw ValidationException::withMessages([
|
||||
'event_id' => 'Provide either event_id or slug.',
|
||||
]);
|
||||
}
|
||||
|
||||
$eventQuery = Event::query();
|
||||
|
||||
if ($eventId) {
|
||||
$eventQuery->whereKey($eventId);
|
||||
} else {
|
||||
$eventQuery->where('slug', $slug);
|
||||
}
|
||||
|
||||
/** @var Event|null $event */
|
||||
$event = $eventQuery->first();
|
||||
|
||||
if (! $event) {
|
||||
return response()->json([
|
||||
'data' => null,
|
||||
], 404);
|
||||
}
|
||||
|
||||
/** @var EventJoinToken|null $token */
|
||||
$token = $event->joinTokens()->latest()->first();
|
||||
|
||||
if (! $token || (($validated['ensure_active'] ?? true) && ! $token->isActive())) {
|
||||
$token = $tokens->createToken($event, [
|
||||
'label' => 'Automation',
|
||||
'metadata' => [
|
||||
'generated_for' => 'testing_api',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$plainToken = $token->token;
|
||||
|
||||
if (! $plainToken) {
|
||||
throw ValidationException::withMessages([
|
||||
'token' => 'Failed to resolve token value.',
|
||||
]);
|
||||
}
|
||||
|
||||
$joinUrl = route('guest.event', ['token' => $plainToken]);
|
||||
|
||||
$qrSvg = QrCode::format('svg')
|
||||
->size(256)
|
||||
->generate($joinUrl);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'event_id' => $event->id,
|
||||
'token_id' => $token->id,
|
||||
'token' => $plainToken,
|
||||
'join_url' => $joinUrl,
|
||||
'qr_svg' => $qrSvg,
|
||||
'expires_at' => $token->expires_at?->toIso8601String(),
|
||||
'usage_count' => $token->usage_count,
|
||||
'usage_limit' => $token->usage_limit,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
192
app/Http/Controllers/Testing/TestGuestEventController.php
Normal file
192
app/Http/Controllers/Testing/TestGuestEventController.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Testing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventType;
|
||||
use App\Models\Task;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TestGuestEventController extends Controller
|
||||
{
|
||||
public function store(Request $request, EventJoinTokenService $joinTokens): JsonResponse
|
||||
{
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'slug' => ['nullable', 'string', 'max:100'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'date' => ['nullable', 'date'],
|
||||
'event_type' => ['nullable', 'string', 'max:100'],
|
||||
'tasks' => ['nullable', 'array'],
|
||||
'tasks.*.slug' => ['required_with:tasks', 'string', 'max:120'],
|
||||
'tasks.*.title' => ['required_with:tasks', 'string', 'max:255'],
|
||||
'tasks.*.description' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$slug = Str::slug($validated['slug'] ?? 'pwa-demo-event');
|
||||
|
||||
[$event, $tokenValue] = DB::transaction(function () use ($validated, $slug, $joinTokens) {
|
||||
$tenant = $this->ensureTenant();
|
||||
$eventType = $this->ensureEventType($validated['event_type'] ?? 'wedding');
|
||||
|
||||
$event = Event::updateOrCreate(
|
||||
['slug' => $slug],
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_type_id' => $eventType->id,
|
||||
'name' => [
|
||||
'de' => $validated['name'] ?? 'PWA Demo Event',
|
||||
'en' => $validated['name'] ?? 'PWA Demo Event',
|
||||
],
|
||||
'description' => [
|
||||
'de' => 'Automatisches Demo-Event für Playwright.',
|
||||
'en' => 'Automated demo event for Playwright.',
|
||||
],
|
||||
'default_locale' => 'de',
|
||||
'status' => 'published',
|
||||
'is_active' => true,
|
||||
'date' => ($validated['date'] ?? Carbon::now()->addWeeks(2)->toDateString()),
|
||||
'settings' => [
|
||||
'branding' => [
|
||||
'primary_color' => '#f43f5e',
|
||||
'secondary_color' => '#fb7185',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$taskIds = $this->ensureTasks($tenant->id, $eventType->id, $validated['tasks'] ?? null);
|
||||
if ($taskIds !== []) {
|
||||
$event->tasks()->syncWithoutDetaching($taskIds);
|
||||
}
|
||||
|
||||
$token = $event->joinTokens()->latest()->first();
|
||||
if (! $token || ! $token->isActive()) {
|
||||
$token = $joinTokens->createToken($event, [
|
||||
'label' => 'Testing Automation',
|
||||
'metadata' => ['generator' => 'testing_api'],
|
||||
]);
|
||||
}
|
||||
|
||||
return [$event, $token?->token];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'event_id' => $event->id,
|
||||
'slug' => $event->slug,
|
||||
'name' => $event->name,
|
||||
'join_token' => $tokenValue,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureTenant(): Tenant
|
||||
{
|
||||
$user = User::firstOrCreate(
|
||||
['email' => 'guest-suite@example.com'],
|
||||
[
|
||||
'name' => 'Guest Suite Admin',
|
||||
'first_name' => 'Guest',
|
||||
'last_name' => 'Suite',
|
||||
'password' => Hash::make('Password123!'),
|
||||
'role' => 'tenant_admin',
|
||||
]
|
||||
);
|
||||
|
||||
$tenant = Tenant::firstOrCreate(
|
||||
['slug' => 'guest-suite-tenant'],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Guest Suite Tenant',
|
||||
'email' => $user->email,
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
if (! $user->tenant_id) {
|
||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function ensureEventType(string $slug): EventType
|
||||
{
|
||||
$slug = Str::slug($slug) ?: 'wedding';
|
||||
|
||||
return EventType::updateOrCreate(
|
||||
['slug' => $slug],
|
||||
[
|
||||
'name' => [
|
||||
'de' => Str::title($slug),
|
||||
'en' => Str::title($slug),
|
||||
],
|
||||
'settings' => [],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function ensureTasks(int $tenantId, int $eventTypeId, ?array $payload): array
|
||||
{
|
||||
$definitions = $payload ?: [
|
||||
[
|
||||
'slug' => 'guest-demo-rings',
|
||||
'title' => 'Ringe im Fokus',
|
||||
'description' => 'Halte die Ringe oder eure Hände mit einem kreativen Hintergrund fest.',
|
||||
],
|
||||
[
|
||||
'slug' => 'guest-demo-dancefloor',
|
||||
'title' => 'Tanzfläche in Bewegung',
|
||||
'description' => 'Zeigt uns eure besten Moves – gerne mit Motion Blur.',
|
||||
],
|
||||
[
|
||||
'slug' => 'guest-demo-cheers',
|
||||
'title' => 'Cheers!',
|
||||
'description' => 'Ein Toast-Moment mit Gläsern oder Konfetti.',
|
||||
],
|
||||
];
|
||||
|
||||
$ids = [];
|
||||
foreach ($definitions as $index => $definition) {
|
||||
$task = Task::updateOrCreate(
|
||||
['slug' => $definition['slug']],
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'event_type_id' => $eventTypeId,
|
||||
'title' => [
|
||||
'de' => $definition['title'],
|
||||
'en' => $definition['title'],
|
||||
],
|
||||
'description' => [
|
||||
'de' => $definition['description'] ?? $definition['title'],
|
||||
'en' => $definition['description'] ?? $definition['title'],
|
||||
],
|
||||
'instructions' => [
|
||||
'de' => 'Creatives Willkommen',
|
||||
'en' => 'Get creative',
|
||||
],
|
||||
'priority' => $index === 0 ? 'high' : 'medium',
|
||||
]
|
||||
);
|
||||
|
||||
$ids[] = $task->id;
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
30
app/Http/Controllers/Testing/TestMailboxController.php
Normal file
30
app/Http/Controllers/Testing/TestMailboxController.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Testing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Testing\Mailbox;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class TestMailboxController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
|
||||
return response()->json([
|
||||
'data' => Mailbox::all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(): JsonResponse
|
||||
{
|
||||
abort_unless(app()->environment(['local', 'testing']), 404);
|
||||
|
||||
Mailbox::flush();
|
||||
|
||||
return response()->json([
|
||||
'status' => 'ok',
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
app/Http/Middleware/HandleInvalidSignedUrl.php
Normal file
42
app/Http/Middleware/HandleInvalidSignedUrl.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Exceptions\InvalidSignatureException;
|
||||
|
||||
class HandleInvalidSignedUrl
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
try {
|
||||
return $next($request);
|
||||
} catch (InvalidSignatureException $exception) {
|
||||
if ($request->expectsJson()) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
if ($this->isVerificationRoute($request)) {
|
||||
$request->session()->flash('verification', [
|
||||
'status' => 'error',
|
||||
'title' => __('auth.verification.expired_title'),
|
||||
'message' => __('auth.verification.expired_message'),
|
||||
]);
|
||||
|
||||
return redirect()->route('verification.notice');
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function isVerificationRoute(Request $request): bool
|
||||
{
|
||||
if ($request->route()?->getName() === 'verification.verify') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_starts_with($request->path(), 'verify-email/');
|
||||
}
|
||||
}
|
||||
@@ -35,13 +35,16 @@ use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Security\PhotoSecurityScanner;
|
||||
use App\Services\Storage\EventStorageManager;
|
||||
use App\Services\Storage\StorageHealthService;
|
||||
use App\Testing\Mailbox;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Mail\Events\MessageSent;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
use Illuminate\Support\Facades\Event as EventFacade;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Inertia\Inertia;
|
||||
@@ -70,6 +73,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
Route::aliasMiddleware('signed', \App\Http\Middleware\ValidateSignature::class);
|
||||
|
||||
$this->app->make(EventStorageManager::class)->registerDynamicDisks();
|
||||
|
||||
EventFacade::listen(
|
||||
@@ -137,6 +142,13 @@ class AppServiceProvider extends ServiceProvider
|
||||
[DispatchGuestNotificationPush::class, 'handle']
|
||||
);
|
||||
|
||||
if ($this->app->environment(['local', 'testing'])) {
|
||||
EventFacade::listen(
|
||||
MessageSent::class,
|
||||
[Mailbox::class, 'record']
|
||||
);
|
||||
}
|
||||
|
||||
RateLimiter::for('tenant-api', function (Request $request) {
|
||||
$tenantId = $request->attributes->get('tenant_id')
|
||||
?? $request->user()?->tenant_id
|
||||
|
||||
91
app/Testing/Mailbox.php
Normal file
91
app/Testing/Mailbox.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Testing;
|
||||
|
||||
use Illuminate\Mail\Events\MessageSent;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Mime\Address;
|
||||
|
||||
class Mailbox
|
||||
{
|
||||
private const string STORAGE_PATH = 'testing/mailbox.json';
|
||||
|
||||
public static function record(MessageSent $event): void
|
||||
{
|
||||
if (! app()->environment(['local', 'testing'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$messages = self::read();
|
||||
$messages[] = [
|
||||
'id' => (string) Str::uuid(),
|
||||
'subject' => (string) $event->message->getSubject(),
|
||||
'from' => self::formatAddresses($event->message->getFrom()),
|
||||
'to' => self::formatAddresses($event->message->getTo()),
|
||||
'cc' => self::formatAddresses($event->message->getCc()),
|
||||
'bcc' => self::formatAddresses($event->message->getBcc()),
|
||||
'html' => method_exists($event->message, 'getHtmlBody') ? $event->message->getHtmlBody() : null,
|
||||
'text' => method_exists($event->message, 'getTextBody') ? $event->message->getTextBody() : null,
|
||||
'sent_at' => now()->toIso8601String(),
|
||||
'headers' => (string) $event->message->getHeaders(),
|
||||
];
|
||||
|
||||
self::write($messages);
|
||||
}
|
||||
|
||||
public static function all(): array
|
||||
{
|
||||
return self::read();
|
||||
}
|
||||
|
||||
public static function flush(): void
|
||||
{
|
||||
self::write([]);
|
||||
}
|
||||
|
||||
private static function read(): array
|
||||
{
|
||||
$disk = Storage::disk('local');
|
||||
|
||||
if (! $disk->exists(self::STORAGE_PATH)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($disk->get(self::STORAGE_PATH), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private static function write(array $messages): void
|
||||
{
|
||||
$disk = Storage::disk('local');
|
||||
|
||||
$disk->put(self::STORAGE_PATH, json_encode($messages, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Address[]|array|null $addresses
|
||||
*/
|
||||
private static function formatAddresses(?array $addresses): array
|
||||
{
|
||||
return Collection::make($addresses)
|
||||
->filter()
|
||||
->map(function ($address) {
|
||||
if ($address instanceof Address) {
|
||||
return [
|
||||
'name' => $address->getName() ?: null,
|
||||
'email' => $address->getAddress(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => null,
|
||||
'email' => (string) $address,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,121 @@
|
||||
# End-to-End Testing (Playwright)
|
||||
# UI Test Suites (Playwright)
|
||||
|
||||
This document tracks the UI/E2E automation efforts. The suites now live under `tests/ui` and are organized by product surface (Purchase, Auth, Admin, Guest PWA).
|
||||
|
||||
## Prerequisites
|
||||
- Node 18+
|
||||
- `npm install`
|
||||
- Laravel backend running on `http://localhost:8000`
|
||||
- Seeded tenant admin account for automation (see below)
|
||||
- Laravel app running at `http://localhost:8000`
|
||||
- Seeded tenant admin account (see below)
|
||||
- Paddle sandbox credentials/config applied to the local `.env`
|
||||
|
||||
## Seed Test Tenant
|
||||
Run the dedicated seeder to provision a deterministic tenant + credentials:
|
||||
## Deterministic Data
|
||||
|
||||
```
|
||||
### Tenant Admin
|
||||
Use the existing seeder to provision a reusable tenant account for Admin suite flows:
|
||||
|
||||
```bash
|
||||
php artisan db:seed --class=E2ETenantSeeder
|
||||
```
|
||||
|
||||
By default the seeder creates `tenant-e2e@example.com` with password `password123`. Override via environment variables before seeding:
|
||||
Override defaults when necessary:
|
||||
|
||||
```
|
||||
```bash
|
||||
E2E_TENANT_EMAIL="tenant@example.com" \
|
||||
E2E_TENANT_PASSWORD="super-secret" \
|
||||
php artisan db:seed --class=E2ETenantSeeder
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
Export the same credentials for Playwright so it can sign in:
|
||||
Expose the same credentials to Playwright:
|
||||
|
||||
```
|
||||
E2E_TENANT_EMAIL="tenant-e2e@example.com"
|
||||
E2E_TENANT_PASSWORD="password123"
|
||||
```bash
|
||||
export E2E_TENANT_EMAIL="tenant@example.com"
|
||||
export E2E_TENANT_PASSWORD="super-secret"
|
||||
```
|
||||
|
||||
Inline checkout scenarios additionally require:
|
||||
### Coupon Presets & Mailbox API
|
||||
The backend exposes `/api/_testing/...` endpoints (local/testing env only):
|
||||
|
||||
```
|
||||
VITE_STRIPE_PUBLISHABLE_KEY="pk_test_..."
|
||||
VITE_PAYPAL_CLIENT_ID="Abc123..."
|
||||
```
|
||||
| Endpoint | Description |
|
||||
| --- | --- |
|
||||
| `POST /api/_testing/coupons/seed` | Seeds default coupons (`PERCENT10`, `FLAT50`, `EXPIRED25`) or accepts a custom payload. |
|
||||
| `GET /api/_testing/mailbox` | Returns every captured email (see `App\Testing\Mailbox`). |
|
||||
| `DELETE /api/_testing/mailbox` | Flushes the captured emails. |
|
||||
| `GET /api/_testing/checkout/sessions/latest` | Fetches the newest checkout session for a given email/tenant filter. |
|
||||
| `POST /api/_testing/checkout/sessions/{session}/simulate-paddle` | Triggers the Paddle webhook handler for the given session with a mock payload. |
|
||||
| `GET /api/_testing/events/join-token` | Resolves (and optionally regenerates) a join token + QR for a given event ID or slug. |
|
||||
| `POST /api/_testing/guest-events` | Provisions a deterministic guest/tenant event with sample tasks and returns its slug + join token. |
|
||||
|
||||
Inject these into the shell that runs both Laravel (Vite) and Playwright so the onboarding PWA can render payment elements.
|
||||
### Guest Demo Event
|
||||
- Call `POST /api/_testing/guest-events` (optionally pass a custom `slug` or `name`) to ensure there is an event with ready-to-use tasks and join token.
|
||||
- Export the slug so the guest suite knows which event to target:
|
||||
```bash
|
||||
export E2E_GUEST_EVENT_SLUG="pwa-demo-event"
|
||||
export E2E_GUEST_BASE_URL="http://localhost:8000"
|
||||
```
|
||||
- The response includes `join_token` if you need to debug locally, but the UI tests grab a fresh token through `fetchJoinToken`.
|
||||
|
||||
Playwright fixtures (`tests/ui/helpers/test-fixtures.ts`) provide helpers that wrap these endpoints.
|
||||
|
||||
## Suite Layout & Goals
|
||||
|
||||
| Suite | Location | Primary Coverage |
|
||||
| --- | --- | --- |
|
||||
| Purchase | `tests/ui/purchase` | Marketing site package selection, checkout flow, coupon handling, Paddle sandbox hand-off, post-purchase dashboard verification. |
|
||||
| Auth | `tests/ui/auth` | Registration/login fuzzing, password reset, Social/OAuth hooks, email delivery assertions, throttling/error UX. |
|
||||
| Admin | `tests/ui/admin` | Tenant onboarding wizard, dashboard widgets, event creation (incl. wedding preset), task assignment, join-token + QR verification, Paddle billing history. |
|
||||
| Guest | `tests/ui/guest` | Guest PWA onboarding, join-token entry, offline sync, uploads/likes/tasks for ≥15 guests, achievement + notification UX. |
|
||||
|
||||
Each suite should be executable independently to keep CI fast and to allow targeted debugging.
|
||||
|
||||
## Commands
|
||||
- `npm run test:e2e` - execute the full Playwright suite.
|
||||
- `npx playwright test tests/e2e/tenant-onboarding-flow.test.ts` - focus on the onboarding spec.
|
||||
- `npm run test:ui` — run all suites serially.
|
||||
- `npm run test:ui:purchase` — Purchase-only regressions.
|
||||
- `npm run test:ui:auth` — Authentication fuzzing.
|
||||
- `npm run test:ui:admin` — Admin panel journeys.
|
||||
- `npm run test:ui:guest` — Guest PWA scenarios.
|
||||
|
||||
## Notes
|
||||
- Fixtures live in `tests/e2e/utils/test-fixtures.ts`. They automatically skip onboarding assertions when credentials are absent.
|
||||
- Traces are captured on the first retry (`playwright.config.ts`); inspect via `npx playwright show-trace` on failure.
|
||||
- Configure CI by injecting the same environment variables and pointing `use.baseURL` to the deployed environment under test.
|
||||
Traces are recorded on first retry (`playwright.config.ts`); open via `npx playwright show-trace path/to/trace.zip`.
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
1. **Purchase suite**
|
||||
- Seed coupons via helper.
|
||||
- Cover `/de/packages` Standard selection, coupon states (valid/invalid/expired), Paddle inline + hosted checkout using sandbox card `4000 0566 5566 5557 / CVV 100`.
|
||||
- Simulate webhook success (helper endpoint TBD) so dashboard reflects the purchase.
|
||||
- Assert confirmation emails captured via mailbox API.
|
||||
|
||||
2. **Auth suite**
|
||||
- Expand current scaffold to fully automate registration, login, password reset, MFA prompts, and throttling.
|
||||
- Fuzz invalid inputs; assert inline validation + error banners.
|
||||
- Use mailbox helper to fetch verification/reset emails and follow links.
|
||||
|
||||
3. **Admin suite**
|
||||
- After purchase, log into `/event-admin`, confirm latest package appears, create a wedding event, assign predefined tasks, fetch join token + QR (helper should expose raw token/URL).
|
||||
- Cover task management UX (assign, reorder, complete).
|
||||
- Verify billing history shows the recent Paddle transaction.
|
||||
|
||||
4. **Guest suite**
|
||||
- Use join token from Admin suite (or seed via helper) to onboard 15 simulated guests in parallel contexts.
|
||||
- Exercise uploads (with quota edge cases), likes, task completion, achievements, push subscription, offline queue + resync.
|
||||
- Validate guest-facing error states (expired token, upload failure, network loss).
|
||||
|
||||
5. **Shared helpers (backend + Playwright)**
|
||||
- Webhook trigger endpoint for Paddle sandbox.
|
||||
- Join token + QR extraction endpoint for tests.
|
||||
- Task template seeding helper.
|
||||
- Optional guest factory endpoint to mint attendees quickly.
|
||||
|
||||
Track implementation progress in this document to keep future contributors aligned.
|
||||
|
||||
### Guest Suite
|
||||
|
||||
`tests/ui/guest/guest-pwa-journey.test.ts` simulates 15 independent guests joining the event, naming themselves, visiting the task list, opening the upload screen (via gallery picker), toggling offline mode for the final wave, and visiting the gallery to like a photo when one exists.
|
||||
|
||||
#### Requirements
|
||||
|
||||
1. **Existing event** – create a tenant admin event with active join token and set `E2E_GUEST_EVENT_SLUG` (e.g., `export E2E_GUEST_EVENT_SLUG=wedding-showcase`).
|
||||
2. **Guest base URL** – defaults to `http://localhost:8000`. Override with `E2E_GUEST_BASE_URL` if the PWA runs elsewhere (Capacitor/TWA build).
|
||||
3. **Media fixture** – the test auto-generates `tests/ui/guest/fixtures/sample-upload.png` for the gallery upload flow.
|
||||
|
||||
Run with `npm run test:ui:guest`. The test creates Playwright contexts sequentially to keep memory usage predictable.
|
||||
|
||||
@@ -10,7 +10,12 @@
|
||||
"format:check": "prettier --check resources/",
|
||||
"lint": "eslint . --fix",
|
||||
"types": "tsc --noEmit",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e": "npm run test:ui",
|
||||
"test:ui": "playwright test",
|
||||
"test:ui:purchase": "playwright test --project=purchase",
|
||||
"test:ui:auth": "playwright test --project=auth",
|
||||
"test:ui:admin": "playwright test --project=admin",
|
||||
"test:ui:guest": "playwright test --project=guest",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://playwright.dev/docs/test-configuration#launching-the-player
|
||||
*/
|
||||
function getDisplayValue(value: string | undefined) {
|
||||
return value === undefined ? '1' : value;
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testDir: './tests/ui',
|
||||
timeout: 90_000,
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
@@ -23,10 +15,25 @@ export default defineConfig({
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
name: 'purchase',
|
||||
testDir: './tests/ui/purchase',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'auth',
|
||||
testDir: './tests/ui/auth',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'admin',
|
||||
testDir: './tests/ui/admin',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'guest',
|
||||
testDir: './tests/ui/guest',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -181,7 +181,7 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
|
||||
{navLinks.map((item) => (
|
||||
item.children ? (
|
||||
<div key={item.key} className="relative group">
|
||||
<span className="inline-flex cursor-default items-center gap-1 text-sm font-semibold text-gray-700 transition-colors group-hover:text-pink-600 font-sans-marketing">
|
||||
<span className="inline-flex cursor-default items-center gap-1 text-md font-md text-gray-700 transition-colors group-hover:text-pink-600 font-sans-marketing">
|
||||
{item.label}
|
||||
<svg
|
||||
className="h-4 w-4 text-gray-400 transition group-hover:text-pink-500"
|
||||
@@ -210,7 +210,7 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
|
||||
<Link
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className="text-sm font-semibold text-gray-700 transition hover:text-pink-600 font-sans-marketing"
|
||||
className="text-md font-md text-gray-700 transition hover:text-pink-600 font-sans-marketing"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
|
||||
@@ -279,3 +279,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
if (app()->environment(['local', 'testing'])) {
|
||||
require __DIR__.'/testing.php';
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
|
||||
->middleware([
|
||||
\App\Http\Middleware\NormalizeSignedUrlParameters::class,
|
||||
\App\Http\Middleware\HandleInvalidSignedUrl::class,
|
||||
'signed:relative',
|
||||
'throttle:6,1',
|
||||
])
|
||||
|
||||
23
routes/testing.php
Normal file
23
routes/testing.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Testing\TestCheckoutController;
|
||||
use App\Http\Controllers\Testing\TestCouponController;
|
||||
use App\Http\Controllers\Testing\TestGuestEventController;
|
||||
use App\Http\Controllers\Testing\TestEventController;
|
||||
use App\Http\Controllers\Testing\TestMailboxController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('_testing')->group(function () {
|
||||
Route::get('/mailbox', [TestMailboxController::class, 'index'])->name('testing.mailbox.index');
|
||||
Route::delete('/mailbox', [TestMailboxController::class, 'destroy'])->name('testing.mailbox.destroy');
|
||||
|
||||
Route::post('/coupons/seed', [TestCouponController::class, 'store'])->name('testing.coupons.seed');
|
||||
|
||||
Route::get('/checkout/sessions/latest', [TestCheckoutController::class, 'latest'])->name('testing.checkout.sessions.latest');
|
||||
Route::post('/checkout/sessions/{session}/simulate-paddle', [TestCheckoutController::class, 'simulatePaddle'])
|
||||
->whereUuid('session')
|
||||
->name('testing.checkout.sessions.simulate-paddle');
|
||||
|
||||
Route::get('/events/join-token', [TestEventController::class, 'joinToken'])->name('testing.events.join-token');
|
||||
Route::post('/guest-events', [TestGuestEventController::class, 'store'])->name('testing.guest-events.store');
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import 'dotenv/config';
|
||||
import { test as base, expect, Page, APIRequestContext } from '@playwright/test';
|
||||
|
||||
export type TenantCredentials = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type TenantAdminFixtures = {
|
||||
tenantAdminCredentials: TenantCredentials | null;
|
||||
signInTenantAdmin: () => Promise<void>;
|
||||
};
|
||||
|
||||
const tenantAdminEmail = process.env.E2E_TENANT_EMAIL ?? 'hello@lumen-moments.demo';
|
||||
const tenantAdminPassword = process.env.E2E_TENANT_PASSWORD ?? 'Demo1234!';
|
||||
|
||||
export const test = base.extend<TenantAdminFixtures>({
|
||||
tenantAdminCredentials: async ({}, use) => {
|
||||
if (!tenantAdminEmail || !tenantAdminPassword) {
|
||||
await use(null);
|
||||
return;
|
||||
}
|
||||
|
||||
await use({
|
||||
email: tenantAdminEmail,
|
||||
password: tenantAdminPassword,
|
||||
});
|
||||
},
|
||||
|
||||
signInTenantAdmin: async ({ page, tenantAdminCredentials }, use) => {
|
||||
if (!tenantAdminCredentials) {
|
||||
await use(async () => {
|
||||
throw new Error('Tenant admin credentials missing. Provide E2E_TENANT_EMAIL and E2E_TENANT_PASSWORD.');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await use(async () => {
|
||||
await performTenantSignIn(page, tenantAdminCredentials);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const expectFixture = expect;
|
||||
|
||||
async function performTenantSignIn(page: Page, credentials: TenantCredentials) {
|
||||
const token = await exchangeToken(page.request, credentials);
|
||||
|
||||
await page.addInitScript(({ stored }) => {
|
||||
localStorage.setItem('tenant_admin.token.v1', JSON.stringify(stored));
|
||||
sessionStorage.setItem('tenant_admin.token.session.v1', JSON.stringify(stored));
|
||||
}, { stored: token });
|
||||
|
||||
await page.goto('/event-admin');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
|
||||
type StoredTokenPayload = {
|
||||
accessToken: string;
|
||||
abilities: string[];
|
||||
issuedAt: number;
|
||||
};
|
||||
|
||||
async function exchangeToken(request: APIRequestContext, credentials: TenantCredentials): Promise<StoredTokenPayload> {
|
||||
const response = await request.post('/api/v1/tenant-auth/login', {
|
||||
data: {
|
||||
login: credentials.email,
|
||||
password: credentials.password,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Tenant PAT login failed: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
return {
|
||||
accessToken: body.token,
|
||||
abilities: Array.isArray(body.abilities) ? body.abilities : [],
|
||||
issuedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { test, expectFixture as expect } from './utils/test-fixtures';
|
||||
import { test, expectFixture as expect } from '../helpers/test-fixtures';
|
||||
|
||||
const futureDate = (daysAhead = 10): string => {
|
||||
const date = new Date();
|
||||
@@ -106,4 +106,55 @@ test.describe('Tenant Admin PWA – end-to-end coverage', () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByRole('heading', { name: /Einstellungen/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('wedding event workflow assigns tasks and exposes join token', async ({ page, fetchJoinToken }) => {
|
||||
const eventName = `Playwright Hochzeit ${Date.now()}`;
|
||||
const eventDate = futureDate(21);
|
||||
|
||||
await page.goto('/event-admin/events/new');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByLabel(/Eventname/i).fill(eventName);
|
||||
await page.getByLabel(/Datum/i).fill(eventDate);
|
||||
|
||||
const eventTypeCombo = page.getByRole('combobox', { name: /Event-Typ/i });
|
||||
await eventTypeCombo.click();
|
||||
const weddingOption = page.getByRole('option', { name: /Hochzeit|Wedding/i }).first();
|
||||
await expect(weddingOption).toBeVisible();
|
||||
await weddingOption.click();
|
||||
|
||||
await page.getByRole('button', { name: /^Speichern/i }).click();
|
||||
await page.waitForURL(/\/event-admin\/events\/[a-z0-9-]+$/i, { timeout: 20_000 });
|
||||
const createdSlug = page.url().split('/').pop() ?? '';
|
||||
|
||||
await expect(page.getByText(/Hochzeit|Wedding/i)).toBeVisible();
|
||||
|
||||
await page.goto(`/event-admin/events/${createdSlug}/tasks`);
|
||||
await expect(page.getByRole('heading', { name: /Event-Tasks/i })).toBeVisible();
|
||||
|
||||
const librarySection = page
|
||||
.locator('section')
|
||||
.filter({ hasText: /Tasks aus Bibliothek hinzufügen|Add tasks/i })
|
||||
.first();
|
||||
const availableTaskLabels = librarySection.locator('label');
|
||||
const availableCount = await availableTaskLabels.count();
|
||||
test.skip(availableCount === 0, 'No task library entries available to assign');
|
||||
|
||||
const firstLabel = availableTaskLabels.first();
|
||||
const taskTitle = ((await firstLabel.locator('p').first().textContent()) ?? '').trim();
|
||||
await firstLabel.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /Ausgewählte Tasks zuweisen|Assign selected tasks/i })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.locator('section').filter({ hasText: /Zugeordnete Tasks|Assigned tasks/i }).getByText(taskTitle, { exact: false }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const joinToken = await fetchJoinToken({ slug: createdSlug });
|
||||
expect(joinToken.token).toBeTruthy();
|
||||
expect(joinToken.join_url).toContain(joinToken.token);
|
||||
expect(joinToken.qr_svg).toContain('<svg');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expectFixture as expect } from './utils/test-fixtures';
|
||||
import { test, expectFixture as expect } from '../helpers/test-fixtures';
|
||||
|
||||
/**
|
||||
* Skeleton E2E coverage for the tenant onboarding journey.
|
||||
58
tests/ui/auth/auth-flows.test.ts
Normal file
58
tests/ui/auth/auth-flows.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { test, expectFixture as expect } from '../helpers/test-fixtures';
|
||||
|
||||
test.describe('Marketing auth flows', () => {
|
||||
test('registers a new account and captures welcome email', async ({ page, clearTestMailbox, getTestMailbox }) => {
|
||||
await clearTestMailbox();
|
||||
|
||||
const stamp = Date.now();
|
||||
const email = `playwright-register-${stamp}@example.test`;
|
||||
const username = `playwright-${stamp}`;
|
||||
const password = 'Password123!';
|
||||
|
||||
await page.goto('/register');
|
||||
|
||||
await page.getByLabel(/Vorname/i).fill('Playwright');
|
||||
await page.getByLabel(/Nachname/i).fill('Tester');
|
||||
await page.getByLabel(/^E-Mail/i).fill(email);
|
||||
await page.getByLabel(/Telefon/i).fill('+49123456789');
|
||||
await page.getByLabel(/Adresse/i).fill('Teststr. 1, 12345 Berlin');
|
||||
await page.getByLabel(/Username/i).fill(username);
|
||||
await page.getByLabel(/^Passwort$/i).fill(password);
|
||||
await page.getByLabel(/Passwort bestätigen/i).fill(password);
|
||||
await page.locator('#privacy_consent').check();
|
||||
|
||||
await page.getByRole('button', { name: /^Registrieren$/i }).click();
|
||||
|
||||
await expect.poll(() => page.url()).not.toContain('/register');
|
||||
|
||||
const messages = await getTestMailbox();
|
||||
const hasWelcome = messages.some((message) =>
|
||||
message.to.some((recipient) => recipient.email === email)
|
||||
);
|
||||
|
||||
expect(hasWelcome).toBe(true);
|
||||
});
|
||||
|
||||
test('shows inline error on invalid login', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.fill('input[name="login"]', `unknown-${Date.now()}@example.test`);
|
||||
await page.fill('input[name="password"]', 'totally-wrong');
|
||||
await page.getByRole('button', { name: /^Anmelden$/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/Diese Anmeldedaten wurden nicht gefunden/i).first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('sends password reset email notice', async ({ page }) => {
|
||||
await page.goto('/forgot-password');
|
||||
|
||||
await page.getByLabel(/Email address/i).fill(`ghost-${Date.now()}@example.test`);
|
||||
await page.getByRole('button', { name: /Email password reset link/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/reset link will be sent if the account exists/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
89
tests/ui/guest/guest-pwa-journey.test.ts
Normal file
89
tests/ui/guest/guest-pwa-journey.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { expectFixture as expect, test } from '../helpers/test-fixtures';
|
||||
|
||||
const guestCount = 15;
|
||||
const uploadFixturePath = ensureUploadFixture();
|
||||
|
||||
test.describe('Guest PWA multi-guest journey', () => {
|
||||
test('15 guests can onboard, explore tasks, trigger upload review, and reach gallery', async ({
|
||||
browser,
|
||||
fetchJoinToken,
|
||||
}) => {
|
||||
const eventSlug = process.env.E2E_GUEST_EVENT_SLUG;
|
||||
test.skip(!eventSlug, 'Set E2E_GUEST_EVENT_SLUG to point the guest suite at an existing event.');
|
||||
|
||||
const joinToken = await fetchJoinToken({ slug: eventSlug!, ensureActive: true });
|
||||
const baseUrl = (process.env.E2E_GUEST_BASE_URL ?? 'http://localhost:8000').replace(/\/+$/, '');
|
||||
const landingUrl = `${baseUrl}/event`;
|
||||
const eventBaseUrl = `${baseUrl}/e/${joinToken.token}`;
|
||||
|
||||
for (let index = 0; index < guestCount; index += 1) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const guestName = `Gast ${index + 1}`;
|
||||
|
||||
await page.goto(landingUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByPlaceholder(/Event-Code eingeben|Enter event code/i).fill(joinToken.token);
|
||||
await page.getByRole('button', { name: /Event beitreten|Join event/i }).click();
|
||||
await completeProfileSetup(page, guestName, joinToken.token);
|
||||
|
||||
await page.goto(`${eventBaseUrl}/tasks`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('body')).toContainText(/Aufgaben|Tasks/);
|
||||
|
||||
await page.goto(`${eventBaseUrl}/upload`, { waitUntil: 'domcontentloaded' });
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await expect(fileInput).toBeVisible({ timeout: 15_000 });
|
||||
await fileInput.setInputFiles(uploadFixturePath);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Nochmal aufnehmen|Retake/i })
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByRole('button', { name: /Nochmal aufnehmen|Retake/i }).click();
|
||||
|
||||
// Simulate offline queue testing for the last five guests.
|
||||
if (index >= guestCount - 5) {
|
||||
await context.setOffline(true);
|
||||
await page.goto(`${eventBaseUrl}/tasks`, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
|
||||
await context.setOffline(false);
|
||||
}
|
||||
|
||||
await page.goto(`${eventBaseUrl}/gallery`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('body')).toContainText(/Galerie|Gallery/);
|
||||
const likeButtons = page.getByLabel(/Foto liken|Like photo/i);
|
||||
if (await likeButtons.count()) {
|
||||
await likeButtons.first().click();
|
||||
}
|
||||
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function completeProfileSetup(page: import('@playwright/test').Page, guestName: string, token: string) {
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
if (page.url().includes('/setup/')) {
|
||||
await page.getByLabel(/Dein Name|Your name/i).fill(guestName);
|
||||
await page.getByRole('button', { name: /Los gehts|Let's go/i }).click();
|
||||
}
|
||||
|
||||
await page.waitForURL(new RegExp(`/e/${token}`), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function ensureUploadFixture(): string {
|
||||
const fixtureDir = path.join(process.cwd(), 'tests/ui/guest/fixtures');
|
||||
const fixturePath = path.join(fixtureDir, 'sample-upload.png');
|
||||
if (!fs.existsSync(fixtureDir)) {
|
||||
fs.mkdirSync(fixtureDir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(fixturePath)) {
|
||||
const png1x1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
fs.writeFileSync(fixturePath, png1x1);
|
||||
}
|
||||
|
||||
return fixturePath;
|
||||
}
|
||||
236
tests/ui/helpers/test-fixtures.ts
Normal file
236
tests/ui/helpers/test-fixtures.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import 'dotenv/config';
|
||||
import { test as base, expect, Page, APIRequestContext, APIResponse } from '@playwright/test';
|
||||
|
||||
export type TenantCredentials = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type TenantAdminFixtures = {
|
||||
tenantAdminCredentials: TenantCredentials | null;
|
||||
signInTenantAdmin: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type MailboxEntry = {
|
||||
id: string;
|
||||
subject: string | null;
|
||||
to: Array<{ email: string; name: string | null }>;
|
||||
from: Array<{ email: string; name: string | null }>;
|
||||
html: string | null;
|
||||
text: string | null;
|
||||
sent_at: string;
|
||||
};
|
||||
|
||||
export type CouponSeedDefinition = {
|
||||
code: string;
|
||||
type: 'percentage' | 'flat' | 'flat_per_seat';
|
||||
amount: number;
|
||||
currency?: string | null;
|
||||
description?: string;
|
||||
enabled_for_checkout?: boolean;
|
||||
usage_limit?: number | null;
|
||||
per_customer_limit?: number | null;
|
||||
starts_at?: string | null;
|
||||
ends_at?: string | null;
|
||||
packages?: number[];
|
||||
};
|
||||
|
||||
export type TestingApiFixtures = {
|
||||
clearTestMailbox: () => Promise<void>;
|
||||
getTestMailbox: () => Promise<MailboxEntry[]>;
|
||||
seedTestCoupons: (definitions?: CouponSeedDefinition[]) => Promise<Array<{ id: number; code: string }>>;
|
||||
getLatestCheckoutSession: (filters?: { email?: string; tenantId?: number; status?: string }) => Promise<CheckoutSessionSummary | null>;
|
||||
simulatePaddleCompletion: (sessionId: string, overrides?: Partial<PaddleSimulationOverrides>) => Promise<void>;
|
||||
fetchJoinToken: (params: { eventId?: number; slug?: string; ensureActive?: boolean }) => Promise<JoinTokenPayload>;
|
||||
};
|
||||
|
||||
export type CheckoutSessionSummary = {
|
||||
id: string;
|
||||
status: string;
|
||||
provider: string | null;
|
||||
tenant_id: number | null;
|
||||
package_id: number | null;
|
||||
user_email: string | null;
|
||||
coupon_id: number | null;
|
||||
amount_subtotal: string | null;
|
||||
amount_total: string | null;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
export type PaddleSimulationOverrides = {
|
||||
event_type: string;
|
||||
transaction_id?: string;
|
||||
status?: string;
|
||||
checkout_id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type JoinTokenPayload = {
|
||||
event_id: number;
|
||||
token_id: number;
|
||||
token: string;
|
||||
join_url: string;
|
||||
qr_svg: string;
|
||||
expires_at: string | null;
|
||||
usage_count: number;
|
||||
usage_limit: number | null;
|
||||
};
|
||||
|
||||
const tenantAdminEmail = process.env.E2E_TENANT_EMAIL ?? 'hello@lumen-moments.demo';
|
||||
const tenantAdminPassword = process.env.E2E_TENANT_PASSWORD ?? 'Demo1234!';
|
||||
|
||||
export const test = base.extend<TenantAdminFixtures & TestingApiFixtures>({
|
||||
tenantAdminCredentials: async ({}, use) => {
|
||||
if (!tenantAdminEmail || !tenantAdminPassword) {
|
||||
await use(null);
|
||||
return;
|
||||
}
|
||||
|
||||
await use({
|
||||
email: tenantAdminEmail,
|
||||
password: tenantAdminPassword,
|
||||
});
|
||||
},
|
||||
|
||||
signInTenantAdmin: async ({ page, tenantAdminCredentials }, use) => {
|
||||
if (!tenantAdminCredentials) {
|
||||
await use(async () => {
|
||||
throw new Error('Tenant admin credentials missing. Provide E2E_TENANT_EMAIL and E2E_TENANT_PASSWORD.');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await use(async () => {
|
||||
await performTenantSignIn(page, tenantAdminCredentials);
|
||||
});
|
||||
},
|
||||
|
||||
clearTestMailbox: async ({ request }, use) => {
|
||||
await use(async () => {
|
||||
await expectApiSuccess(request.delete('/api/_testing/mailbox'));
|
||||
});
|
||||
},
|
||||
|
||||
getTestMailbox: async ({ request }, use) => {
|
||||
await use(async () => {
|
||||
const response = await expectApiSuccess(request.get('/api/_testing/mailbox'));
|
||||
const json = await response.json();
|
||||
|
||||
return Array.isArray(json.data) ? (json.data as MailboxEntry[]) : [];
|
||||
});
|
||||
},
|
||||
|
||||
seedTestCoupons: async ({ request }, use) => {
|
||||
await use(async (definitions?: CouponSeedDefinition[]) => {
|
||||
const response = await expectApiSuccess(
|
||||
request.post('/api/_testing/coupons/seed', {
|
||||
data: definitions && definitions.length > 0 ? { coupons: definitions } : undefined,
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
return Array.isArray(json.data) ? json.data : [];
|
||||
});
|
||||
},
|
||||
|
||||
getLatestCheckoutSession: async ({ request }, use) => {
|
||||
await use(async (filters?: { email?: string; tenantId?: number; status?: string }) => {
|
||||
const response = await request.get('/api/_testing/checkout/sessions/latest', {
|
||||
params: {
|
||||
email: filters?.email,
|
||||
tenant_id: filters?.tenantId,
|
||||
status: filters?.status,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status() === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await expectApiSuccess(Promise.resolve(response));
|
||||
const json = await response.json();
|
||||
|
||||
return json.data as CheckoutSessionSummary;
|
||||
});
|
||||
},
|
||||
|
||||
simulatePaddleCompletion: async ({ request }, use) => {
|
||||
await use(async (sessionId: string, overrides?: Partial<PaddleSimulationOverrides>) => {
|
||||
await expectApiSuccess(
|
||||
request.post(`/api/_testing/checkout/sessions/${sessionId}/simulate-paddle`, {
|
||||
data: overrides,
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
fetchJoinToken: async ({ request }, use) => {
|
||||
await use(async ({ eventId, slug, ensureActive = true }: { eventId?: number; slug?: string; ensureActive?: boolean }) => {
|
||||
const response = await expectApiSuccess(
|
||||
request.get('/api/_testing/events/join-token', {
|
||||
params: {
|
||||
event_id: eventId,
|
||||
slug,
|
||||
ensure_active: ensureActive,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
return json.data as JoinTokenPayload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const expectFixture = expect;
|
||||
|
||||
async function performTenantSignIn(page: Page, credentials: TenantCredentials) {
|
||||
const token = await exchangeToken(page.request, credentials);
|
||||
|
||||
await page.addInitScript(({ stored }) => {
|
||||
localStorage.setItem('tenant_admin.token.v1', JSON.stringify(stored));
|
||||
sessionStorage.setItem('tenant_admin.token.session.v1', JSON.stringify(stored));
|
||||
}, { stored: token });
|
||||
|
||||
await page.goto('/event-admin');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
|
||||
type StoredTokenPayload = {
|
||||
accessToken: string;
|
||||
abilities: string[];
|
||||
issuedAt: number;
|
||||
};
|
||||
|
||||
async function exchangeToken(request: APIRequestContext, credentials: TenantCredentials): Promise<StoredTokenPayload> {
|
||||
const response = await request.post('/api/v1/tenant-auth/login', {
|
||||
data: {
|
||||
login: credentials.email,
|
||||
password: credentials.password,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Tenant PAT login failed: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
return {
|
||||
accessToken: body.token,
|
||||
abilities: Array.isArray(body.abilities) ? body.abilities : [],
|
||||
issuedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function expectApiSuccess(responsePromise: Promise<APIResponse>): Promise<APIResponse> {
|
||||
const response = await responsePromise;
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Test API request failed: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
12
tests/ui/purchase/coupon-setup.test.ts
Normal file
12
tests/ui/purchase/coupon-setup.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { test, expectFixture as expect } from '../helpers/test-fixtures';
|
||||
|
||||
test.describe('Coupon scaffolding', () => {
|
||||
test('default coupon presets are created via testing API', async ({ seedTestCoupons }) => {
|
||||
const seeded = await seedTestCoupons();
|
||||
|
||||
expect(seeded.length).toBeGreaterThanOrEqual(3);
|
||||
expect(seeded.map((coupon) => coupon.code)).toEqual(
|
||||
expect.arrayContaining(['PERCENT10', 'FLAT50', 'EXPIRED25'])
|
||||
);
|
||||
});
|
||||
});
|
||||
159
tests/ui/purchase/standard-package-checkout.test.ts
Normal file
159
tests/ui/purchase/standard-package-checkout.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { test, expectFixture as expect } from '../helpers/test-fixtures';
|
||||
|
||||
test.describe('Standard package checkout with Paddle completion', () => {
|
||||
test('registers, applies coupon, and reaches confirmation', async ({
|
||||
page,
|
||||
clearTestMailbox,
|
||||
getLatestCheckoutSession,
|
||||
simulatePaddleCompletion,
|
||||
getTestMailbox,
|
||||
}) => {
|
||||
await clearTestMailbox();
|
||||
|
||||
const unique = Date.now();
|
||||
const email = `checkout+${unique}@example.test`;
|
||||
const password = 'Password123!';
|
||||
const username = `playwright-${unique}`;
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.__openedWindows = [];
|
||||
const originalOpen = window.open;
|
||||
window.open = function (...args) {
|
||||
window.__openedWindows.push(args);
|
||||
return originalOpen?.apply(this, args) ?? null;
|
||||
};
|
||||
});
|
||||
|
||||
await page.route('https://cdn.paddle.com/paddle/v2/paddle.js', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/javascript',
|
||||
body: `
|
||||
window.__paddleEventCallback = null;
|
||||
window.__paddleInitOptions = null;
|
||||
window.__paddleCheckoutConfig = null;
|
||||
window.Paddle = {
|
||||
Environment: { set() {} },
|
||||
Initialize(options) {
|
||||
window.__paddleInitOptions = options;
|
||||
window.__paddleEventCallback = options?.eventCallback || null;
|
||||
},
|
||||
Checkout: {
|
||||
open(config) {
|
||||
window.__paddleCheckoutConfig = config;
|
||||
},
|
||||
},
|
||||
};
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
let paddleRequestPayload: Record<string, unknown> | null = null;
|
||||
await page.route('**/paddle/create-checkout', async (route) => {
|
||||
paddleRequestPayload = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
checkout_url: 'https://sandbox.paddle.test/checkout/abc123',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/de/packages');
|
||||
|
||||
const standardDetailsButton = page
|
||||
.getByRole('heading', { name: /^Standard$/ })
|
||||
.locator('..')
|
||||
.getByRole('button', { name: /Details/i })
|
||||
.first();
|
||||
|
||||
await expect(standardDetailsButton).toBeVisible();
|
||||
await standardDetailsButton.click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByRole('link', { name: /Jetzt bestellen|Order now/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/purchase-wizard/);
|
||||
await page.getByRole('button', { name: /^Weiter$/ }).first().click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Registrieren' })).toBeVisible();
|
||||
|
||||
await page.getByLabel(/Vorname/i).fill('Playwright');
|
||||
await page.getByLabel(/Nachname/i).fill('Tester');
|
||||
await page.getByLabel(/E-Mail/i).fill(email);
|
||||
await page.getByLabel(/Telefon/i).fill('+49123456789');
|
||||
await page.getByLabel(/Adresse/i).fill('Teststr. 1, 12345 Berlin');
|
||||
await page.getByLabel(/Username/i).fill(username);
|
||||
await page.getByLabel(/^Passwort$/i).fill(password);
|
||||
await page.getByLabel(/Passwort bestätigen/i).fill(password);
|
||||
await page.getByLabel(/Datenschutzerklärung/i).check();
|
||||
await page.getByRole('button', { name: /^Registrieren$/ }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Zahlung' })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder(/Gutscheincode/i).fill('PERCENT10');
|
||||
await page.getByRole('button', { name: /Gutschein anwenden|Apply coupon/i }).click();
|
||||
await expect(page.getByText(/Gutschein PERCENT10/i)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /Weiter mit Paddle|Continue with Paddle/i }).click();
|
||||
|
||||
await expect.poll(async () => page.evaluate(() => window.__paddleCheckoutConfig)).not.toBeNull();
|
||||
await expect.poll(async () => {
|
||||
return page.evaluate(() => window.__openedWindows?.length ?? 0);
|
||||
}).toBe(1);
|
||||
await expect.poll(async () => {
|
||||
return page.evaluate(() => window.__openedWindows?.[0]?.[0] ?? null);
|
||||
}).toContain('https://sandbox.paddle.test/checkout/abc123');
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__paddleEventCallback?.({ name: 'checkout.completed' });
|
||||
});
|
||||
|
||||
let session = null;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
session = await getLatestCheckoutSession({ email });
|
||||
if (session) {
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
expect(session).not.toBeNull();
|
||||
await simulatePaddleCompletion(session!.id);
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const refreshed = await getLatestCheckoutSession({ email });
|
||||
if (refreshed?.status === 'completed') {
|
||||
session = refreshed;
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
expect(session?.status).toBe('completed');
|
||||
|
||||
await expect(page.getByRole('button', { name: /^Weiter$/ })).toBeEnabled();
|
||||
await page.getByRole('button', { name: /^Weiter$/ }).last().click();
|
||||
|
||||
await expect(page.getByText(/Marketing-Dashboard/)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Zum Admin-Bereich|To Admin Area/i })
|
||||
).toBeVisible();
|
||||
|
||||
expect(paddleRequestPayload).not.toBeNull();
|
||||
expect(paddleRequestPayload?.['coupon_code']).toBe('PERCENT10');
|
||||
|
||||
const messages = await getTestMailbox();
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__openedWindows?: unknown[];
|
||||
__paddleEventCallback?: ((event: { name: string }) => void) | null;
|
||||
__paddleInitOptions?: unknown;
|
||||
__paddleCheckoutConfig?: unknown;
|
||||
}
|
||||
}
|
||||
49
tests/ui/purchase/standard-package-coupon.test.ts
Normal file
49
tests/ui/purchase/standard-package-coupon.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { test, expectFixture as expect } from '../helpers/test-fixtures';
|
||||
|
||||
test.describe('Standard package checkout with coupons', () => {
|
||||
test('applies seeded coupon and shows discount summary', async ({
|
||||
page,
|
||||
tenantAdminCredentials,
|
||||
seedTestCoupons,
|
||||
}) => {
|
||||
test.skip(!tenantAdminCredentials, 'Tenant admin credentials required via E2E_TENANT_EMAIL/PASSWORD');
|
||||
|
||||
await seedTestCoupons();
|
||||
|
||||
await page.goto('/de/packages');
|
||||
|
||||
const detailsButtons = page.getByRole('button', {
|
||||
name: /Details ansehen|Details anzeigen|View details/i,
|
||||
});
|
||||
await expect(detailsButtons.first()).toBeVisible();
|
||||
|
||||
await detailsButtons.nth(1).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading', { name: /Standard/i })).toBeVisible();
|
||||
|
||||
await dialog.getByRole('link', { name: /Jetzt bestellen|Order now|Jetzt buchen/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/purchase-wizard\/\d+/);
|
||||
|
||||
await page.getByRole('button', { name: /^Weiter$/ }).first().click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Registrieren/i })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /^Anmelden$/ }).first().click();
|
||||
|
||||
await page.fill('input[name="identifier"]', tenantAdminCredentials.email);
|
||||
await page.fill('input[name="password"]', tenantAdminCredentials.password);
|
||||
await page.getByRole('button', { name: /^Anmelden$/ }).last().click();
|
||||
|
||||
await expect(page.getByPlaceholder(/Gutscheincode/i)).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder(/Gutscheincode/i).fill('PERCENT10');
|
||||
await page.getByRole('button', { name: /Gutschein anwenden|Apply coupon/i }).click();
|
||||
|
||||
await expect(page.getByText(/Gutschein PERCENT10 aktiviert/i)).toBeVisible();
|
||||
await expect(page.getByText(/Rabatt|Discount/i)).toBeVisible();
|
||||
await expect(page.getByText(/Total|Gesamt/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user