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 (or RUST_LOG=es_server=debug in your systemd unit / docker env). Tail the logs in a side terminal — every push, pull, WebSocket subscribe, and apply_op will print a span line.

  • Two app instances with the new build. Recommended pairings, easiest first:

    1. Desktop dev build (npm run tauri dev from your laptop) + Android release on phone — independent SQLite stores by definition.

    2. Two Tauri dev windows pointed at separate SQLite files via XDG_DATA_HOME=/tmp/instance-A npm run tauri dev and XDG_DATA_HOME=/tmp/instance-B npm run tauri dev — quickest if you don’t want to build Android.

    3. 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

  1. On A: log in. Open or create an event both instances are members of.

  2. On B: log in to the same event. Stay on the event detail screen so the protocol list is visible.

  3. On A: create a new protocol (Patientendokumentation, fill in a recognisable name like "Smoke Test Müller" so you can spot it).

  4. 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.

  5. 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

  1. With both instances on the same protocol open: on A, edit a field (e.g. patient first name).

  2. On B: refresh the protocol view (back arrow, then re-open).

  3. Expected on B: the edited field shows A’s new value.

  4. Server logs: an apply_op span with op_type="protocol.field.updated" appears.


Test 3 — Close + archive propagate

Lifecycle transitions are op types of their own.

Steps

  1. On A: close a protocol (the "Protokoll schließen" action).

  2. On B: refresh the protocol list.

  3. Expected on B: protocol shows the "closed" status.

  4. Repeat for archive (close the parent event, which in turn produces an event.closed op).

  5. Server logs: op_type="protocol.closed" and later op_type="event.closed" spans.


Test 4 — Offline tolerance

Ops queue locally and flush when connectivity returns.

Steps

  1. On B: disable network (airplane mode on phone, or Toggle Network → Offline in DevTools on desktop dev).

  2. On A: create another protocol.

  3. Expected on B (still offline): nothing changes — the local SQLite has no remote ops to apply.

  4. Re-enable network on B. Navigate away from the event and back (this triggers syncPull).

  5. Expected on B: the new protocol appears.

  6. 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:

  1. On A: disable network.

  2. On A: create a protocol while offline. The local UI shows it immediately (writes always hit local SQLite first).

  3. Re-enable network on A.

  4. Expected: next time anything triggers syncFlush on 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.

  5. Server logs: push lands when A flushes.


Test 5 — Idempotent restart

Re-pulling already-applied ops must not duplicate rows.

Steps

  1. On B: kill the app entirely.

  2. Restart it. Navigate to the same event.

  3. Expected: no duplicate protocols. The protocol list should look identical to before the restart.

  4. Behind the scenes: sync_state.last_seen_seq was persisted; pull with since=<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

  1. With network enabled: any normal write (create / edit / close) must NOT show a spinner or status badge for sync.

  2. 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.warn only — no toast, no banner.

  3. 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

  1. Start server with RUST_LOG=es_server=debug cargo run.

  2. Do any sync round-trip (e.g. Test 1).

  3. 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

  1. In the admin panel, create a new event and assign the test user as a member.

  2. On the device: log out and log back in (or kill + restart the app).

  3. Expected: the new event appears in the Dashboard event list automatically. No join code required.

  4. 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.

  5. Server logs: my_events{user_id=…}: my_events returning 1 on 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)

  1. 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_token to secrets.bin (currently only the refresh token is persisted). Tracked in src-tauri/src/commands/sync.rs (top-of-file comment) and in RELEASE-PLAN.md decision log.

  2. No WebSocket client. The server /ws endpoint 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 call syncPull (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.

  3. Plaintext payloads. The op_log.payload BYTEA column carries plaintext JSON. M4 (payload encryption) replaces this with AES-256-GCM ciphertext. The transport path doesn’t change — only the column contents.

  4. No conflict-resolution UI. Last-write-wins by client_ts happens 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.

  5. No sync status indicator. By design — see Test 7.


After all tests pass

  1. Merge MR !89 (glab mr merge 89).

  2. Update RELEASE-PLAN.md to move M3 from "Open lanes" to "Shipped".

  3. 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).

  4. 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.