Initial Ladill Frontdesk release with deploy pipeline.
Deploy Ladill Frontdesk / deploy (push) Failing after 35s
Test / test (push) Failing after 2m45s

Visitor management app with SSO, kiosk, badges, reports, and Gitea CI deploy to frontdesk.ladill.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-27 20:37:15 +00:00
co-authored by Cursor
commit 9e2d79936c
284 changed files with 29134 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace Tests\Feature;
use App\Models\Organization;
use App\Models\User;
use App\Models\Visit;
use App\Models\Visitor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class FrontdeskApiTest extends TestCase
{
use RefreshDatabase;
protected string $apiKey = 'test-frontdesk-api-key';
protected User $user;
protected Organization $organization;
protected function setUp(): void
{
parent::setUp();
config(['frontdesk.service_api_keys' => ['care' => $this->apiKey]]);
$this->user = User::create([
'public_id' => 'api-user-001',
'name' => 'API User',
'email' => 'api@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'API Org',
'slug' => 'api-org',
'settings' => ['onboarded' => true],
]);
}
public function test_api_requires_service_key(): void
{
$this->getJson('/api/visits?owner='.$this->user->public_id)
->assertUnauthorized();
}
public function test_api_can_create_visit(): void
{
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->apiKey])
->postJson('/api/visits', [
'owner' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'API Visitor',
'visitor_type' => 'visitor',
]);
$response->assertCreated();
$this->assertDatabaseHas('frontdesk_visits', ['visitor_type' => 'visitor']);
}
public function test_api_can_list_visits(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Listed Visitor',
]);
Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'visitor_id' => $visitor->id,
'visitor_type' => 'visitor',
'status' => Visit::STATUS_CHECKED_IN,
'checked_in_at' => now(),
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->apiKey])
->getJson('/api/visits?owner='.$this->user->public_id)
->assertOk()
->assertJsonPath('total', 1);
}
public function test_api_scopes_to_owner(): void
{
$other = User::create([
'public_id' => 'other-user',
'name' => 'Other',
'email' => 'other@example.com',
]);
$visitor = Visitor::create([
'owner_ref' => $other->public_id,
'organization_id' => Organization::create([
'owner_ref' => $other->public_id,
'name' => 'Other Org',
'slug' => 'other',
])->id,
'full_name' => 'Secret Visitor',
]);
Visit::create([
'owner_ref' => $other->public_id,
'organization_id' => $visitor->organization_id,
'visitor_id' => $visitor->id,
'visitor_type' => 'visitor',
'status' => Visit::STATUS_CHECKED_IN,
'checked_in_at' => now(),
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->apiKey])
->getJson('/api/visits?owner='.$this->user->public_id)
->assertOk()
->assertJsonPath('total', 0);
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace Tests\Feature;
use App\Models\Organization;
use App\Models\User;
use App\Models\Visit;
use App\Models\Visitor;
use App\Models\WebhookEndpoint;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class FrontdeskPhase10Test extends TestCase
{
use RefreshDatabase;
protected string $careKey = 'test-care-api-key';
protected User $user;
protected Organization $organization;
protected function setUp(): void
{
parent::setUp();
config(['frontdesk.service_api_keys' => ['care' => $this->careKey, 'lab' => 'test-lab-key']]);
$this->user = User::create([
'public_id' => 'phase10-user-001',
'name' => 'Phase10 User',
'email' => 'phase10@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Phase10 Org',
'slug' => 'phase10-org',
'settings' => ['onboarded' => true],
]);
}
public function test_care_api_can_create_visit_with_external_ref(): void
{
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->careKey])
->postJson('/api/visits', [
'owner' => $this->user->public_id,
'organization_id' => $this->organization->id,
'external_ref' => 'care-appt-001',
'full_name' => 'Care Patient',
'visitor_type' => 'visitor',
'integration_metadata' => ['appointment_id' => 'A-100'],
]);
$response->assertCreated();
$this->assertDatabaseHas('frontdesk_visits', [
'external_ref' => 'care-appt-001',
'source' => 'care',
]);
}
public function test_external_ref_is_idempotent_for_same_source(): void
{
$payload = [
'owner' => $this->user->public_id,
'organization_id' => $this->organization->id,
'external_ref' => 'lab-specimen-42',
'full_name' => 'Lab Walk-in',
'visitor_type' => 'visitor',
];
config(['frontdesk.service_api_keys' => ['lab' => 'test-lab-key']]);
$this->withHeaders(['Authorization' => 'Bearer test-lab-key'])
->postJson('/api/visits', $payload)
->assertCreated();
$this->withHeaders(['Authorization' => 'Bearer test-lab-key'])
->postJson('/api/visits', $payload)
->assertOk();
$this->assertSame(1, Visit::where('external_ref', 'lab-specimen-42')->count());
}
public function test_webhook_dispatched_on_check_in(): void
{
Http::fake();
WebhookEndpoint::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'url' => 'https://example.com/webhooks/frontdesk',
'secret' => 'secret123',
'events' => ['visit.checked_in'],
'is_active' => true,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->careKey])
->postJson('/api/visits', [
'owner' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Webhook Guest',
'visitor_type' => 'visitor',
])
->assertCreated();
Http::assertSent(fn ($request) => $request->url() === 'https://example.com/webhooks/frontdesk');
}
public function test_ical_feed_returns_calendar_data(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Scheduled Guest',
]);
Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'visitor_id' => $visitor->id,
'visitor_type' => 'visitor',
'status' => Visit::STATUS_EXPECTED,
'scheduled_at' => now()->addHours(2),
]);
$token = hash_hmac('sha256', "{$this->organization->id}:{$this->user->public_id}", (string) config('app.key'));
$this->get(route('frontdesk.integrations.ical', [
'organization' => $this->organization->id,
'owner' => $this->user->public_id,
'token' => $token,
]))
->assertOk()
->assertHeader('content-type', 'text/calendar; charset=utf-8')
->assertSee('BEGIN:VCALENDAR');
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\Device;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use Tests\TestCase;
class FrontdeskPhase11Test 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' => 'phase11-user-001',
'name' => 'Phase11 User',
'email' => 'phase11@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Phase11 Org',
'slug' => 'phase11-org',
'settings' => ['onboarded' => true, 'badge_expiry_hours' => 8],
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'org_admin',
]);
Branch::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'HQ',
'is_active' => true,
]);
}
public function test_dashboard_stats_are_cached(): void
{
Cache::flush();
$this->actingAs($this->user)->get(route('frontdesk.dashboard'))->assertOk();
$this->assertTrue(Cache::has('fd:dashboard:'.$this->user->public_id.':'.$this->organization->id.':all'));
}
public function test_kiosk_device_route_is_rate_limited(): void
{
RateLimiter::clear('kiosk-device');
$device = Device::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'Limited Kiosk',
'type' => 'kiosk',
'device_token' => 'rate-limit-token',
'status' => 'offline',
]);
for ($i = 0; $i < 31; $i++) {
$response = $this->postJson(route('frontdesk.kiosk.device.check-in', $device->device_token), [
'full_name' => 'Guest '.$i,
'visitor_type' => 'visitor',
'policies_accepted' => 1,
]);
}
$response->assertStatus(429);
}
public function test_offline_sync_replays_queued_check_in(): void
{
$device = Device::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => Branch::first()->id,
'name' => 'Offline Kiosk',
'type' => 'kiosk',
'device_token' => 'offline-sync-token',
'status' => 'online',
]);
$clientId = '550e8400-e29b-41d4-a716-446655440000';
$this->postJson('/api/kiosk/offline-sync', [
'client_id' => $clientId,
'payload' => [
'full_name' => 'Offline Guest',
'visitor_type' => 'visitor',
'policies_accepted' => true,
],
], ['X-Device-Token' => $device->device_token])
->assertCreated()
->assertJsonPath('status', 'synced');
$this->postJson('/api/kiosk/offline-sync', [
'client_id' => $clientId,
'payload' => [
'full_name' => 'Offline Guest',
'visitor_type' => 'visitor',
'policies_accepted' => true,
],
], ['X-Device-Token' => $device->device_token])
->assertOk()
->assertJsonPath('status', 'already_synced');
}
public function test_app_layout_supports_dark_mode_toggle_markup(): void
{
$this->actingAs($this->user)
->get(route('frontdesk.dashboard'))
->assertOk()
->assertSee('Toggle dark mode', false);
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class FrontdeskPhase2Test extends TestCase
{
use RefreshDatabase;
protected User $admin;
protected Organization $organization;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->admin = User::create([
'public_id' => 'admin-001',
'name' => 'Admin User',
'email' => 'admin@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->admin->public_id,
'name' => 'Phase2 Org',
'slug' => 'phase2-org',
'settings' => ['onboarded' => true],
]);
Member::create([
'owner_ref' => $this->admin->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->admin->public_id,
'role' => 'org_admin',
]);
}
public function test_onboarding_creates_organization_and_branch(): void
{
Organization::query()->delete();
Member::query()->delete();
$this->actingAs($this->admin)
->post(route('frontdesk.onboarding.store'), [
'organization_name' => 'New Corp',
'branch_name' => 'HQ',
'timezone' => 'UTC',
'badge_expiry_hours' => 8,
])
->assertRedirect(route('frontdesk.dashboard'));
$this->assertDatabaseHas('frontdesk_organizations', ['name' => 'New Corp']);
$this->assertDatabaseHas('frontdesk_branches', ['name' => 'HQ']);
$this->assertDatabaseHas('frontdesk_members', ['role' => 'org_admin']);
}
public function test_org_admin_can_create_branch(): void
{
$this->actingAs($this->admin)
->post(route('frontdesk.branches.store'), [
'name' => 'East Wing',
'address' => '123 Main St',
])
->assertRedirect(route('frontdesk.branches.index'));
$this->assertDatabaseHas('frontdesk_branches', ['name' => 'East Wing']);
}
public function test_org_admin_can_add_team_member(): void
{
$branch = Branch::create([
'owner_ref' => $this->admin->public_id,
'organization_id' => $this->organization->id,
'name' => 'Main',
'is_active' => true,
]);
$this->actingAs($this->admin)
->post(route('frontdesk.members.store'), [
'user_ref' => 'receptionist-001',
'role' => 'receptionist',
'branch_id' => $branch->id,
])
->assertRedirect(route('frontdesk.members.index'));
$this->assertDatabaseHas('frontdesk_members', [
'user_ref' => 'receptionist-001',
'role' => 'receptionist',
]);
}
public function test_receptionist_cannot_manage_branches(): void
{
$receptionist = User::create([
'public_id' => 'receptionist-001',
'name' => 'Receptionist',
'email' => 'rec@example.com',
]);
Member::create([
'owner_ref' => $this->admin->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $receptionist->public_id,
'role' => 'receptionist',
]);
$this->actingAs($receptionist)
->get(route('frontdesk.branches.create'))
->assertForbidden();
}
public function test_receptionist_can_access_check_in(): void
{
$receptionist = User::create([
'public_id' => 'receptionist-002',
'name' => 'Receptionist Two',
'email' => 'rec2@example.com',
]);
Member::create([
'owner_ref' => $this->admin->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $receptionist->public_id,
'role' => 'receptionist',
]);
$this->actingAs($receptionist)
->get(route('frontdesk.visits.create'))
->assertOk();
}
}
+258
View File
@@ -0,0 +1,258 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\Host;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Models\Visit;
use App\Models\Visitor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class FrontdeskPhase3Test extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Branch $branch;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'phase3-user-001',
'name' => 'Phase3 User',
'email' => 'phase3@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Phase3 Org',
'slug' => 'phase3-org',
'settings' => ['onboarded' => true, 'badge_expiry_hours' => 8],
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'org_admin',
]);
$this->branch = Branch::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'HQ',
'is_active' => true,
]);
}
public function test_can_schedule_future_visit(): void
{
$scheduledAt = now()->addDay()->format('Y-m-d\TH:i');
$this->actingAs($this->user)
->post(route('frontdesk.visits.schedule.store'), [
'full_name' => 'Future Guest',
'email' => 'future@example.com',
'visitor_type' => 'visitor',
'scheduled_at' => $scheduledAt,
'purpose' => 'Meeting',
])
->assertRedirect();
$this->assertDatabaseHas('frontdesk_visits', [
'status' => Visit::STATUS_SCHEDULED,
'purpose' => 'Meeting',
]);
}
public function test_can_schedule_today_visit_as_expected(): void
{
$scheduledAt = now()->addHour()->format('Y-m-d\TH:i');
$this->actingAs($this->user)
->post(route('frontdesk.visits.schedule.store'), [
'full_name' => 'Today Guest',
'email' => 'today@example.com',
'visitor_type' => 'visitor',
'scheduled_at' => $scheduledAt,
])
->assertRedirect();
$this->assertDatabaseHas('frontdesk_visits', [
'status' => Visit::STATUS_EXPECTED,
]);
}
public function test_can_check_in_scheduled_visit(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Scheduled Guest',
'email' => 'scheduled@example.com',
]);
$visit = Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'visitor_id' => $visitor->id,
'visitor_type' => 'visitor',
'status' => Visit::STATUS_EXPECTED,
'scheduled_at' => now()->addHour(),
]);
$this->actingAs($this->user)
->post(route('frontdesk.visits.activate', $visit), [
'policies_accepted' => '1',
])
->assertRedirect(route('frontdesk.visits.show', $visit));
$visit->refresh();
$this->assertSame(Visit::STATUS_CHECKED_IN, $visit->status);
$this->assertNotNull($visit->checked_in_at);
}
public function test_can_mark_visit_waiting_and_cancel(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Waiting Guest',
]);
$visit = Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'visitor_id' => $visitor->id,
'visitor_type' => 'visitor',
'status' => Visit::STATUS_EXPECTED,
'scheduled_at' => now(),
]);
$this->actingAs($this->user)
->post(route('frontdesk.visits.waiting', $visit))
->assertRedirect();
$this->assertSame(Visit::STATUS_WAITING, $visit->fresh()->status);
$this->actingAs($this->user)
->post(route('frontdesk.visits.cancel', $visit), ['reason' => 'No show'])
->assertRedirect(route('frontdesk.visits.index', ['status' => 'cancelled']));
$this->assertSame(Visit::STATUS_CANCELLED, $visit->fresh()->status);
}
public function test_returning_visitor_quick_check_in(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Regular Guest',
'visit_count' => 3,
]);
$this->actingAs($this->user)
->post(route('frontdesk.visitors.check-in.store', $visitor), [
'visitor_type' => 'visitor',
'policies_accepted' => '1',
])
->assertRedirect();
$this->assertDatabaseHas('frontdesk_visits', [
'visitor_id' => $visitor->id,
'status' => Visit::STATUS_CHECKED_IN,
]);
}
public function test_can_update_visitor_profile(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Profile Guest',
'notes' => 'Old note',
]);
$this->actingAs($this->user)
->patch(route('frontdesk.visitors.update', $visitor), [
'full_name' => 'Updated Guest',
'company' => 'Acme Corp',
'notes' => 'VIP guest',
])
->assertRedirect();
$visitor->refresh();
$this->assertSame('Updated Guest', $visitor->full_name);
$this->assertSame('Acme Corp', $visitor->company);
$this->assertSame('VIP guest', $visitor->notes);
}
public function test_mark_overdue_command_updates_past_visits(): void
{
Carbon::setTestNow('2026-06-27 12:00:00');
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Late Guest',
]);
Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'visitor_id' => $visitor->id,
'visitor_type' => 'visitor',
'status' => Visit::STATUS_EXPECTED,
'scheduled_at' => now()->subHour(),
]);
$this->artisan('frontdesk:mark-overdue-visits')
->assertSuccessful();
$this->assertDatabaseHas('frontdesk_visits', [
'visitor_id' => $visitor->id,
'status' => Visit::STATUS_OVERDUE,
]);
Carbon::setTestNow();
}
public function test_calendar_page_loads(): void
{
$this->actingAs($this->user)
->get(route('frontdesk.visits.calendar'))
->assertOk()
->assertSee('Visit calendar');
}
public function test_api_can_schedule_visit(): void
{
config(['frontdesk.service_api_keys.crm' => 'test-api-key']);
$scheduledAt = now()->addDays(2)->toIso8601String();
$this->withHeader('Authorization', 'Bearer test-api-key')
->postJson('/api/visits?owner='.$this->user->public_id, [
'schedule' => true,
'organization_id' => $this->organization->id,
'full_name' => 'API Guest',
'email' => 'api@example.com',
'visitor_type' => 'visitor',
'scheduled_at' => $scheduledAt,
])
->assertCreated()
->assertJsonPath('status', Visit::STATUS_SCHEDULED);
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Models\Visit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class FrontdeskPhase4Test 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' => 'phase4-user-001',
'name' => 'Phase4 User',
'email' => 'phase4@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Phase4 Org',
'slug' => 'phase4-org',
'settings' => ['onboarded' => true, 'badge_expiry_hours' => 8],
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'org_admin',
]);
Branch::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'HQ',
'is_active' => true,
]);
}
public function test_contractor_check_in_stores_details_and_extended_badge(): void
{
$this->actingAs($this->user)
->post(route('frontdesk.visits.store'), [
'full_name' => 'Site Worker',
'visitor_type' => 'contractor',
'policies_accepted' => '1',
'type_fields' => [
'contract_company' => 'BuildCo',
'supervisor' => 'Jane Smith',
'safety_induction_completed' => '1',
'permit_number' => 'PERMIT-99',
],
])
->assertRedirect();
$visit = Visit::first();
$this->assertSame(Visit::STATUS_CHECKED_IN, $visit->status);
$this->assertSame('BuildCo', $visit->contractor_details['contract_company']);
$this->assertSame('PERMIT-99', $visit->contractor_details['permit_number']);
$this->assertTrue($visit->badge_expires_at->greaterThan(now()->addHours(11)));
}
public function test_delivery_check_in_records_received_time(): void
{
$this->actingAs($this->user)
->post(route('frontdesk.visits.store'), [
'full_name' => 'Courier',
'visitor_type' => 'delivery',
'policies_accepted' => '1',
'type_fields' => [
'courier_company' => 'FastPost',
'recipient' => 'Finance Dept',
'tracking_number' => 'TRK123',
],
])
->assertRedirect();
$visit = Visit::first();
$this->assertSame('FastPost', $visit->delivery_details['courier_company']);
$this->assertArrayHasKey('received_at', $visit->delivery_details);
$this->assertTrue($visit->badge_expires_at->lessThanOrEqualTo(now()->addHours(2)->addMinute()));
}
public function test_vendor_check_in_requires_approval(): void
{
$this->actingAs($this->user)
->post(route('frontdesk.visits.store'), [
'full_name' => 'Vendor Rep',
'visitor_type' => 'vendor',
'policies_accepted' => '1',
'type_fields' => [
'vendor_company' => 'Supply Ltd',
'service_type' => 'Office supplies',
],
])
->assertRedirect()
->assertSessionHas('success', 'Visit submitted for approval.');
$visit = Visit::first();
$this->assertSame(Visit::STATUS_WAITING, $visit->status);
$this->assertNull($visit->checked_in_at);
$this->assertTrue($visit->awaitingApproval());
$this->actingAs($this->user)
->post(route('frontdesk.visits.approve', $visit))
->assertRedirect();
$visit->refresh();
$this->assertSame(Visit::STATUS_CHECKED_IN, $visit->status);
$this->assertNotNull($visit->checked_in_at);
}
public function test_kiosk_contractor_check_in_via_json(): void
{
$this->actingAs($this->user)
->postJson(route('frontdesk.kiosk.check-in'), [
'full_name' => 'Kiosk Contractor',
'visitor_type' => 'contractor',
'policies_accepted' => true,
'type_fields' => [
'contract_company' => 'Kiosk Build',
'supervisor' => 'Supervisor',
'safety_induction_completed' => true,
],
])
->assertOk()
->assertJsonPath('visit.status', Visit::STATUS_CHECKED_IN);
$this->assertDatabaseHas('frontdesk_visits', [
'visitor_type' => 'contractor',
'status' => Visit::STATUS_CHECKED_IN,
]);
}
public function test_org_can_override_contractor_badge_expiry(): void
{
$this->actingAs($this->user)
->put(route('frontdesk.settings.update'), [
'name' => 'Phase4 Org',
'timezone' => 'UTC',
'badge_expiry_hours' => 8,
'kiosk_reset_seconds' => 120,
'contractor_badge_expiry_hours' => 16,
])
->assertRedirect();
$this->actingAs($this->user)
->post(route('frontdesk.visits.store'), [
'full_name' => 'Long Shift Worker',
'visitor_type' => 'contractor',
'policies_accepted' => '1',
'type_fields' => [
'contract_company' => 'BuildCo',
'supervisor' => 'Jane Smith',
'safety_induction_completed' => '1',
],
]);
$visit = Visit::latest()->first();
$this->assertTrue($visit->badge_expires_at->greaterThan(now()->addHours(15)));
}
}
+219
View File
@@ -0,0 +1,219 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\AuditLog;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Models\Visit;
use App\Models\Visitor;
use App\Models\WatchlistEntry;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class FrontdeskPhase5Test 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' => 'phase5-user-001',
'name' => 'Phase5 User',
'email' => 'phase5@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Phase5 Org',
'slug' => 'phase5-org',
'settings' => ['onboarded' => true, 'badge_expiry_hours' => 8],
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'org_admin',
]);
Branch::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'HQ',
'is_active' => true,
]);
}
public function test_blacklisted_check_in_records_audit_log(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Blocked Person',
'email' => 'blocked@example.com',
'watchlist_status' => Visitor::WATCHLIST_BLACKLISTED,
]);
$this->actingAs($this->user)
->post(route('frontdesk.visits.store'), [
'visitor_id' => $visitor->id,
'full_name' => 'Blocked Person',
'email' => 'blocked@example.com',
'visitor_type' => 'visitor',
'policies_accepted' => '1',
])
->assertForbidden();
$this->assertDatabaseHas('frontdesk_audit_logs', [
'action' => 'watchlist.blocked_attempt',
]);
}
public function test_flagged_visitor_check_in_queues_for_approval(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Flagged Person',
'watchlist_status' => Visitor::WATCHLIST_REQUIRES_APPROVAL,
]);
$this->actingAs($this->user)
->post(route('frontdesk.visitors.check-in.store', $visitor), [
'visitor_type' => 'visitor',
'policies_accepted' => '1',
])
->assertRedirect();
$visit = Visit::first();
$this->assertSame(Visit::STATUS_WAITING, $visit->status);
$this->assertTrue($visit->awaitingApproval());
$this->assertDatabaseHas('frontdesk_audit_logs', [
'action' => 'watchlist.flagged_checkin',
]);
}
public function test_watchlist_admin_can_create_entry(): void
{
$this->actingAs($this->user)
->post(route('frontdesk.watchlist.store'), [
'full_name' => 'Known Person',
'status' => Visitor::WATCHLIST_BLACKLISTED,
'reason' => 'Previous incident',
])
->assertRedirect(route('frontdesk.watchlist.index'));
$this->assertDatabaseHas('frontdesk_watchlist_entries', [
'full_name' => 'Known Person',
'status' => Visitor::WATCHLIST_BLACKLISTED,
]);
}
public function test_audit_log_page_loads_and_exports_csv(): void
{
AuditLog::record(
$this->user->public_id,
'visit.checked_in',
$this->organization->id,
$this->user->public_id,
Visit::class,
1,
['visitor' => 'Test'],
);
$this->actingAs($this->user)
->get(route('frontdesk.audit.index'))
->assertOk()
->assertSee('Audit log');
$this->actingAs($this->user)
->get(route('frontdesk.audit.export'))
->assertOk()
->assertHeader('content-type', 'text/csv; charset=UTF-8');
}
public function test_security_can_verify_badge_code(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Verify Me',
]);
$visit = Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'visitor_id' => $visitor->id,
'visitor_type' => 'visitor',
'status' => Visit::STATUS_CHECKED_IN,
'badge_code' => 'ABC12345',
'checked_in_at' => now(),
'badge_expires_at' => now()->addHours(4),
]);
$this->actingAs($this->user)
->post(route('frontdesk.security.verify.lookup'), ['lookup' => 'ABC12345'])
->assertOk()
->assertSee('Verify Me')
->assertSee('Valid');
}
public function test_soft_deleted_visitor_can_be_restored(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Archived Person',
]);
$visitor->delete();
$this->actingAs($this->user)
->post(route('frontdesk.compliance.visitors.restore', $visitor->id))
->assertRedirect();
$this->assertNull($visitor->fresh()->deleted_at);
$this->assertDatabaseHas('frontdesk_audit_logs', ['action' => 'visitor.restored']);
}
public function test_expired_badge_command_logs_alert(): void
{
Carbon::setTestNow('2026-06-27 18:00:00');
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Expired Badge Guest',
]);
Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'visitor_id' => $visitor->id,
'visitor_type' => 'visitor',
'status' => Visit::STATUS_CHECKED_IN,
'checked_in_at' => now()->subHours(10),
'badge_expires_at' => now()->subHour(),
'badge_code' => 'EXP12345',
]);
$this->artisan('frontdesk:mark-expired-badges')->assertSuccessful();
$this->assertDatabaseHas('frontdesk_audit_logs', ['action' => 'badge.expired']);
Carbon::setTestNow();
}
}
+206
View File
@@ -0,0 +1,206 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\Device;
use App\Models\Host;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Models\Visit;
use App\Models\Visitor;
use App\Notifications\FrontdeskAlertNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class FrontdeskPhase6Test extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Host $host;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'phase6-user-001',
'name' => 'Phase6 User',
'email' => 'phase6@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Phase6 Org',
'slug' => 'phase6-org',
'settings' => [
'onboarded' => true,
'badge_expiry_hours' => 8,
'notification_channels' => ['email'],
'notification_events' => config('frontdesk.default_notification_events'),
],
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'host',
]);
Branch::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'HQ',
'is_active' => true,
]);
$this->host = Host::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'Phase6 Host',
'email' => 'host@example.com',
'user_ref' => $this->user->public_id,
'is_available' => true,
]);
}
public function test_host_portal_lists_linked_profile(): void
{
$this->actingAs($this->user)
->get(route('frontdesk.host.index'))
->assertOk()
->assertSee('Phase6 Host');
}
public function test_host_can_pre_register_visitor(): void
{
$this->actingAs($this->user)
->post(route('frontdesk.host.schedule.store'), [
'full_name' => 'Expected Guest',
'visitor_type' => 'visitor',
'scheduled_at' => now()->addDay()->format('Y-m-d\TH:i'),
'purpose' => 'Project review',
])
->assertRedirect(route('frontdesk.host.index'));
$visit = Visit::first();
$this->assertSame($this->host->id, $visit->host_id);
$this->assertSame('Expected Guest', $visit->visitor->full_name);
$this->assertSame(Visit::STATUS_SCHEDULED, $visit->status);
}
public function test_host_can_approve_pending_visit(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Vendor Rep',
]);
$visit = Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'visitor_id' => $visitor->id,
'host_id' => $this->host->id,
'visitor_type' => 'vendor',
'status' => Visit::STATUS_WAITING,
'policies_accepted' => true,
'contractor_details' => [
'vendor_company' => 'Acme',
'service_type' => 'IT',
'_awaiting_approval' => true,
],
]);
$this->actingAs($this->user)
->post(route('frontdesk.host.approve', $visit))
->assertRedirect(route('frontdesk.host.index'));
$this->assertSame(Visit::STATUS_CHECKED_IN, $visit->fresh()->status);
}
public function test_check_in_notifies_linked_host_via_database(): void
{
Notification::fake();
$admin = User::create([
'public_id' => 'phase6-admin-001',
'name' => 'Admin',
'email' => 'admin@example.com',
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $admin->public_id,
'role' => 'org_admin',
]);
$this->actingAs($admin)
->post(route('frontdesk.visits.store'), [
'full_name' => 'Walk-in Guest',
'host_id' => $this->host->id,
'visitor_type' => 'visitor',
'policies_accepted' => '1',
])
->assertRedirect();
Notification::assertSentTo($this->user, FrontdeskAlertNotification::class);
}
public function test_disabled_notification_event_skips_host_alert(): void
{
Notification::fake();
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'notification_events' => array_merge(
config('frontdesk.default_notification_events'),
['visitor_arrived' => false],
),
]),
]);
$admin = User::create([
'public_id' => 'phase6-admin-002',
'name' => 'Admin Two',
'email' => 'admin2@example.com',
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $admin->public_id,
'role' => 'org_admin',
]);
$this->actingAs($admin)
->post(route('frontdesk.visits.store'), [
'full_name' => 'Silent Guest',
'host_id' => $this->host->id,
'visitor_type' => 'visitor',
'policies_accepted' => '1',
])
->assertRedirect();
Notification::assertNotSentTo($this->user, FrontdeskAlertNotification::class);
}
public function test_host_can_toggle_availability(): void
{
$this->actingAs($this->user)
->post(route('frontdesk.host.availability'))
->assertRedirect();
$this->assertFalse($this->host->fresh()->is_available);
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\Device;
use App\Models\Host;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Models\Visit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class FrontdeskPhase7Test extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected string $deviceToken;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'phase7-user-001',
'name' => 'Phase7 User',
'email' => 'phase7@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Phase7 Org',
'slug' => 'phase7-org',
'settings' => ['onboarded' => true, 'badge_expiry_hours' => 8],
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'org_admin',
]);
Branch::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'HQ',
'is_active' => true,
]);
Host::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'Lobby Host',
'is_available' => true,
]);
$this->deviceToken = 'kiosk-test-token-'.str_repeat('x', 32);
}
public function test_admin_can_register_kiosk_device(): void
{
$this->actingAs($this->user)
->post(route('frontdesk.devices.store'), [
'name' => 'Lobby Kiosk',
'type' => 'kiosk',
])
->assertRedirect(route('frontdesk.devices.index'));
$device = Device::first();
$this->assertSame('kiosk', $device->type);
$this->assertNotEmpty($device->device_token);
}
public function test_kiosk_device_page_works_without_auth(): void
{
$device = $this->createKioskDevice();
$this->get(route('frontdesk.kiosk.device', $device->device_token))
->assertOk()
->assertSee('Visitor Check-in');
$this->assertTrue($device->fresh()->isOnline());
}
public function test_kiosk_device_can_check_in_visitor(): void
{
$device = $this->createKioskDevice();
$host = Host::first();
$this->postJson(route('frontdesk.kiosk.device.check-in', $device->device_token), [
'full_name' => 'Kiosk Guest',
'host_id' => $host->id,
'visitor_type' => 'visitor',
'policies_accepted' => 1,
])->assertOk()
->assertJsonPath('visit.visitor_name', 'Kiosk Guest');
$visit = Visit::first();
$this->assertSame(Visit::STATUS_CHECKED_IN, $visit->status);
$this->assertSame($device->branch_id, $visit->branch_id);
}
public function test_device_heartbeat_api_marks_online(): void
{
$device = $this->createKioskDevice(['status' => 'offline', 'last_online_at' => null]);
$this->postJson('/api/devices/heartbeat', [], [
'X-Device-Token' => $device->device_token,
])->assertOk()
->assertJsonPath('status', 'online');
$this->assertTrue($device->fresh()->isOnline());
}
public function test_mark_devices_offline_command(): void
{
Carbon::setTestNow(now());
$device = $this->createKioskDevice([
'status' => 'online',
'last_online_at' => now()->subMinutes(30),
]);
$this->artisan('frontdesk:mark-devices-offline')->assertSuccessful();
$this->assertSame('offline', $device->fresh()->status);
Carbon::setTestNow();
}
public function test_invalid_device_token_returns_not_found(): void
{
$this->get(route('frontdesk.kiosk.device', 'invalid-token'))
->assertNotFound();
}
/** @param array<string, mixed> $overrides */
protected function createKioskDevice(array $overrides = []): Device
{
return Device::create(array_merge([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => Branch::first()->id,
'name' => 'Test Kiosk',
'type' => 'kiosk',
'status' => 'offline',
'device_token' => $this->deviceToken,
'config' => ['mode' => 'self_service'],
], $overrides));
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Models\Visit;
use App\Models\Visitor;
use App\Services\Frontdesk\VisitorTypeService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class FrontdeskPhase8Test extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'phase8-user-001',
'name' => 'Phase8 User',
'email' => 'phase8@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Phase8 Org',
'slug' => 'phase8-org',
'settings' => [
'onboarded' => true,
'badge_expiry_hours' => 8,
'badge_template' => ['show_qr' => true, 'show_photo' => true],
],
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'org_admin',
]);
Branch::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'HQ',
'is_active' => true,
]);
}
public function test_badge_template_settings_can_be_updated(): void
{
$this->actingAs($this->user)
->put(route('frontdesk.settings.badge.update'), [
'show_photo' => '1',
'show_qr' => '1',
'show_host' => '1',
'show_company' => '0',
'show_type' => '1',
'primary_color' => '#ff0000',
'footer_text' => 'Property of Phase8 Org',
])
->assertRedirect();
$settings = $this->organization->fresh()->settings;
$this->assertSame('#ff0000', $settings['badge_template']['primary_color']);
$this->assertFalse($settings['badge_template']['show_company']);
}
public function test_check_in_stores_photo_from_base64(): void
{
$png = 'data:image/png;base64,'.base64_encode(UploadedFile::fake()->image('photo.png')->getContent());
$this->actingAs($this->user)
->post(route('frontdesk.visits.store'), [
'full_name' => 'Photo Guest',
'visitor_type' => 'visitor',
'policies_accepted' => '1',
'photo_data' => $png,
])
->assertRedirect();
$visit = Visit::first();
$this->assertNotNull($visit->photo_path);
Storage::disk('public')->assertExists($visit->photo_path);
}
public function test_badge_preview_renders_for_checked_in_visit(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Badge Guest',
]);
$visit = Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'visitor_id' => $visitor->id,
'visitor_type' => 'visitor',
'status' => Visit::STATUS_CHECKED_IN,
'checked_in_at' => now(),
'badge_code' => 'ABC123',
'qr_token' => 'qr-token-123',
'badge_expires_at' => now()->addHours(8),
]);
$this->actingAs($this->user)
->get(route('frontdesk.visits.badge.preview', $visit))
->assertOk()
->assertSee('Badge Guest')
->assertSee('ABC123');
}
public function test_evacuation_badges_page_lists_current_visitors(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Inside Guest',
]);
Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'visitor_id' => $visitor->id,
'visitor_type' => 'visitor',
'status' => Visit::STATUS_CHECKED_IN,
'checked_in_at' => now(),
'badge_code' => 'EVAC1',
'qr_token' => 'evac-token',
'badge_expires_at' => now()->addHours(8),
]);
$this->actingAs($this->user)
->get(route('frontdesk.security.evacuation.badges'))
->assertOk()
->assertSee('Inside Guest');
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Models\Visit;
use App\Models\Visitor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class FrontdeskPhase9Test 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' => 'phase9-user-001',
'name' => 'Phase9 User',
'email' => 'phase9@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Phase9 Org',
'slug' => 'phase9-org',
'settings' => ['onboarded' => true, 'badge_expiry_hours' => 8],
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'org_admin',
]);
Branch::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'HQ',
'is_active' => true,
]);
}
public function test_reports_page_loads_with_summary(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Report Guest',
'is_frequent' => true,
'visit_count' => 6,
]);
Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'visitor_id' => $visitor->id,
'visitor_type' => 'visitor',
'status' => Visit::STATUS_CHECKED_IN,
'checked_in_at' => now(),
'checked_out_at' => now()->addHour(),
'badge_code' => 'RPT1',
'qr_token' => 'rpt-token',
'badge_expires_at' => now()->addHours(8),
]);
$this->actingAs($this->user)
->get(route('frontdesk.reports.index'))
->assertOk()
->assertSee('Reports')
->assertSee('Report Guest');
}
public function test_reports_can_export_csv(): void
{
$this->actingAs($this->user)
->get(route('frontdesk.reports.export'))
->assertOk()
->assertHeader('content-type', 'text/csv; charset=UTF-8');
}
public function test_daily_report_command_runs(): void
{
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'report_daily_recipients' => ['admin@example.com'],
]),
]);
$this->artisan('frontdesk:send-daily-reports')->assertSuccessful();
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\Host;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Models\Visit;
use App\Models\Visitor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class FrontdeskWebTest 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, 'badge_expiry_hours' => 8, 'kiosk_reset_seconds' => 120],
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'org_admin',
]);
Branch::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'Main Office',
'is_active' => true,
]);
}
public function test_guest_is_redirected_to_sso(): void
{
$this->get('/dashboard')->assertRedirect();
}
public function test_unonboarded_user_is_redirected_to_onboarding(): void
{
Organization::query()->delete();
Member::query()->delete();
$this->actingAs($this->user)
->get(route('frontdesk.dashboard'))
->assertRedirect(route('frontdesk.onboarding.show'));
}
public function test_dashboard_loads_for_authenticated_user(): void
{
$this->actingAs($this->user)
->get(route('frontdesk.dashboard'))
->assertOk()
->assertSee('Reception dashboard');
}
public function test_receptionist_can_check_in_visitor(): void
{
$host = Host::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'Jane Host',
'email' => 'jane@example.com',
]);
$response = $this->actingAs($this->user)->post(route('frontdesk.visits.store'), [
'full_name' => 'John Visitor',
'company' => 'Acme Corp',
'phone' => '+233201234567',
'email' => 'john@example.com',
'host_id' => $host->id,
'visitor_type' => 'visitor',
'purpose' => 'Meeting',
'policies_accepted' => '1',
]);
$response->assertRedirect();
$this->assertDatabaseHas('frontdesk_visitors', ['full_name' => 'John Visitor']);
$this->assertDatabaseHas('frontdesk_visits', ['status' => Visit::STATUS_CHECKED_IN]);
}
public function test_receptionist_can_check_out_visitor(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'John Visitor',
]);
$visit = Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'visitor_id' => $visitor->id,
'visitor_type' => 'visitor',
'status' => Visit::STATUS_CHECKED_IN,
'checked_in_at' => now(),
]);
$this->actingAs($this->user)
->post(route('frontdesk.visits.checkout', $visit))
->assertRedirect();
$this->assertEquals(Visit::STATUS_CHECKED_OUT, $visit->fresh()->status);
}
public function test_blacklisted_visitor_cannot_check_in(): void
{
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Blocked Person',
'watchlist_status' => Visitor::WATCHLIST_BLACKLISTED,
]);
$this->actingAs($this->user)->post(route('frontdesk.visits.store'), [
'visitor_id' => $visitor->id,
'full_name' => 'Blocked Person',
'visitor_type' => 'visitor',
'policies_accepted' => '1',
])->assertStatus(403);
}
public function test_org_admin_can_update_settings(): void
{
$this->actingAs($this->user)
->put(route('frontdesk.settings.update'), [
'name' => 'Updated Org',
'timezone' => 'Africa/Accra',
'badge_expiry_hours' => 6,
'kiosk_reset_seconds' => 90,
'notification_channels' => ['email'],
])
->assertRedirect();
$this->organization->refresh();
$this->assertEquals('Updated Org', $this->organization->name);
$this->assertEquals(6, $this->organization->settings['badge_expiry_hours']);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace Tests\Unit;
use App\Models\Organization;
use App\Models\Visitor;
use App\Services\Frontdesk\WatchlistService;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
class WatchlistServiceTest extends TestCase
{
#[Test]
public function blacklisted_visitor_is_blocked(): void
{
$visitor = new Visitor(['watchlist_status' => Visitor::WATCHLIST_BLACKLISTED]);
$this->expectException(HttpException::class);
app(WatchlistService::class)->assertCanCheckIn($visitor);
}
#[Test]
public function allowed_visitor_passes_watchlist_check(): void
{
$visitor = new Visitor(['watchlist_status' => Visitor::WATCHLIST_ALLOWED]);
app(WatchlistService::class)->assertCanCheckIn($visitor);
$this->assertTrue(true);
}
#[Test]
public function requires_approval_visitor_is_not_blocked_at_gate(): void
{
$visitor = new Visitor(['watchlist_status' => Visitor::WATCHLIST_REQUIRES_APPROVAL]);
app(WatchlistService::class)->assertCanCheckIn($visitor);
$this->assertTrue(app(WatchlistService::class)->visitorNeedsApprovalQueue($visitor));
}
}