withoutMiddleware(EnsurePlatformSession::class); $this->user = User::create([ 'public_id' => 'test-user-001', 'name' => 'Test User', 'email' => 'test@example.com', ]); $this->organization = Organization::create([ 'owner_ref' => $this->user->public_id, 'name' => 'Test Org', 'slug' => 'test-org', 'timezone' => 'UTC', 'settings' => ['onboarded' => true], ]); Member::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'user_ref' => $this->user->public_id, 'role' => 'owner', ]); $base = rtrim((string) config('billing.api_url'), '/'); Http::fake([ $base.'/can-afford*' => Http::response(['affordable' => true]), $base.'/debit' => Http::response(['ok' => true]), $base.'/balance*' => Http::response(['balance_minor' => 100000]), ]); } public function test_health_endpoint(): void { $this->getJson('/api/health') ->assertOk() ->assertJson(['status' => 'ok', 'app' => 'meet']); } public function test_guest_redirected_from_dashboard(): void { $this->get('/dashboard')->assertRedirect(); } public function test_guest_root_uses_silent_sso_not_interactive_auth(): void { $response = $this->get('/'); $response->assertRedirect(route('sso.connect')); $this->assertStringNotContainsString('interactive=1', (string) $response->headers->get('Location')); } public function test_sso_callback_sends_guest_without_session_to_marketing_page(): void { $this->get('/sso/callback?error=login_required') ->assertRedirect(config('ladill.marketing_url')); } public function test_onboarded_user_can_view_dashboard(): void { $this->actingAs($this->user) ->get('/dashboard') ->assertOk(); } public function test_user_can_create_instant_meeting(): void { $this->actingAs($this->user) ->get('/meetings/instant') ->assertRedirect(); $this->assertDatabaseHas('meet_rooms', [ 'host_user_ref' => $this->user->public_id, 'type' => 'instant', ]); } public function test_instant_meeting_opens_room(): void { $response = $this->actingAs($this->user) ->get('/meetings/instant'); $response->assertRedirect(); $session = Session::where('owner_ref', $this->user->public_id)->latest()->first(); $this->assertNotNull($session); $this->assertTrue($session->isLive()); $this->actingAs($this->user) ->get('/room/'.$session->uuid) ->assertOk() ->assertSee($session->room->title); } public function test_host_can_start_scheduled_meeting_into_room(): void { $room = $this->createRoom(); $this->actingAs($this->user) ->post('/meetings/'.$room->uuid.'/start') ->assertRedirect(); $session = $room->fresh()->activeSession(); $this->assertNotNull($session); $this->actingAs($this->user) ->get('/room/'.$session->uuid) ->assertOk(); } public function test_guest_cannot_join_before_host_starts(): void { $room = $this->createRoom([ 'settings' => array_merge(config('meet.default_settings'), [ 'join_before_host' => false, ]), ]); $this->post('/r/'.$room->uuid.'/enter', [ 'display_name' => 'Guest User', ]) ->assertRedirect() ->assertSessionHasErrors('join'); } public function test_guest_can_join_live_meeting_and_enter_room(): void { $room = $this->createRoom([ 'settings' => array_merge(config('meet.default_settings'), [ 'join_before_host' => true, 'waiting_room' => false, ]), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'room-'.$room->uuid, 'status' => 'live', 'started_at' => now(), ]); $room->update(['status' => 'live']); $this->post('/r/'.$room->uuid.'/enter', [ 'display_name' => 'Guest User', ]) ->assertRedirect(route('meet.room', $session)); $this->get('/room/'.$session->uuid) ->assertOk() ->assertSee('data-display-name="Guest User"', false); } public function test_anonymous_guest_does_not_take_over_host_participant(): void { $room = $this->createRoom([ 'settings' => array_merge(config('meet.default_settings'), [ 'join_before_host' => true, 'waiting_room' => false, ]), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'room-'.$room->uuid, 'status' => 'live', 'started_at' => now(), ]); $room->update(['status' => 'live']); $hostParticipant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => $this->user->name, 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $this->post('/r/'.$room->uuid.'/enter', [ 'display_name' => 'Guest User', ])->assertRedirect(route('meet.room', $session)); $hostParticipant->refresh(); $this->assertSame('host', $hostParticipant->role); $this->assertSame($this->user->name, $hostParticipant->display_name); $guest = \App\Models\Participant::query() ->where('session_id', $session->id) ->where('display_name', 'Guest User') ->first(); $this->assertNotNull($guest); $this->assertSame('guest', $guest->role); $this->assertNotSame($hostParticipant->uuid, $guest->uuid); } public function test_guest_is_placed_in_waiting_room_when_enabled(): void { $room = $this->createRoom([ 'settings' => array_merge(config('meet.default_settings'), [ 'join_before_host' => true, 'waiting_room' => true, ]), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'room-'.$room->uuid, 'status' => 'live', 'started_at' => now(), ]); $room->update(['status' => 'live']); $this->post('/r/'.$room->uuid.'/enter', [ 'display_name' => 'Waiting Guest', ]) ->assertRedirect(route('meet.join.waiting', $room)); $this->assertDatabaseHas('meet_participants', [ 'session_id' => $session->id, 'display_name' => 'Waiting Guest', 'status' => 'waiting', ]); } public function test_leave_redirects_to_feedback_page(): void { $room = $this->createRoom(['settings' => array_merge(config('meet.default_settings'), ['waiting_room' => false])]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'room-'.$room->uuid, 'status' => 'live', 'started_at' => now(), ]); $participant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'display_name' => 'Guest', 'role' => 'guest', 'status' => 'joined', 'joined_at' => now(), ]); $this->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) ->post('/room/'.$session->uuid.'/leave') ->assertRedirect(route('meet.left', $session)); } public function test_participant_redirected_to_ended_page_when_meeting_ended(): void { $room = $this->createRoom(['settings' => array_merge(config('meet.default_settings'), ['waiting_room' => false])]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'room-'.$room->uuid, 'status' => 'ended', 'started_at' => now()->subHour(), 'ended_at' => now(), ]); $room->update(['status' => 'ended']); $participant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'display_name' => 'Guest', 'role' => 'guest', 'status' => 'left', 'joined_at' => now()->subHour(), 'left_at' => now(), ]); $this->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) ->get('/room/'.$session->uuid) ->assertRedirect(route('meet.ended', $session)); } public function test_poll_returns_ended_payload_when_meeting_ended(): void { $room = $this->createRoom(); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'room-'.$room->uuid, 'status' => 'ended', 'started_at' => now()->subHour(), 'ended_at' => now(), ]); $participant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'display_name' => 'Guest', 'role' => 'guest', 'status' => 'left', 'joined_at' => now()->subHour(), 'left_at' => now(), ]); $this->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) ->getJson('/room/'.$session->uuid.'/poll') ->assertOk() ->assertJson([ 'ended' => true, 'redirect' => route('meet.ended', $session), ]); } public function test_waiting_guest_redirected_when_host_ends_meeting(): void { $room = $this->createRoom(['settings' => array_merge(config('meet.default_settings'), ['waiting_room' => true])]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'room-'.$room->uuid, 'status' => 'ended', 'started_at' => now()->subHour(), 'ended_at' => now(), ]); $room->update(['status' => 'ended']); $participant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'display_name' => 'Waiting Guest', 'role' => 'guest', 'status' => 'left', 'joined_at' => now()->subHour(), 'left_at' => now(), ]); $this->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) ->getJson('/r/'.$room->uuid.'/waiting/status') ->assertOk() ->assertJson([ 'ended' => true, 'redirect' => route('meet.ended', $session), ]); } public function test_authenticated_user_can_join_live_meeting_without_display_name(): void { $room = $this->createRoom(); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'room-'.$room->uuid, 'status' => 'live', 'started_at' => now(), ]); $room->update(['status' => 'live']); $this->actingAs($this->user) ->post('/r/'.$room->uuid.'/enter') ->assertRedirect(route('meet.room', $session)); $this->actingAs($this->user) ->get('/room/'.$session->uuid) ->assertOk(); } public function test_room_has_join_url(): void { $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Test Meeting', 'type' => 'instant', 'status' => 'scheduled', 'scheduled_at' => now(), 'timezone' => 'UTC', 'settings' => config('meet.default_settings'), ]); $this->assertStringContainsString('meet.ladill.com/r/'.$room->uuid, $room->joinUrl()); } public function test_user_can_view_recordings_index(): void { $room = $this->createRoom(); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'room-'.$room->uuid, 'status' => 'ended', 'started_at' => now()->subHour(), 'ended_at' => now(), ]); Recording::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'status' => 'ready', 'storage_path' => 'recordings/test.mp4', ]); $this->actingAs($this->user) ->get('/recordings') ->assertOk() ->assertSee('Recordings'); } public function test_user_can_view_summaries_index(): void { $room = $this->createRoom(); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'room-'.$room->uuid, 'status' => 'ended', 'started_at' => now()->subHour(), 'ended_at' => now(), ]); AiSummary::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'status' => 'ready', 'summary' => 'We agreed to ship the dashboard on Friday.', 'decisions' => ['Ship Friday'], ]); $this->actingAs($this->user) ->get('/summaries') ->assertOk() ->assertSee('AI Summaries') ->assertSee($room->title) ->assertSee('We agreed to ship the dashboard on Friday.'); $this->actingAs($this->user) ->get('/summaries/') ->assertOk() ->assertSee('AI Summaries'); } public function test_admin_can_update_security_policy(): void { $this->actingAs($this->user) ->put('/settings/security', [ 'org_only' => '1', 'authenticated_only' => '1', 'allowed_domains' => 'example.com, ladill.com', 'recording_host_only' => '1', 'session_idle_minutes' => 90, ]) ->assertRedirect(route('meet.settings.security')); $this->organization->refresh(); $this->assertTrue($this->organization->securitySetting('org_only')); $this->assertContains('example.com', $this->organization->securitySetting('allowed_domains')); } public function test_user_can_access_personal_room(): void { $this->actingAs($this->user) ->get('/meetings/personal') ->assertRedirect(); $this->assertDatabaseHas('meet_rooms', [ 'host_user_ref' => $this->user->public_id, 'type' => 'personal', ]); } public function test_admin_can_view_reports(): void { $this->actingAs($this->user) ->get('/reports') ->assertOk() ->assertSee('Reports'); } public function test_service_api_can_create_room(): void { \Illuminate\Support\Facades\Http::fake(); config(['meet.service_api_keys.care' => 'test-care-key']); $this->postJson('/api/service/v1/rooms', [ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Care consultation', 'source' => ['app' => 'care', 'entity_type' => 'appointment', 'entity_id' => 'appt-123'], ], ['Authorization' => 'Bearer test-care-key']) ->assertCreated() ->assertJsonPath('room.title', 'Care consultation'); $this->assertDatabaseHas('meet_rooms', [ 'title' => 'Care consultation', 'host_user_ref' => $this->user->public_id, ]); } public function test_service_api_resolves_organization_from_owner_ref(): void { \Illuminate\Support\Facades\Http::fake(); config(['meet.service_api_keys.crm' => 'test-crm-key']); $this->postJson('/api/service/v1/rooms', [ 'owner_ref' => $this->user->public_id, 'host_user_ref' => $this->user->public_id, 'title' => 'CRM follow-up', 'scheduled_at' => now()->addDay()->toIso8601String(), 'source' => ['app' => 'crm', 'entity_type' => 'deal', 'entity_id' => '42'], ], ['Authorization' => 'Bearer test-crm-key']) ->assertCreated() ->assertJsonPath('room.organization_id', $this->organization->id); } public function test_service_api_can_create_room_for_invoice_app(): void { \Illuminate\Support\Facades\Http::fake(); config(['meet.service_api_keys.invoice' => 'test-invoice-key']); $this->postJson('/api/service/v1/rooms', [ 'owner_ref' => $this->user->public_id, 'host_user_ref' => $this->user->public_id, 'title' => 'Invoice review', 'scheduled_at' => now()->addDay()->toIso8601String(), 'source' => ['app' => 'invoice', 'entity_type' => 'invoice', 'entity_id' => '99'], ], ['Authorization' => 'Bearer test-invoice-key']) ->assertCreated() ->assertJsonPath('room.title', 'Invoice review'); } public function test_service_api_can_create_room_for_sales_centre_app(): void { \Illuminate\Support\Facades\Http::fake(); config(['meet.service_api_keys.sales_centre' => 'test-sales-centre-key']); $this->postJson('/api/service/v1/rooms', [ 'owner_ref' => $this->user->public_id, 'host_user_ref' => $this->user->public_id, 'title' => 'Ladill demo with Prospect', 'type' => 'instant', 'source' => ['app' => 'sales_centre', 'entity_type' => 'sales_lead', 'entity_id' => '42'], 'invite_emails' => ['prospect@demo.test'], ], ['Authorization' => 'Bearer test-sales-centre-key']) ->assertCreated() ->assertJsonPath('room.title', 'Ladill demo with Prospect'); } public function test_service_api_provisions_sales_centre_workspace_when_missing(): void { \Illuminate\Support\Facades\Http::fake(); config(['meet.service_api_keys.sales_centre' => 'test-sales-centre-key']); $this->postJson('/api/service/v1/rooms', [ 'owner_ref' => 'sales-rep-without-meet', 'host_user_ref' => 'sales-rep-without-meet', 'host_name' => 'Ama Sales', 'host_email' => 'ama@sales.test', 'title' => 'Ladill demo with Prospect', 'type' => 'instant', 'source' => ['app' => 'sales_centre', 'entity_type' => 'sales_lead', 'entity_id' => '99'], 'invite_emails' => ['prospect@demo.test'], ], ['Authorization' => 'Bearer test-sales-centre-key']) ->assertCreated() ->assertJsonPath('room.host_user_ref', 'sales-rep-without-meet'); $this->assertDatabaseHas('users', [ 'public_id' => 'sales-rep-without-meet', 'email' => 'ama@sales.test', ]); $this->assertDatabaseHas('meet_organizations', [ 'owner_ref' => 'sales-rep-without-meet', ]); } public function test_service_api_can_create_town_hall_room(): void { \Illuminate\Support\Facades\Http::fake(); config(['meet.service_api_keys.events' => 'test-events-key']); $this->postJson('/api/service/v1/rooms', [ 'owner_ref' => $this->user->public_id, 'host_user_ref' => $this->user->public_id, 'title' => 'Opening keynote', 'type' => 'town_hall', 'scheduled_at' => now()->addDay()->toIso8601String(), 'duration_minutes' => 90, 'source' => ['app' => 'events', 'entity_type' => 'virtual_session', 'entity_id' => 'vs-1'], ], ['Authorization' => 'Bearer test-events-key']) ->assertCreated() ->assertJsonPath('room.type', 'town_hall'); $this->assertDatabaseHas('meet_rooms', ['type' => 'town_hall', 'title' => 'Opening keynote']); } public function test_service_api_upserts_room_by_source_entity_id(): void { \Illuminate\Support\Facades\Http::fake(); config(['meet.service_api_keys.events' => 'test-events-key']); $payload = [ 'owner_ref' => $this->user->public_id, 'host_user_ref' => $this->user->public_id, 'title' => 'Session A', 'type' => 'webinar', 'scheduled_at' => now()->addDay()->toIso8601String(), 'source' => ['app' => 'events', 'entity_type' => 'virtual_session', 'entity_id' => 'vs-dup'], ]; $this->postJson('/api/service/v1/rooms', $payload, ['Authorization' => 'Bearer test-events-key']) ->assertCreated(); $payload['title'] = 'Session A (updated)'; $this->postJson('/api/service/v1/rooms', $payload, ['Authorization' => 'Bearer test-events-key']) ->assertOk() ->assertJsonPath('room.title', 'Session A (updated)'); $this->assertSame(1, \App\Models\Room::where('source->entity_id', 'vs-dup')->count()); } public function test_service_api_upserts_existing_room_by_meet_room_uuid(): void { \Illuminate\Support\Facades\Http::fake(); config(['meet.service_api_keys.events' => 'test-events-key']); $room = \App\Models\Room::create([ 'uuid' => 'prelinked-room-uuid', 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Prelinked town hall', 'type' => 'town_hall', 'status' => 'scheduled', 'scheduled_at' => now()->addDay(), 'timezone' => 'UTC', 'settings' => config('meet.default_settings'), ]); $programme = [ 'title' => 'Summit programme', 'days' => [ [ 'label' => 'Day 1', 'items' => [ ['time' => '09:00', 'title' => 'Opening keynote', 'host' => 'Alex'], ], ], ], ]; $this->postJson('/api/service/v1/rooms', [ 'owner_ref' => $this->user->public_id, 'host_user_ref' => $this->user->public_id, 'meet_room_uuid' => $room->uuid, 'title' => 'Opening keynote', 'type' => 'town_hall', 'scheduled_at' => now()->addDay()->toIso8601String(), 'settings' => [ 'programme_snapshot' => $programme, 'linked_programme_items' => [], ], 'source' => [ 'app' => 'events', 'entity_type' => 'virtual_session', 'entity_id' => 'vs-prelinked', 'event_id' => '42', ], ], ['Authorization' => 'Bearer test-events-key']) ->assertOk() ->assertJsonPath('room.uuid', $room->uuid) ->assertJsonPath('room.settings.programme_snapshot.title', 'Summit programme'); $this->assertSame(1, \App\Models\Room::count()); $room->refresh(); $this->assertSame('Summit programme', $room->setting('programme_snapshot')['title'] ?? null); $this->assertSame('vs-prelinked', $room->source['entity_id'] ?? null); } public function test_service_api_lookup_by_source(): void { \Illuminate\Support\Facades\Http::fake(); config(['meet.service_api_keys.events' => 'test-events-key']); $this->postJson('/api/service/v1/rooms', [ 'owner_ref' => $this->user->public_id, 'host_user_ref' => $this->user->public_id, 'title' => 'Lookup test', 'type' => 'town_hall', 'scheduled_at' => now()->addDay()->toIso8601String(), 'source' => ['app' => 'events', 'entity_type' => 'virtual_session', 'entity_id' => 'vs-lookup'], ], ['Authorization' => 'Bearer test-events-key'])->assertCreated(); $this->getJson('/api/service/v1/rooms/by-source?source_app=events&entity_id=vs-lookup&owner_ref='.$this->user->public_id, [ 'Authorization' => 'Bearer test-events-key', ]) ->assertOk() ->assertJsonPath('room.title', 'Lookup test'); } public function test_scheduled_room_syncs_to_ladill_mail_calendar(): void { \Illuminate\Support\Facades\Http::fake([ 'mail.ladill.com/*' => \Illuminate\Support\Facades\Http::response(['data' => ['id' => 99]], 201), ]); config([ 'meet.calendar.mail.api_url' => 'https://mail.ladill.com/api/service/v1', 'meet.calendar.mail.api_key' => 'mail-test-key', ]); $this->actingAs($this->user) ->post('/meetings', [ 'title' => 'Calendar sync test', 'scheduled_at' => now()->addDay()->format('Y-m-d H:i'), 'duration_minutes' => 30, ]) ->assertRedirect(); $room = \App\Models\Room::where('title', 'Calendar sync test')->first(); $this->assertNotNull($room); $this->assertSame('99', (string) $room->calendar_event_id); \Illuminate\Support\Facades\Http::assertSent(function ($request) { return str_contains($request->url(), '/calendar/events') && $request['mailbox_email'] === strtolower($this->user->email); }); } public function test_invitation_rsvp_accepts(): void { $room = $this->createRoom(); $invitation = \App\Models\Invitation::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'email' => 'guest@example.com', 'status' => 'pending', 'rsvp_token' => 'test-rsvp-token', ]); $this->post('/invite/'.$invitation->uuid.'/rsvp?token=test-rsvp-token', [ 'status' => 'accepted', ])->assertOk(); $this->assertDatabaseHas('meet_invitations', [ 'id' => $invitation->id, 'status' => 'accepted', ]); } public function test_admin_can_add_webhook_endpoint(): void { $this->actingAs($this->user) ->post('/settings/webhooks', [ 'url' => 'https://care.example.com/webhooks/meet', 'secret' => 'whsec_test', 'events' => ['meeting.started', 'meeting.ended'], ]) ->assertRedirect(route('meet.settings.webhooks')); $this->assertDatabaseHas('meet_webhook_endpoints', [ 'organization_id' => $this->organization->id, 'url' => 'https://care.example.com/webhooks/meet', ]); } public function test_webinar_registration_redirects_without_events_link(): void { $room = $this->createRoom(['type' => 'webinar']); $this->post('/w/'.$room->uuid.'/register', [ 'email' => 'attendee@example.com', 'display_name' => 'Attendee One', ])->assertRedirect(route('meet.webinars.show', $room)); } public function test_host_can_create_webinar_room(): void { $this->actingAs($this->user) ->post('/webinars', [ 'title' => 'Product launch', 'scheduled_at' => now()->addDay()->format('Y-m-d\TH:i'), 'duration_minutes' => 60, ]) ->assertRedirect(); $this->assertDatabaseHas('meet_rooms', [ 'title' => 'Product launch', 'type' => 'webinar', ]); } public function test_host_can_enable_panel_discussions_on_webinar(): void { $this->actingAs($this->user) ->post('/webinars', [ 'title' => 'Panel webinar', 'scheduled_at' => now()->addDay()->format('Y-m-d\TH:i'), 'duration_minutes' => 60, 'panel_discussions' => '1', ]) ->assertRedirect(); $room = Room::where('title', 'Panel webinar')->firstOrFail(); $this->assertTrue($room->setting('panel_discussions')); $this->assertSame('plenary', $room->setting('stage_layout')); $this->actingAs($this->user) ->post(route('meet.webinars.settings.update', $room), [ 'panel_discussions' => '0', ]) ->assertRedirect(route('meet.webinars.show', $room)); $this->assertFalse($room->fresh()->setting('panel_discussions')); } public function test_user_can_create_channel_and_post_message(): void { $this->actingAs($this->user) ->post('/channels', [ 'name' => 'General', 'visibility' => 'public', ]) ->assertRedirect(); $channel = \App\Models\Channel::first(); $this->assertNotNull($channel); $this->actingAs($this->user) ->post('/channels/'.$channel->uuid.'/messages', ['body' => 'Hello team']) ->assertRedirect(); $this->assertDatabaseHas('meet_channel_messages', [ 'channel_id' => $channel->id, 'body' => 'Hello team', ]); } public function test_admin_can_view_usage_dashboard(): void { $this->actingAs($this->user) ->get('/admin/usage') ->assertOk() ->assertSee('Usage'); } public function test_mail_calendar_can_be_connected(): void { config(['meet.calendar.mail.api_key' => 'test-mail-key']); $this->actingAs($this->user) ->post('/settings/calendar/mail') ->assertRedirect(route('meet.settings.calendar')); $this->assertDatabaseHas('meet_calendar_connections', [ 'user_ref' => $this->user->public_id, 'provider' => 'mail', ]); } public function test_scheduled_room_uses_connected_mail_mailbox(): void { \Illuminate\Support\Facades\Http::fake([ 'mail.ladill.com/*' => \Illuminate\Support\Facades\Http::response(['data' => ['id' => 42]], 201), ]); config([ 'meet.calendar.mail.api_url' => 'https://mail.ladill.com/api/service/v1', 'meet.calendar.mail.api_key' => 'mail-test-key', ]); $this->actingAs($this->user) ->post('/settings/calendar/mail', ['mailbox_email' => 'host@customdomain.com']) ->assertRedirect(route('meet.settings.calendar')); $this->actingAs($this->user) ->post('/meetings', [ 'title' => 'Custom mailbox sync', 'scheduled_at' => now()->addDay()->format('Y-m-d H:i'), 'duration_minutes' => 30, ]) ->assertRedirect(); \Illuminate\Support\Facades\Http::assertSent(function ($request) { return str_contains($request->url(), '/calendar/events') && $request['mailbox_email'] === 'host@customdomain.com'; }); } public function test_events_sourced_room_does_not_sync_to_mail_calendar(): void { \Illuminate\Support\Facades\Http::fake([ 'mail.ladill.com/*' => \Illuminate\Support\Facades\Http::response(['data' => ['id' => 99]], 201), ]); config([ 'meet.calendar.mail.api_url' => 'https://mail.ladill.com/api/service/v1', 'meet.calendar.mail.api_key' => 'mail-test-key', ]); $room = $this->createRoom([ 'scheduled_at' => now()->addDay(), 'source' => [ 'app' => 'events', 'entity_type' => 'virtual_session', 'entity_id' => 'vs-1', 'event_id' => '42', ], ]); app(\App\Services\Meet\CalendarService::class)->syncRoomCreated($room, $this->user); \Illuminate\Support\Facades\Http::assertNothingSent(); $this->assertNull($room->fresh()->calendar_event_id); } public function test_host_can_create_conference(): void { $this->actingAs($this->user) ->post('/conferences', [ 'title' => 'All hands', 'scheduled_at' => now()->addDay()->format('Y-m-d\TH:i'), 'duration_minutes' => 90, ]) ->assertRedirect(); $this->assertDatabaseHas('meet_rooms', [ 'title' => 'All hands', 'type' => 'town_hall', ]); } public function test_green_room_register_presenter_keeps_host_role(): void { $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'NextGen Summit', 'type' => 'town_hall', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => config('meet.default_settings'), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid.'-green', 'status' => 'live', 'session_mode' => 'green_room', 'started_at' => now(), ]); $participant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => $this->user->name, 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); app(\App\Services\Meet\ConferenceService::class)->registerPresenter($session, $this->user, 'host'); $this->assertSame('host', $participant->fresh()->role); } public function test_host_go_live_auto_joins_live_session(): void { $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'NextGen Summit', 'type' => 'town_hall', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => array_merge(config('meet.default_settings'), [ 'green_room' => true, 'stage_mode' => true, ]), ]); $greenSession = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid.'-green', 'status' => 'live', 'session_mode' => 'green_room', 'presenter_refs' => [$this->user->public_id], 'started_at' => now(), ]); $hostParticipant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $greenSession->id, 'user_ref' => $this->user->public_id, 'display_name' => $this->user->name, 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $this->actingAs($this->user) ->withSession(["meet.participant.{$greenSession->uuid}" => $hostParticipant->uuid]) ->postJson(route('meet.room.townhall.live', $greenSession)) ->assertOk() ->assertJson(['mode' => 'live']); $liveSession = Session::query() ->where('room_id', $room->id) ->where('session_mode', 'live') ->first(); $this->assertNotNull($liveSession); $this->assertSame('ended', $greenSession->fresh()->status); $this->actingAs($this->user) ->get(route('meet.room', $liveSession)) ->assertOk(); $this->assertDatabaseHas('meet_participants', [ 'session_id' => $liveSession->id, 'user_ref' => $this->user->public_id, 'role' => 'host', 'status' => 'joined', ]); } public function test_green_room_poll_redirects_presenters_to_live_session(): void { $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Panel prep', 'type' => 'town_hall', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => config('meet.default_settings'), ]); $greenSession = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid.'-green', 'status' => 'live', 'session_mode' => 'green_room', 'presenter_refs' => [$this->user->public_id], 'started_at' => now(), ]); $hostParticipant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $greenSession->id, 'user_ref' => $this->user->public_id, 'display_name' => $this->user->name, 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); app(\App\Services\Meet\TownHallService::class)->goLive($greenSession, $this->user); $liveSession = Session::query() ->where('room_id', $room->id) ->where('session_mode', 'live') ->firstOrFail(); $this->actingAs($this->user) ->withSession(["meet.participant.{$greenSession->uuid}" => $hostParticipant->uuid]) ->getJson(route('meet.room.poll', $greenSession)) ->assertOk() ->assertJson([ 'ended' => true, 'transition' => 'go_live', 'redirect' => route('meet.room', $liveSession), ]); } public function test_host_can_end_conference_and_view_details(): void { $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'NextGen Summit', 'type' => 'town_hall', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => array_merge(config('meet.default_settings'), [ 'green_room' => true, 'stage_mode' => true, 'presenter_refs' => [], ]), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid, 'status' => 'live', 'session_mode' => 'live', 'started_at' => now(), ]); $participant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => $this->user->name, 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) ->post('/room/'.$session->uuid.'/end') ->assertRedirect(route('meet.conferences.show', $room)) ->assertSessionHas('success', 'Conference ended.'); $this->actingAs($this->user) ->get(route('meet.conferences.show', $room)) ->assertOk() ->assertSee('NextGen Summit'); } public function test_host_can_add_speakers_after_conference_created(): void { $speaker = User::create([ 'public_id' => 'speaker-user-001', 'name' => 'Keynote Speaker', 'email' => 'speaker@example.com', ]); Member::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'user_ref' => $speaker->public_id, 'role' => 'member', ]); $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Product launch', 'type' => 'town_hall', 'status' => 'scheduled', 'scheduled_at' => now()->addDay(), 'timezone' => 'UTC', 'settings' => array_merge(config('meet.default_settings'), [ 'green_room' => true, 'stage_mode' => true, 'presenter_refs' => [], ]), ]); $this->actingAs($this->user) ->post(route('meet.conferences.speakers.update', $room), [ 'presenter_refs' => [$speaker->public_id], 'invite_emails' => 'guest-speaker@example.com', ]) ->assertRedirect(route('meet.conferences.show', $room)) ->assertSessionHas('success'); $room->refresh(); $this->assertContains($speaker->public_id, $room->setting('presenter_refs')); $this->assertDatabaseHas('meet_invitations', [ 'room_id' => $room->id, 'email' => 'guest-speaker@example.com', 'role' => 'panelist', ]); } public function test_host_can_update_conference_stage_layout(): void { $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'NextGen Summit', 'type' => 'town_hall', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => array_merge(config('meet.default_settings'), [ 'panel_discussions' => true, 'stage_layout' => 'plenary', 'panels' => [], ]), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid, 'status' => 'live', 'session_mode' => 'green_room', 'started_at' => now(), ]); $participant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => $this->user->name, 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) ->postJson('/room/'.$session->uuid.'/stage', [ 'stage_layout' => 'panel', 'panels' => [ ['name' => 'Opening panel', 'speaker_refs' => [$this->user->public_id]], ], 'active_panel_id' => null, ]) ->assertOk() ->assertJson([ 'stage_layout' => 'panel', 'panel_discussions' => true, ]); $room->refresh(); $this->assertSame('panel', $room->setting('stage_layout')); $this->assertCount(1, $room->setting('panels')); } public function test_host_can_spotlight_guest_panelist_by_participant_uuid(): void { $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Summit keynote', 'type' => 'webinar', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => array_merge(config('meet.default_settings'), [ 'stage_layout' => 'plenary', ]), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid, 'status' => 'live', 'started_at' => now(), ]); $hostParticipant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => $this->user->name, 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $guestSpeaker = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'display_name' => 'Guest Speaker', 'email' => 'speaker@example.com', 'role' => 'panelist', 'status' => 'joined', 'joined_at' => now(), ]); $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $hostParticipant->uuid]) ->postJson('/room/'.$session->uuid.'/stage', [ 'stage_layout' => 'plenary', 'spotlight_speaker_ref' => $guestSpeaker->uuid, ]) ->assertOk() ->assertJson([ 'stage_layout' => 'plenary', 'spotlight_speaker_ref' => $guestSpeaker->uuid, ]); $room->refresh(); $this->assertSame($guestSpeaker->uuid, $room->setting('spotlight_speaker_ref')); } public function test_poll_includes_stage_config_for_conference(): void { $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'NextGen Summit', 'type' => 'town_hall', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => array_merge(config('meet.default_settings'), [ 'panel_discussions' => true, 'stage_layout' => 'plenary', ]), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid, 'status' => 'live', 'session_mode' => 'live', 'started_at' => now(), ]); $participant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => $this->user->name, 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $this->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) ->getJson('/room/'.$session->uuid.'/poll') ->assertOk() ->assertJson([ 'uses_stage_layout' => true, 'stage_layout' => 'plenary', 'panel_discussions' => true, ]); } public function test_regular_meeting_rejects_stage_update(): void { $room = $this->createRoom(); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid, 'status' => 'live', 'started_at' => now(), ]); $participant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => $this->user->name, 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) ->postJson('/room/'.$session->uuid.'/stage', [ 'stage_layout' => 'panel', ]) ->assertStatus(422); } public function test_host_can_create_space_room(): void { $this->actingAs($this->user) ->post('/rooms', [ 'title' => 'Leadership space', ]) ->assertRedirect(); $room = Room::where('title', 'Leadership space')->firstOrFail(); $this->assertDatabaseHas('meet_rooms', [ 'title' => 'Leadership space', 'type' => 'space', ]); $this->assertFalse($room->setting('invite_only')); } public function test_space_create_sends_listener_email_invitations(): void { $this->actingAs($this->user) ->post('/rooms', [ 'title' => 'Listener room', 'invite_emails' => 'listener@example.com, bad-email', 'invite_only' => true, ]) ->assertSessionHasErrors('invite_emails'); $this->actingAs($this->user) ->post('/rooms', [ 'title' => 'Listener room', 'invite_emails' => 'listener@example.com, guest@example.com', 'invite_only' => true, ]) ->assertRedirect(); $room = Room::where('title', 'Listener room')->firstOrFail(); $this->assertDatabaseHas('meet_invitations', [ 'room_id' => $room->id, 'email' => 'listener@example.com', 'role' => 'attendee', 'status' => 'pending', ]); $this->assertDatabaseHas('meet_invitations', [ 'room_id' => $room->id, 'email' => 'guest@example.com', 'role' => 'attendee', ]); } public function test_private_space_denies_uninvited_join(): void { $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Private space', 'type' => 'space', 'status' => 'scheduled', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => array_merge(config('meet.default_settings'), [ 'audio_only' => true, 'invite_only' => true, ]), ]); $stranger = User::create([ 'public_id' => 'stranger-001', 'name' => 'Stranger', 'email' => 'stranger@example.com', ]); $this->actingAs($stranger) ->get('/r/'.$room->uuid) ->assertOk() ->assertSee('This room is private'); } public function test_private_space_allows_invited_email_to_join(): void { $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Invited space', 'type' => 'space', 'status' => 'scheduled', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => array_merge(config('meet.default_settings'), [ 'audio_only' => true, 'invite_only' => true, ]), ]); \App\Models\Invitation::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'email' => 'invited@example.com', 'role' => 'attendee', 'status' => 'pending', 'rsvp_token' => 'test-token', ]); $invited = User::create([ 'public_id' => 'invited-001', 'name' => 'Invited Listener', 'email' => 'invited@example.com', ]); $this->actingAs($invited) ->get('/r/'.$room->uuid) ->assertOk() ->assertDontSee('This room is private'); } public function test_space_host_cannot_create_breakouts(): void { $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Leadership space', 'type' => 'space', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => array_merge(config('meet.default_settings'), [ 'audio_only' => true, ]), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid, 'status' => 'live', 'session_mode' => 'live', 'started_at' => now(), ]); $host = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => 'Host', 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $host->uuid]) ->postJson('/room/'.$session->uuid.'/breakouts', ['count' => 2]) ->assertStatus(422); } public function test_space_live_room_view_omits_meeting_only_features(): void { config([ 'meet.media.livekit.api_key' => 'APIxxxxxxxxxxxxxxxx', 'meet.media.livekit.api_secret' => 'secretsecretsecretsecretsecret12', ]); $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Leadership space', 'type' => 'space', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => array_merge(config('meet.default_settings'), [ 'audio_only' => true, ]), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid, 'status' => 'live', 'session_mode' => 'live', 'started_at' => now(), ]); $host = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => 'Host', 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $response = $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $host->uuid]) ->get('/room/'.$session->uuid); $response->assertOk(); $response->assertSee('data-is-space="1"', false); $response->assertSee('title="Chat"', false); $response->assertDontSee('title="People"', false); $response->assertDontSee('Start breakouts', false); $response->assertDontSee('Share files', false); $response->assertDontSee('View the room lineup from Events', false); } public function test_space_host_can_invite_listener_to_speak_and_listener_accepts(): void { $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Leadership space', 'type' => 'space', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => array_merge(config('meet.default_settings'), [ 'audio_only' => true, 'speaker_refs' => [], ]), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid, 'status' => 'live', 'session_mode' => 'live', 'started_at' => now(), ]); $host = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => 'Host', 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $listener = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => 'listener-ref', 'display_name' => 'Listener', 'role' => 'attendee', 'status' => 'joined', 'hand_raised' => true, 'joined_at' => now(), ]); $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $host->uuid]) ->postJson('/room/'.$session->uuid.'/participants/'.$listener->uuid.'/space/speaker') ->assertOk() ->assertJsonPath('participant.role', 'attendee') ->assertJsonPath('participant.speak_requested', true); $listener->refresh(); $this->assertSame('attendee', $listener->role); $this->assertTrue($listener->speak_requested); $this->assertFalse($listener->hand_raised); $this->withSession(["meet.participant.{$session->uuid}" => $listener->uuid]) ->postJson('/room/'.$session->uuid.'/space/speaker/accept') ->assertOk() ->assertJsonPath('participant.role', 'panelist') ->assertJsonPath('participant.speak_requested', false); $listener->refresh(); $room->refresh(); $this->assertSame('panelist', $listener->role); $this->assertFalse($listener->speak_requested); $this->assertContains('listener-ref', (array) $room->setting('speaker_refs', [])); } public function test_space_host_can_make_speaker_co_host_without_acceptance(): void { $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Leadership space', 'type' => 'space', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => array_merge(config('meet.default_settings'), [ 'audio_only' => true, 'speaker_refs' => ['speaker-ref'], ]), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid, 'status' => 'live', 'session_mode' => 'live', 'started_at' => now(), ]); $host = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => 'Host', 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $speaker = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => 'speaker-ref', 'display_name' => 'Speaker', 'role' => 'panelist', 'status' => 'joined', 'joined_at' => now(), ]); $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $host->uuid]) ->postJson('/room/'.$session->uuid.'/participants/'.$speaker->uuid.'/space/co-host') ->assertOk() ->assertJsonPath('participant.role', 'co_host'); $speaker->refresh(); $this->assertSame('co_host', $speaker->role); } public function test_breakouts_exclude_host_and_enable_meeting_mode_for_attendees(): void { config([ 'meet.media.livekit.api_key' => 'APIxxxxxxxxxxxxxxxx', 'meet.media.livekit.api_secret' => 'secretsecretsecretsecretsecret12', ]); $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Summit', 'type' => 'town_hall', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => config('meet.default_settings'), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid, 'status' => 'live', 'session_mode' => 'live', 'started_at' => now(), ]); $host = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => 'Host', 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $attendee = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'display_name' => 'Attendee', 'role' => 'attendee', 'status' => 'joined', 'joined_at' => now(), ]); $sessions = app(\App\Services\Meet\SessionService::class); $this->assertFalse($sessions->canParticipantPublish($attendee)); $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $host->uuid]) ->postJson('/room/'.$session->uuid.'/breakouts', ['count' => 2]) ->assertOk(); $host->refresh(); $attendee->refresh(); $this->assertNull($host->breakout_room_id); $this->assertNotNull($attendee->breakout_room_id); $this->assertTrue($sessions->canParticipantPublish($attendee)); $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $attendee->uuid]) ->getJson('/room/'.$session->uuid.'/media-token') ->assertOk() ->assertJsonPath('in_breakout', true) ->assertJsonPath('can_publish', true) ->assertJsonPath('uses_stage_layout', false); } public function test_live_room_shows_programme_when_linked_from_events(): void { config([ 'meet.media.livekit.api_key' => 'APIxxxxxxxxxxxxxxxx', 'meet.media.livekit.api_secret' => 'secretsecretsecretsecretsecret12', ]); $programme = [ 'title' => 'Summit programme', 'days' => [ [ 'label' => 'Day 1', 'items' => [ ['time' => '09:00', 'title' => 'Opening keynote', 'host' => 'Alex'], ], ], ], ]; $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Summit', 'type' => 'town_hall', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => array_merge(config('meet.default_settings'), [ 'programme_snapshot' => $programme, 'linked_programme_items' => [ ['time' => '09:00', 'title' => 'Opening keynote', 'host' => 'Alex'], ], ]), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid, 'status' => 'live', 'session_mode' => 'live', 'started_at' => now(), ]); $participant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => $this->user->name, 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) ->get('/room/'.$session->uuid) ->assertOk() ->assertSee('Opening keynote', false) ->assertSee('Programme', false) ->assertSee('meet-icons/programme.svg', false) ->assertSee('View the conference lineup from Events', false); } public function test_live_room_always_lists_programme_in_more_menu_without_linked_data(): void { config([ 'meet.media.livekit.api_key' => 'APIxxxxxxxxxxxxxxxx', 'meet.media.livekit.api_secret' => 'secretsecretsecretsecretsecret12', ]); $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Summit', 'type' => 'town_hall', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => config('meet.default_settings'), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid, 'status' => 'live', 'session_mode' => 'live', 'started_at' => now(), ]); $participant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => $this->user->name, 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) ->get('/room/'.$session->uuid) ->assertOk() ->assertSee('Programme', false) ->assertSee('meet-icons/programme.svg', false) ->assertSee('View the conference lineup from Events', false) ->assertSee('No programme linked to this conference yet.', false); } public function test_webinar_live_room_lists_programme_in_more_menu(): void { config([ 'meet.media.livekit.api_key' => 'APIxxxxxxxxxxxxxxxx', 'meet.media.livekit.api_secret' => 'secretsecretsecretsecretsecret12', ]); $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Product launch', 'type' => 'webinar', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => config('meet.default_settings'), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid, 'status' => 'live', 'session_mode' => 'live', 'started_at' => now(), ]); $participant = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => $this->user->name, 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) ->get('/room/'.$session->uuid) ->assertOk() ->assertSee('View the webinar lineup from Events', false) ->assertSee('data-session-noun="webinar"', false); } public function test_webinar_attendee_can_request_and_receive_speak_access(): void { config([ 'meet.media.livekit.api_key' => 'APIxxxxxxxxxxxxxxxx', 'meet.media.livekit.api_secret' => 'secretsecretsecretsecretsecret12', ]); $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Town hall', 'type' => 'webinar', 'status' => 'live', 'scheduled_at' => now()->addHour(), 'timezone' => 'UTC', 'settings' => config('meet.default_settings'), ]); $session = Session::create([ 'owner_ref' => $this->user->public_id, 'room_id' => $room->id, 'media_room_name' => 'meet-'.$room->uuid, 'status' => 'live', 'session_mode' => 'live', 'started_at' => now(), ]); $host = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'user_ref' => $this->user->public_id, 'display_name' => 'Host', 'role' => 'host', 'status' => 'joined', 'joined_at' => now(), ]); $attendee = \App\Models\Participant::create([ 'owner_ref' => $this->user->public_id, 'session_id' => $session->id, 'display_name' => 'Attendee', 'role' => 'attendee', 'status' => 'joined', 'joined_at' => now(), ]); $sessions = app(\App\Services\Meet\SessionService::class); $this->assertFalse($sessions->canParticipantPublish($attendee)); $this->withSession(["meet.participant.{$session->uuid}" => $attendee->uuid]) ->get('/room/'.$session->uuid) ->assertOk() ->assertSee('Request to speak', false) ->assertSee('meet-icons/speak.svg', false) ->assertSee('data-uses-speak-access="1"', false); $this->withSession(["meet.participant.{$session->uuid}" => $attendee->uuid]) ->postJson(route('meet.room.speak.request', $session)) ->assertOk() ->assertJsonPath('participant.speak_requested', true); $attendee->refresh(); $this->assertTrue($attendee->speak_requested); $this->assertFalse($attendee->speak_granted); $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $host->uuid]) ->postJson(route('meet.room.speak.grant', [$session, $attendee])) ->assertOk() ->assertJsonPath('participant.speak_granted', true) ->assertJsonPath('participant.speak_requested', false); $attendee->refresh(); $this->assertTrue($attendee->speak_granted); $this->assertTrue($sessions->canParticipantPublish($attendee)); $mediaState = $sessions->mediaSessionState($attendee); $this->assertTrue($mediaState['can_publish']); $this->assertTrue($mediaState['audio_only_publish']); $this->assertTrue($mediaState['speak_granted']); $this->withSession(["meet.participant.{$session->uuid}" => $attendee->uuid]) ->get('/room/'.$session->uuid) ->assertOk() ->assertSee('You can speak', false) ->assertSee('data-speak-granted="1"', false); $this->actingAs($this->user) ->withSession(["meet.participant.{$session->uuid}" => $host->uuid]) ->postJson(route('meet.room.speak.revoke', [$session, $attendee])) ->assertOk() ->assertJsonPath('participant.speak_granted', false); $attendee->refresh(); $this->assertFalse($sessions->canParticipantPublish($attendee)); } public function test_webinar_show_events_create_link_uses_events_create_path(): void { config([ 'meet.events_app_url' => 'https://events.test', 'meet.events_api_url' => 'https://events.test/api/service/v1', 'meet.events_api_key' => '', ]); $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Sales webinar', 'type' => 'webinar', 'status' => 'ended', 'scheduled_at' => now()->subDay(), 'timezone' => 'UTC', 'settings' => config('meet.default_settings'), ]); $this->actingAs($this->user) ->get(route('meet.webinars.show', $room)) ->assertOk() ->assertSee('https://events.test/events/create', false) ->assertSee('Events linking is not configured on this Meet instance.', false) ->assertDontSee('/qr-codes/create', false); } public function test_conference_events_link_uses_app_modals_not_native_confirm(): void { config([ 'meet.events_api_url' => 'https://events.test/api/service/v1', 'meet.events_api_key' => 'test-events-key', ]); Http::fake([ 'https://events.test/api/service/v1/events*' => Http::response(['events' => []]), ]); $room = Room::create([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Leadership conference', 'type' => 'town_hall', 'status' => 'scheduled', 'scheduled_at' => now()->addDay(), 'timezone' => 'UTC', 'settings' => config('meet.default_settings'), 'source' => [ 'app' => 'events', 'entity_type' => 'virtual_session', 'entity_id' => 'vs-1', 'event_id' => '42', 'event_title' => 'Summit', ], ]); $this->actingAs($this->user) ->get(route('meet.conferences.show', $room)) ->assertOk() ->assertSee('Remove Events link?', false) ->assertSee('data-modal-name="events-unlink-'.$room->uuid.'"', false) ->assertDontSee("confirm('Remove the Events link?", false); $room->update(['source' => null]); $this->actingAs($this->user) ->get(route('meet.conferences.show', $room)) ->assertOk() ->assertSee('open-modal', false) ->assertSee('data-modal-name="events-link-'.$room->uuid.'"', false) ->assertSee('Link to Ladill Events', false); $this->actingAs($this->user) ->get(route('meet.events.link', $room)) ->assertRedirect(route('meet.conferences.show', $room)); } public function test_afia_chat_returns_reply_when_ai_configured(): void { config([ 'afia.api_key' => 'test-openai-key', 'afia.platform_api_key' => '', ]); Http::fake([ 'api.openai.com/*' => Http::response([ 'choices' => [['message' => ['content' => 'Go to Meetings and click Schedule meeting.']]], ]), ]); $this->createRoom(['title' => 'Upcoming sync']); $this->actingAs($this->user) ->postJson('/ai/chat', ['message' => 'How do I schedule a meeting?']) ->assertOk() ->assertJson(['reply' => 'Go to Meetings and click Schedule meeting.']); } protected function createRoom(array $overrides = []): Room { return Room::create(array_merge([ 'owner_ref' => $this->user->public_id, 'organization_id' => $this->organization->id, 'host_user_ref' => $this->user->public_id, 'title' => 'Test Meeting', 'type' => 'scheduled', 'status' => 'scheduled', 'scheduled_at' => now()->addDay(), 'timezone' => 'UTC', 'settings' => config('meet.default_settings'), ], $overrides)); } }