Full application test guide

Facilities Manager - end-to-end demo & test script

Every feature in the app, in demo order: what's live and clickable today, and what's backend-verified but has no UI yet — so nobody hunts for a button that doesn't exist mid-demo. Covers roughly 290 of 302 shipped micro-tasks (216-225, a batch of release/ops hardening tasks, are intentionally paused).

Before you start: seed a demo tenant

No admin or sample data ships by default (credentials are never committed). Run this once against the running stack — it builds one full location branch (portfolio → room), an asset in that room, and your login:

docker compose exec php php artisan tinker --execute="
\$org = App\Models\Organization::factory()->create(['name' => 'Demo Org']);
\$admin = App\Models\User::factory()->create([
    'name' => 'Demo Admin',
    'email' => 'demo@facilities-manager.localhost',
    'password' => 'demo-password-123',
    'is_admin' => true,
]);
App\Models\Membership::factory()->for(\$admin)->for(\$org)->create();

\$portfolio = App\Models\Portfolio::factory()->for(\$org)->create(['name' => 'EMEA Portfolio']);
\$continent = App\Models\Continent::factory()->create(['portfolio_id' => \$portfolio->id]);
\$country = App\Models\Country::factory()->create(['continent_id' => \$continent->id]);
\$city = App\Models\City::factory()->create(['country_id' => \$country->id]);
\$facility = App\Models\Facility::factory()->create(['city_id' => \$city->id]);
\$building = App\Models\Building::factory()->create(['facility_id' => \$facility->id]);
\$floor = App\Models\Floor::factory()->create(['building_id' => \$building->id]);
\$room = App\Models\Room::factory()->create(['floor_id' => \$floor->id, 'name' => 'Room 301', 'kind' => 'room']);

\$category = App\Models\AssetCategory::factory()->create(['organization_id' => \$org->id]);
\$asset = App\Models\Asset::factory()->create([
    'organization_id' => \$org->id,
    'location_node_id' => \$room->locationNode()->firstOrFail()->id,
    'asset_category_id' => \$category->id,
    'name' => 'Rooftop AHU Unit 1',
]);

echo 'Login: '.\$admin->email.' / demo-password-123'.PHP_EOL;
echo 'Asset: '.\$asset->name.' (id '.\$asset->id.')'.PHP_EOL;
echo 'Room path: '.\$room->locationNode()->firstOrFail()->path.PHP_EOL;
"

Panel login: http://facilities-manager.localhost/operations/login with the printed email/password. Everyone — requesters, technicians, inspectors — signs in at this same URL/session; there's no separate portal login. What differs is which pages they're pointed at.

Part A — live in the browser

  1. Marketing home page
  2. Facilities location hierarchy
  3. Assets
  4. Work orders (CRUD)
  5. Requester portal
  6. Dispatch & inspection workspaces
  7. Inspection report
  8. Tenancy & access administration
  9. Secure response headers
  10. Rate limiting
  11. Multi-factor authentication
  12. Audit trail (store, viewer, export)
  13. Structured (JSON) logging
  14. Application metrics endpoint
  15. Mobile app (Flutter)

Part B — backend-verified, no UI yet

  1. Service-request triage & work-order lifecycle
  2. Checklists, PM schedules, inspection workflow
  3. Asset photos, documents, movement, CSV import
  4. Fleet operations
  5. Inventory & stock ledger
  6. Vendors, vendor portal, approvals

Part A — live in the browser

Everything below is a real page or route today. Click through it in this order for a coherent demo.

Marketing home page

Live

Public landing page: hero, feature grid, "how it works" flow, stats band, closing CTA — all routing back to the panel.

  1. Open http://facilities-manager.localhost/.
  2. Scroll through the feature grid (Locations & assets, Work orders, Preventive maintenance, Inspections, Fleet, Inventory) and the 4-step "how it works" band (Submit → Triage & approve → Assign → Complete & close).
  3. Click "Open the dashboard →" (hero, CTA band, or footer) or Sign in in the nav.
Expect: lands on /operations, which redirects to /operations/login since you're not authenticated yet.

Facilities location hierarchy

LiveTasks 66-82

Full portfolio → continent → country → city → facility → building → floor → room/zone chain, plus a lazy-loading tree and cross-level search — all under nav group Facilities administration.

  1. Log in, then visit each resource in turn: /operations/portfolios, /operations/continents, /operations/countries, /operations/cities, /operations/facilities, /operations/buildings, /operations/floors, /operations/rooms (labelled "Rooms and zones"). The seed script already created one full branch — open each to see its one row, or create a second sibling to show the picker.
  2. Go to Facilities administration → Hierarchy (/operations/hierarchy-tree) and expand nodes down to Room 301.
  3. In the same page, type Room 301 (or EMEA) into the search box.
Expect: each resource's create form only offers its direct parent; the tree lazy-loads children on expand; search returns the matching node with its full breadcrumb path (e.g. "EMEA Portfolio / … / Room 301").

Assets

LiveTasks 83-93, 97-98

Asset register with category/location/condition/status, archive lifecycle, and a printable QR label.

  1. Go to /operations/assets — the seeded "Rooftop AHU Unit 1" is listed.
  2. Click New asset, fill code/tag/name/category/location/condition/status, save.
  3. Use the filters (Location, Category, Status, Condition, Operational status, Criticality, Vendor, purchase date).
  4. Open a record, click Archive, then filter by Archived status and Reactivate it.
  5. In a new tab, go to /assets/{id}/label (swap in the asset's id) to show the printable QR label.
Expect: list/create/edit/filter all work; archive hides it from the default Active filter; the label page renders a scannable QR that links to /asset-scan/{token}, which redirects back to the asset's panel page.

Work orders (CRUD)

LiveTasks 105-106, 110

Nav group Maintenance. Only list/create/view/edit exist in the panel today — the full lifecycle (assign, start, complete, approve, close) is real and tested, but backend-only; see Part B.

  1. Go to /operations/work-ordersNew work order.
  2. Fill Summary, Type, Source, Priority, Status, and under Scope pick the seeded location (search "Room 301") and the seeded asset.
  3. Save, then open it and use Edit to change status manually.
Expect: record saves and is scoped to your organization. There is no Assign/Start/Complete/Approve/Close button in this UI — narrate that as a known gap (Part B) rather than looking for it live.

Requester portal

LiveTasks 186-193

Plain web routes under /portal (not Filament) — same login/session as the panel, just a different landing area for non-admin requesters.

  1. Go to /portal — the portal home.
  2. Click through to /portal/requests/create and submit: pick a location, category (electrical/plumbing/hvac/cleaning/security/accessibility/general), a description of 20+ characters, and a priority.
  3. From /portal, open the new request at /portal/requests/{id} and use the Follow action.
  4. Visit /portal/suggestions to show the issue-suggestion feature.
Expect: submission validates description length/priority/category; the request appears in the requester's own timeline; suggestions page renders without error even with little data.

Dispatch & inspection workspaces

LiveTasks 194-198, 209-217 (roadmap numbering)

Role-specific Filament pages under /operations, plus one plain route for the technician's paper-style job sheet.

  1. /operations/supervisor-dispatch — "Supervisor dispatch".
  2. /dispatch — "Assignment board".
  3. /operations/technician-workbench — technician's personal queue.
  4. /technician/job-sheet — plain route (not Filament), the printable daily job sheet.
  5. /operations/inspector-workspace, /operations/guided-inspection-execution ("Perform inspection"), /operations/compliance-evidence ("Compliance evidence workspace"), /operations/auditor-workspace, /operations/corrective-verification, /operations/failed-item-triage.
Expect: each page loads without error even against the mostly-empty seed data (they're built to handle empty states). Populate a work order/inspection first if you want non-empty tiles.

Inspection report

LiveTask 140

A single inspection's printable report — plain route, not Filament.

  1. Requires an existing Inspection record (there's no create-inspection UI yet — seed one via tinker, see Part B).
  2. Visit /inspections/{id}/report.
Expect: renders inspector, location path, checklist responses, evidence, score, and any linked corrective work.

Tenancy & access administration

LiveTasks 226-235

Nav group Tenancy and access: Organizations, Users, Memberships, Roles, Departments, Teams.

  1. Organizations (/operations/organizations) — create one, filter by status, edit status to Suspended.
  2. Users (/operations/users) — open a user, flip the Administrator toggle. No password field is ever shown.
  3. Memberships (/operations/memberships) — new membership: pick a user + one or more roles in one screen. Try a duplicate user+org to see the uniqueness constraint reject it.
  4. Roles (/operations/roles) — new role: name it, multi-select permissions from the catalogue, save, reopen to confirm they persisted. Try a duplicate name in the same org.
  5. Departments (/operations/departments) — create, Archive, filter by status, Reactivate.
  6. Teams (/operations/teams) — create, multi-select members, reopen and remove one, check the Members count column.
Expect: all six screens are platform-admin-only or org-scoped as described; duplicate role names and duplicate memberships are rejected at the database level.

Secure response headers

LiveTask 209

Every response carries a hardened header set (CSP, X-Frame-Options, nosniff, referrer policy, permissions policy, HSTS over HTTPS).

  1. curl -sI http://facilities-manager.localhost/health
Expect: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy, a Content-Security-Policy line. No Strict-Transport-Security over plain HTTP.

Rate limiting

LiveTask 210

Named limiters guard login, search, uploads, exports, and API mutations. Easiest to demo on mobile API login (5/min).

  1. for i in 1 2 3 4 5 6; do
      curl -s -o /dev/null -w "%{http_code}\n" \
        -X POST http://facilities-manager.localhost/api/v1/auth/login \
        -H 'Content-Type: application/json' \
        -d '{"email":"nobody@example.com","password":"wrong","device_id":"demo"}'
    done
Expect: first 5 return 422, the 6th returns 429 with a Retry-After header and X-RateLimit-Remaining: 0.

Multi-factor authentication

LiveTask 211

TOTP app-based MFA available to every panel user via the profile page: enroll, get challenged, recover, or remove. Not mandatory yet.

  1. Log in, open the user menu → Profile.
  2. Under Multi-factor authentication, click Set up, scan the QR with an authenticator app, enter the code, save a recovery code.
  3. Log out, log back in with email/password, enter the current 6-digit code at the prompt.
  4. Log out again, log back in, click "use a recovery code instead", paste the saved code.
  5. Back on Profile, click Disable (requires a valid code) to remove it.
Expect: login is blocked at a verify-identity step until a valid code (or unused recovery code) is given; a used recovery code can't be reused; disabling removes the challenge next login.

Audit trail: event store, viewer, export

LiveTasks 212-213

Critical actions write an immutable, redacted audit event, browsable and exportable from the panel.

  1. Toggle another user's Administrator switch (Users resource, above) to generate an event.
  2. Go to Security → Audit events.
  3. Confirm the user.privilege_changed row, open it, check Before/After show is_admin: false → true.
  4. Use the actor/date filters, then click Export CSV.
  5. Reopen the list — a new audit.exported row appears (the export logs itself).
Expect: no edit/delete actions anywhere on this resource; export produces a CSV download and its own audit row.

Structured (JSON) logging

LiveTask 214

A JSON log channel stamps every line with a correlation ID, tenant, and actor — shared between a web request and any job it triggers.

  1. curl -sI http://facilities-manager.localhost/health | grep -i x-correlation-id
  2. docker compose exec php tail -5 storage/logs/structured.log
Expect: the response header's ID appears in JSON log lines under extra.correlation_id, alongside organization_id and actor_id.

Application metrics endpoint

LiveTask 215

A private, token-gated /metrics endpoint: request latency/errors, mobile sync failures, queue lag/failures, scheduler runs, disk usage.

  1. curl -i http://facilities-manager.localhost/metrics
  2. curl -s http://facilities-manager.localhost/metrics \
      -H "X-Metrics-Token: $(grep METRICS_TOKEN .env | cut -d= -f2)"
Expect: first call → 401. Second → 200, Prometheus text format, including http_requests_total, http_request_duration_seconds_*.

Mobile app (Flutter)

LiveTasks 194-207

Flutter 3.44 / Dart 3.12, Android + iOS. Offline outbox, conflict resolution, evidence upload.

  1. Start the backend: docker compose up -d nginx (from repo root).
  2. cd mobile && flutter pub get
  3. iOS Simulator: flutter run -d "iPhone <sim name>" (default base URL http://localhost works as-is).
    Android emulator: flutter run -d emulator-5554 (default base URL is already http://10.0.2.2).
    Real device: flutter run --dart-define=API_BASE_URL=http://facilities-manager.localhost
  4. Sign in, land on the 4-tab shell: Work orders, Scan, Conflicts, Evidence.
  5. To demo offline: actually toggle airplane mode / disable network on the simulator/device (there's no in-app simulated toggle), open a work order, attempt an action — a banner reading "Showing cached data — offline" appears and the action queues locally.
  6. Re-enable network — the queued action syncs; any conflicts surface on the Conflicts tab, queued photos/signatures on the Evidence tab.
Expect: offline actions don't fail silently — they queue and sync exactly once on reconnect (task 207's idempotency guarantee).

Part B — backend-verified, no UI yet

These are fully built and covered by passing feature tests, but nothing in the panel or a web route surfaces them yet. Demo by referencing the test file or running one tinker snippet — don't hunt for a button.

Service-request triage & work-order lifecycle

Backend onlyTasks 104, 108-124
CapabilityBackend classReference test
Triage / approve a requestServiceRequestTriageEventServiceRequestTriageTest.php
Convert request → work order(service action)ConvertServiceRequestTest.php
Assign supervisor/technician/team/vendorApp\Actions\Maintenance\*AssignWorkOrder{Supervisor,Technician,Team,Vendor}Test.php
Start / pause / resumeApp\Actions\Maintenance\*StartWorkOrderTest.php, PauseAndResumeWorkOrderTest.php
Tasks / labor / photos / notesrespective modelsWorkOrderTaskTest.php, WorkOrderLaborEntryTest.php, WorkOrderPhotoTest.php, WorkOrderNoteTest.php
Complete / approve / close / reopenApp\Actions\Maintenance\CloseAndReopenWorkOrder + othersCompleteWorkOrderTest.php, ApproveWorkOrderCompletionTest.php, CloseAndReopenWorkOrderTest.php
To show the lifecycle live anyway: docker compose exec php php artisan test --filter=CloseAndReopenWorkOrderTest — it drives the full close/reopen flow (with immutable history) end-to-end and prints green.

Checklists, PM schedules, inspection workflow

Backend onlyTasks 125-143
CapabilityBackend classReference test
Checklist templates + versioningChecklistTemplate, ChecklistTemplateVersionChecklistTemplateTest.php, PublishChecklistTemplateTest.php
PM schedule recurrence + generationPmSchedule, App\Actions\Reliability\*AdvancePmScheduleTest.php, GeneratePmWorkOrderTest.php (generates a real WorkOrder, visible in the panel afterward)
Schedule / perform / complete an inspectionInspectionScheduleInspectionTest.php, PerformInspectionTest.php, CompleteInspectionTest.php
Corrective work from a failed itemInspectionCorrectiveWorkCreateCorrectiveWorkFromInspectionTest.php
To get an Inspection row you can view at /inspections/{id}/report (Part A), run/read ScheduleInspectionTest.php for the exact factory chain, or seed one directly via Inspection::factory()->create([...]) in tinker.

Asset photos, documents, movement, CSV import

Backend only (API-reachable)Tasks 90-91, 94-96, 99-100
CapabilityHow to exerciseReference test
Photo uploadPOST /assets/{asset}/photos (multipart, auth session, throttle:uploads) — curl/Postman only, no panel widgetAssetPhotoTest.php
Document uploadPOST /assets/{asset}/documentsAssetDocumentTest.php
Movement request/approvalno route at all — App\Actions\Assets\DecideAssetMovement in tinker onlyAssetMovementTest.php, DecideAssetMovementTest.php
CSV import preview + commitPOST /asset-imports/preview then POST /asset-imports/{token}/commit — no GET form, multipart POST onlyAssetImportPreviewTest.php, CommitAssetImportTest.php

Fleet operations

Backend onlyTasks 144-153, 169

No Filament resource or route for any of this yet — vehicles, drivers, readings, maintenance triggers, accidents, and the fleet dashboard all live in App\Models\FleetAsset/App\Actions\Fleet\*/App\Support\FleetDashboard.php, exercised only by tests/Feature/FleetCoordinatorWorkspaceTest.php and VehicleCheckoutServiceTest.php.

Demo via docker compose exec php php artisan test --filter=FleetCoordinatorWorkspaceTest, or open the model/action classes directly if asked "show me the code."

Inventory & stock ledger

Backend onlyTasks 154-164, 170

Stores, items, and the receive/reserve/issue/return/transfer/adjust ledger operations are all App\Actions\Inventory\* classes with no Filament resource — exercised by tests/Feature/StocktakeWorkspaceTest.php. LowStockQuery.php and InventoryDashboard.php compute real numbers but nothing renders them yet.

Demo via docker compose exec php php artisan test --filter=StocktakeWorkspaceTest.

Vendors, vendor portal, manager approvals

Backend onlyTasks 165-168, 186-225 (vendor-approvals phase)

No vendor login, no vendor-prefixed route, no manager-approval-inbox page exists yet. Vendor/VendorContract models, VendorPortalShell, VendorAssignmentResponder, VendorCommercialWorkflow, VendorComplianceWorkspace, VendorVisitCompletionFlow, ManagerApprovalInbox, and ApprovalDelegationService are all plain service objects with no controller/guard/view. The one real HTTP surface touching "approval" anywhere in the app is the JSON API POST /api/v1/work-orders/{workOrder}/approve.

Demo via docker compose exec php php artisan test --filter=VendorPortalShellTest (or the equivalent workflow test), or call the API approve endpoint directly with a Sanctum token.