Preconditions
- Inherits native-app-shell trunk: signed build installed, user auth, event roster synced.
- Event has at least one access type with confirmed registrations.
- iOS minimum: 16.0. Android minimum: API 30 (Android 11). Older versions show a forced-upgrade screen on launch.
- Camera permission granted (or manual-entry fallback available).
Happy path
-
Staff taps Check-In tab.
Inherits native-app-shell tab navigation. Tab opens the camera viewfinder by default with a "Manual entry" toggle in the top corner.
-
Camera scans guest QR.
QR decode + payload validation happens locally. If valid + recognized for this event, transitions to confirmation screen showing guest name, access type, status, any check-in notes (EF-047). Staff taps "Confirm check-in" or "Cancel."
-
Confirm writes locally + sends to server.
Local write goes immediately (works offline). Network call queues if offline (per native-app-shell trunk's queue contract). Confirmation screen shows green check + auto-dismisses after 1.5s, returning to viewfinder for the next scan.
-
Manual entry path.
Toggle to manual entry — text field for typing the guest's confirmation code OR an autocomplete picker over the local roster (search by name/email). Same confirmation screen + same write path as QR.
Failure modes
Force-upgrade on too-old OS
Trigger: launch on iOS 15 or Android 10 (below minimums).
App shows a forced-upgrade screen with App Store / Play Store deep-link. No bypass — older versions can't access the data layer reliably. Staff handed an older device should swap it. Harness: launch on emulator at OS < minimum, assert force-upgrade screen visible, no other navigation accessible.
Two-staff idempotent same-guest
Trigger: two staff devices scan the same guest within 30s of each other.
Inherits native-app-shell trunk's two-staff-idempotent contract. The second staff's confirmation screen surfaces "Checked in by [first staff name] at [time]" — informational, not error. Server-side check-in count stays at 1. Harness: physical 2-device test, scan same guest, server row count = 1, second device shows informational banner.
QR for wrong event
Trigger: staff scans a QR from a different event (guest brought the wrong code).
Local payload validation catches event-id mismatch. Confirmation screen replaced with red banner: "This QR is for a different event. Ask the guest to confirm." Anti-probing — the banner does NOT name the other event (could leak across tenants). Harness: scan cross-event QR, banner visible, no API call to wrong event.
Tampered QR payload
Trigger: scanned QR has a forged or mutated signature.
QR signature validation fails locally. Same red banner as wrong-event ("This QR is invalid"). Identical UI shape — anti-probing prevents an attacker from distinguishing forgery from cross-event confusion. Harness: synthetic forged QR, banner visible, no API call.
Camera permission revoked between launches
Trigger: user denies camera in OS settings between sessions.
App detects on tab entry (per trunk contract) and shows the camera-needed prompt with deeplink to settings. Manual-entry tab remains accessible — never blocks the flow on camera alone. Harness: revoke camera, tap Check-In, prompt visible, manual entry still works.
Network flaky during confirm
Trigger: spotty WiFi during confirm tap; request hangs.
Local write commits immediately (idempotent by deviceId + guestId + scanTimestamp). Confirmation screen advances with a small "Syncing..." indicator. Pending sync banner increments. Same idempotency-key prevents duplicates if the request later succeeds. Harness: stub network with 50% packet loss, confirm 10 scans, assert all 10 land server-side exactly once.
Battery drain pacing
Trigger: staff uses the app for 6+ hours of continuous check-in at a long event.
Camera viewfinder uses adaptive frame rate (15 fps idle, 30 fps when motion detected). Background sync uses jittered backoff. Battery drain target: ≤ 30% over 6h of moderate use on a 2022+ device. Harness: physical device 6h burn-in test with battery telemetry, assert drain ≤ threshold.
Device clock skew
Trigger: staff device clock is 5 minutes ahead of server.
Local check-in timestamps use device clock for ordering, but server reconciles using server-clock at receive time. The audit log records both client_ts + server_ts. UI-displayed times use server_ts. Harness: stub device clock +5min, scan, assert server row's check-in timestamp is server's now, not device's now.
Already-checked-in guest scanned again
Trigger: staff scans a guest who already checked in earlier today.
Confirmation screen shows the prior check-in time + "Already checked in" status. Staff can choose: dismiss (no second check-in row) OR "Re-confirm" if they need to update notes. Re-confirm doesn't create a duplicate row; it appends to a check-in events list. Harness: scan same guest twice, second shows already-checked-in screen, server has 1 check-in row + 1 check-in event row.
App update mid-event preserves pending queue
Trigger: store auto-update pushes new version while staff has 12 unsynced check-ins.
Inherits trunk's app-update-while-event-running contract. Storage migrations run before UI mounts. Pending queue is preserved through the update. Harness: simulate mid-event update with pending queue, post-update queue length unchanged.
Cross-tenant guest scan
Trigger: a staff token from tenant A scans a QR signed by tenant B (theoretically impossible with proper token scoping, but defense-in-depth).
Server returns 404 (anti-probing — same as wrong-event). Audit log captures the attempt for monitoring. Harness: forge cross-tenant QR + auth, attempt confirm, server 404 with no leak.
Stable test attributes
| identifier | Where | Purpose |
|---|---|---|
checkin-camera-viewfinder | Check-In tab default | QR scan surface |
checkin-manual-entry-toggle | Top corner of Check-In tab | Switches to manual entry mode |
checkin-manual-entry-input | Manual entry mode | Code or roster autocomplete |
checkin-confirmation-screen | After valid scan | Guest details + confirm/cancel |
checkin-confirm-button | Confirmation screen | Commits the check-in |
checkin-cancel-button | Confirmation screen | Returns to viewfinder without commit |
checkin-already-checked-in | Visible if guest already checked in | Shows prior time + by whom |
checkin-cross-event-banner | Visible on wrong-event QR | Generic "different event" message |
checkin-tampered-banner | Visible on forged QR | Identical shape to cross-event banner |
checkin-force-upgrade-screen | Visible on too-old OS | App Store / Play Store deeplink |
checkin-syncing-indicator | After confirm, while offline-queued | Small "Syncing..." badge |
Agent test plan
Native runner not yet wired. Until then, evidence is Gate 7 packet: signed TestFlight + Play Store internal-track build, physical-device scans, screenshots of each probe state, server-side row inspection.
Probe list
- (manual) launch-on-min-os: install on iOS 16.0 + Android API 30, no force-upgrade
- (manual) launch-on-too-old-os: emulator iOS 15 + API 28, force-upgrade-screen visible
- (manual) scan-valid-guest: physical QR scan, confirmation screen shows correct guest
- (manual) two-staff-idempotent: 2 devices scan same guest, server row count=1, second shows informational
- (manual) wrong-event-banner: scan QR from different event, cross-event-banner visible
- (manual) tampered-qr-banner: synthetic forged QR, tampered-banner visible (identical shape)
- (manual) camera-revoked-manual-entry-works: revoke camera, manual entry remains
- (manual) network-flaky-no-duplicates: physical device with simulated packet loss, 10 scans, server has exactly 10 rows
- (manual) battery-drain-6h: physical 6h burn, ≤30% drain on 2022+ device
- (manual) clock-skew-server-ts-wins: device clock +5min, server row uses server_ts
- (manual) already-checked-in-screen: scan same guest 2x, second shows already-checked-in
- (manual) app-update-preserves-queue: mid-event store update, pending queue intact
- (manual) cross-tenant-qr-404: forged cross-tenant QR, server 404
- (when wired) detox-or-xcuitest-run: full test plan executes via native runner