Skip to main content

TESTING_STRATEGY — staff-service

Sibling: DOMAIN_MODEL · APPLICATION_LOGIC · API_CONTRACTS · EVENT_SCHEMAS · SYNC_CONTRACT

Strategic anchors: 02 §15 Testing · standards/SERVICE_TEMPLATE · standards/DEFINITION_OF_DONE

staff-service is shaped by three forces: domain correctness (state machines + invariants, especially the multi-property and clock-sequence rules), offline correctness (Electron desktop replicated subset never diverges, append-only never duplicates), and integration correctness with iam-service / tenant-service (the cascade on termination is the most consequential cross-service contract). Coverage targets follow the platform DOD — overall ≥ 85 %, domain layer ≥ 95 % — but this service additionally must ship the multi-property invariant property test, the offline-replay duplicate test, and the IAM revoke-cascade contract test before production.


1. Test Pyramid

LayerFrameworkTargetsWhere
Unit (domain)Vitest≥ 95 % statements; 100 % branches on state machinessrc/domain/__tests__/, test/unit/domain/
Unit (application)Vitest≥ 90 % statements per use casetest/unit/application/
Integration (in-service)Vitest + Testcontainers (Postgres + Pub/Sub emulator + Redis)≥ 80 % pathstest/integration/
Contract (consumer + producer)Pact (broker)every produced + consumed eventtest/contract/
API contractOpenAPI snapshot diff + dreddevery endpointCI gate
End-to-endPlaywright via bff-backoffice-servicetop 8 user journeys (§6)test/e2e/
Offline / syncCustom harness (@ghasi/sync-test-kit)replay correctness, conflict policiestest/sync/
Chaos / low-bandwidthk6 + toxiproxylatency, packet loss, disconnecttest/chaos/
SecurityOWASP ZAP baseline + custom RLS sweepRLS, RBAC, PIN brute-forcetest/security/

The three mandatory integration tests gate the readiness checklist and must be present before any feature work — see SERVICE_TEMPLATE §Mandatory tests:

  • tenant-isolation.spec.ts — RLS denies cross-tenant reads/writes for every table.
  • outbox.spec.ts — every state-changing use case writes to the outbox in the same Tx.
  • inbox.spec.ts — every consumed subject is idempotent on replay.

2. Unit — Domain

SpecCoverage
staff.state-machine.spec.tsEvery legal employmentStatus transition; every illegal transition rejected with IllegalStaffTransitionError
shift.state-machine.spec.tsEvery legal Shift.status transition; cancellation forbidden after in_progress
leave.state-machine.spec.tsSubmit → approve / reject / cancel; terminal states
staff.invariants.spec.tsOne pass + one fail per I-Staff-{1..6}
shift.invariants.spec.tsI-Shift-{1..4}
assignment.invariants.spec.tsI-Asn-{1..3}, including the multi-property double-shift rule
clock.invariants.spec.tsI-Clock-{1..6}; property test (fast-check) on random sequences for I-Clock-1
leave.invariants.spec.tsI-Leave-{1..3}
cross-aggregate.invariants.spec.tsI-X-{1, 2}
pattern-materializer.spec.tsCross-midnight, DST transitions (Asia/Kabul, Asia/Tehran, Asia/Dushanbe), bi-weekly cadence, holiday skip
overlap-detector.spec.tsHalf-open intervals; equal endpoints; nested; disjoint
conflict-evaluator.spec.tsEach conflict kind isolated; combined conflict list ordering is deterministic
staff-code-generator.spec.tsDeterministic with seed; collision retry
pin-format-validator.spec.tsSequential, all-same, length, non-digit
capacity-snapshot-builder.spec.tsActive / on-break / scheduled-next bucket placement; deficit math

Domain test suite imports nothing outside @ghasi/domain-primitives and Vitest. CI runs lint:layer-boundaries to enforce.

Property tests (fast-check):

  • I-Clock-1 (single open punch invariant) over random sequences of in/out/break punches.
  • OverlapDetector symmetry and transitivity.
  • PatternMaterializer idempotence (materialize(p, w) == materialize(p, w)).

3. Unit — Application

SpecWhat it asserts
create-staff.use-case.spec.tsEmail-or-manager-required; PIN hashed via port; staff_code generated; outbox created.v1; idempotent
update-staff.use-case.spec.tsOCC; partial; immutable fields rejected
change-position.use-case.spec.tsCancels future shifts where staff is sole primary; emits position_changed.v1
terminate-staff.use-case.spec.tsCascade: auto-clock-out, soft-unassign, cancel sole-primary shifts, IAM revoke retried, audit + event order
reactivate-staff.use-case.spec.tsRe-issues staff_code if reused; emits reactivated.v1
set-clock-pin.use-case.spec.tsSelf-rotate requires current PIN; manager-rotate requires reason; lockout reset; audit
add-certification.use-case.spec.tsTTL scheduling; emits added.v1
upsert-shift-pattern.use-case.spec.tsValidation; OCC
generate-shifts.use-case.spec.tsIdempotent on replay (key = patternId, fromDate, toDate); dryRun returns no-op; bulk outbox
create-ad-hoc-shift.use-case.spec.tsValidates department exists at property
cancel-shift.use-case.spec.tsForbidden after in_progress; emits cancelled.v1
assign-staff.use-case.spec.tsCalls ConflictEvaluator; returns 409 with violations[]; force=true bypasses cert
unassign-staff.use-case.spec.tsSoft-removal preserves audit; emits unassigned.v1
swap-assignments.use-case.spec.tsAtomic; both staff conflict-checked against opposite shift
promote-standby.use-case.spec.tsSource = auto_promoted
punch-clock.use-case.spec.tsAll branches: PIN/JWT modes, kinds, multi-property reject, sequence reject, shift transitions emit started.v1/ended.v1
manager-override-punch.use-case.spec.tsReason required; audit row; event source
auto-close-shifts.use-case.spec.tsIdempotent via staffing_gap_emitted_at; emits system_auto clock-out
submit-leave.use-case.spec.tsWindow validation; emits requested.v1
decide-leave.use-case.spec.tsApprove with collisions: force=false rejects, force=true cascades; emits + audit
cancel-leave.use-case.spec.tsForbidden after window started + approved
add-handoff-note.use-case.spec.tsAppend-only DB; emits note_added.v1
on-iam-user-registered.handler.spec.tsLinks existing pending staff; idempotent on replay
on-tenant-membership-created.handler.spec.tsProvisions default-shell staff
on-tenant-membership-removed.handler.spec.tsCascade-terminates
on-property-deactivated.handler.spec.tsCancels future shifts
on-ai-shift-optimization.handler.spec.tsPersists suggestion with provenance; never auto-applies

Use-case suite uses in-memory port fakes for repositories, outbox, IAM, property, AI. No Postgres, no Pub/Sub.


4. Integration

Testcontainers spin up Postgres 16 (with the schema migrated), Redis 7, and the Pub/Sub emulator.

SpecCoverage
tenant-isolation.spec.ts (mandatory)For each table: insert two tenants, run all CRUD as tenant A, assert zero rows visible as tenant B; insert as B, verify isolation
outbox.spec.ts (mandatory)Every state-changing use case writes outbox in the same Tx; rollback drops both
inbox.spec.ts (mandatory)Replay each consumed subject 3× and assert single side-effect
rls-policies.spec.tsDirect SQL connections without app.tenant_id set return 0 rows (RLS deny by default)
shift-overlap-exclude.spec.tsThe EXCLUDE constraint on (tenant_id, pattern_id, local_date) rejects duplicate generation
clock-dedupe-uk.spec.tsThe (tenant_id, staff_id, occurred_at_utc, kind) unique index dedupes double-tap
append-only-trigger.spec.tsUPDATE/DELETE on clock_entries, handoff_notes, audit_events raises 0L000
pin-attempt-counter.spec.tsRedis token bucket + per-staff lockout
outbox-relay.spec.tsWorker publishes within 5 s under load (250 events/s)
inbox-dlq.spec.tsSchema-mismatch event lands in DLQ with MELMASTOON.STAFF.EVENT_SCHEMA_MISMATCH
capacity-cache.spec.tsPunch event invalidates cache within 1 s; concurrent reads consistent
kms-pin-rotation.spec.tsOld pepper version verifies; lazy re-hashes to new version
migration-rollback.spec.tsEach migration in db/migrations/ has a successful rollback path

5. Contract Tests (Pact)

5.1 Producer contracts (we publish; they consume)

For each event in EVENT_SCHEMAS §2:

  • A pact verifies the JSON payload against schemas/events/staff/**/*.json.
  • Consumers (housekeeping-service, maintenance-service, bff-backoffice-service, notification-service, ai-orchestrator-service, analytics-service) provide their expected schemas via Pact broker.

5.2 Consumer contracts (we consume; they publish)

For each consumed subject in EVENT_SCHEMAS §4:

  • We publish our expected payload schema to the Pact broker.
  • Producers (iam-service, tenant-service, property-service, ai-orchestrator-service) verify against ours.

5.3 REST contract

  • bff-backoffice-service and bff-tenant-booking-service are our REST consumers.
  • Pact contracts cover every endpoint listed in API_CONTRACTS, including error envelopes.

CI gate: a PR that breaks any pact fails. The Pact broker URL is pact-broker.melmastoon.internal.


6. End-to-End (Playwright)

The top 8 user journeys are covered:

  1. GM creates staff with email + invite, accepts invite, sets PIN, clocks in.
  2. GM creates PIN-only staff, clocks in via Electron-emulated PIN entry.
  3. Manager generates a week of shifts, assigns staff, swaps two assignments.
  4. Staff submits leave; GM approves with forceUnassign; staff is auto-unassigned.
  5. Staff fails PIN 5 times, gets locked out, GM unlocks.
  6. Manager-override punch for a staff who forgot to clock in.
  7. Termination cascade: GM terminates an active staff; verify auto-clock-out, future-unassign, IAM revoke, no future bookings.
  8. Staffing gap detection: scheduled shift with no clocked-in primary at T-15 → event emitted, backoffice dashboard alert.

E2E runs against ephemeral preview environments (per PR) and weekly against staging.


7. Offline / Sync Tests

@ghasi/sync-test-kit simulates the Electron desktop:

SpecCoverage
offline-clock-replay.spec.tsQueue 100 punches offline, restore connectivity, assert all accepted with correct order; assert no duplicates
multi-device-collision.spec.tsTwo devices, same staff, simultaneous offline punches → server reconciles per SYNC_CONTRACT §6
pull-cursor-idempotence.spec.tsReplay same cursor 3× returns identical changeset
out-of-window-punch.spec.tsPunch with offline_queue_age_seconds > 86400 is accepted with flagged_late_replay
conflict-policy-server-authoritative.spec.tsLocal edit to Shift.status is rejected on push
conflict-policy-lww.spec.tsTwo devices edit Staff.displayName; latest updatedAt wins
conflict-policy-set-union.spec.tsTwo devices ack same handoff note; both ack records preserved
pii-omission.spec.tsPull without staff.read_pii returns staff DTO with PII fields absent

8. Chaos / Low-Bandwidth

k6 + toxiproxy simulate:

  • 30 % packet loss between API and DB
  • 5 s latency on Pub/Sub publish
  • Redis unreachable (cache miss path)
  • Cloud SQL failover (15 s blackout)
  • KMS unreachable for 30 s (PIN verification path)

For each scenario, assert: punch latency p95 < 1.5 s, no data loss in clock entries, outbox catches up within 60 s after recovery.


9. Security

SpecCoverage
rls-sweep.spec.tsProgrammatic check: every table in staff schema has RLS enabled and at least one policy
rbac-matrix.spec.tsEach capability denies the actor without it (per SECURITY_MODEL §2)
pin-bruteforce.spec.ts100 wrong PINs in 60 s → all rejected, lockout triggered, no DB exhaustion
pii-no-log.spec.tsSynthetic logs scanned; no email / phone / emergency contact strings
audit-row-immutability.spec.tsUPDATE/DELETE on audit_events raises
zap-baseline.yamlOWASP ZAP baseline scan against staging API; zero High findings

10. CI Pipeline

unit (parallel)
└─ domain
└─ application
└─ ports
contract (parallel)
└─ pact-producer
└─ pact-consumer
└─ openapi-diff
integration (Testcontainers)
└─ tenant-isolation, outbox, inbox, rls, dedupe, append-only, kms, capacity-cache
sync (Testcontainers + sync-test-kit)
└─ offline-replay, multi-device, conflict policies, pii-omission
security
└─ rls-sweep, rbac, pin-bruteforce, pii-no-log
e2e (preview env)
└─ Playwright top-8 journeys
chaos (nightly, not blocking)
└─ k6 + toxiproxy

PR-blocking: unit, contract, integration, sync, security, e2e. Chaos is informational. Coverage gate: ≥ 85 % overall, ≥ 95 % domain (Vitest reporter).