Files
ladill-meet/tests/Feature/MeetWebTest.php
T
isaaccladandCursor 158b7b650a
Deploy Ladill Meet / deploy (push) Successful in 35s
Allow adding conference speakers after creation on details page.
Hosts can assign team speakers and invite panelists by email from the
conference show page, with presenter refs synced to live sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 20:29:16 +00:00

826 lines
28 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_create_template(): void
{
$this->actingAs($this->user)
->post('/templates', [
'name' => 'Weekly standup',
'duration_minutes' => 30,
'waiting_room' => '1',
])
->assertRedirect(route('meet.templates.index'));
$this->assertDatabaseHas('meet_templates', [
'name' => 'Weekly standup',
'organization_id' => $this->organization->id,
]);
}
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_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_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_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_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_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));
}
}