Eine veroeffentlichte Vorlagen-Version ist fuer immer unveraenderlich — daran haengt, dass ein archivierter Lauf in zehn Jahren noch genau die Schritte zeigt, mit denen er gestartet wurde. Die Regel steht deshalb zweimal: im Service, der die verstaendliche Meldung gibt, und in der Datenbank, die die Garantie gibt. Vier Trigger fangen Schreibzugriffe ab, die nicht durch den Service kommen — Migrationen, psql, der Seed und ab Phase 4 die Connectoren — und versperren den Rueckweg nach ENTWURF, der alles andere ausgehebelt haette. - Service: anlegen, Kopfdaten bearbeiten, Entwurf als tiefe Kopie der letzten veroeffentlichten Version, speichern, veroeffentlichen, verwerfen, duplizieren, archivieren - Hoechstens ein Entwurf je Vorlage, durchgesetzt vom partiellen Index; zwei gleichzeitige Klicks auf „Bearbeiten“ bekommen denselben Entwurf statt eines Fehlers - Optimistisches Sperren ueber geaendertAm: wer auf einem ueberholten Stand speichert, wird abgewiesen statt die Arbeit des anderen spurlos zu ersetzen - JSON-Export und -Import samt Format-Dokumentation; der Export ist nachsichtig, der Import streng - src/lib/validierung.ts als einzige Zod-Quelle mit deutschen Meldungen, per ESLint-Regel erzwungen Der unabhaengige Review fand drei Blocker, alle an der Mandantengrenze: der Suchfilter brachte ein zweites OR mit und ueberschrieb die Kundenbedingung, Historie und Aufbau einer Version filterten gar nicht. Zu queries/vorlagen.ts gab es keinen Test — jetzt gibt es tests/vorlagen-sicht.test.ts. 428 Tests gruen (vorher 336). Schliesst #009 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
681 lines
25 KiB
TypeScript
681 lines
25 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||
|
||
/**
|
||
* Haelt die Regeln fest, die in der Datenbank selbst stehen (#006).
|
||
*
|
||
* Sie stehen dort und nicht nur in der Service-Schicht, damit sie auch dann
|
||
* greifen, wenn jemand an ihr vorbei schreibt – per Migration, per psql oder aus
|
||
* einem spaeteren Connector. Die Service-Schicht prueft dieselben Regeln noch
|
||
* einmal, aber mit verstaendlicheren Meldungen.
|
||
*/
|
||
|
||
async function kundeAnlegen(kuerzel = "TST", eigene = false) {
|
||
return testPrisma.kunde.create({
|
||
data: { name: `Kunde ${kuerzel}`, kuerzel, istEigeneOrganisation: eigene },
|
||
});
|
||
}
|
||
|
||
async function benutzerAnlegen(email = "techniker@firma.example") {
|
||
return testPrisma.benutzer.create({
|
||
data: { email, name: "Techniker", rolle: "TECHNIKER" },
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Kleinste Vorlage, die ausreicht, um einen Lauf daran zu haengen.
|
||
*
|
||
* Erst als Entwurf anlegen, dann veroeffentlichen – an einer veroeffentlichten
|
||
* Version laesst sich nichts mehr anhaengen, dafuer sorgen Trigger in der
|
||
* Datenbank. Wer die Fixture umdreht, baut genau den Zustand, den die Anwendung
|
||
* nie herstellen darf.
|
||
*/
|
||
async function veroeffentlichteVersionAnlegen() {
|
||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Probe" } });
|
||
return testPrisma.vorlagenVersion.create({
|
||
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
|
||
});
|
||
}
|
||
|
||
/** Schliesst eine Fixture-Version ab, nachdem ihr Aufbau steht. */
|
||
async function versionVeroeffentlichen(versionId: string) {
|
||
return testPrisma.vorlagenVersion.update({
|
||
where: { id: versionId },
|
||
// veroeffentlichtAm ist Pflicht, sobald der Status VEROEFFENTLICHT ist –
|
||
// eine Bedingung in der Datenbank haelt das fest.
|
||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||
});
|
||
}
|
||
|
||
describe.runIf(datenbankVerfuegbar)("Datenmodell: Regeln in der Datenbank", () => {
|
||
beforeEach(async () => {
|
||
await datenbankLeeren();
|
||
});
|
||
|
||
describe("Kunde", () => {
|
||
it("laesst nur eine eigene Organisation zu", async () => {
|
||
await kundeAnlegen("EIGEN", true);
|
||
// Gemeint ist der partielle Index, nicht irgendeine Ablehnung.
|
||
await expect(kundeAnlegen("ZWEIT", true)).rejects.toThrow(/Kunde_eigene_organisation_einmalig/);
|
||
});
|
||
|
||
it("laesst beliebig viele gewoehnliche Kunden zu", async () => {
|
||
await kundeAnlegen("EIGEN", true);
|
||
await kundeAnlegen("MUS");
|
||
await kundeAnlegen("REI");
|
||
expect(await testPrisma.kunde.count()).toBe(3);
|
||
});
|
||
|
||
it("weist ein Kuerzel ausserhalb von 2 bis 6 Zeichen zurueck", async () => {
|
||
await expect(kundeAnlegen("X")).rejects.toThrow(/Kunde_kuerzel_form/);
|
||
await expect(kundeAnlegen("ZULANG7")).rejects.toThrow(/Kunde_kuerzel_form/);
|
||
});
|
||
|
||
it("weist ein Kuerzel mit Kleinbuchstaben oder Sonderzeichen zurueck", async () => {
|
||
await expect(kundeAnlegen("mus")).rejects.toThrow();
|
||
await expect(kundeAnlegen("MU-S")).rejects.toThrow();
|
||
});
|
||
|
||
it("haelt das Kuerzel eindeutig", async () => {
|
||
await kundeAnlegen("MUS");
|
||
await expect(kundeAnlegen("MUS")).rejects.toThrow();
|
||
});
|
||
});
|
||
|
||
describe("Einstellung", () => {
|
||
it("laesst nur die eine Zeile mit der Kennung global zu", async () => {
|
||
await testPrisma.einstellung.create({ data: { id: "global" } });
|
||
await expect(testPrisma.einstellung.create({ data: { id: "zweite" } })).rejects.toThrow();
|
||
});
|
||
});
|
||
|
||
describe("Geraet", () => {
|
||
it("erlaubt einen vorgedruckten Tag ohne Hostname und ohne Bezeichnung", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
const tag = await testPrisma.geraet.create({
|
||
data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q" },
|
||
});
|
||
expect(tag.registriertAm).toBeNull();
|
||
expect(tag.hostname).toBeNull();
|
||
});
|
||
|
||
it("verlangt beim Registrieren einen Hostname oder eine Bezeichnung", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
await expect(
|
||
testPrisma.geraet.create({
|
||
data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q", registriertAm: new Date() },
|
||
}),
|
||
).rejects.toThrow(/Geraet_registriert_braucht_namen/);
|
||
});
|
||
|
||
it("wertet reine Leerzeichen nicht als Bezeichnung", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
await expect(
|
||
testPrisma.geraet.create({
|
||
data: {
|
||
kundeId: kunde.id,
|
||
tagCode: "LZ-7K3F9Q",
|
||
bezeichnung: " ",
|
||
registriertAm: new Date(),
|
||
},
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it("nimmt ein registriertes Geraet mit Bezeichnung statt Hostname an", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
const raum = await testPrisma.geraet.create({
|
||
data: {
|
||
kundeId: kunde.id,
|
||
tagCode: "LZ-M2XH4T",
|
||
bezeichnung: "Serverschrank Keller",
|
||
typ: "RAUM",
|
||
registriertAm: new Date(),
|
||
},
|
||
});
|
||
expect(raum.bezeichnung).toBe("Serverschrank Keller");
|
||
});
|
||
|
||
it("weist einen tagCode in falscher Form zurueck", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
for (const falsch of ["7K3F9Q", "LZ-7K3F9", "lz-7k3f9q", "LZ-7K3F9O", "LZ-7K3F91"]) {
|
||
await expect(
|
||
testPrisma.geraet.create({ data: { kundeId: kunde.id, tagCode: falsch } }),
|
||
).rejects.toThrow();
|
||
}
|
||
});
|
||
|
||
it("haelt den tagCode ueber alle Kunden hinweg eindeutig", async () => {
|
||
const einer = await kundeAnlegen("MUS");
|
||
const anderer = await kundeAnlegen("REI");
|
||
await testPrisma.geraet.create({ data: { kundeId: einer.id, tagCode: "LZ-7K3F9Q" } });
|
||
await expect(
|
||
testPrisma.geraet.create({ data: { kundeId: anderer.id, tagCode: "LZ-7K3F9Q" } }),
|
||
).rejects.toThrow();
|
||
});
|
||
});
|
||
|
||
describe("Mitarbeiter", () => {
|
||
it("haelt die externe Kennung je Quelle eindeutig", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
const quelle = await testPrisma.verzeichnisquelle.create({
|
||
data: {
|
||
kundeId: kunde.id,
|
||
name: "Firmen-AD",
|
||
url: "ldaps://dc01.firma.local",
|
||
bindDn: "CN=Dienst,DC=firma,DC=local",
|
||
bindPasswortVerschluesselt: "v1.probe",
|
||
basisDn: "DC=firma,DC=local",
|
||
filter: "(objectClass=user)",
|
||
attributZuordnung: {},
|
||
},
|
||
});
|
||
const stammdaten = {
|
||
kundeId: kunde.id,
|
||
quelleId: quelle.id,
|
||
externeId: "guid-1",
|
||
vorname: "Maria",
|
||
nachname: "Huber",
|
||
anzeigename: "Maria Huber",
|
||
herkunft: "ABGLEICH" as const,
|
||
};
|
||
await testPrisma.mitarbeiter.create({ data: stammdaten });
|
||
await expect(testPrisma.mitarbeiter.create({ data: stammdaten })).rejects.toThrow();
|
||
});
|
||
|
||
it("laesst beliebig viele manuell angelegte Personen ohne Quelle zu", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
// Postgres behandelt NULL als verschieden, deshalb greift der eindeutige
|
||
// Index hier nicht – genau so ist es gewollt.
|
||
for (const name of ["Ada", "Grace", "Alan"]) {
|
||
await testPrisma.mitarbeiter.create({
|
||
data: { kundeId: kunde.id, vorname: name, nachname: "Test", anzeigename: name },
|
||
});
|
||
}
|
||
expect(await testPrisma.mitarbeiter.count()).toBe(3);
|
||
});
|
||
});
|
||
|
||
describe("Abteilung", () => {
|
||
it("haelt den Namen je Kunde eindeutig, aber nicht ueber Kunden hinweg", async () => {
|
||
const einer = await kundeAnlegen("MUS");
|
||
const anderer = await kundeAnlegen("REI");
|
||
await testPrisma.abteilung.create({ data: { kundeId: einer.id, name: "Buchhaltung" } });
|
||
await expect(
|
||
testPrisma.abteilung.create({ data: { kundeId: einer.id, name: "Buchhaltung" } }),
|
||
).rejects.toThrow();
|
||
const beimAnderen = await testPrisma.abteilung.create({
|
||
data: { kundeId: anderer.id, name: "Buchhaltung" },
|
||
});
|
||
expect(beimAnderen.name).toBe("Buchhaltung");
|
||
});
|
||
});
|
||
|
||
describe("Lauf", () => {
|
||
it("laesst einen Lauf ohne Mitarbeiter und ohne Geraet zu", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
const benutzer = await benutzerAnlegen();
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
const lauf = await testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
titel: "Patchday 2026-10",
|
||
kundeId: kunde.id,
|
||
erstelltVonId: benutzer.id,
|
||
},
|
||
});
|
||
expect(lauf.status).toBe("OFFEN");
|
||
expect(lauf.gestartetAm).toBeNull();
|
||
});
|
||
|
||
it("weist Mitarbeiter und Freitextname zugleich zurueck", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
const benutzer = await benutzerAnlegen();
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
const person = await testPrisma.mitarbeiter.create({
|
||
data: { kundeId: kunde.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
|
||
});
|
||
await expect(
|
||
testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
titel: "Onboarding",
|
||
kundeId: kunde.id,
|
||
erstelltVonId: benutzer.id,
|
||
mitarbeiterId: person.id,
|
||
mitarbeiterName: "Maria Huber",
|
||
},
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it("verhindert das Loeschen eines Geraets, an dem ein Lauf haengt", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
const benutzer = await benutzerAnlegen();
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
const geraet = await testPrisma.geraet.create({
|
||
data: {
|
||
kundeId: kunde.id,
|
||
tagCode: "LZ-7K3F9Q",
|
||
hostname: "PC-001",
|
||
typ: "PC",
|
||
registriertAm: new Date(),
|
||
},
|
||
});
|
||
await testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
titel: "PC Neuinstallation",
|
||
kundeId: kunde.id,
|
||
erstelltVonId: benutzer.id,
|
||
geraetId: geraet.id,
|
||
},
|
||
});
|
||
await expect(testPrisma.geraet.delete({ where: { id: geraet.id } })).rejects.toThrow();
|
||
});
|
||
|
||
it("verhindert das Loeschen einer Version, auf die ein Lauf verweist", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
const benutzer = await benutzerAnlegen();
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
await testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
titel: "Probe",
|
||
kundeId: kunde.id,
|
||
erstelltVonId: benutzer.id,
|
||
},
|
||
});
|
||
await expect(testPrisma.vorlagenVersion.delete({ where: { id: version.id } })).rejects.toThrow();
|
||
});
|
||
});
|
||
|
||
describe("Schrittergebnis", () => {
|
||
it("laesst je Lauf und Schritt nur ein Ergebnis zu", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
const benutzer = await benutzerAnlegen();
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||
data: { versionId: version.id, titel: "Vorbereitung" },
|
||
});
|
||
const schritt = await testPrisma.vorlagenSchritt.create({
|
||
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst", pflicht: true },
|
||
});
|
||
await versionVeroeffentlichen(version.id);
|
||
const lauf = await testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
titel: "Probe",
|
||
kundeId: kunde.id,
|
||
erstelltVonId: benutzer.id,
|
||
},
|
||
});
|
||
await testPrisma.schrittergebnis.create({
|
||
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id },
|
||
});
|
||
await expect(
|
||
testPrisma.schrittergebnis.create({
|
||
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id },
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
});
|
||
|
||
describe("Vorlagen-Version", () => {
|
||
it("haelt die Versionsnummer je Vorlage eindeutig", async () => {
|
||
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
|
||
await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1 } });
|
||
await expect(
|
||
testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1 } }),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it("raeumt Abschnitte, Schritte und Variablen mit der Version ab", async () => {
|
||
// Ein Entwurf darf als Ganzes verworfen werden; nur deshalb steht hier
|
||
// Cascade und nicht Restrict.
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||
data: { versionId: version.id, titel: "Vorbereitung" },
|
||
});
|
||
await testPrisma.vorlagenSchritt.create({
|
||
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" },
|
||
});
|
||
await testPrisma.vorlagenVariable.create({
|
||
data: { versionId: version.id, schluessel: "os_version", beschriftung: "Betriebssystem" },
|
||
});
|
||
|
||
await testPrisma.vorlagenVersion.delete({ where: { id: version.id } });
|
||
|
||
expect(await testPrisma.vorlagenAbschnitt.count()).toBe(0);
|
||
expect(await testPrisma.vorlagenSchritt.count()).toBe(0);
|
||
expect(await testPrisma.vorlagenVariable.count()).toBe(0);
|
||
});
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Die Zusicherungen, die erst der unabhaengige Review sichtbar gemacht hat:
|
||
* Mandantengrenze, Unversehrtheit des Archivs und die Vorleistung fuer Phase 4.
|
||
*/
|
||
describe.runIf(datenbankVerfuegbar)("Datenmodell: Grenzen und Zusicherungen", () => {
|
||
beforeEach(async () => {
|
||
await datenbankLeeren();
|
||
});
|
||
|
||
describe("Mandantengrenze", () => {
|
||
it("laesst einen Lauf kein Geraet eines fremden Kunden waehlen", async () => {
|
||
const einer = await kundeAnlegen("MUS");
|
||
const anderer = await kundeAnlegen("REI");
|
||
const benutzer = await benutzerAnlegen();
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
const fremdesGeraet = await testPrisma.geraet.create({
|
||
data: {
|
||
kundeId: anderer.id,
|
||
tagCode: "LZ-7K3F9Q",
|
||
hostname: "REI-PC-001",
|
||
registriertAm: new Date(),
|
||
},
|
||
});
|
||
await expect(
|
||
testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
titel: "Quer ueber Kunden",
|
||
kundeId: einer.id,
|
||
erstelltVonId: benutzer.id,
|
||
geraetId: fremdesGeraet.id,
|
||
},
|
||
}),
|
||
).rejects.toThrow(/Lauf_geraet_gleicher_kunde/);
|
||
});
|
||
|
||
it("laesst einen Lauf keine Person eines fremden Kunden waehlen", async () => {
|
||
const einer = await kundeAnlegen("MUS");
|
||
const anderer = await kundeAnlegen("REI");
|
||
const benutzer = await benutzerAnlegen();
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
const fremdePerson = await testPrisma.mitarbeiter.create({
|
||
data: { kundeId: anderer.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
|
||
});
|
||
await expect(
|
||
testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
titel: "Quer ueber Kunden",
|
||
kundeId: einer.id,
|
||
erstelltVonId: benutzer.id,
|
||
mitarbeiterId: fremdePerson.id,
|
||
},
|
||
}),
|
||
).rejects.toThrow(/Lauf_mitarbeiter_gleicher_kunde/);
|
||
});
|
||
|
||
it("laesst einem Geraet keinen Besitzer eines fremden Kunden geben", async () => {
|
||
const einer = await kundeAnlegen("MUS");
|
||
const anderer = await kundeAnlegen("REI");
|
||
const fremdePerson = await testPrisma.mitarbeiter.create({
|
||
data: { kundeId: anderer.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
|
||
});
|
||
await expect(
|
||
testPrisma.geraet.create({
|
||
data: {
|
||
kundeId: einer.id,
|
||
tagCode: "LZ-7K3F9Q",
|
||
hostname: "MUS-PC-001",
|
||
registriertAm: new Date(),
|
||
mitarbeiterId: fremdePerson.id,
|
||
},
|
||
}),
|
||
).rejects.toThrow(/Geraet_besitzer_gleicher_kunde/);
|
||
});
|
||
});
|
||
|
||
describe("Unversehrtheit des Archivs", () => {
|
||
it("verhindert, dass ein Lauf seine Schrittergebnisse mitnimmt", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
const benutzer = await benutzerAnlegen();
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||
data: { versionId: version.id, titel: "Vorbereitung" },
|
||
});
|
||
const schritt = await testPrisma.vorlagenSchritt.create({
|
||
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" },
|
||
});
|
||
await versionVeroeffentlichen(version.id);
|
||
const lauf = await testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
titel: "Probe",
|
||
kundeId: kunde.id,
|
||
erstelltVonId: benutzer.id,
|
||
},
|
||
});
|
||
await testPrisma.schrittergebnis.create({
|
||
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id },
|
||
});
|
||
// Ohne diese Sperre verschwaende ein DELETE auf Lauf das halbe Archiv.
|
||
await expect(testPrisma.lauf.delete({ where: { id: lauf.id } })).rejects.toThrow();
|
||
expect(await testPrisma.schrittergebnis.count()).toBe(1);
|
||
});
|
||
|
||
it("verhindert, dass ein Protokolleintrag seinen Urheber verliert", async () => {
|
||
const benutzer = await benutzerAnlegen();
|
||
await testPrisma.protokoll.create({
|
||
data: {
|
||
entitaet: "Benutzer",
|
||
entitaetId: benutzer.id,
|
||
aktion: "angemeldet",
|
||
benutzerId: benutzer.id,
|
||
},
|
||
});
|
||
await expect(testPrisma.benutzer.delete({ where: { id: benutzer.id } })).rejects.toThrow();
|
||
});
|
||
});
|
||
|
||
describe("Vorlagen-Versionierung", () => {
|
||
it("laesst je Vorlage nur einen Entwurf zu", async () => {
|
||
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
|
||
await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1 } });
|
||
await expect(
|
||
testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 2 } }),
|
||
).rejects.toThrow(/VorlagenVersion_ein_entwurf/);
|
||
});
|
||
|
||
it("verlangt beim Veroeffentlichen einen Zeitpunkt", async () => {
|
||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Probe" } });
|
||
await expect(
|
||
testPrisma.vorlagenVersion.create({
|
||
data: { vorlageId: vorlage.id, version: 1, status: "VEROEFFENTLICHT" },
|
||
}),
|
||
).rejects.toThrow(/VorlagenVersion_veroeffentlicht_mit_zeitpunkt/);
|
||
});
|
||
});
|
||
|
||
describe("Status und Zeitstempel", () => {
|
||
it("verlangt einen Startzeitpunkt, sobald der Lauf nicht mehr offen ist", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
const benutzer = await benutzerAnlegen();
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
await expect(
|
||
testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
titel: "Probe",
|
||
kundeId: kunde.id,
|
||
erstelltVonId: benutzer.id,
|
||
status: "IN_ARBEIT",
|
||
},
|
||
}),
|
||
).rejects.toThrow(/Lauf_begonnen_mit_zeitpunkt/);
|
||
});
|
||
|
||
it("verlangt beim Archivieren Abschluss- und Archivzeitpunkt", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
const benutzer = await benutzerAnlegen();
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
await expect(
|
||
testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
titel: "Probe",
|
||
kundeId: kunde.id,
|
||
erstelltVonId: benutzer.id,
|
||
status: "ARCHIVIERT",
|
||
gestartetAm: new Date(),
|
||
},
|
||
}),
|
||
).rejects.toThrow(/Lauf_abgeschlossen_mit_zeitpunkt/);
|
||
});
|
||
});
|
||
|
||
describe("Schrittergebnis", () => {
|
||
it("verlangt eine Begruendung bei nicht zutreffend und fehlgeschlagen", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
const benutzer = await benutzerAnlegen();
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||
data: { versionId: version.id, titel: "Vorbereitung" },
|
||
});
|
||
const schritt = await testPrisma.vorlagenSchritt.create({
|
||
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" },
|
||
});
|
||
await versionVeroeffentlichen(version.id);
|
||
const lauf = await testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
titel: "Probe",
|
||
kundeId: kunde.id,
|
||
erstelltVonId: benutzer.id,
|
||
},
|
||
});
|
||
for (const zustand of ["NICHT_ZUTREFFEND", "FEHLGESCHLAGEN"] as const) {
|
||
await expect(
|
||
testPrisma.schrittergebnis.create({
|
||
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id, zustand },
|
||
}),
|
||
).rejects.toThrow(/Schrittergebnis_begruendung_noetig/);
|
||
}
|
||
const mitNotiz = await testPrisma.schrittergebnis.create({
|
||
data: {
|
||
laufId: lauf.id,
|
||
vorlagenSchrittId: schritt.id,
|
||
zustand: "NICHT_ZUTREFFEND",
|
||
notiz: "Geraet hat kein TPM.",
|
||
},
|
||
});
|
||
expect(mitNotiz.notiz).toBe("Geraet hat kein TPM.");
|
||
});
|
||
|
||
it("nimmt ein Aktionsergebnis verlustfrei auf", async () => {
|
||
// Vorleistung fuer Phase 4: der Wert muss Log, Kennungen und Zeitstempel
|
||
// einer ausgefuehrten Aktion tragen koennen, nicht nur einen Haken.
|
||
const kunde = await kundeAnlegen();
|
||
const benutzer = await benutzerAnlegen();
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||
data: { versionId: version.id, titel: "Konto anlegen" },
|
||
});
|
||
const schritt = await testPrisma.vorlagenSchritt.create({
|
||
data: { abschnittId: abschnitt.id, titel: "New-ADUser", typ: "BEFEHL" },
|
||
});
|
||
await versionVeroeffentlichen(version.id);
|
||
const lauf = await testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
titel: "Onboarding",
|
||
kundeId: kunde.id,
|
||
erstelltVonId: benutzer.id,
|
||
},
|
||
});
|
||
const ergebnis = {
|
||
connector: "ldap",
|
||
ausgefuehrtAm: "2026-09-25T08:15:00.000Z",
|
||
dauerMs: 842,
|
||
objekte: [{ typ: "Benutzer", dn: "CN=Maria Huber,OU=Buchhaltung,DC=firma,DC=local" }],
|
||
log: ["Verbindung aufgebaut", "Konto angelegt", "Gruppen zugewiesen"],
|
||
erfolgreich: true,
|
||
};
|
||
const gespeichert = await testPrisma.schrittergebnis.create({
|
||
data: {
|
||
laufId: lauf.id,
|
||
vorlagenSchrittId: schritt.id,
|
||
zustand: "ERLEDIGT",
|
||
wert: ergebnis,
|
||
},
|
||
});
|
||
expect(gespeichert.wert).toEqual(ergebnis);
|
||
});
|
||
});
|
||
|
||
describe("Vorleistung fuer Phase 4", () => {
|
||
it("laesst einen Lauf ohne erstellenden Menschen zu", async () => {
|
||
// Wiederkehrende Pruefungen und der Start per Webhook legen Laeufe an,
|
||
// hinter denen kein Benutzer steht.
|
||
const kunde = await kundeAnlegen();
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
const systemlauf = await testPrisma.lauf.create({
|
||
data: { vorlagenVersionId: version.id, titel: "Jaehrliche Pruefung", kundeId: kunde.id },
|
||
});
|
||
expect(systemlauf.erstelltVonId).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("Vergabebuch fuer tagCodes", () => {
|
||
it("haelt einen vergebenen Code fest, auch wenn das Geraet verschwindet", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
await testPrisma.tagCodeVergabe.create({ data: { tagCode: "LZ-7K3F9Q" } });
|
||
const geraet = await testPrisma.geraet.create({
|
||
data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q" },
|
||
});
|
||
await testPrisma.geraet.delete({ where: { id: geraet.id } });
|
||
|
||
expect(await testPrisma.geraet.count()).toBe(0);
|
||
// Der Code bleibt vergeben – ein alter Aufkleber darf nie auf ein neues
|
||
// Objekt zeigen.
|
||
expect(await testPrisma.tagCodeVergabe.count()).toBe(1);
|
||
await expect(testPrisma.tagCodeVergabe.create({ data: { tagCode: "LZ-7K3F9Q" } })).rejects.toThrow();
|
||
});
|
||
|
||
it("weist einen Code in falscher Form zurueck", async () => {
|
||
await expect(testPrisma.tagCodeVergabe.create({ data: { tagCode: "LZ-7K3F9O" } })).rejects.toThrow(
|
||
/TagCodeVergabe_form/,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("Verzeichnisquelle", () => {
|
||
it("weist ein Bind-Passwort ohne Versionspraefix zurueck", async () => {
|
||
const kunde = await kundeAnlegen();
|
||
await expect(
|
||
testPrisma.verzeichnisquelle.create({
|
||
data: {
|
||
kundeId: kunde.id,
|
||
name: "Firmen-AD",
|
||
url: "ldaps://dc01.firma.local",
|
||
bindDn: "CN=Dienst,DC=firma,DC=local",
|
||
// So saehe ein versehentlich im Klartext gespeichertes Passwort aus.
|
||
bindPasswortVerschluesselt: "GeheimesPasswort123",
|
||
basisDn: "DC=firma,DC=local",
|
||
filter: "(objectClass=user)",
|
||
attributZuordnung: {},
|
||
},
|
||
}),
|
||
).rejects.toThrow(/Verzeichnisquelle_bindpasswort_verschluesselt/);
|
||
});
|
||
});
|
||
|
||
describe("Abgleich-Sperre", () => {
|
||
it("laesst dieselbe Sperre nicht zweimal halten", async () => {
|
||
const laeuftAbAm = new Date(Date.now() + 60_000);
|
||
await testPrisma.abgleichSperre.create({
|
||
data: { schluessel: "abgleich:quelle-1", gehaltenVon: "instanz-a", laeuftAbAm },
|
||
});
|
||
await expect(
|
||
testPrisma.abgleichSperre.create({
|
||
data: { schluessel: "abgleich:quelle-1", gehaltenVon: "instanz-b", laeuftAbAm },
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
});
|
||
});
|