Add photobooth connect codes and uploader pipeline
This commit is contained in:
100
tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
Normal file
100
tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Photobooth;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPhotoboothSetting;
|
||||
use App\Models\PhotoboothConnectCode;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\Feature\Tenant\TenantTestCase;
|
||||
|
||||
class PhotoboothConnectCodeTest extends TenantTestCase
|
||||
{
|
||||
#[Test]
|
||||
public function it_creates_a_connect_code_for_sparkbooth(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'connect-code-event',
|
||||
]);
|
||||
|
||||
EventPhotoboothSetting::factory()
|
||||
->for($event)
|
||||
->activeSparkbooth()
|
||||
->create([
|
||||
'username' => 'pbconnect',
|
||||
'password' => 'SECRET12',
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/connect-codes");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.code', fn ($value) => is_string($value) && strlen($value) === 6)
|
||||
->assertJsonPath('data.expires_at', fn ($value) => is_string($value) && $value !== '');
|
||||
|
||||
$this->assertDatabaseCount('photobooth_connect_codes', 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_redeems_a_connect_code_and_returns_upload_credentials(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'connect-code-redeem',
|
||||
]);
|
||||
|
||||
EventPhotoboothSetting::factory()
|
||||
->for($event)
|
||||
->activeSparkbooth()
|
||||
->create([
|
||||
'username' => 'pbconnect',
|
||||
'password' => 'SECRET12',
|
||||
]);
|
||||
|
||||
$codeResponse = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/connect-codes");
|
||||
$codeResponse->assertOk();
|
||||
|
||||
$code = (string) $codeResponse->json('data.code');
|
||||
|
||||
$redeem = $this->postJson('/api/v1/photobooth/connect', [
|
||||
'code' => $code,
|
||||
]);
|
||||
|
||||
$redeem->assertOk()
|
||||
->assertJsonPath('data.upload_url', fn ($value) => is_string($value) && $value !== '')
|
||||
->assertJsonPath('data.username', 'pbconnect')
|
||||
->assertJsonPath('data.password', 'SECRET12');
|
||||
|
||||
$this->assertDatabaseHas('photobooth_connect_codes', [
|
||||
'event_id' => $event->id,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_expired_connect_codes(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'connect-code-expired',
|
||||
]);
|
||||
|
||||
EventPhotoboothSetting::factory()
|
||||
->for($event)
|
||||
->activeSparkbooth()
|
||||
->create([
|
||||
'username' => 'pbconnect',
|
||||
'password' => 'SECRET12',
|
||||
]);
|
||||
|
||||
$code = '123456';
|
||||
|
||||
PhotoboothConnectCode::query()->create([
|
||||
'event_id' => $event->id,
|
||||
'code_hash' => hash('sha256', $code),
|
||||
'expires_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/photobooth/connect', [
|
||||
'code' => $code,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
}
|
||||
26
tests/Feature/Tenant/PhotoModerationControllerTest.php
Normal file
26
tests/Feature/Tenant/PhotoModerationControllerTest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
|
||||
class PhotoModerationControllerTest extends TenantTestCase
|
||||
{
|
||||
public function test_tenant_admin_can_approve_photo(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'moderation-event',
|
||||
]);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('PATCH', "/api/v1/tenant/events/{$event->slug}/photos/{$photo->id}", [
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertSame('approved', $photo->refresh()->status);
|
||||
}
|
||||
}
|
||||
46
tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php
Normal file
46
tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantCheckoutSessionStatusTest extends TenantTestCase
|
||||
{
|
||||
public function test_tenant_can_fetch_checkout_session_status(): void
|
||||
{
|
||||
$package = Package::factory()->create([
|
||||
'price' => 129,
|
||||
]);
|
||||
|
||||
$session = CheckoutSession::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'user_id' => $this->tenantUser->id,
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'status' => CheckoutSession::STATUS_FAILED,
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'provider_metadata' => [
|
||||
'paddle_checkout_url' => 'https://checkout.paddle.test/checkout/123',
|
||||
],
|
||||
'status_history' => [
|
||||
[
|
||||
'status' => CheckoutSession::STATUS_FAILED,
|
||||
'reason' => 'paddle_failed',
|
||||
'at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest(
|
||||
'GET',
|
||||
"/api/v1/tenant/packages/checkout-session/{$session->id}/status"
|
||||
);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', CheckoutSession::STATUS_FAILED)
|
||||
->assertJsonPath('reason', 'paddle_failed')
|
||||
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123');
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,10 @@ class TenantPaddleCheckoutTest extends TenantTestCase
|
||||
return $tenant->is($this->tenant)
|
||||
&& $payloadPackage->is($package)
|
||||
&& array_key_exists('success_url', $payload)
|
||||
&& array_key_exists('return_url', $payload);
|
||||
&& array_key_exists('return_url', $payload)
|
||||
&& array_key_exists('metadata', $payload)
|
||||
&& is_array($payload['metadata'])
|
||||
&& ! empty($payload['metadata']['checkout_session_id']);
|
||||
})
|
||||
->andReturn([
|
||||
'checkout_url' => 'https://checkout.paddle.test/checkout/123',
|
||||
@@ -42,7 +45,8 @@ class TenantPaddleCheckoutTest extends TenantTestCase
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123');
|
||||
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123')
|
||||
->assertJsonStructure(['checkout_session_id']);
|
||||
}
|
||||
|
||||
public function test_paddle_checkout_requires_paddle_price_id(): void
|
||||
|
||||
67
tests/Unit/RateLimitConfigTest.php
Normal file
67
tests/Unit/RateLimitConfigTest.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\GuestPolicySetting;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RateLimitConfigTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_tenant_api_rate_limiter_allows_higher_throughput(): void
|
||||
{
|
||||
$request = Request::create('/api/v1/tenant/events', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => '10.0.0.1',
|
||||
]);
|
||||
$request->attributes->set('tenant_id', 42);
|
||||
|
||||
$limiter = RateLimiter::limiter('tenant-api');
|
||||
|
||||
$this->assertNotNull($limiter);
|
||||
|
||||
$limit = $limiter($request);
|
||||
|
||||
$this->assertInstanceOf(Limit::class, $limit);
|
||||
$this->assertSame(600, $limit->maxAttempts);
|
||||
}
|
||||
|
||||
public function test_guest_api_rate_limiter_allows_higher_throughput(): void
|
||||
{
|
||||
$request = Request::create('/api/v1/events/sample', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => '10.0.0.2',
|
||||
]);
|
||||
|
||||
$limiter = RateLimiter::limiter('guest-api');
|
||||
|
||||
$this->assertNotNull($limiter);
|
||||
|
||||
$limit = $limiter($request);
|
||||
|
||||
$this->assertInstanceOf(Limit::class, $limit);
|
||||
$this->assertSame(300, $limit->maxAttempts);
|
||||
}
|
||||
|
||||
public function test_guest_policy_defaults_follow_join_token_limits(): void
|
||||
{
|
||||
$accessLimit = 300;
|
||||
$downloadLimit = 120;
|
||||
|
||||
config([
|
||||
'join_tokens.access_limit' => $accessLimit,
|
||||
'join_tokens.download_limit' => $downloadLimit,
|
||||
]);
|
||||
|
||||
GuestPolicySetting::query()->delete();
|
||||
GuestPolicySetting::flushCache();
|
||||
|
||||
$settings = GuestPolicySetting::current();
|
||||
|
||||
$this->assertSame($accessLimit, $settings->join_token_access_limit);
|
||||
$this->assertSame($downloadLimit, $settings->join_token_download_limit);
|
||||
}
|
||||
}
|
||||
144
tests/Unit/SendPhotoUploadedNotificationTest.php
Normal file
144
tests/Unit/SendPhotoUploadedNotificationTest.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Enums\GuestNotificationType;
|
||||
use App\Events\GuestPhotoUploaded;
|
||||
use App\Listeners\GuestNotifications\SendPhotoUploadedNotification;
|
||||
use App\Models\Event;
|
||||
use App\Models\GuestNotification;
|
||||
use App\Models\GuestNotificationReceipt;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SendPhotoUploadedNotificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_dedupes_recent_photo_activity_notifications(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-01-12 13:48:01');
|
||||
|
||||
$event = Event::factory()->create();
|
||||
$listener = $this->app->make(SendPhotoUploadedNotification::class);
|
||||
|
||||
GuestNotification::factory()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'type' => GuestNotificationType::PHOTO_ACTIVITY,
|
||||
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
|
||||
'payload' => [
|
||||
'photo_id' => 123,
|
||||
'photo_ids' => [123],
|
||||
'count' => 1,
|
||||
],
|
||||
'created_at' => now()->subSeconds(5),
|
||||
'updated_at' => now()->subSeconds(5),
|
||||
]);
|
||||
|
||||
$listener->handle(new GuestPhotoUploaded(
|
||||
$event,
|
||||
123,
|
||||
'device-123',
|
||||
'Fotospiel-Test'
|
||||
));
|
||||
|
||||
$notification = GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->first();
|
||||
|
||||
$this->assertSame(1, GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->count());
|
||||
$this->assertSame(1, (int) ($notification?->payload['count'] ?? 0));
|
||||
}
|
||||
|
||||
public function test_it_groups_recent_photo_activity_notifications(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-01-12 13:48:01');
|
||||
|
||||
$event = Event::factory()->create();
|
||||
$listener = $this->app->make(SendPhotoUploadedNotification::class);
|
||||
|
||||
GuestNotification::factory()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'type' => GuestNotificationType::PHOTO_ACTIVITY,
|
||||
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
|
||||
'payload' => [
|
||||
'photo_id' => 122,
|
||||
'photo_ids' => [122],
|
||||
'count' => 1,
|
||||
],
|
||||
'created_at' => now()->subMinutes(5),
|
||||
'updated_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$listener->handle(new GuestPhotoUploaded(
|
||||
$event,
|
||||
123,
|
||||
'device-123',
|
||||
'Fotospiel-Test'
|
||||
));
|
||||
|
||||
$this->assertSame(1, GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->count());
|
||||
|
||||
$notification = GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->first();
|
||||
|
||||
$this->assertSame('Es gibt 2 neue Fotos!', $notification?->title);
|
||||
$this->assertSame(2, (int) ($notification?->payload['count'] ?? 0));
|
||||
|
||||
$this->assertSame(1, GuestNotificationReceipt::query()
|
||||
->where('guest_identifier', 'device-123')
|
||||
->where('status', 'read')
|
||||
->count());
|
||||
}
|
||||
|
||||
public function test_it_creates_notification_outside_group_window(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-01-12 13:48:01');
|
||||
|
||||
$event = Event::factory()->create();
|
||||
$listener = $this->app->make(SendPhotoUploadedNotification::class);
|
||||
|
||||
GuestNotification::factory()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'type' => GuestNotificationType::PHOTO_ACTIVITY,
|
||||
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
|
||||
'payload' => [
|
||||
'photo_id' => 122,
|
||||
'photo_ids' => [122],
|
||||
'count' => 1,
|
||||
],
|
||||
'created_at' => now()->subMinutes(20),
|
||||
'updated_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
$listener->handle(new GuestPhotoUploaded(
|
||||
$event,
|
||||
123,
|
||||
'device-123',
|
||||
'Fotospiel-Test'
|
||||
));
|
||||
|
||||
$this->assertSame(2, GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->count());
|
||||
|
||||
$this->assertSame(1, GuestNotificationReceipt::query()
|
||||
->where('guest_identifier', 'device-123')
|
||||
->where('status', 'read')
|
||||
->count());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user