fixed event join token handling in the event admin. created new seeders with new tenants and package purchases. added new playwright test scenarios.
This commit is contained in:
@@ -2,17 +2,17 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Package;
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\User;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventControllerTest extends TestCase
|
||||
{
|
||||
@@ -46,6 +46,10 @@ class EventControllerTest extends TestCase
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('event_join_tokens', [
|
||||
'event_id' => $event->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('package_purchases', [
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
|
||||
115
tests/Feature/Tenant/DashboardSummaryTest.php
Normal file
115
tests/Feature/Tenant/DashboardSummaryTest.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Http\Controllers\Api\Tenant\DashboardController;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventType;
|
||||
use App\Models\Package;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Task;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DashboardSummaryTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_returns_dashboard_metrics_for_tenant(): void
|
||||
{
|
||||
app()->setLocale('de');
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'event_credits_balance' => 5,
|
||||
]);
|
||||
|
||||
$eventType = EventType::factory()->create();
|
||||
|
||||
$eventWithTasks = Event::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_type_id' => $eventType->id,
|
||||
'status' => 'published',
|
||||
'is_active' => true,
|
||||
'date' => now()->addDays(3),
|
||||
]);
|
||||
|
||||
$eventWithoutTasks = Event::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_type_id' => $eventType->id,
|
||||
'status' => 'draft',
|
||||
'is_active' => false,
|
||||
'date' => now()->addDays(10),
|
||||
]);
|
||||
|
||||
$task = Task::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_completed' => true,
|
||||
]);
|
||||
|
||||
$eventWithTasks->tasks()->attach($task->id);
|
||||
|
||||
Photo::factory()->create([
|
||||
'event_id' => $eventWithTasks->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'created_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
Photo::factory()->create([
|
||||
'event_id' => $eventWithTasks->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'created_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$package = Package::factory()
|
||||
->reseller()
|
||||
->create([
|
||||
'name' => 'Standard',
|
||||
'name_translations' => ['de' => 'Standard', 'en' => 'Standard'],
|
||||
'price' => 59,
|
||||
]);
|
||||
|
||||
$activePackage = TenantPackage::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'expires_at' => now()->addMonth(),
|
||||
'used_events' => 1,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$controller = new DashboardController;
|
||||
$request = Request::create('/api/v1/tenant/dashboard', 'GET');
|
||||
$request->attributes->set('decoded_token', ['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $controller($request);
|
||||
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
|
||||
$payload = $response->getData(true);
|
||||
|
||||
$this->assertSame(1, Arr::get($payload, 'active_events'));
|
||||
$this->assertSame(1, Arr::get($payload, 'new_photos'));
|
||||
$this->assertSame(50, Arr::get($payload, 'task_progress'));
|
||||
$this->assertSame(5, Arr::get($payload, 'credit_balance'));
|
||||
$this->assertSame(2, Arr::get($payload, 'upcoming_events'));
|
||||
|
||||
$activePackagePayload = Arr::get($payload, 'active_package');
|
||||
|
||||
$this->assertIsArray($activePackagePayload);
|
||||
$this->assertSame('Standard', Arr::get($activePackagePayload, 'name'));
|
||||
$this->assertSame($activePackage->remaining_events, Arr::get($activePackagePayload, 'remaining_events'));
|
||||
|
||||
$this->assertSame(
|
||||
$activePackage->expires_at->toIso8601String(),
|
||||
Arr::get($payload, 'active_package.expires_at')
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
$tenant->event_credits_balance,
|
||||
Arr::get($payload, 'credit_balance')
|
||||
);
|
||||
}
|
||||
}
|
||||
138
tests/Feature/Tenant/EventListTest.php
Normal file
138
tests/Feature/Tenant/EventListTest.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class EventListTest extends TenantTestCase
|
||||
{
|
||||
public function test_index_returns_events_without_packages(): void
|
||||
{
|
||||
$event = Event::factory()
|
||||
->for($this->tenant)
|
||||
->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Package-less Event',
|
||||
'slug' => 'package-less-event',
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/events');
|
||||
|
||||
$response->assertOk();
|
||||
$payload = $response->json('data');
|
||||
|
||||
$this->assertNotEmpty($payload, 'Expected at least one event in the response');
|
||||
|
||||
$matchingEvent = collect($payload)->firstWhere('id', $event->id);
|
||||
|
||||
$this->assertNotNull($matchingEvent, 'Created event should be present in response payload');
|
||||
$this->assertNull($matchingEvent['package'], 'Events without package should return null package data');
|
||||
}
|
||||
|
||||
public function test_index_includes_package_details_when_available(): void
|
||||
{
|
||||
$package = Package::factory()->create([
|
||||
'type' => 'endcustomer',
|
||||
'name' => 'Standard',
|
||||
'name_translations' => [
|
||||
'de' => 'Standard',
|
||||
'en' => 'Standard',
|
||||
],
|
||||
'price' => 59,
|
||||
'gallery_days' => 45,
|
||||
]);
|
||||
|
||||
$event = Event::factory()
|
||||
->for($this->tenant)
|
||||
->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Packaged Event',
|
||||
'slug' => 'packaged-event',
|
||||
]);
|
||||
|
||||
EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => Carbon::now()->subDay(),
|
||||
'gallery_expires_at' => Carbon::now()->addDays($package->gallery_days ?? 30),
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/events');
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$payload = collect($response->json('data'));
|
||||
$matchingEvent = $payload->firstWhere('id', $event->id);
|
||||
|
||||
$this->assertNotNull($matchingEvent, 'Packaged event should be present in response payload');
|
||||
|
||||
$this->assertIsArray($matchingEvent['package']);
|
||||
$this->assertSame($package->id, $matchingEvent['package']['id']);
|
||||
$this->assertSame('Standard', $matchingEvent['package']['name']);
|
||||
$this->assertSame('59.00', $matchingEvent['package']['price']);
|
||||
}
|
||||
|
||||
public function test_index_scopes_events_to_authenticated_tenant(): void
|
||||
{
|
||||
$foreignTenant = Tenant::factory()->create();
|
||||
|
||||
Event::factory()->for($foreignTenant)->create([
|
||||
'name' => 'Foreign Event',
|
||||
'slug' => 'foreign-event',
|
||||
]);
|
||||
|
||||
$ownEvent = Event::factory()->for($this->tenant)->create([
|
||||
'name' => 'Own Event',
|
||||
'slug' => 'own-event',
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/events');
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$payload = collect($response->json('data'));
|
||||
|
||||
$this->assertTrue($payload->pluck('id')->contains($ownEvent->id), 'Authenticated tenant should see own event.');
|
||||
$this->assertFalse($payload->pluck('slug')->contains('foreign-event'), 'Events from other tenants must be filtered out.');
|
||||
}
|
||||
|
||||
public function test_index_handles_event_package_without_package_model(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'name' => 'Legacy Event',
|
||||
'slug' => 'legacy-event',
|
||||
]);
|
||||
|
||||
$package = Package::factory()->create([
|
||||
'type' => 'endcustomer',
|
||||
'price' => 49,
|
||||
]);
|
||||
|
||||
EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => Carbon::now()->subDays(5),
|
||||
'gallery_expires_at' => Carbon::now()->addDays(15),
|
||||
'used_photos' => 10,
|
||||
'used_guests' => 25,
|
||||
]);
|
||||
|
||||
$package->delete();
|
||||
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/events');
|
||||
$response->assertOk();
|
||||
|
||||
$matchingEvent = collect($response->json('data'))->firstWhere('id', $event->id);
|
||||
|
||||
$this->assertNotNull($matchingEvent, 'Event should still be returned even if package record is missing.');
|
||||
$this->assertNull($matchingEvent['package'], 'Package payload should be null when relation cannot be resolved.');
|
||||
}
|
||||
}
|
||||
86
tests/Feature/Tenant/EventManagementTest.php
Normal file
86
tests/Feature/Tenant/EventManagementTest.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventType;
|
||||
use App\Models\Package;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class EventManagementTest extends TenantTestCase
|
||||
{
|
||||
public function test_event_types_endpoint_returns_translated_types(): void
|
||||
{
|
||||
$types = EventType::factory()->count(2)->create([
|
||||
'name' => [
|
||||
'de' => 'Feier',
|
||||
'en' => 'Celebration',
|
||||
],
|
||||
'icon' => 'party',
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/event-types');
|
||||
|
||||
$response->assertOk();
|
||||
$payload = $response->json('data');
|
||||
|
||||
$this->assertCount(2, $payload);
|
||||
$first = collect($payload)->firstWhere('id', $types->first()->id);
|
||||
|
||||
$this->assertNotNull($first, 'Expected event type to be present');
|
||||
$this->assertSame('Feier', $first['name']);
|
||||
$this->assertArrayHasKey('slug', $first);
|
||||
$this->assertArrayHasKey('name_translations', $first);
|
||||
$this->assertArrayHasKey('icon', $first);
|
||||
$this->assertArrayHasKey('settings', $first);
|
||||
}
|
||||
|
||||
public function test_event_can_be_created_with_event_type_and_date(): void
|
||||
{
|
||||
$eventType = EventType::factory()->create([
|
||||
'name' => [
|
||||
'de' => 'Hochzeit',
|
||||
'en' => 'Wedding',
|
||||
],
|
||||
'icon' => 'ring',
|
||||
]);
|
||||
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'price' => 79.90,
|
||||
]);
|
||||
|
||||
TenantPackage::factory()
|
||||
->for($this->tenant)
|
||||
->for($package)
|
||||
->create([
|
||||
'used_events' => 0,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$this->tenant->update([
|
||||
'event_credits_balance' => 1,
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'name' => 'Launch Event',
|
||||
'slug' => 'launch-event',
|
||||
'event_type_id' => $eventType->id,
|
||||
'event_date' => Carbon::now()->addDays(10)->toDateString(),
|
||||
'status' => 'draft',
|
||||
];
|
||||
|
||||
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', $payload);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$response->assertJsonPath('data.slug', 'launch-event');
|
||||
$response->assertJsonPath('data.event_type_id', $eventType->id);
|
||||
|
||||
$this->assertDatabaseHas(Event::class, [
|
||||
'slug' => 'launch-event',
|
||||
'event_type_id' => $eventType->id,
|
||||
'tenant_id' => $this->tenant->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
39
tests/e2e/event-admin-dashboard.test.ts
Normal file
39
tests/e2e/event-admin-dashboard.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expectFixture as expect } from './utils/test-fixtures';
|
||||
|
||||
test.describe('Tenant Admin – core flows', () => {
|
||||
test('dashboard shows key sections for seeded tenant', async ({ signInTenantAdmin, page }) => {
|
||||
await signInTenantAdmin();
|
||||
|
||||
await expect(page).toHaveURL(/\/event-admin(\/welcome)?/);
|
||||
|
||||
if (page.url().includes('/event-admin/welcome')) {
|
||||
await page.getByRole('button', { name: /Direkt zum Dashboard/i }).click();
|
||||
}
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Hallo/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Neues Event/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Guided Setup/i })).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Hallo Lumen Moments!/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Neues Event/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Guided Setup/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('events overview lists published and draft events', async ({ signInTenantAdmin, page }) => {
|
||||
await signInTenantAdmin();
|
||||
await page.goto('/event-admin/events');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Deine Events/i })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole('button', { name: /Neues Event/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('billing page lists the active package and history', async ({ signInTenantAdmin, page }) => {
|
||||
await signInTenantAdmin();
|
||||
await page.goto('/event-admin/billing');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Pakete & Abrechnung/i })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole('heading', { name: /Paketübersicht/i })).toBeVisible();
|
||||
await expect(page.getByText(/Paket-Historie/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { test as base, expect, Page } from '@playwright/test';
|
||||
import 'dotenv/config';
|
||||
import { test as base, expect, Page, APIRequestContext } from '@playwright/test';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
|
||||
export type TenantCredentials = {
|
||||
email: string;
|
||||
@@ -10,8 +12,8 @@ export type TenantAdminFixtures = {
|
||||
signInTenantAdmin: () => Promise<void>;
|
||||
};
|
||||
|
||||
const tenantAdminEmail = process.env.E2E_TENANT_EMAIL;
|
||||
const tenantAdminPassword = process.env.E2E_TENANT_PASSWORD;
|
||||
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) => {
|
||||
@@ -42,10 +44,93 @@ export const test = base.extend<TenantAdminFixtures>({
|
||||
|
||||
export const expectFixture = expect;
|
||||
|
||||
async function performTenantSignIn(page: Page, credentials: TenantCredentials) {
|
||||
await page.goto('/event-admin/login');
|
||||
await page.fill('input[name="email"]', credentials.email);
|
||||
await page.fill('input[name="password"]', credentials.password);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/\/event-admin(\/welcome)?/);
|
||||
const clientId = process.env.VITE_OAUTH_CLIENT_ID ?? 'tenant-admin-app';
|
||||
const redirectUri = new URL('/event-admin/auth/callback', process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:8000').toString();
|
||||
const scopes = (process.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
|
||||
|
||||
async function performTenantSignIn(page: Page, _credentials: TenantCredentials) {
|
||||
const tokens = await exchangeTokens(page.request);
|
||||
|
||||
await page.addInitScript(({ stored }) => {
|
||||
localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(stored));
|
||||
}, { stored: tokens });
|
||||
|
||||
await page.goto('/event-admin');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
|
||||
type StoredTokenPayload = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
async function exchangeTokens(request: APIRequestContext): Promise<StoredTokenPayload> {
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = generateCodeChallenge(verifier);
|
||||
const state = randomBytes(12).toString('hex');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: scopes,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
const authResponse = await request.get(`/api/v1/oauth/authorize?${params.toString()}`, {
|
||||
maxRedirects: 0,
|
||||
headers: {
|
||||
'x-playwright-test': 'tenant-admin',
|
||||
},
|
||||
});
|
||||
|
||||
if (authResponse.status() >= 400) {
|
||||
throw new Error(`OAuth authorize failed: ${authResponse.status()} ${await authResponse.text()}`);
|
||||
}
|
||||
|
||||
const location = authResponse.headers()['location'];
|
||||
if (!location) {
|
||||
throw new Error('OAuth authorize did not return redirect location');
|
||||
}
|
||||
|
||||
const code = new URL(location).searchParams.get('code');
|
||||
if (!code) {
|
||||
throw new Error('OAuth authorize response missing code');
|
||||
}
|
||||
|
||||
const tokenResponse = await request.post('/api/v1/oauth/token', {
|
||||
form: {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok()) {
|
||||
throw new Error(`OAuth token exchange failed: ${tokenResponse.status()} ${await tokenResponse.text()}`);
|
||||
}
|
||||
|
||||
const body = await tokenResponse.json();
|
||||
const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600;
|
||||
|
||||
return {
|
||||
accessToken: body.access_token,
|
||||
refreshToken: body.refresh_token,
|
||||
expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000,
|
||||
scope: body.scope,
|
||||
};
|
||||
}
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function generateCodeChallenge(verifier: string): string {
|
||||
return createHash('sha256').update(verifier).digest('base64url');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user