M3 Sync — Manueller Smoke-Test

Dies ist der Smoke-Test für Bediener für den Meilenstein M3 Sync. CI kann die Synchronisierung nicht durchgängig nachweisen (dazu wären ein laufendes PostgreSQL sowie zwei App-Instanzen nötig), daher ersetzt diese Checkliste das automatisierte Abnahmekriterium. Führe sie aus, nachdem du den neuen Server-Build bereitgestellt und das neue Android-Release installiert hast.

Spec: docs/superpowers/plans/2026-04-10-milestone-3-sync.md (im Repo) Branch / MR: feature/m3-sync / MR !89


Pre-flight

  • Server-Bereitstellung: neues Server-Binary läuft mit erreichbarem PostgreSQL. Migrationen 0001—​0003 bereits angewendet — M3 fügt keine neue Server-Migration hinzu. Erforderliche Umgebungsvariablen (bereits vorhanden, wenn M2 funktioniert hat): DATABASE_URL, JWT_SECRET, ADMIN_COOKIE_SECRET, ORG_SECRET.

  • Server-Tracing: starte mit RUST_LOG=es_server=debug cargo run (oder RUST_LOG=es_server=debug in deiner systemd-Unit / Docker-Umgebung). Verfolge die Logs in einem Nebenterminal — jeder Push, Pull, WebSocket-Subscribe und apply_op gibt eine Span-Zeile aus.

  • Zwei App-Instanzen mit dem neuen Build. Empfohlene Paarungen, einfachste zuerst:

    1. Desktop-Dev-Build (npm run tauri dev auf deinem Laptop) + Android-Release auf dem Telefon — per Definition unabhängige SQLite-Speicher.

    2. Zwei Tauri-Dev-Fenster, die über XDG_DATA_HOME=/tmp/instance-A npm run tauri dev und XDG_DATA_HOME=/tmp/instance-B npm run tauri dev auf separate SQLite-Dateien zeigen — am schnellsten, wenn du kein Android bauen möchtest.

    3. Zwei Android-Geräte — am realistischsten, am langsamsten einzurichten.

  • Beide Instanzen registriert unter demselben Benutzer (oder zwei Benutzern, die sich eine Event-Mitgliedschaft teilen). Durchlaufe auf jeder Instanz den bestehenden Erstinbetriebnahme-Enrolment-Ablauf — das ist Enrolment, nicht Teil von M3.


Test 1 — Live-Synchronisierung zweier Instanzen (das Highlight)

Der Endzustand, den M3 verspricht: ein Schreibvorgang auf Instanz A erscheint auf Instanz B, ohne dass der Bediener auf B etwas tut.

Schritte

  1. Auf A: einloggen. Öffne oder erstelle ein Event, in dem beide Instanzen Mitglied sind.

  2. Auf B: in dasselbe Event einloggen. Bleibe auf dem Event-Detailbildschirm, sodass die Protokollliste sichtbar ist.

  3. Auf A: erstelle ein neues Protokoll (Patientendokumentation, trage einen wiedererkennbaren Namen wie "Smoke Test Müller" ein, damit du es erkennst).

  4. Erwartet auf B innerhalb von 30 s: das neue Protokoll erscheint automatisch in der Protokollliste. Kein manuelles Neuladen, kein Spinner.

    • Mit funktionierendem WebSocket: unter 1 Sekunde.

    • Mit defektem WebSocket: bis zu ~30 s (Poll-Fallback). Hintergrund-Polling ist derzeit zurückgestellt — siehe "Bekannte Einschränkung" weiter unten — daher erwarte bei diesem MVP-Test, dass das Protokoll erscheint, wenn der Benutzer auf B zur Event-Ansicht zurücknavigiert oder sie aktualisiert, nicht nach einem 30-s-Timer.

  5. In den Server-Logs (mit RUST_LOG=es_server=debug verfolgen): du solltest beim Push eine Span-Sequenz sehen:

    push{device_id=… op_count=1}: apply_op{op_type="protocol.created" event_id=…}
        op stored in op_log seq=…

    Und beim Pull (wenn B aktualisiert):

    pull{event_id=… since=…}: pull returning ops returned=1

Falls es fehlschlägt: prüfe, ob beide Instanzen auf dieselbe Server-URL zeigen (sichtbar unter Einstellungen → Server oder über die Konstante SERVER_URL in src/lib/auth.ts). Nicht übereinstimmende URLs → stiller No-op.


Test 2 — Bearbeitungen propagieren

Nur Erstellen ist nicht die ganze Geschichte; auch Feldaktualisierungen reihen Ops ein.

Schritte

  1. Mit beiden Instanzen auf demselben geöffneten Protokoll: bearbeite auf A ein Feld (z. B. den Vornamen des Patienten).

  2. Auf B: aktualisiere die Protokollansicht (Zurück-Pfeil, dann erneut öffnen).

  3. Erwartet auf B: das bearbeitete Feld zeigt den neuen Wert von A.

  4. Server-Logs: eine apply_op-Span mit op_type="protocol.field.updated" erscheint.


Test 3 — Schließen + Archivieren propagieren

Lebenszyklus-Übergänge sind eigene Op-Typen.

Schritte

  1. Auf A: schließe ein Protokoll (die Aktion "Protokoll schließen").

  2. Auf B: aktualisiere die Protokollliste.

  3. Erwartet auf B: das Protokoll zeigt den Status "geschlossen".

  4. Wiederhole dies für das Archivieren (schließe das übergeordnete Event, was wiederum eine event.closed-Op erzeugt).

  5. Server-Logs: op_type="protocol.closed"- und später op_type="event.closed"-Spans.


Test 4 — Offline-Toleranz

Ops werden lokal eingereiht und übertragen, sobald die Konnektivität zurückkehrt.

Schritte

  1. Auf B: Netzwerk deaktivieren (Flugmodus am Telefon oder Toggle Network → Offline in den DevTools auf dem Desktop-Dev-Build).

  2. Auf A: erstelle ein weiteres Protokoll.

  3. Erwartet auf B (noch offline): nichts ändert sich — das lokale SQLite hat keine Remote-Ops anzuwenden.

  4. Netzwerk auf B wieder aktivieren. Navigiere vom Event weg und zurück (dies löst syncPull aus).

  5. Erwartet auf B: das neue Protokoll erscheint.

  6. Server-Logs: der Push von A ist zu dem Zeitpunkt eingegangen, als A ihn geschrieben hat. Der Pull von B erfolgt, wenn Bs App ihn erneut auslöst.

Umgekehrt:

  1. Auf A: Netzwerk deaktivieren.

  2. Auf A: erstelle im Offline-Zustand ein Protokoll. Die lokale UI zeigt es sofort an (Schreibvorgänge treffen immer zuerst das lokale SQLite).

  3. Netzwerk auf A wieder aktivieren.

  4. Erwartet: wenn das nächste Mal irgendetwas syncFlush auf A auslöst (jeder neue Schreibvorgang oder das Navigieren zu einem anderen Event und zurück), wird die eingereihte Op gepusht. Derzeit geschieht dies beim nächsten benutzerausgelösten Schreibvorgang, nicht durch einen automatischen Timer — siehe Bekannte Einschränkung.

  5. Server-Logs: der Push geht ein, wenn A überträgt.


Test 5 — Idempotenter Neustart

Das erneute Abrufen bereits angewendeter Ops darf keine Zeilen duplizieren.

Schritte

  1. Auf B: beende die App vollständig.

  2. Starte sie neu. Navigiere zum selben Event.

  3. Erwartet: keine doppelten Protokolle. Die Protokollliste sollte identisch aussehen wie vor dem Neustart.

  4. Hinter den Kulissen: sync_state.last_seen_seq wurde persistiert; der Pull mit since=<last_seq> gibt nur neuere Ops zurück.


Test 6 — Mitgliedschaft 403

Unbefugte Pulls werden am Server abgewiesen.

Setup: du bräuchtest ein zweites Benutzerkonto, das kein Mitglied des Events ist. Wenn du gerade keins zur Hand hast, überspringe diesen Test — er wird indirekt durch die serverseitigen Unit-Tests abgedeckt (die is_member-Prüfung ist reines SQL, leicht durch Inspektion zu verifizieren).

Falls du einen zweiten Benutzer hast: logge ihn auf einer dritten Instanz ein, richte ihn manuell auf eine Event-ID, in der er nicht ist (du müsstest die UUID aus der URL oder DB herausfischen), löse sync_pull aus. Der Server gibt HTTP 403 zurück. Die Server-Logs zeigen:

pull{event_id=… since=0}: pull rejected: user not in event

Test 7 — Die UI bleibt ungestört

Die Synchronisierung MUSS unsichtbar sein. Der Benutzer sollte keine Ladezustände, Sync-Fehler oder sichtbaren Verzögerungen sehen, die durch das Netzwerk verursacht werden.

Schritte

  1. Mit aktiviertem Netzwerk: jeder normale Schreibvorgang (Erstellen / Bearbeiten / Schließen) darf KEINEN Spinner oder Status-Badge für die Synchronisierung anzeigen.

  2. Mit deaktiviertem Netzwerk: dasselbe. Schreibvorgänge werden lokal abgeschlossen; die zurückgestellte Sync-Übertragung schlägt stillschweigend fehl, bis das Netzwerk zurückkehrt. Der Frontend-Store fängt den Fehler nur mit console.warn ab — kein Toast, kein Banner.

  3. Abnahme: nichts in der UI deutet darauf hin, dass Synchronisierung existiert. Der einzige Ort, an dem ein Benutzer Sync-Aktivität sieht, ist das Aktivieren der Browser-DevTools-Konsole und das Beobachten der Warnungen.


Test 8 — Tracing-Sichtbarkeit

Bestätigt, dass die Inline-N2-Instrumentierung (Lane 2 von RELEASE-PLAN.md) korrekt verdrahtet ist.

Schritte

  1. Starte den Server mit RUST_LOG=es_server=debug cargo run.

  2. Führe irgendeinen Sync-Roundtrip aus (z. B. Test 1).

  3. Erwartete Log-Zeilen (du solltest von jeder mindestens eine sehen):

    • push{...} — äußere Push-Handler-Span

    • apply_op{...} — Per-Op-Span innerhalb des Push

    • op stored in op_log seq=N — Debug-Zeile

    • pull{...} — Pull-Handler-Span

    • pull returning ops returned=N — Debug-Zeile

    • ws subscribed event_id=… — WebSocket-Subscribe (falls du tatsächlich eine WS-Verbindung hast — der Tauri-seitige WS-Client ist in M3 noch nicht implementiert; nur HTTP-Push/Pull. WS-Subscriptions erscheinen, sobald eine M4- oder N2-Aufgabe die Client-Seite hinzufügt.)

Spans werden während normaler Schreibpfade übersprungen, weil tracing nicht in die Tauri-seitigen Befehle verdrahtet ist (auf das vollständige N2 zurückgestellt). Das ist zu erwarten.


Test 9 — Im Admin-Panel erstelltes Event auf dem Gerät sichtbar (QA D1)

Verifiziert, dass ein im Admin-Panel erstelltes Event ohne manuellen Beitrittsschritt auf einem Gerät erscheint.

Schritte

  1. Erstelle im Admin-Panel ein neues Event und weise den Testbenutzer als Mitglied zu.

  2. Auf dem Gerät: ausloggen und wieder einloggen (oder App beenden + neu starten).

  3. Erwartet: das neue Event erscheint automatisch in der Event-Liste des Dashboards. Kein Beitrittscode erforderlich.

  4. Teste auch den Beitrittscode: tippe auf "Einsatz beitreten", gib die ersten 8 Zeichen der UUID des neuen Events ein (sichtbar in der Admin-Panel-URL oder der Event-Liste). Erwartet: das Event wird gefunden und als aktuelles Event gesetzt.

  5. Server-Logs: my_events{user_id=…}: my_events returning 1 beim Login.

Falls das Event NICHT erscheint: prüfe, ob der Benutzer in der memberships-Tabelle für dieses Event steht (SELECT * FROM memberships WHERE user_id = '<id>' auf der Server-DB). Falls die Mitgliedschaft existiert, das Event aber immer noch nicht erscheint, prüfe das Server-Log auf die my_events-Span — ein 403 oder 500 deutet auf ein Auth- oder DB-Problem hin.


Bekannte Einschränkungen von M3 (melde diese NICHT als Bugs)

  1. Kein Hintergrund-Polling. Das im ursprünglichen Plan beschriebene automatische 30-s-Polling ist zurückgestellt. Die Synchronisierung wird nur bei Benutzeraktionen ausgelöst (Schreiben, Event wechseln, App-Ladung). Um das Polling zu aktivieren, muss der Auth-Ablauf zunächst access_token in secrets.bin persistieren (derzeit wird nur das Refresh-Token persistiert). Nachverfolgt in src-tauri/src/commands/sync.rs (Kommentar am Dateianfang) und im Entscheidungs-Log von RELEASE-PLAN.md.

  2. Kein WebSocket-Client. Der Server-Endpunkt /ws existiert und die Fan-outs funktionieren, aber die Tauri-Seite öffnet noch keine WS-Verbindung. Echtzeit-Push vom Server zum Client ist daher nicht aktiv — Clients sehen Remote-Änderungen erst, wenn sie das nächste Mal syncPull aufrufen (d. h. wenn der Benutzer navigiert). Deshalb kann Test 1 Sekunden statt Millisekunden dauern. Das Hinzufügen eines WS-Clients ist eine kleine Folgeaufgabe; es ist nicht streng genommen Teil von M3.

  3. Klartext-Payloads. Die Spalte op_log.payload vom Typ BYTEA trägt Klartext-JSON. M4 (Payload-Verschlüsselung) ersetzt dies durch AES-256-GCM-Chiffretext. Der Transportpfad ändert sich nicht — nur der Spalteninhalt.

  4. Keine Konfliktauflösungs-UI. Last-Write-Wins nach client_ts geschieht stillschweigend. Wenn A und B dasselbe Feld innerhalb von Millisekunden voneinander bearbeiten, gewinnt der spätere; der frühere verliert ohne Warnung. Eine UI hierfür ist Post-v1.

  5. Kein Sync-Status-Indikator. So beabsichtigt — siehe Test 7.


Nachdem alle Tests bestanden sind

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

  2. Aktualisiere RELEASE-PLAN.md, um M3 von "Open lanes" nach "Shipped" zu verschieben.

  3. Tagge ein neues Release mit scripts/bump-version.sh <X.Y.Z> und lass CI die Artefakte bauen (wenn das CI-Kontingent zurückkehrt; andernfalls lokal bauen und manuell ausliefern).

  4. Weiter zu Lane 3 (M4 Payload-Verschlüsselung) gemäß dem sequenzierten Plan.

Falls ein Test fehlschlägt, lege ein Folge-Issue an mit dem fehlgeschlagenen Schritt, der Server-Log-Span und dem sichtbaren Symptom auf jeder Instanz.