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
| Layer | Framework | Targets | Where |
|---|---|---|---|
| Unit (domain) | Vitest | ≥ 95 % statements; 100 % branches on state machines | src/domain/__tests__/, test/unit/domain/ |
| Unit (application) | Vitest | ≥ 90 % statements per use case | test/unit/application/ |
| Integration (in-service) | Vitest + Testcontainers (Postgres + Pub/Sub emulator + Redis) | ≥ 80 % paths | test/integration/ |
| Contract (consumer + producer) | Pact (broker) | every produced + consumed event | test/contract/ |
| API contract | OpenAPI snapshot diff + dredd | every endpoint | CI gate |
| End-to-end | Playwright via bff-backoffice-service | top 8 user journeys (§6) | test/e2e/ |
| Offline / sync | Custom harness (@ghasi/sync-test-kit) | replay correctness, conflict policies | test/sync/ |
| Chaos / low-bandwidth | k6 + toxiproxy | latency, packet loss, disconnect | test/chaos/ |
| Security | OWASP ZAP baseline + custom RLS sweep | RLS, RBAC, PIN brute-force | test/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
| Spec | Coverage |
|---|---|
staff.state-machine.spec.ts | Every legal employmentStatus transition; every illegal transition rejected with IllegalStaffTransitionError |
shift.state-machine.spec.ts | Every legal Shift.status transition; cancellation forbidden after in_progress |
leave.state-machine.spec.ts | Submit → approve / reject / cancel; terminal states |
staff.invariants.spec.ts | One pass + one fail per I-Staff-{1..6} |
shift.invariants.spec.ts | I-Shift-{1..4} |
assignment.invariants.spec.ts | I-Asn-{1..3}, including the multi-property double-shift rule |
clock.invariants.spec.ts | I-Clock-{1..6}; property test (fast-check) on random sequences for I-Clock-1 |
leave.invariants.spec.ts | I-Leave-{1..3} |
cross-aggregate.invariants.spec.ts | I-X-{1, 2} |
pattern-materializer.spec.ts | Cross-midnight, DST transitions (Asia/Kabul, Asia/Tehran, Asia/Dushanbe), bi-weekly cadence, holiday skip |
overlap-detector.spec.ts | Half-open intervals; equal endpoints; nested; disjoint |
conflict-evaluator.spec.ts | Each conflict kind isolated; combined conflict list ordering is deterministic |
staff-code-generator.spec.ts | Deterministic with seed; collision retry |
pin-format-validator.spec.ts | Sequential, all-same, length, non-digit |
capacity-snapshot-builder.spec.ts | Active / 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.
OverlapDetectorsymmetry and transitivity.PatternMaterializeridempotence (materialize(p, w) == materialize(p, w)).
3. Unit — Application
| Spec | What it asserts |
|---|---|
create-staff.use-case.spec.ts | Email-or-manager-required; PIN hashed via port; staff_code generated; outbox created.v1; idempotent |
update-staff.use-case.spec.ts | OCC; partial; immutable fields rejected |
change-position.use-case.spec.ts | Cancels future shifts where staff is sole primary; emits position_changed.v1 |
terminate-staff.use-case.spec.ts | Cascade: auto-clock-out, soft-unassign, cancel sole-primary shifts, IAM revoke retried, audit + event order |
reactivate-staff.use-case.spec.ts | Re-issues staff_code if reused; emits reactivated.v1 |
set-clock-pin.use-case.spec.ts | Self-rotate requires current PIN; manager-rotate requires reason; lockout reset; audit |
add-certification.use-case.spec.ts | TTL scheduling; emits added.v1 |
upsert-shift-pattern.use-case.spec.ts | Validation; OCC |
generate-shifts.use-case.spec.ts | Idempotent on replay (key = patternId, fromDate, toDate); dryRun returns no-op; bulk outbox |
create-ad-hoc-shift.use-case.spec.ts | Validates department exists at property |
cancel-shift.use-case.spec.ts | Forbidden after in_progress; emits cancelled.v1 |
assign-staff.use-case.spec.ts | Calls ConflictEvaluator; returns 409 with violations[]; force=true bypasses cert |
unassign-staff.use-case.spec.ts | Soft-removal preserves audit; emits unassigned.v1 |
swap-assignments.use-case.spec.ts | Atomic; both staff conflict-checked against opposite shift |
promote-standby.use-case.spec.ts | Source = auto_promoted |
punch-clock.use-case.spec.ts | All branches: PIN/JWT modes, kinds, multi-property reject, sequence reject, shift transitions emit started.v1/ended.v1 |
manager-override-punch.use-case.spec.ts | Reason required; audit row; event source |
auto-close-shifts.use-case.spec.ts | Idempotent via staffing_gap_emitted_at; emits system_auto clock-out |
submit-leave.use-case.spec.ts | Window validation; emits requested.v1 |
decide-leave.use-case.spec.ts | Approve with collisions: force=false rejects, force=true cascades; emits + audit |
cancel-leave.use-case.spec.ts | Forbidden after window started + approved |
add-handoff-note.use-case.spec.ts | Append-only DB; emits note_added.v1 |
on-iam-user-registered.handler.spec.ts | Links existing pending staff; idempotent on replay |
on-tenant-membership-created.handler.spec.ts | Provisions default-shell staff |
on-tenant-membership-removed.handler.spec.ts | Cascade-terminates |
on-property-deactivated.handler.spec.ts | Cancels future shifts |
on-ai-shift-optimization.handler.spec.ts | Persists 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.
| Spec | Coverage |
|---|---|
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.ts | Direct SQL connections without app.tenant_id set return 0 rows (RLS deny by default) |
shift-overlap-exclude.spec.ts | The EXCLUDE constraint on (tenant_id, pattern_id, local_date) rejects duplicate generation |
clock-dedupe-uk.spec.ts | The (tenant_id, staff_id, occurred_at_utc, kind) unique index dedupes double-tap |
append-only-trigger.spec.ts | UPDATE/DELETE on clock_entries, handoff_notes, audit_events raises 0L000 |
pin-attempt-counter.spec.ts | Redis token bucket + per-staff lockout |
outbox-relay.spec.ts | Worker publishes within 5 s under load (250 events/s) |
inbox-dlq.spec.ts | Schema-mismatch event lands in DLQ with MELMASTOON.STAFF.EVENT_SCHEMA_MISMATCH |
capacity-cache.spec.ts | Punch event invalidates cache within 1 s; concurrent reads consistent |
kms-pin-rotation.spec.ts | Old pepper version verifies; lazy re-hashes to new version |
migration-rollback.spec.ts | Each 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-serviceandbff-tenant-booking-serviceare 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:
- GM creates staff with email + invite, accepts invite, sets PIN, clocks in.
- GM creates PIN-only staff, clocks in via Electron-emulated PIN entry.
- Manager generates a week of shifts, assigns staff, swaps two assignments.
- Staff submits leave; GM approves with
forceUnassign; staff is auto-unassigned. - Staff fails PIN 5 times, gets locked out, GM unlocks.
- Manager-override punch for a staff who forgot to clock in.
- Termination cascade: GM terminates an active staff; verify auto-clock-out, future-unassign, IAM revoke, no future bookings.
- 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:
| Spec | Coverage |
|---|---|
offline-clock-replay.spec.ts | Queue 100 punches offline, restore connectivity, assert all accepted with correct order; assert no duplicates |
multi-device-collision.spec.ts | Two devices, same staff, simultaneous offline punches → server reconciles per SYNC_CONTRACT §6 |
pull-cursor-idempotence.spec.ts | Replay same cursor 3× returns identical changeset |
out-of-window-punch.spec.ts | Punch with offline_queue_age_seconds > 86400 is accepted with flagged_late_replay |
conflict-policy-server-authoritative.spec.ts | Local edit to Shift.status is rejected on push |
conflict-policy-lww.spec.ts | Two devices edit Staff.displayName; latest updatedAt wins |
conflict-policy-set-union.spec.ts | Two devices ack same handoff note; both ack records preserved |
pii-omission.spec.ts | Pull 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
| Spec | Coverage |
|---|---|
rls-sweep.spec.ts | Programmatic check: every table in staff schema has RLS enabled and at least one policy |
rbac-matrix.spec.ts | Each capability denies the actor without it (per SECURITY_MODEL §2) |
pin-bruteforce.spec.ts | 100 wrong PINs in 60 s → all rejected, lockout triggered, no DB exhaustion |
pii-no-log.spec.ts | Synthetic logs scanned; no email / phone / emergency contact strings |
audit-row-immutability.spec.ts | UPDATE/DELETE on audit_events raises |
zap-baseline.yaml | OWASP 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).