Terminal im Geraetehaus mit Offline-Betrieb ergaenzen
Phase 4: eine eigene Oberflaeche unter /kiosk fuer das Wandtablet in der Fahrzeughalle — mit Handschuhen bedienbar und arbeitsfaehig, wenn das Netz weg ist. Datenmodell: Fahrt, Anwesenheit, Waescheabgabe, Inventurgang, Inventurposten, KioskVorgang und SyncKonflikt. Die Kennung eines Vorgangs erzeugt der Browser beim Einreihen und ist damit der Idempotenzschluessel; kommt derselbe Vorgang nach abgebrochener Uebertragung erneut an, erkennt ihn der Server wieder. Die Datenbank setzt vier Regeln selbst durch und verweigert UPDATE und DELETE auf kiosk_vorgang. Offline: von Hand geschriebener Service Worker, Warteschlange in der IndexedDB (Dexie), Fotos als Blob daneben, sichtbarer Zustand als Leiste ueber allem. Was sich beim Nachtragen nicht anwenden laesst, landet als Konflikt unter /terminal statt verworfen zu werden. Dazu: Anmeldung per Ausweis, PIN oder signierter QR-Marke vom Handy, Hallenmonitor mit optionalen DWD-Warnungen, Installation als App. Behoben: die Warteschlange zog ihren Wiederholungstakt bei jedem Rendern neu auf und haette einen liegen gebliebenen Vorgang nie ein zweites Mal geschickt. Gefunden vom Offline-E2E-Test. 31 neue Tests (20 Unit, 11 gegen eine echte Datenbank, 9 E2E je Browser). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d8d692098d
commit
791523027f
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Die Warteschlange lebt im Browser: sie fragt `window` und `indexedDB`. Der
|
||||
* Rest der Testsuite läuft in Node, deshalb hier ausdrücklich jsdom — und
|
||||
* darin fake-indexeddb, denn eine IndexedDB bringt jsdom nicht mit.
|
||||
*/
|
||||
|
||||
// IndexedDB gibt es in Node nicht — fake-indexeddb legt eine echte
|
||||
// Implementierung ins globale Objekt. Der Import muss vor allem stehen, was
|
||||
// Dexie anfasst, deshalb ganz oben.
|
||||
import "fake-indexeddb/auto";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
anzahlOffen,
|
||||
datenbank,
|
||||
einreihen,
|
||||
synchronisieren,
|
||||
verwerfen,
|
||||
warteschlange,
|
||||
type SendeVorgang,
|
||||
} from "@/lib/kiosk/outbox";
|
||||
import type { VorgangErgebnis } from "@/lib/kiosk/vorgaenge";
|
||||
|
||||
/**
|
||||
* Die Warteschlange des Terminals.
|
||||
*
|
||||
* Hier hängt daran, ob im Gerätehaus etwas verloren geht: Meldungen dürfen
|
||||
* erst verschwinden, wenn der Server sie hat, und ein zweiter Sendeversuch
|
||||
* darf nichts doppeln. Beides lässt sich ohne Browser prüfen — die Logik
|
||||
* steckt in dieser Datei, nicht in der Oberfläche.
|
||||
*/
|
||||
|
||||
/** Antwortet auf alles mit „angenommen". */
|
||||
function serverNimmtAlles(): { sender: ReturnType<typeof vi.fn>; gesehen: SendeVorgang[][] } {
|
||||
const gesehen: SendeVorgang[][] = [];
|
||||
const sender = vi.fn(async (vorgaenge: SendeVorgang[]) => {
|
||||
gesehen.push(vorgaenge);
|
||||
return {
|
||||
ergebnisse: vorgaenge.map<VorgangErgebnis>((vorgang) => ({
|
||||
id: vorgang.id,
|
||||
erfolgreich: true,
|
||||
bereitsVerarbeitet: false,
|
||||
})),
|
||||
};
|
||||
});
|
||||
return { sender, gesehen };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbank().vorgaenge.clear();
|
||||
});
|
||||
|
||||
describe("Warteschlange des Terminals", () => {
|
||||
it("nimmt einen Vorgang an und gibt ihm eine eigene Kennung", async () => {
|
||||
const eintrag = await einreihen(
|
||||
{ art: "ANWESENHEIT_KOMMEN", daten: { anlass: "UEBUNG" } },
|
||||
"Gekommen (Übung)",
|
||||
);
|
||||
|
||||
expect(eintrag.id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(await anzahlOffen()).toBe(1);
|
||||
|
||||
const liste = await warteschlange();
|
||||
expect(liste[0]?.beschriftung).toBe("Gekommen (Übung)");
|
||||
expect(liste[0]?.versuche).toBe(0);
|
||||
});
|
||||
|
||||
it("arbeitet die Vorgänge in der Reihenfolge der Erfassung ab", async () => {
|
||||
await einreihen({ art: "ANWESENHEIT_KOMMEN", daten: { anlass: "DIENST" } }, "erster");
|
||||
// Ohne Abstand hätten beide dieselbe ISO-Zeit — im Gerätehaus liegen
|
||||
// zwischen zwei Vorgängen Sekunden, hier reicht eine Millisekunde.
|
||||
await new Promise((fertig) => setTimeout(fertig, 5));
|
||||
await einreihen({ art: "ANWESENHEIT_GEHEN", daten: { anwesenheitId: null } }, "zweiter");
|
||||
|
||||
const liste = await warteschlange();
|
||||
expect(liste.map((eintrag) => eintrag.beschriftung)).toEqual(["erster", "zweiter"]);
|
||||
});
|
||||
|
||||
it("löscht erst, wenn der Server bestätigt hat", async () => {
|
||||
await einreihen(
|
||||
{
|
||||
art: "MANGEL_MELDEN",
|
||||
daten: { inventarnummer: "INV-0001", beschreibung: "Riss", prioritaet: "MITTEL" },
|
||||
},
|
||||
"Mangel an INV-0001",
|
||||
);
|
||||
|
||||
const { sender } = serverNimmtAlles();
|
||||
const ergebnis = await synchronisieren(sender);
|
||||
|
||||
expect(ergebnis.gesendet).toBe(1);
|
||||
expect(ergebnis.erledigt).toBe(1);
|
||||
expect(await anzahlOffen()).toBe(0);
|
||||
});
|
||||
|
||||
it("behält alles, wenn kein Netz da ist, und zählt den Versuch", async () => {
|
||||
await einreihen({ art: "ANWESENHEIT_KOMMEN", daten: { anlass: "EINSATZ" } }, "Gekommen");
|
||||
|
||||
const ergebnis = await synchronisieren(async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
});
|
||||
|
||||
expect(ergebnis.erledigt).toBe(0);
|
||||
expect(ergebnis.verblieben).toBe(1);
|
||||
expect(ergebnis.fehler).toContain("Failed to fetch");
|
||||
|
||||
const liste = await warteschlange();
|
||||
expect(liste[0]?.versuche).toBe(1);
|
||||
});
|
||||
|
||||
it("schickt denselben Vorgang mit derselben Kennung erneut", async () => {
|
||||
const eintrag = await einreihen(
|
||||
{ art: "WAESCHE_ABGEBEN", daten: { bezeichnung: "Einsatzjacke", menge: 1 } },
|
||||
"Wäsche abgegeben",
|
||||
);
|
||||
|
||||
// Erster Versuch: die Antwort geht verloren.
|
||||
await synchronisieren(async () => {
|
||||
throw new Error("Verbindung abgebrochen");
|
||||
});
|
||||
expect(await anzahlOffen()).toBe(1);
|
||||
|
||||
// Zweiter Versuch: derselbe Vorgang, dieselbe Kennung — daran erkennt ihn
|
||||
// der Server wieder.
|
||||
const { sender, gesehen } = serverNimmtAlles();
|
||||
await synchronisieren(sender);
|
||||
|
||||
expect(gesehen[0]?.[0]?.id).toBe(eintrag.id);
|
||||
expect(await anzahlOffen()).toBe(0);
|
||||
});
|
||||
|
||||
it("nimmt „bereits verarbeitet“ als Erfolg — sonst bliebe der Vorgang ewig liegen", async () => {
|
||||
const eintrag = await einreihen(
|
||||
{ art: "ANWESENHEIT_GEHEN", daten: { anwesenheitId: null } },
|
||||
"Gegangen",
|
||||
);
|
||||
|
||||
const ergebnis = await synchronisieren(async () => ({
|
||||
ergebnisse: [{ id: eintrag.id, erfolgreich: false, bereitsVerarbeitet: true }],
|
||||
}));
|
||||
|
||||
expect(ergebnis.erledigt).toBe(1);
|
||||
expect(await anzahlOffen()).toBe(0);
|
||||
});
|
||||
|
||||
it("nimmt einen Konflikt aus der Warteschlange und zählt ihn getrennt", async () => {
|
||||
const eintrag = await einreihen(
|
||||
{
|
||||
art: "MANGEL_MELDEN",
|
||||
daten: { inventarnummer: "INV-9999", beschreibung: "kaputt", prioritaet: "HOCH" },
|
||||
},
|
||||
"Mangel an INV-9999",
|
||||
);
|
||||
|
||||
const ergebnis = await synchronisieren(async () => ({
|
||||
ergebnisse: [
|
||||
{
|
||||
id: eintrag.id,
|
||||
erfolgreich: false,
|
||||
bereitsVerarbeitet: false,
|
||||
konflikt: true,
|
||||
meldung: "Gerät gibt es nicht",
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// Der Vorgang liegt jetzt beim Server in der Konfliktliste — hier ist er
|
||||
// fertig, sonst käme er bei jedem Sendeversuch wieder.
|
||||
expect(ergebnis.konflikte).toBe(1);
|
||||
expect(ergebnis.erledigt).toBe(1);
|
||||
expect(await anzahlOffen()).toBe(0);
|
||||
});
|
||||
|
||||
it("merkt sich den Grund, wenn der Server einen Vorgang ablehnt", async () => {
|
||||
const eintrag = await einreihen(
|
||||
{ art: "FAHRT_BEENDEN", daten: { fahrtId: eintragKennung(), kilometerRueckkehr: 100 } },
|
||||
"Fahrt beendet",
|
||||
);
|
||||
|
||||
await synchronisieren(async () => ({
|
||||
ergebnisse: [
|
||||
{
|
||||
id: eintrag.id,
|
||||
erfolgreich: false,
|
||||
bereitsVerarbeitet: false,
|
||||
meldung: "Kilometerstand kleiner als bei der Abfahrt",
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const liste = await warteschlange();
|
||||
expect(liste).toHaveLength(1);
|
||||
expect(liste[0]?.versuche).toBe(1);
|
||||
expect(liste[0]?.letzterFehler).toContain("Kilometerstand");
|
||||
});
|
||||
|
||||
it("lädt ein wartendes Foto zuerst hoch und hängt die Kennung an die Meldung", async () => {
|
||||
const bild = new Blob(["nicht wirklich ein Bild"], { type: "image/jpeg" });
|
||||
|
||||
await einreihen(
|
||||
{
|
||||
art: "MANGEL_MELDEN",
|
||||
daten: { inventarnummer: "INV-0002", beschreibung: "Delle", prioritaet: "MITTEL" },
|
||||
},
|
||||
"Mangel an INV-0002",
|
||||
{ inhalt: bild, name: "mangel.jpg" },
|
||||
);
|
||||
|
||||
const fotoSender = vi.fn(async () => "3f2504e0-4f89-41d3-9a0c-0305e82c3301");
|
||||
const { sender, gesehen } = serverNimmtAlles();
|
||||
|
||||
await synchronisieren(sender, fotoSender);
|
||||
|
||||
expect(fotoSender).toHaveBeenCalledOnce();
|
||||
expect(gesehen[0]?.[0]?.daten).toMatchObject({
|
||||
fotoDateiId: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
||||
});
|
||||
});
|
||||
|
||||
it("schickt die Meldung auch dann, wenn das Foto nicht durchgeht", async () => {
|
||||
const bild = new Blob(["x"], { type: "image/jpeg" });
|
||||
|
||||
await einreihen(
|
||||
{
|
||||
art: "MANGEL_MELDEN",
|
||||
daten: { inventarnummer: "INV-0003", beschreibung: "Riss", prioritaet: "KRITISCH" },
|
||||
},
|
||||
"Mangel an INV-0003",
|
||||
{ inhalt: bild, name: "mangel.jpg" },
|
||||
);
|
||||
|
||||
const { sender } = serverNimmtAlles();
|
||||
const ergebnis = await synchronisieren(sender, async () => {
|
||||
throw new Error("Speicher nicht erreichbar");
|
||||
});
|
||||
|
||||
// Eine Meldung ohne Bild ist besser als gar keine.
|
||||
expect(ergebnis.erledigt).toBe(1);
|
||||
expect(await anzahlOffen()).toBe(0);
|
||||
});
|
||||
|
||||
it("überträgt höchstens so viele Vorgänge, wie erlaubt sind", async () => {
|
||||
for (let zaehler = 0; zaehler < 5; zaehler += 1) {
|
||||
await einreihen({ art: "ANWESENHEIT_KOMMEN", daten: { anlass: "DIENST" } }, `Nr ${zaehler}`);
|
||||
await new Promise((fertig) => setTimeout(fertig, 2));
|
||||
}
|
||||
|
||||
const { sender } = serverNimmtAlles();
|
||||
const ergebnis = await synchronisieren(sender, undefined, 2);
|
||||
|
||||
expect(ergebnis.gesendet).toBe(2);
|
||||
expect(ergebnis.verblieben).toBe(3);
|
||||
});
|
||||
|
||||
it("lässt einen Vorgang von Hand verwerfen", async () => {
|
||||
const eintrag = await einreihen(
|
||||
{ art: "ANWESENHEIT_KOMMEN", daten: { anlass: "SONSTIGES" } },
|
||||
"Gekommen",
|
||||
);
|
||||
|
||||
await verwerfen(eintrag.id);
|
||||
expect(await anzahlOffen()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/** Eine beliebige, aber gültige UUID für Nutzlasten, die eine verlangen. */
|
||||
function eintragKennung(): string {
|
||||
return "6b1d2f3a-4c5e-4a7b-8c9d-0e1f2a3b4c5d";
|
||||
}
|
||||
Reference in New Issue
Block a user