Files
ladill-queue/tests/Feature/ServicePointRoutingTest.php
T
isaaccladandCursor b208e59bd0
Deploy Ladill Queue / deploy (push) Successful in 3m12s
Strip healthcare packaging and Care integration from Queue.
Queue is general-purpose QMS only; Care runs its own native engine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 10:15:32 +00:00

403 lines
15 KiB
PHP

<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\Counter;
use App\Models\DisplayScreen;
use App\Models\Organization;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use App\Models\User;
use App\Services\Qms\DisplayService;
use App\Services\Qms\OrganizationResolver;
use App\Services\Qms\QueueEngine;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ServicePointRoutingTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
}
/**
* @return array{0: User, 1: Organization, 2: Branch, 3: ServiceQueue, 4: Counter, 5: Counter}
*/
protected function setUpAssignedConsultation(): array
{
$user = User::create([
'public_id' => 'sp-owner',
'name' => 'Owner',
'email' => 'sp@example.com',
'password' => bcrypt('password'),
]);
$org = app(OrganizationResolver::class)->completeOnboarding($user, [
'organization_name' => 'Clinic',
'industry' => 'banking',
'appointment_mode' => 'hybrid',
'branch_name' => 'Main',
'timezone' => 'UTC',
]);
$branch = Branch::first();
$queue = ServiceQueue::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Consultation',
'prefix' => 'C',
'strategy' => 'fifo',
'is_active' => true,
'settings' => ['routing_mode' => 'assigned_only'],
]);
$room4 = Counter::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Dr. Mensah',
'destination' => 'Consultation Room 4',
'staff_ref' => 'doc-mensah',
'staff_display_name' => 'Dr. Mensah',
'status' => 'available',
'is_active' => true,
]);
$room5 = Counter::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Dr. Okai',
'destination' => 'Consultation Room 5',
'staff_ref' => 'doc-okai',
'staff_display_name' => 'Dr. Okai',
'status' => 'available',
'is_active' => true,
]);
$room4->serviceQueues()->sync([$queue->id]);
$room5->serviceQueues()->sync([$queue->id]);
return [$user, $org, $branch, $queue, $room4, $room5];
}
public function test_call_next_cannot_steal_ticket_assigned_to_another_service_point(): void
{
[$user, , , $queue, $room4, $room5] = $this->setUpAssignedConsultation();
$engine = app(QueueEngine::class);
$ticket = $engine->issueTicket($queue, $user->public_id, [
'customer_name' => 'Ada',
'assigned_counter_uuid' => $room4->uuid,
]);
$this->assertSame($room4->id, $ticket->assigned_counter_id);
$stolen = $engine->callNext($queue, $room5, $user->public_id);
$this->assertNull($stolen);
$called = $engine->callNext($queue, $room4, $user->public_id);
$this->assertNotNull($called);
$this->assertSame($ticket->id, $called->id);
$this->assertSame('called', $called->status);
$this->assertSame($room4->id, $called->counter_id);
}
public function test_assigned_only_queue_requires_service_point_on_issue(): void
{
[$user, , , $queue] = $this->setUpAssignedConsultation();
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('assigned service point');
app(QueueEngine::class)->issueTicket($queue, $user->public_id, [
'customer_name' => 'No Point',
]);
}
public function test_web_issue_create_page_shows_service_point_without_alpine_leak(): void
{
[$user, , , $queue] = $this->setUpAssignedConsultation();
$this->actingAs($user)
->get(route('qms.tickets.create', ['queue' => $queue->id]))
->assertOk()
->assertSee('Service point')
->assertSee('data-create-url', false)
->assertDontSee('needsPoint')
->assertDontSee('onQueueChange')
->assertDontSee('@json');
}
public function test_ticket_priority_labels_follow_industry_package(): void
{
$user = User::create([
'public_id' => 'prio-rest-owner',
'name' => 'Restaurant Owner',
'email' => 'prio-rest@example.com',
'password' => bcrypt('password'),
]);
app(OrganizationResolver::class)->completeOnboarding($user, [
'organization_name' => 'Bistro',
'industry' => 'restaurant',
'appointment_mode' => 'hybrid',
'branch_name' => 'Main',
'timezone' => 'UTC',
]);
$this->actingAs($user)
->get(route('qms.tickets.create'))
->assertOk()
->assertSee('Rush')
->assertSee('Reservation')
->assertSee('VIP table')
->assertDontSee('>Emergency<', false)
->assertDontSee('>Elderly<', false);
}
public function test_web_issue_without_service_point_returns_validation_error_not_500(): void
{
[$user, , , $queue] = $this->setUpAssignedConsultation();
$this->actingAs($user)
->from(route('qms.tickets.create'))
->post(route('qms.tickets.store'), [
'service_queue_id' => $queue->id,
'customer_name' => 'Walk-in',
'priority' => 'walk_in',
])
->assertRedirect(route('qms.tickets.create'))
->assertSessionHasErrors('assigned_counter_id');
$this->assertSame(0, Ticket::query()->where('service_queue_id', $queue->id)->count());
}
public function test_web_issue_with_service_point_succeeds(): void
{
[$user, , , $queue, $room4] = $this->setUpAssignedConsultation();
$this->actingAs($user)
->post(route('qms.tickets.store'), [
'service_queue_id' => $queue->id,
'assigned_counter_id' => $room4->id,
'customer_name' => 'Walk-in',
'priority' => 'walk_in',
])
->assertRedirect();
$ticket = Ticket::query()->where('service_queue_id', $queue->id)->first();
$this->assertNotNull($ticket);
$this->assertSame($room4->id, $ticket->assigned_counter_id);
}
public function test_display_shows_latest_per_service_point_with_destination_and_staff(): void
{
[$user, , $branch, $queue, $room4, $room5] = $this->setUpAssignedConsultation();
$engine = app(QueueEngine::class);
$t1 = $engine->issueTicket($queue, $user->public_id, [
'assigned_counter_uuid' => $room4->uuid,
'customer_name' => 'Patient A',
]);
$t2 = $engine->issueTicket($queue, $user->public_id, [
'assigned_counter_uuid' => $room5->uuid,
'customer_name' => 'Patient B',
]);
$engine->callTicket($t1, $room4, $user->public_id);
$engine->callTicket($t2, $room5, $user->public_id);
$screen = DisplayScreen::create([
'owner_ref' => $user->public_id,
'organization_id' => $queue->organization_id,
'branch_id' => $branch->id,
'name' => 'Lobby',
'layout' => 'standard',
'service_queue_ids' => [$queue->id],
'is_active' => true,
]);
$payload = app(DisplayService::class)->payload($screen);
$this->assertCount(2, $payload['now_serving']);
$destinations = collect($payload['now_serving'])->pluck('destination')->all();
$this->assertContains('Consultation Room 4', $destinations);
$this->assertContains('Consultation Room 5', $destinations);
$staff = collect($payload['now_serving'])->pluck('staff_display_name')->all();
$this->assertContains('Dr. Mensah', $staff);
$this->assertContains('Dr. Okai', $staff);
$mensah = collect($payload['now_serving'])->firstWhere('staff_display_name', 'Dr. Mensah');
$this->assertStringContainsString('Dr. Mensah', (string) ($mensah['announcement_text'] ?? ''));
$this->assertStringContainsString('Consultation Room 4', (string) ($mensah['announcement_text'] ?? ''));
}
public function test_shared_pool_still_allows_unassigned_call_next(): void
{
$user = User::create([
'public_id' => 'bank-owner',
'name' => 'Bank',
'email' => 'bank@example.com',
'password' => bcrypt('password'),
]);
$org = app(OrganizationResolver::class)->completeOnboarding($user, [
'organization_name' => 'Bank',
'industry' => 'banking',
'appointment_mode' => 'walk_in_only',
'branch_name' => 'Main',
'timezone' => 'UTC',
]);
$branch = Branch::first();
$queue = ServiceQueue::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Teller',
'prefix' => 'T',
'strategy' => 'fifo',
'is_active' => true,
'settings' => ['routing_mode' => 'shared_pool'],
]);
$window = Counter::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Window 1',
'destination' => 'Window 1',
'status' => 'available',
'is_active' => true,
]);
$window->serviceQueues()->sync([$queue->id]);
$engine = app(QueueEngine::class);
$ticket = $engine->issueTicket($queue, $user->public_id, ['customer_name' => 'Customer']);
$this->assertNull($ticket->assigned_counter_id);
$called = $engine->callNext($queue, $window, $user->public_id);
$this->assertNotNull($called);
$this->assertSame($ticket->id, $called->id);
}
public function test_shared_pool_call_next_is_fifo_and_one_at_a_time_per_counter(): void
{
$user = User::create([
'public_id' => 'pharm-owner',
'name' => 'Pharmacy',
'email' => 'pharm@example.com',
'password' => bcrypt('password'),
]);
$org = app(OrganizationResolver::class)->completeOnboarding($user, [
'organization_name' => 'Clinic Pharmacy',
'industry' => 'custom',
'appointment_mode' => 'walk_in_only',
'branch_name' => 'Main',
'timezone' => 'UTC',
]);
$branch = Branch::first();
$queue = ServiceQueue::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Pharmacy',
'prefix' => 'P',
'strategy' => 'fifo',
'is_active' => true,
'settings' => ['routing_mode' => 'shared_pool'],
]);
$c1 = Counter::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Counter 1',
'destination' => 'Counter 1',
'status' => 'available',
'is_active' => true,
]);
$c2 = Counter::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Counter 2',
'destination' => 'Counter 2',
'status' => 'available',
'is_active' => true,
]);
$c1->serviceQueues()->sync([$queue->id]);
$c2->serviceQueues()->sync([$queue->id]);
$engine = app(QueueEngine::class);
$first = $engine->issueTicket($queue, $user->public_id, ['customer_name' => 'A']);
$second = $engine->issueTicket($queue->fresh(), $user->public_id, ['customer_name' => 'B']);
$third = $engine->issueTicket($queue->fresh(), $user->public_id, ['customer_name' => 'C']);
// Pre-assign first ticket to the "wrong" counter — shared pool must still allow FIFO claim.
$first->update(['assigned_counter_id' => $c2->id]);
$called1 = $engine->callNext($queue, $c1, $user->public_id);
$this->assertSame($first->id, $called1->id);
$this->assertSame('called', $called1->status);
$this->assertSame($c1->id, $called1->counter_id);
// Second Call next at same counter recalls the same patient — does not stack.
$again = $engine->callNext($queue, $c1, $user->public_id);
$this->assertSame($first->id, $again->id);
$this->assertSame(1, Ticket::where('service_queue_id', $queue->id)->where('status', 'called')->count());
$engine->startServing($called1->fresh(), $user->public_id);
$this->assertNull($engine->callNext($queue, $c1, $user->public_id));
// Other counter still gets the next waiting patient in FIFO order.
$called2 = $engine->callNext($queue, $c2, $user->public_id);
$this->assertSame($second->id, $called2->id);
$this->assertSame($third->id, $engine->waitingTickets($queue)[0]->id);
}
public function test_frontdesk_api_can_issue_assigned_ticket(): void
{
config(['qms.service_api_keys.frontdesk' => 'test-frontdesk-key']);
$owner = 'frontdesk-sp-owner';
$headers = [
'Authorization' => 'Bearer test-frontdesk-key',
'Accept' => 'application/json',
];
$this->postJson('/api/v1/integrations/provision', [
'owner' => $owner,
'organization_name' => 'Visitor Desk',
'branch_name' => 'Main',
], $headers)->assertSuccessful();
$queueUuid = $this->postJson('/api/v1/queues', [
'owner' => $owner,
'name' => 'Consultation',
'prefix' => 'C',
'branch_name' => 'Main',
'external_key' => 'frontdesk:dept:consultation:queue:1',
'routing_mode' => 'assigned_only',
], $headers)->assertCreated()->json('data.uuid');
$counterUuid = $this->postJson('/api/v1/counters', [
'owner' => $owner,
'name' => 'Dr. Mensah',
'destination' => 'Room 4',
'staff_display_name' => 'Dr. Mensah',
'branch_name' => 'Main',
'queue_uuids' => [$queueUuid],
'external_key' => 'frontdesk:point:consultation:practitioner:9',
], $headers)->assertCreated()->json('data.uuid');
$this->postJson('/api/v1/tickets', [
'owner' => $owner,
'service_queue_id' => $queueUuid,
'customer_name' => 'Ada',
'assigned_counter_id' => $counterUuid,
], $headers)
->assertCreated()
->assertJsonPath('data.assigned_counter.uuid', $counterUuid)
->assertJsonPath('data.destination', 'Room 4')
->assertJsonPath('data.staff_display_name', 'Dr. Mensah');
}
}