Files
ladill-meet/tests/Feature/MeetWebTest.php
T
isaaccladandCursor fa9b4138af
Deploy Ladill Meet / deploy (push) Successful in 1m3s
Implement conference breakouts with meeting mode and programme lineup UI.
Attendees reconnect to breakout rooms with publish access while hosts stay on stage; poll and media-token endpoints drive LiveKit reconnection, and the live room shows Events-linked programme when available.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 00:06:45 +00:00

1205 lines
42 KiB
PHP

<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Recording;
use App\Models\Room;
use App\Models\Session;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class MeetWebTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected function setUp(): void
{
parent::setUp();
$this->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_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_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_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_flow(): void
{
$room = $this->createRoom([
'type' => 'webinar',
'settings' => array_merge(config('meet.default_settings'), ['webinar_auto_approve' => true]),
]);
$this->post('/w/'.$room->uuid.'/register', [
'email' => 'attendee@example.com',
'display_name' => 'Attendee One',
])->assertOk();
$this->assertDatabaseHas('meet_webinar_registrations', [
'room_id' => $room->id,
'email' => 'attendee@example.com',
'status' => 'approved',
]);
}
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_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_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_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',
'invite_only' => true,
])
->assertRedirect();
$this->assertDatabaseHas('meet_rooms', [
'title' => 'Leadership space',
'type' => 'space',
]);
}
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);
}
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));
}
}