tokenService = app(EventJoinTokenService::class); } protected function tearDown(): void { Mockery::close(); parent::tearDown(); } private function createPublishedEvent(): Event { return Event::factory()->create([ 'status' => 'published', ]); } private function seedGuestUploadPrerequisites(Event $event): void { $package = Package::factory()->endcustomer()->create([ 'max_photos' => 100, ]); EventPackage::create([ 'event_id' => $event->id, 'package_id' => $package->id, 'purchased_price' => $package->price, 'purchased_at' => now(), 'used_photos' => 0, 'used_guests' => 0, ]); MediaStorageTarget::create([ 'key' => 'public', 'name' => 'Public', 'driver' => 'local', 'is_hot' => true, 'is_default' => true, 'is_active' => true, ]); Mockery::mock('alias:App\Support\ImageHelper') ->shouldReceive('makeThumbnailOnDisk') ->andReturn("events/{$event->id}/photos/thumbs/generated_thumb.jpg") ->shouldReceive('copyWithWatermark') ->andReturnNull(); } public function test_guest_can_access_stats_using_join_token(): void { $event = $this->createPublishedEvent(); Photo::factory()->count(3)->create([ 'event_id' => $event->id, 'guest_name' => 'device-stats', ]); $token = $this->tokenService->createToken($event); $response = $this->getJson("/api/v1/events/{$token->token}/stats"); $response->assertOk() ->assertJsonStructure([ 'online_guests', 'tasks_solved', 'latest_photo_at', ]); } public function test_guest_can_upload_photo_with_join_token(): void { Storage::fake('public'); $event = $this->createPublishedEvent(); $this->seedGuestUploadPrerequisites($event); $token = $this->tokenService->createToken($event); $file = UploadedFile::fake()->image('example.jpg', 1200, 800); $response = $this->withHeader('X-Device-Id', 'token-device') ->postJson("/api/v1/events/{$token->token}/upload", [ 'photo' => $file, 'live_show_opt_in' => true, ]); $response->assertCreated() ->assertJsonStructure(['id', 'status', 'message']); $this->assertDatabaseCount('photos', 1); $saved = Photo::first(); $this->assertNotNull($saved); $this->assertEquals($event->id, $saved->event_id); $this->assertSame(PhotoLiveStatus::PENDING, $saved->live_status); $this->assertNotNull($saved->live_submitted_at); $storedPath = $saved->file_path ? ltrim(str_replace('/storage/', '', $saved->file_path), '/') : null; if ($storedPath) { $this->assertTrue( Storage::disk('public')->exists($storedPath), sprintf('Uploaded file [%s] was not stored on the public disk.', $storedPath) ); } } public function test_force_review_uploader_overrides_immediate_visibility(): void { Storage::fake('public'); $event = $this->createPublishedEvent(); $event->update([ 'settings' => [ 'guest_upload_visibility' => 'immediate', 'control_room' => [ 'auto_add_approved_to_live' => true, 'force_review_uploaders' => [ [ 'device_id' => 'blocked-device', 'label' => 'Blocked', ], ], ], ], ]); $this->seedGuestUploadPrerequisites($event); $token = $this->tokenService->createToken($event); $file = UploadedFile::fake()->image('example.jpg', 1200, 800); $response = $this->withHeader('X-Device-Id', 'blocked-device') ->postJson("/api/v1/events/{$token->token}/upload", [ 'photo' => $file, 'live_show_opt_in' => true, ]); $response->assertCreated() ->assertJsonPath('status', 'pending'); $photo = Photo::first(); $this->assertNotNull($photo); $this->assertSame('pending', $photo->status); $this->assertSame(PhotoLiveStatus::REJECTED, $photo->live_status); $this->assertSame('blocked-device', $photo->created_by_device_id); } public function test_trusted_uploader_auto_approves_and_adds_to_live_show(): void { Storage::fake('public'); $event = $this->createPublishedEvent(); $event->update([ 'settings' => [ 'guest_upload_visibility' => 'review', 'control_room' => [ 'auto_add_approved_to_live' => true, 'trusted_uploaders' => [ [ 'device_id' => 'trusted-device', 'label' => 'VIP', ], ], ], ], ]); $this->seedGuestUploadPrerequisites($event); $token = $this->tokenService->createToken($event); $file = UploadedFile::fake()->image('example.jpg', 1200, 800); $response = $this->withHeader('X-Device-Id', 'trusted-device') ->postJson("/api/v1/events/{$token->token}/upload", [ 'photo' => $file, ]); $response->assertCreated() ->assertJsonPath('status', 'approved'); $photo = Photo::first(); $this->assertNotNull($photo); $this->assertSame('approved', $photo->status); $this->assertSame(PhotoLiveStatus::APPROVED, $photo->live_status); $this->assertNotNull($photo->live_submitted_at); $this->assertNotNull($photo->live_approved_at); } public function test_guest_event_response_includes_demo_read_only_flag(): void { $event = $this->createPublishedEvent(); $token = $this->tokenService->createToken($event, [ 'metadata' => ['demo_read_only' => true], ]); $response = $this->getJson("/api/v1/events/{$token->token}"); $response->assertOk() ->assertJsonPath('demo_read_only', true); } public function test_guest_limit_counts_unique_devices(): void { $event = $this->createPublishedEvent(); $package = Package::factory()->endcustomer()->create([ 'max_guests' => 2, ]); $eventPackage = EventPackage::create([ 'event_id' => $event->id, 'package_id' => $package->id, 'purchased_price' => $package->price, 'purchased_at' => now(), 'used_photos' => 0, 'used_guests' => 0, 'gallery_expires_at' => now()->addDays(7), ]); $token = $this->tokenService->createToken($event); $this->withHeader('X-Device-Id', 'device-guest') ->getJson("/api/v1/events/{$token->token}") ->assertOk(); $this->assertSame(1, $eventPackage->refresh()->used_guests); $this->withHeader('X-Device-Id', 'device-guest') ->getJson("/api/v1/events/{$token->token}") ->assertOk(); $this->assertSame(1, $eventPackage->refresh()->used_guests); $this->withHeader('X-Device-Id', 'device-guest-2') ->getJson("/api/v1/events/{$token->token}") ->assertOk(); $this->assertSame(2, $eventPackage->refresh()->used_guests); } public function test_guest_event_response_includes_live_show_settings(): void { $event = $this->createPublishedEvent(); $event->update([ 'settings' => [ 'live_show' => [ 'moderation_mode' => 'manual', ], ], ]); $token = $this->tokenService->createToken($event); $response = $this->getJson("/api/v1/events/{$token->token}"); $response->assertOk() ->assertJsonPath('live_show.moderation_mode', 'manual'); } public function test_guest_join_blocks_new_devices_after_grace_limit(): void { $event = $this->createPublishedEvent(); $package = Package::factory()->endcustomer()->create([ 'max_guests' => 1, ]); EventPackage::create([ 'event_id' => $event->id, 'package_id' => $package->id, 'purchased_price' => $package->price, 'purchased_at' => now(), 'used_photos' => 0, 'used_guests' => 11, 'gallery_expires_at' => now()->addDays(7), ]); $token = $this->tokenService->createToken($event); $this->withHeader('X-Device-Id', 'new-device') ->getJson("/api/v1/events/{$token->token}") ->assertStatus(402) ->assertJsonPath('error.code', 'guest_limit_exceeded'); EventJoinTokenEvent::create([ 'event_join_token_id' => $token->id, 'event_id' => $event->id, 'tenant_id' => $event->tenant_id, 'event_type' => 'access_granted', 'device_id' => 'known-device', 'occurred_at' => now(), ]); $this->withHeader('X-Device-Id', 'known-device') ->getJson("/api/v1/events/{$token->token}") ->assertOk(); } public function test_guest_cannot_upload_photo_with_demo_token(): void { Storage::fake('public'); $event = $this->createPublishedEvent(); $token = $this->tokenService->createToken($event, [ 'metadata' => ['demo_read_only' => true], ]); $file = UploadedFile::fake()->image('example.jpg', 1200, 800); $response = $this->withHeader('X-Device-Id', 'token-device') ->postJson("/api/v1/events/{$token->token}/upload", [ 'photo' => $file, ]); $response->assertStatus(403) ->assertJsonPath('error.code', 'demo_read_only'); $this->assertDatabaseCount('photos', 0); } public function test_guest_can_like_photo_after_joining_with_token(): void { $event = $this->createPublishedEvent(); $token = $this->tokenService->createToken($event); $photo = Photo::factory()->create([ 'event_id' => $event->id, 'likes_count' => 0, ]); $this->getJson("/api/v1/events/{$token->token}"); $response = $this->withHeader('X-Device-Id', 'device-like') ->postJson("/api/v1/photos/{$photo->id}/like"); $response->assertOk() ->assertJson([ 'liked' => true, ]); $this->assertDatabaseHas('photo_likes', [ 'photo_id' => $photo->id, 'guest_name' => 'device-like', ]); $this->assertEquals(1, $photo->fresh()->likes_count); } public function test_guest_cannot_access_event_with_expired_token(): void { $event = $this->createPublishedEvent(); $token = $this->tokenService->createToken($event, [ 'expires_at' => now()->subDay(), ]); $response = $this->getJson("/api/v1/events/{$token->token}"); $response->assertStatus(410) ->assertJsonPath('error.code', 'token_expired'); } public function test_slug_access_is_rejected(): void { $event = $this->createPublishedEvent(); $response = $this->getJson("/api/v1/events/{$event->slug}"); $response->assertStatus(404) ->assertJsonPath('error.code', 'invalid_token'); } public function test_gallery_defaults_use_guest_policy_settings(): void { GuestPolicySetting::flushCache(); GuestPolicySetting::query()->create([ 'id' => 1, 'guest_downloads_enabled' => false, 'guest_sharing_enabled' => false, ]); $event = $this->createPublishedEvent(); $token = $this->tokenService->createToken($event); $response = $this->getJson("/api/v1/gallery/{$token->token}"); $response->assertOk() ->assertJsonPath('event.guest_downloads_enabled', false) ->assertJsonPath('event.guest_sharing_enabled', false); } public function test_guest_cannot_access_event_with_revoked_token(): void { $event = $this->createPublishedEvent(); $token = $this->tokenService->createToken($event); $this->tokenService->revoke($token, 'revoked for test'); $response = $this->getJson("/api/v1/events/{$token->token}"); $response->assertStatus(410) ->assertJsonPath('error.code', 'token_revoked'); } }