feat(datenmodell): Schema, tagCodes und Geheimnisse (#20)
Vollständiges Datenmodell in deutscher Benennung: 20 Tabellen, 9 Aufzählungen, eine Migration, dazu die framework-freien Helfer für tagCodes und Geheimnisse. Regeln stehen zusätzlich in der Datenbank, nicht nur im Service: 12 CHECKs, 2 partielle Eindeutigkeitsindizes, 4 zusammengesetzte Fremdschlüssel. Die Mandantengrenze ist damit strukturell erzwungen. Schrittergebnisse, Kommentare und Protokolleinträge kaskadieren bewusst nicht — das Archiv ist das Gedächtnis der Anwendung. 106 Tests, davon 42 gegen die echten Datenbankregeln. Closes #6 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #20.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
TAGCODE_ALPHABET,
|
||||
TAGCODE_HOECHSTVERSUCHE,
|
||||
TAGCODE_MUSTER,
|
||||
TagCodeFehler,
|
||||
istTagCode,
|
||||
tagCodeEindeutigErzeugen,
|
||||
tagCodeErzeugen,
|
||||
} from "@/lib/tagcode";
|
||||
|
||||
function zieheCodes(anzahl: number): string[] {
|
||||
return Array.from({ length: anzahl }, () => tagCodeErzeugen());
|
||||
}
|
||||
|
||||
describe("tagCode erzeugen", () => {
|
||||
it("haelt Praefix und Laenge ein", () => {
|
||||
for (const code of zieheCodes(200)) {
|
||||
expect(code).toMatch(TAGCODE_MUSTER);
|
||||
expect(code).toHaveLength(3 + 6);
|
||||
expect(code.startsWith("LZ-")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("benutzt nur das erlaubte Alphabet", () => {
|
||||
for (const code of zieheCodes(5000)) {
|
||||
for (const zeichen of code.slice(3)) {
|
||||
expect(TAGCODE_ALPHABET).toContain(zeichen);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("enthaelt nie die verwechselbaren Zeichen 0, O, 1 oder I", () => {
|
||||
// Genau das ist der Grund fuer das eingeschraenkte Alphabet: die Codes werden
|
||||
// von Aufklebern abgetippt und am Telefon vorgelesen. Das L ist bewusst nicht
|
||||
// dabei: ohne es bliebe das Alphabet bei 31 Zeichen statt der gewuenschten 32,
|
||||
// und das Praefix `LZ-` enthaelt es ohnehin.
|
||||
for (const code of zieheCodes(5000)) {
|
||||
expect(code.slice(3)).not.toMatch(/[0O1I]/);
|
||||
}
|
||||
});
|
||||
|
||||
it("liefert bei 10 000 Ziehungen praktisch keine Dubletten", () => {
|
||||
const anzahl = 10_000;
|
||||
const eindeutige = new Set(zieheCodes(anzahl));
|
||||
// Bei 32^6 rund 1,07 Mrd. Moeglichkeiten sind nach dem Geburtstagsproblem
|
||||
// etwa 0,05 Dubletten zu erwarten -- meistens also keine. Ein strikter
|
||||
// Vergleich auf 10 000 waere deshalb flatterhaft: er wuerde selten, aber
|
||||
// regelmaessig rot, ohne dass etwas kaputt ist. Die verbindliche
|
||||
// Eindeutigkeit sichert der Unique-Index in der Datenbank.
|
||||
expect(eindeutige.size).toBeGreaterThanOrEqual(anzahl - 5);
|
||||
});
|
||||
|
||||
it("verteilt die Zeichen grob gleichmaessig ueber das Alphabet", () => {
|
||||
const codes = zieheCodes(5000);
|
||||
const haeufigkeit = new Map<string, number>();
|
||||
for (const code of codes) {
|
||||
for (const zeichen of code.slice(3)) {
|
||||
haeufigkeit.set(zeichen, (haeufigkeit.get(zeichen) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const gezogeneZeichen = codes.length * 6;
|
||||
const erwartet = gezogeneZeichen / TAGCODE_ALPHABET.length;
|
||||
expect(haeufigkeit.size).toBe(TAGCODE_ALPHABET.length);
|
||||
for (const zeichen of TAGCODE_ALPHABET) {
|
||||
const anzahl = haeufigkeit.get(zeichen) ?? 0;
|
||||
// Grosszuegige Schranken (rund 15 Standardabweichungen), damit der Test
|
||||
// eine echte Schieflage findet, aber nicht am Zufall scheitert.
|
||||
expect(anzahl).toBeGreaterThan(erwartet * 0.5);
|
||||
expect(anzahl).toBeLessThan(erwartet * 1.5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("istTagCode", () => {
|
||||
it("nimmt einen frisch erzeugten Code an", () => {
|
||||
expect(istTagCode(tagCodeErzeugen())).toBe(true);
|
||||
expect(istTagCode("LZ-7K3F9Q")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Kleinbuchstaben", "lz-7k3f9q"],
|
||||
["gemischte Schreibweise", "LZ-7k3F9Q"],
|
||||
["fehlendes Praefix", "7K3F9Q"],
|
||||
["falsches Praefix", "XY-7K3F9Q"],
|
||||
["Praefix ohne Bindestrich", "LZ7K3F9Q"],
|
||||
["zu kurz", "LZ-7K3F9"],
|
||||
["zu lang", "LZ-7K3F9QA"],
|
||||
["verbotene Null", "LZ-7K3F90"],
|
||||
["verbotenes O", "LZ-7K3F9O"],
|
||||
["verbotene Eins", "LZ-7K3F91"],
|
||||
["verbotenes I", "LZ-7K3F9I"],
|
||||
["Sonderzeichen", "LZ-7K3F9-"],
|
||||
["Leerzeichen", "LZ-7K3F9 "],
|
||||
["leer", ""],
|
||||
["nur Praefix", "LZ-"],
|
||||
["Zeilenumbruch angehaengt", "LZ-7K3F9Q\n"],
|
||||
["Text davor", "xLZ-7K3F9Q"],
|
||||
])("weist %s zurueck", (_fall, wert) => {
|
||||
expect(istTagCode(wert)).toBe(false);
|
||||
});
|
||||
|
||||
it("urteilt bei mehrfachem Aufruf gleich", () => {
|
||||
// Das Muster wird geteilt; ein globales Flag darauf wuerde den Zustand
|
||||
// zwischen Aufrufen mitschleppen.
|
||||
expect(istTagCode("LZ-7K3F9Q")).toBe(true);
|
||||
expect(istTagCode("LZ-7K3F9Q")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tagCodeEindeutigErzeugen", () => {
|
||||
it("liefert den ersten Code, wenn er frei ist", async () => {
|
||||
const istVergeben = vi.fn(async () => false);
|
||||
const code = await tagCodeEindeutigErzeugen(istVergeben);
|
||||
|
||||
expect(istTagCode(code)).toBe(true);
|
||||
expect(istVergeben).toHaveBeenCalledTimes(1);
|
||||
expect(istVergeben).toHaveBeenCalledWith(code);
|
||||
});
|
||||
|
||||
it("zieht erneut, wenn der erste Code vergeben ist", async () => {
|
||||
const gesehen: string[] = [];
|
||||
const istVergeben = vi.fn(async (code: string) => {
|
||||
gesehen.push(code);
|
||||
return gesehen.length === 1;
|
||||
});
|
||||
|
||||
const code = await tagCodeEindeutigErzeugen(istVergeben);
|
||||
|
||||
expect(istVergeben).toHaveBeenCalledTimes(2);
|
||||
expect(gesehen).toHaveLength(2);
|
||||
expect(gesehen[0]).not.toBe(code);
|
||||
expect(gesehen[1]).toBe(code);
|
||||
});
|
||||
|
||||
it("wirft nach zehn vergeblichen Versuchen einen TagCodeFehler", async () => {
|
||||
const istVergeben = vi.fn(async () => true);
|
||||
|
||||
await expect(tagCodeEindeutigErzeugen(istVergeben)).rejects.toThrow(TagCodeFehler);
|
||||
expect(istVergeben).toHaveBeenCalledTimes(TAGCODE_HOECHSTVERSUCHE);
|
||||
});
|
||||
|
||||
it("gibt einen Fehler der Vergabepruefung unveraendert weiter", async () => {
|
||||
const istVergeben = vi.fn(async () => {
|
||||
throw new Error("Datenbank nicht erreichbar");
|
||||
});
|
||||
|
||||
await expect(tagCodeEindeutigErzeugen(istVergeben)).rejects.toThrow("Datenbank nicht erreichbar");
|
||||
expect(istVergeben).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user