M3 Sync — Manual Smoke Test
This is the operator-facing smoke test for the M3 sync milestone. CI cannot prove sync end-to-end (it needs a live PostgreSQL plus two app instances), so this checklist replaces the automated acceptance gate. Run it after deploying the new server build and installing the new Android release.
Spec: docs/superpowers/plans/2026-04-10-milestone-3-sync.md (in-repo) Branch / MR: feature/m3-sync / MR !89
Pre-flight
-
Server deploy: new server binary running with PostgreSQL reachable. Migrations 0001—0003 already applied — M3 adds no new server migration. Required env vars (already in place if M2 worked):
DATABASE_URL,JWT_SECRET,ADMIN_COOKIE_SECRET,ORG_SECRET. -
Server tracing: start with
RUST_LOG=es_server=debug cargo run(orRUST_LOG=es_server=debugin your systemd unit / docker env). Tail the logs in a side terminal — every push, pull, WebSocket subscribe, andapply_opwill print a span line. -
Two app instances with the new build. Recommended pairings, easiest first:
-
Desktop dev build (
npm run tauri devfrom your laptop) + Android release on phone — independent SQLite stores by definition. -
Two Tauri dev windows pointed at separate SQLite files via
XDG_DATA_HOME=/tmp/instance-A npm run tauri devandXDG_DATA_HOME=/tmp/instance-B npm run tauri dev— quickest if you don’t want to build Android. -
Two Android devices — most realistic, slowest to set up.
-
-
Both instances enrolled under the same user (or two users who share an event membership). Run through the existing first-run enrolment flow on each — that’s enrolment, not part of M3.
Test 1 — Two-instance live sync (the headline)
The end-state M3 promises: a write on instance A appears on instance B without the operator on B doing anything.
Steps
-
On A: log in. Open or create an event both instances are members of.
-
On B: log in to the same event. Stay on the event detail screen so the protocol list is visible.
-
On A: create a new protocol (Patientendokumentation, fill in a recognisable name like "Smoke Test Müller" so you can spot it).
-
Expected on B within 30 s: the new protocol appears in the protocol list automatically. No manual reload, no spinner.
-
With WebSocket working: under 1 second.
-
With WebSocket broken: up to ~30 s (poll fallback). Background poll is currently deferred — see "Known limitation" below — so for this MVP test, expect the protocol to appear when the user navigates back to or refreshes the event view on B, not on a 30 s timer.
-
-
In server logs (tail with
RUST_LOG=es_server=debug): you should see a span sequence on the push:push{device_id=… op_count=1}: apply_op{op_type="protocol.created" event_id=…} op stored in op_log seq=…And on the pull (when B refreshes):
pull{event_id=… since=…}: pull returning ops returned=1
If it fails: check that both instances are pointing at the same server URL (visible in Settings → Server, or via the SERVER_URL constant in src/lib/auth.ts). Mismatched URLs → silent no-op.
Test 2 — Edits propagate
Create-only is not the full story; field updates also queue ops.
Steps
-
With both instances on the same protocol open: on A, edit a field (e.g. patient first name).
-
On B: refresh the protocol view (back arrow, then re-open).
-
Expected on B: the edited field shows A’s new value.
-
Server logs: an
apply_opspan withop_type="protocol.field.updated"appears.
Test 3 — Close + archive propagate
Lifecycle transitions are op types of their own.
Steps
-
On A: close a protocol (the "Protokoll schließen" action).
-
On B: refresh the protocol list.
-
Expected on B: protocol shows the "closed" status.
-
Repeat for archive (close the parent event, which in turn produces an
event.closedop). -
Server logs:
op_type="protocol.closed"and laterop_type="event.closed"spans.
Test 4 — Offline tolerance
Ops queue locally and flush when connectivity returns.
Steps
-
On B: disable network (airplane mode on phone, or
Toggle Network → Offlinein DevTools on desktop dev). -
On A: create another protocol.
-
Expected on B (still offline): nothing changes — the local SQLite has no remote ops to apply.
-
Re-enable network on B. Navigate away from the event and back (this triggers
syncPull). -
Expected on B: the new protocol appears.
-
Server logs: the push from A landed at the time A wrote it. The pull from B happens when B’s app re-fires it.
Conversely:
-
On A: disable network.
-
On A: create a protocol while offline. The local UI shows it immediately (writes always hit local SQLite first).
-
Re-enable network on A.
-
Expected: next time anything triggers
syncFlushon A (any new write or navigating to a different event and back), the queued op pushes. Currently this happens on the next user-triggered write, not on an automatic timer — see Known limitation. -
Server logs: push lands when A flushes.
Test 5 — Idempotent restart
Re-pulling already-applied ops must not duplicate rows.
Steps
-
On B: kill the app entirely.
-
Restart it. Navigate to the same event.
-
Expected: no duplicate protocols. The protocol list should look identical to before the restart.
-
Behind the scenes:
sync_state.last_seen_seqwas persisted; pull withsince=<last_seq>returns only newer ops.
Test 6 — Membership 403
Unauthorised pulls are rejected at the server.
Setup: you’d need a second user account that is not a member of the event. If you don’t have one handy, skip this test — it’s exercised by the server-side unit tests indirectly (the is_member check is plain SQL, easy to verify by inspection).
If you do have a second user: log them in on a third instance, manually point them at an event ID they’re not in (you’d need to fish the UUID out of the URL or DB), trigger sync_pull. Server returns HTTP 403. Server logs show:
pull{event_id=… since=0}: pull rejected: user not in event
Test 7 — UI is undisturbed
Sync MUST be invisible. The user should not see loading states, sync errors, or visible delays caused by the network.
Steps
-
With network enabled: any normal write (create / edit / close) must NOT show a spinner or status badge for sync.
-
With network disabled: same. Writes complete locally; the deferred sync flush silently fails until the network comes back. The frontend store catches the error with
console.warnonly — no toast, no banner. -
Acceptance: nothing in the UI hints that sync exists. The only place a user sees sync activity is by enabling browser DevTools console and watching the warnings.
Test 8 — Tracing visibility
Confirms the inline N2 instrumentation (lane 2 of RELEASE-PLAN.md) is wired correctly.
Steps
-
Start server with
RUST_LOG=es_server=debug cargo run. -
Do any sync round-trip (e.g. Test 1).
-
Expected log lines (you should see at least one of each):
-
push{...}— outer push handler span -
apply_op{...}— per-op span inside push -
op stored in op_log seq=N— debug line -
pull{...}— pull handler span -
pull returning ops returned=N— debug line -
ws subscribed event_id=…— WebSocket subscribe (if you actually have a WS connection — Tauri-side WS client is not yet implemented in M3; only HTTP push/pull. WS subscriptions will appear once a M4 or N2 task adds the client side.)
-
Spans skip during normal write paths because tracing is not wired into Tauri-side commands (deferred to full N2). That’s expected.
Test 9 — Admin-created event visible on device (QA D1)
Verifies that an event created in the admin panel appears on a device without any manual join step.
Steps
-
In the admin panel, create a new event and assign the test user as a member.
-
On the device: log out and log back in (or kill + restart the app).
-
Expected: the new event appears in the Dashboard event list automatically. No join code required.
-
Smoke the join code too: tap "Einsatz beitreten", enter the first 8 characters of the new event’s UUID (visible in the admin panel URL or the event list). Expected: the event is found and set as the current event.
-
Server logs:
my_events{user_id=…}: my_events returning 1on login.
If the event does NOT appear: verify the user is in the memberships table for that event (SELECT * FROM memberships WHERE user_id = '<id>' on the server DB). If membership exists but event still doesn’t appear, check the server log for the my_events span — a 403 or 500 indicates an auth or DB issue.
Known limitations of M3 (do NOT report these as bugs)
-
No background polling. The 30 s automatic poll described in the original plan is deferred. Sync only fires on user actions (write, switch event, app load). To enable the poll, the auth flow must first persist
access_tokentosecrets.bin(currently only the refresh token is persisted). Tracked insrc-tauri/src/commands/sync.rs(top-of-file comment) and in RELEASE-PLAN.md decision log. -
No WebSocket client. The server
/wsendpoint exists and fan-outs work, but the Tauri side does not yet open a WS connection. Real-time push from server to client is therefore not active — clients only see remote changes when they next callsyncPull(i.e. when the user navigates). This is why Test 1 may take seconds rather than milliseconds. Adding a WS client is a small follow-up; it isn’t strictly part of M3. -
Plaintext payloads. The
op_log.payloadBYTEA column carries plaintext JSON. M4 (payload encryption) replaces this with AES-256-GCM ciphertext. The transport path doesn’t change — only the column contents. -
No conflict-resolution UI. Last-write-wins by
client_tshappens silently. If A and B edit the same field within milliseconds of each other, the later one wins; the earlier loses with no warning. UI for this is post-v1. -
No sync status indicator. By design — see Test 7.
After all tests pass
-
Merge MR !89 (
glab mr merge 89). -
Update RELEASE-PLAN.md to move M3 from "Open lanes" to "Shipped".
-
Tag a new release with
scripts/bump-version.sh <X.Y.Z>and let CI build the artefacts (when CI quota returns; otherwise build locally and ship manually). -
Move on to lane 3 (M4 payload encryption) per the sequenced plan.
If a test fails, file a follow-up issue with the failing step, the server log span, and the apparent symptom on each instance.