withoutMiddleware(\App\Http\Middleware\EnsurePlatformSession::class); config([ 'mail.from.address' => 'noreply@ladill.com', 'mail.from.name' => 'Ladill', 'events.service_api_keys.meet' => 'test-meet-key', ]); Mail::fake(); Http::fake([ config('billing.api_url').'/balance*' => Http::response(['balance_minor' => 50000]), config('billing.api_url').'/can-afford*' => Http::response(['affordable' => true]), config('billing.api_url').'/debit' => Http::response(['success' => true]), ]); } public function test_speaker_portal_page_renders_for_valid_token(): void { $owner = User::factory()->create(['public_id' => 'usr_sp_portal']); $event = QrCode::create([ 'user_id' => $owner->id, 'short_code' => 'evt-sp-portal', 'type' => QrCode::TYPE_EVENT, 'label' => 'Speaker portal event', 'payload' => [ 'content' => [ 'name' => 'Speaker portal event', 'format' => 'virtual', 'virtual_sessions' => [[ 'id' => 'vs-1', 'title' => 'Opening keynote', 'meet_room_type' => 'webinar', 'join_url' => 'https://meet.test/r/room-uuid', ]], 'speakers' => [[ 'name' => 'Jane Speaker', 'email' => 'jane@example.com', 'role' => 'Keynote', 'bio' => '', 'invite_token' => 'speaker-token-abc', ]], ], 'style' => [], ], 'is_active' => true, ]); $this->get(route('qr.public.speaker.portal', ['shortCode' => $event->short_code, 'token' => 'speaker-token-abc'])) ->assertOk() ->assertSee('Jane Speaker', false) ->assertSee('Opening keynote', false) ->assertSee('speaker-token-abc', false); } public function test_manual_speaker_invite_sends_email_and_records_metadata(): void { $owner = User::factory()->create(['public_id' => 'usr_sp_manual']); $event = QrCode::create([ 'user_id' => $owner->id, 'short_code' => 'evt-sp-manual', 'type' => QrCode::TYPE_EVENT, 'label' => 'Manual invite event', 'payload' => [ 'content' => [ 'name' => 'Manual invite event', 'speakers' => [[ 'name' => 'Alex Host', 'email' => 'alex@example.com', 'role' => 'Panelist', 'bio' => '', ]], ], 'style' => [], ], 'is_active' => true, ]); $this->actingAs($owner) ->from(route('speakers.show', $event)) ->post(route('speakers.invite', $event), ['email' => 'alex@example.com']) ->assertRedirect(route('speakers.show', $event)) ->assertSessionHas('success'); Http::assertSent(fn ($request) => str_contains($request->url(), '/debit')); $event->refresh(); $speaker = $event->content()['speakers'][0]; $this->assertNotEmpty($speaker['invite_token']); $this->assertNotEmpty($speaker['invited_at']); $this->assertSame(EventSpeakerInviteService::SOURCE_MANUAL, $speaker['invite_source']); } public function test_programme_save_invites_assigned_speaker_hosts(): void { $owner = User::factory()->create(['public_id' => 'usr_sp_prog']); $programme = QrCode::create([ 'user_id' => $owner->id, 'short_code' => 'itn-sp-prog', 'type' => QrCode::TYPE_ITINERARY, 'label' => 'Summit programme', 'payload' => [ 'content' => [ 'title' => 'Summit programme', 'days' => [[ 'label' => 'Day 1', 'date' => '2026-07-01', 'items' => [[ 'ref' => 'day0-item0', 'time' => '09:00', 'title' => 'Opening', 'description' => '', 'location' => '', 'host' => 'Pat Speaker', 'host_speaker_email' => 'pat@example.com', ]], ]], ], 'style' => [], ], 'is_active' => true, ]); $event = QrCode::create([ 'user_id' => $owner->id, 'short_code' => 'evt-sp-prog', 'type' => QrCode::TYPE_EVENT, 'label' => 'Summit', 'payload' => [ 'content' => [ 'name' => 'Summit', 'programme_qr_id' => $programme->id, 'speakers' => [[ 'name' => 'Pat Speaker', 'email' => 'pat@example.com', 'role' => 'Host', 'bio' => '', ]], ], 'style' => [], ], 'is_active' => true, ]); app(EventSpeakerInviteService::class)->inviteProgrammeHosts($programme, $owner); Http::assertSent(fn ($request) => str_contains($request->url(), '/debit')); $event->refresh(); $speaker = $event->content()['speakers'][0]; $this->assertSame(EventSpeakerInviteService::SOURCE_PROGRAMME, $speaker['invite_source']); } public function test_verify_speaker_api_returns_speaker_details(): void { $owner = User::factory()->create(['public_id' => 'usr_sp_api']); $event = QrCode::create([ 'user_id' => $owner->id, 'short_code' => 'evt-sp-api', 'type' => QrCode::TYPE_EVENT, 'label' => 'API event', 'payload' => [ 'content' => [ 'name' => 'API event', 'speakers' => [[ 'name' => 'Sam Panel', 'email' => 'sam@example.com', 'role' => 'Panelist', 'bio' => '', 'invite_token' => 'verify-token-xyz', ]], ], 'style' => [], ], 'is_active' => true, ]); $this->withToken('test-meet-key') ->postJson('/api/service/v1/events/'.$event->id.'/verify-speaker', [ 'owner_ref' => $owner->public_id, 'speaker_token' => 'verify-token-xyz', ])->assertOk() ->assertJsonPath('speaker.email', 'sam@example.com') ->assertJsonPath('speaker.name', 'Sam Panel'); } public function test_speaker_roster_requires_email(): void { $owner = User::factory()->create(['public_id' => 'usr_sp_req_email']); $event = QrCode::create([ 'user_id' => $owner->id, 'short_code' => 'evt-sp-email-req', 'type' => QrCode::TYPE_EVENT, 'label' => 'Email required', 'payload' => ['content' => ['name' => 'Email required'], 'style' => []], 'is_active' => true, ]); $this->actingAs($owner) ->from(route('speakers.show', $event)) ->put(route('speakers.update', $event), [ 'speakers' => [ ['name' => 'No Email Person', 'email' => '', 'role' => '', 'bio' => ''], ], ]) ->assertSessionHasErrors('speakers.0.email'); } public function test_manual_speaker_invite_reports_when_email_not_configured(): void { config(['mail.from.address' => '']); $owner = User::factory()->create(['public_id' => 'usr_sp_no_smtp']); $event = QrCode::create([ 'user_id' => $owner->id, 'short_code' => 'evt-sp-no-smtp', 'type' => QrCode::TYPE_EVENT, 'label' => 'No SMTP event', 'payload' => [ 'content' => [ 'name' => 'No SMTP event', 'speakers' => [[ 'name' => 'Alex Host', 'email' => 'alex@example.com', 'role' => 'Panelist', 'bio' => '', ]], ], 'style' => [], ], 'is_active' => true, ]); $this->actingAs($owner) ->from(route('speakers.show', $event)) ->post(route('speakers.invite', $event), ['email' => 'alex@example.com']) ->assertRedirect(route('speakers.show', $event)) ->assertSessionHas('error'); } public function test_manual_speaker_invite_can_be_resent_after_programme_invite(): void { $owner = User::factory()->create(['public_id' => 'usr_sp_resend']); $event = QrCode::create([ 'user_id' => $owner->id, 'short_code' => 'evt-sp-resend', 'type' => QrCode::TYPE_EVENT, 'label' => 'Resend event', 'payload' => [ 'content' => [ 'name' => 'Resend event', 'speakers' => [[ 'name' => 'Pat Speaker', 'email' => 'pat@example.com', 'role' => 'Host', 'bio' => '', 'invite_token' => 'existing-token', 'invited_at' => now()->toIso8601String(), 'invite_source' => EventSpeakerInviteService::SOURCE_PROGRAMME, ]], ], 'style' => [], ], 'is_active' => true, ]); $this->actingAs($owner) ->from(route('speakers.show', $event)) ->post(route('speakers.invite', $event), ['email' => 'pat@example.com']) ->assertRedirect(route('speakers.show', $event)) ->assertSessionHas('success'); Http::assertSent(fn ($request) => str_contains($request->url(), '/debit')); } }