Laufzettel läuft jetzt in zwei Betriebsmodi, aber nicht in zwei Codepfaden. Jede kundenbezogene Zeile trägt ohnehin schon eine Kunden-Zuordnung; im Modus INTERN ist das die eine Organisation, die in der Oberfläche nirgends erscheint. Der Wechsel ist deshalb eine Datenänderung und keine Migration — dieselbe Zeile bleibt, nichts hängt um. src/server/mandant.ts ist die eine Stelle, an der die Mandantenregel steht: kundeErmitteln verwirft im Modus INTERN eine mitgeschickte kundeId, verlangt im Modus DIENSTLEISTUNG eine Wahl und prüft, ob der Benutzer den Kunden überhaupt sehen darf. Die Sichtbarkeit steckte zunächst in einer zweiten Funktion daneben, an die jeder künftige Aufrufer selbst hätte denken müssen; da #009 diese Signatur übernimmt, ist sie jetzt eingebaut. Der unabhängige Review fand drei Blocker, alle behoben. Die Lesemodelle wandten die Mandantensicht nicht an. kundenListe gab jedem alle Kunden zurück, und sichtbareKundenIds hatte in der Produktion keinen einzigen Aufrufer — ein gebundener Techniker hätte Namen, Notizen und Bestandszahlen fremder Kunden gesehen. Die Funktionen verlangen jetzt den Benutzer und filtern selbst, wie CLAUDE.md es verlangt. Mein Kommentar "Alles in einer Transaktion" war falsch: der Protokolleintrag stand dahinter. Schlug er fehl, war der Modus gewechselt und der Vorgang stand nirgends. Und Prüfung und Vollzug lagen auseinander, sodass zwei gleichzeitige Wechsel beide durchliefen und ein zwischendurch angelegter Kunde zu Modus INTERN bei zwei Kunden führte. Beides reproduziert, beides geschlossen: alles in einer Transaktion, mit pg_advisory_xact_lock als erster Anweisung. Dazu sechs kleinere Befunde: ASCII-Faltung in Texten, die Menschen lesen (die Regel gilt für Bezeichner, nicht für Fließtext — steht jetzt so in CLAUDE.md); eine Erfolgsmeldung, die nach dem Wechsel unsichtbar blieb; eine Zusage im Dialog, die der Code nicht durchsetzt; fehlende Fehlerbehandlung beim Archivieren; ein Handy-Screenshot, der das Navigationsmenü statt des Dialogs zeigte. Der Wechsel wurde nicht nur im Service geprüft, sondern per Playwright durch die Oberfläche gefahren: anmelden, Dialog aufklappen, bestätigen, und danach steht die Navigation auf Kunden. 336 Tests. Closes #8 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
||
import { kundeHolen, kundenListe, kundenZurAuswahl } from "@/server/queries/kunden";
|
||
import {
|
||
KundenFehler,
|
||
kuerzelPruefen,
|
||
kundeAnlegen,
|
||
kundeArchivieren,
|
||
kundeBearbeiten,
|
||
type KundenAenderung,
|
||
type NeuerKunde,
|
||
} from "@/server/services/kunden";
|
||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||
|
||
/** Die Lesemodelle verlangen einen Benutzer; ein Administrator sieht alles. */
|
||
const ADMIN = { id: "admin", rolle: "ADMIN" } as const;
|
||
|
||
/**
|
||
* Kundenverwaltung gegen die echte Datenbank (#008).
|
||
*
|
||
* Zwei Regeln haengen unmittelbar am Betriebsmodus und stehen deshalb hier fest:
|
||
* die eigene Organisation entsteht nicht ueber `kundeAnlegen`, und sie laesst
|
||
* sich nicht archivieren – sonst waere der Modus `INTERN` ohne Kunden.
|
||
*/
|
||
|
||
/** Urheber fuer die Protokolleintraege; ohne echten Benutzer greift der Fremdschluessel. */
|
||
async function adminAnlegen() {
|
||
return testPrisma.benutzer.create({
|
||
data: { email: "admin@firma.example", name: "Administrator", rolle: "ADMIN" },
|
||
});
|
||
}
|
||
|
||
async function eigeneOrganisationAnlegen() {
|
||
return testPrisma.kunde.create({
|
||
data: { name: "Eigene Firma", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||
});
|
||
}
|
||
|
||
/** Kleinste Vorlage, die ausreicht, um einen Lauf daran zu haengen. */
|
||
async function veroeffentlichteVersionAnlegen() {
|
||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Probe" } });
|
||
return testPrisma.vorlagenVersion.create({
|
||
data: {
|
||
vorlageId: vorlage.id,
|
||
version: 1,
|
||
status: "VEROEFFENTLICHT",
|
||
veroeffentlichtAm: new Date(),
|
||
},
|
||
});
|
||
}
|
||
|
||
describe.runIf(datenbankVerfuegbar)("Kundenverwaltung", () => {
|
||
beforeEach(async () => {
|
||
await datenbankLeeren();
|
||
});
|
||
|
||
describe("Kuerzel", () => {
|
||
it("trimmt und schreibt gross", () => {
|
||
expect(kuerzelPruefen("mus")).toBe("MUS");
|
||
expect(kuerzelPruefen(" REI ")).toBe("REI");
|
||
expect(kuerzelPruefen("mus ")).toBe("MUS");
|
||
});
|
||
|
||
it("weist alles ab, was nicht 2 bis 6 Zeichen A-Z und 0-9 ist", () => {
|
||
// "MUeS" waere in Ordnung, ein Umlaut nicht: das Kuerzel steht auf
|
||
// Etiketten und in Hostnamen.
|
||
for (const falsch of ["X", "ZULANG7", "MU-S", "MU_S", "MÜS", "", " "]) {
|
||
expect(() => kuerzelPruefen(falsch)).toThrow(KundenFehler);
|
||
}
|
||
});
|
||
|
||
it("sagt in der Meldung, was erwartet wird", () => {
|
||
expect(() => kuerzelPruefen("X")).toThrow(/2 bis 6 Zeichen/);
|
||
});
|
||
|
||
it("nimmt das getrimmte Kuerzel beim Anlegen", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: " Musterfirma ", kuerzel: " mus " }, admin.id);
|
||
expect(kunde.kuerzel).toBe("MUS");
|
||
expect(kunde.name).toBe("Musterfirma");
|
||
});
|
||
|
||
it("weist ein doppeltes Kuerzel ab und benennt den vorhandenen Kunden", async () => {
|
||
const admin = await adminAnlegen();
|
||
await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
await expect(kundeAnlegen({ name: "Muster GmbH", kuerzel: "mus" }, admin.id)).rejects.toThrow(
|
||
/Musterfirma/,
|
||
);
|
||
expect(await testPrisma.kunde.count()).toBe(1);
|
||
});
|
||
|
||
it("weist ein doppeltes Kuerzel auch gegen einen archivierten Kunden ab", async () => {
|
||
// Sonst waere das Kuerzel zweimal im Umlauf, sobald jemand den alten
|
||
// Kunden zurueckholt – und der eindeutige Index kennt kein Archiv.
|
||
const admin = await adminAnlegen();
|
||
const alt = await kundeAnlegen({ name: "Alte Firma", kuerzel: "ALT" }, admin.id);
|
||
await kundeArchivieren(alt.id, true, admin.id);
|
||
|
||
await expect(kundeAnlegen({ name: "Neue Firma", kuerzel: "ALT" }, admin.id)).rejects.toBeInstanceOf(
|
||
KundenFehler,
|
||
);
|
||
});
|
||
|
||
it("weist ein doppeltes Kuerzel auch beim Bearbeiten ab", async () => {
|
||
const admin = await adminAnlegen();
|
||
await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
const rei = await kundeAnlegen({ name: "Baeckerei Reiter", kuerzel: "REI" }, admin.id);
|
||
|
||
await expect(kundeBearbeiten(rei.id, { kuerzel: "MUS" }, admin.id)).rejects.toBeInstanceOf(
|
||
KundenFehler,
|
||
);
|
||
const unveraendert = await testPrisma.kunde.findUniqueOrThrow({ where: { id: rei.id } });
|
||
expect(unveraendert.kuerzel).toBe("REI");
|
||
});
|
||
});
|
||
|
||
describe("Name", () => {
|
||
it("weist einen leeren Namen ab", async () => {
|
||
const admin = await adminAnlegen();
|
||
for (const name of ["", " "]) {
|
||
await expect(kundeAnlegen({ name, kuerzel: "MUS" }, admin.id)).rejects.toBeInstanceOf(KundenFehler);
|
||
}
|
||
expect(await testPrisma.kunde.count()).toBe(0);
|
||
});
|
||
|
||
it("weist einen leeren Namen auch beim Bearbeiten ab", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
await expect(kundeBearbeiten(kunde.id, { name: " " }, admin.id)).rejects.toBeInstanceOf(KundenFehler);
|
||
});
|
||
});
|
||
|
||
describe("Eigene Organisation", () => {
|
||
it("entsteht nie ueber kundeAnlegen, egal was in der Eingabe steht", async () => {
|
||
// Sie entsteht nur im Seed und beim Moduswechsel. Die Eingabe wird hier
|
||
// absichtlich ueber die Typgrenze hinaus befuellt, wie es ein Aufrufer aus
|
||
// einem Formular koennte.
|
||
const admin = await adminAnlegen();
|
||
const eingabe = { name: "Untergeschoben", kuerzel: "UGS", istEigeneOrganisation: true } as NeuerKunde;
|
||
const kunde = await kundeAnlegen(eingabe, admin.id);
|
||
|
||
expect(kunde.istEigeneOrganisation).toBe(false);
|
||
expect(await testPrisma.kunde.count({ where: { istEigeneOrganisation: true } })).toBe(0);
|
||
});
|
||
|
||
it("laesst sich nicht archivieren", async () => {
|
||
const admin = await adminAnlegen();
|
||
const eigene = await eigeneOrganisationAnlegen();
|
||
|
||
await expect(kundeArchivieren(eigene.id, true, admin.id)).rejects.toThrow(/einzige Kunde/);
|
||
const unveraendert = await testPrisma.kunde.findUniqueOrThrow({ where: { id: eigene.id } });
|
||
expect(unveraendert.archiviert).toBe(false);
|
||
expect(await testPrisma.protokoll.count({ where: { entitaetId: eigene.id } })).toBe(0);
|
||
});
|
||
|
||
it("laesst sich umbenennen, ohne ihre Rolle zu verlieren", async () => {
|
||
const admin = await adminAnlegen();
|
||
const eigene = await eigeneOrganisationAnlegen();
|
||
const geaendert = await kundeBearbeiten(eigene.id, { name: "Neuer Firmenname" }, admin.id);
|
||
expect(geaendert.name).toBe("Neuer Firmenname");
|
||
expect(geaendert.istEigeneOrganisation).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("Bearbeiten", () => {
|
||
it("aendert istEigeneOrganisation nicht", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
const aenderung = { name: "Musterfirma AG", istEigeneOrganisation: true } as KundenAenderung;
|
||
|
||
const geaendert = await kundeBearbeiten(kunde.id, aenderung, admin.id);
|
||
|
||
expect(geaendert.name).toBe("Musterfirma AG");
|
||
expect(geaendert.istEigeneOrganisation).toBe(false);
|
||
});
|
||
|
||
it("protokolliert das Umbenennen mit Vorher und Nachher", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
|
||
await kundeBearbeiten(kunde.id, { name: "Musterfirma AG" }, admin.id);
|
||
|
||
const eintraege = await testPrisma.protokoll.findMany({
|
||
where: { entitaet: "Kunde", entitaetId: kunde.id, aktion: { contains: "geändert" } },
|
||
});
|
||
expect(eintraege).toHaveLength(1);
|
||
expect(eintraege[0]?.vorher).toEqual({ name: "Musterfirma" });
|
||
expect(eintraege[0]?.nachher).toEqual({ name: "Musterfirma AG" });
|
||
expect(eintraege[0]?.benutzerId).toBe(admin.id);
|
||
});
|
||
|
||
it("protokolliert eine Kuerzelaenderung mit Vorher und Nachher", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
|
||
await kundeBearbeiten(kunde.id, { kuerzel: "mu2" }, admin.id);
|
||
|
||
const eintraege = await testPrisma.protokoll.findMany({
|
||
where: { entitaetId: kunde.id, aktion: { contains: "Kürzel" } },
|
||
});
|
||
expect(eintraege).toHaveLength(1);
|
||
expect(eintraege[0]?.vorher).toEqual({ kuerzel: "MUS" });
|
||
expect(eintraege[0]?.nachher).toEqual({ kuerzel: "MU2" });
|
||
});
|
||
|
||
it("schreibt nichts, wenn sich nichts aendert", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen(
|
||
{ name: "Musterfirma", kuerzel: "MUS", notizen: "Zwei Standorte" },
|
||
admin.id,
|
||
);
|
||
|
||
await kundeBearbeiten(
|
||
kunde.id,
|
||
// Auch die Schreibweise des Kuerzels macht keinen Unterschied, weil es
|
||
// vor dem Vergleich normalisiert wird.
|
||
{ name: " Musterfirma ", kuerzel: "mus", notizen: "Zwei Standorte" },
|
||
admin.id,
|
||
);
|
||
|
||
expect(
|
||
await testPrisma.protokoll.count({
|
||
where: { entitaetId: kunde.id, aktion: { contains: "geändert" } },
|
||
}),
|
||
).toBe(0);
|
||
});
|
||
|
||
it("loescht Notizen mit null", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS", notizen: "Alt" }, admin.id);
|
||
|
||
const geaendert = await kundeBearbeiten(kunde.id, { notizen: null }, admin.id);
|
||
expect(geaendert.notizen).toBeNull();
|
||
});
|
||
|
||
it("weist eine unbekannte Kennung mit einer eigenen Meldung ab", async () => {
|
||
const admin = await adminAnlegen();
|
||
await expect(kundeBearbeiten("gibtsnicht", { name: "Egal" }, admin.id)).rejects.toBeInstanceOf(
|
||
KundenFehler,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("Archivieren", () => {
|
||
it("archiviert und gibt wieder frei, je mit Protokolleintrag", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
|
||
await kundeArchivieren(kunde.id, true, admin.id);
|
||
expect((await testPrisma.kunde.findUniqueOrThrow({ where: { id: kunde.id } })).archiviert).toBe(true);
|
||
|
||
await kundeArchivieren(kunde.id, false, admin.id);
|
||
expect((await testPrisma.kunde.findUniqueOrThrow({ where: { id: kunde.id } })).archiviert).toBe(false);
|
||
|
||
const eintraege = await testPrisma.protokoll.findMany({
|
||
where: { entitaet: "Kunde", entitaetId: kunde.id, aktion: { contains: "Kunde " } },
|
||
orderBy: { erstelltAm: "asc" },
|
||
});
|
||
// Angelegt, archiviert, wieder freigegeben.
|
||
expect(eintraege.map((eintrag) => eintrag.aktion)).toEqual([
|
||
"Kunde angelegt",
|
||
"Kunde archiviert",
|
||
"Kunde wieder freigegeben",
|
||
]);
|
||
expect(eintraege[1]?.vorher).toEqual({ archiviert: false });
|
||
expect(eintraege[1]?.nachher).toEqual({ archiviert: true });
|
||
expect(eintraege[2]?.nachher).toEqual({ archiviert: false });
|
||
});
|
||
|
||
it("schreibt nichts, wenn der Zustand schon stimmt", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
|
||
await kundeArchivieren(kunde.id, false, admin.id);
|
||
|
||
expect(await testPrisma.protokoll.count({ where: { entitaetId: kunde.id } })).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe("Lesemodelle", () => {
|
||
/** Ein Kunde mit zwei Geraeten, einem Mitarbeiter und einem Lauf. */
|
||
async function kundeMitBestandAnlegen(adminId: string) {
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, adminId);
|
||
await testPrisma.geraet.create({ data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q" } });
|
||
await testPrisma.geraet.create({ data: { kundeId: kunde.id, tagCode: "LZ-M2XH4T" } });
|
||
await testPrisma.mitarbeiter.create({
|
||
data: { kundeId: kunde.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
|
||
});
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
await testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
titel: "PC Neuinstallation",
|
||
kundeId: kunde.id,
|
||
erstelltVonId: adminId,
|
||
},
|
||
});
|
||
return kunde;
|
||
}
|
||
|
||
it("zaehlt Geraete, Mitarbeiter und Laeufe je Kunde", async () => {
|
||
const admin = await adminAnlegen();
|
||
const mus = await kundeMitBestandAnlegen(admin.id);
|
||
await kundeAnlegen({ name: "Baeckerei Reiter", kuerzel: "REI" }, admin.id);
|
||
|
||
const liste = await kundenListe(ADMIN);
|
||
const musZeile = liste.find((zeile) => zeile.id === mus.id);
|
||
expect(musZeile?.anzahlGeraete).toBe(2);
|
||
expect(musZeile?.anzahlMitarbeiter).toBe(1);
|
||
expect(musZeile?.anzahlLaeufe).toBe(1);
|
||
|
||
const reiZeile = liste.find((zeile) => zeile.kuerzel === "REI");
|
||
expect(reiZeile?.anzahlGeraete).toBe(0);
|
||
expect(reiZeile?.anzahlLaeufe).toBe(0);
|
||
});
|
||
|
||
it("sortiert nicht archivierte zuerst, dann nach Namen", async () => {
|
||
const admin = await adminAnlegen();
|
||
const zeta = await kundeAnlegen({ name: "Zeta Werke", kuerzel: "ZET" }, admin.id);
|
||
const alpha = await kundeAnlegen({ name: "Alpha AG", kuerzel: "ALP" }, admin.id);
|
||
const beta = await kundeAnlegen({ name: "Beta GmbH", kuerzel: "BET" }, admin.id);
|
||
await kundeArchivieren(alpha.id, true, admin.id);
|
||
|
||
const liste = await kundenListe(ADMIN, true);
|
||
expect(liste.map((zeile) => zeile.id)).toEqual([beta.id, zeta.id, alpha.id]);
|
||
});
|
||
|
||
it("blendet archivierte aus, solange nicht danach gefragt wird", async () => {
|
||
const admin = await adminAnlegen();
|
||
const mus = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
const alt = await kundeAnlegen({ name: "Alte Firma", kuerzel: "ALT" }, admin.id);
|
||
await kundeArchivieren(alt.id, true, admin.id);
|
||
|
||
expect((await kundenListe(ADMIN, false)).map((zeile) => zeile.id)).toEqual([mus.id]);
|
||
expect((await kundenListe(ADMIN)).map((zeile) => zeile.id)).toEqual([mus.id]);
|
||
expect((await kundenListe(ADMIN, true)).map((zeile) => zeile.id)).toContain(alt.id);
|
||
});
|
||
|
||
it("liefert einen einzelnen Kunden mit Zaehlungen, sonst undefined", async () => {
|
||
const admin = await adminAnlegen();
|
||
const mus = await kundeMitBestandAnlegen(admin.id);
|
||
|
||
const zeile = await kundeHolen(mus.id, ADMIN);
|
||
expect(zeile?.kuerzel).toBe("MUS");
|
||
expect(zeile?.anzahlGeraete).toBe(2);
|
||
expect(zeile?.istEigeneOrganisation).toBe(false);
|
||
expect(await kundeHolen("gibtsnicht", ADMIN)).toBeUndefined();
|
||
});
|
||
|
||
it("nennt im Auswahlfeld keine archivierten Kunden", async () => {
|
||
const admin = await adminAnlegen();
|
||
await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
const alt = await kundeAnlegen({ name: "Alte Firma", kuerzel: "ALT" }, admin.id);
|
||
await kundeArchivieren(alt.id, true, admin.id);
|
||
|
||
const auswahl = await kundenZurAuswahl(ADMIN);
|
||
expect(auswahl.map((eintrag) => eintrag.kuerzel)).toEqual(["MUS"]);
|
||
});
|
||
});
|
||
});
|