feat(stammdaten): Mitarbeiter, Abteilungen und Geraete
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Die Stammdaten, an denen die Historie haengt. Personen werden deaktiviert,
Geraete und Abteilungen archiviert — geloescht wird nichts, woran ein Lauf
haengen kann.

- Mitarbeiterliste mit Suche ueber Anzeigename, Mail, UPN und sAMAccountName,
  Filter nach Abteilung, standardmaessig nur aktive; die Suche ist ein eigenes
  Bauteil, weil #013 und #014 sie wieder brauchen
- Personenseite mit Stammdaten, Geraeten, Vorgesetztem; was aus dem Verzeichnis
  kommt, ist gesperrt — der naechste Abgleich wuerde es ohnehin zurueckdrehen
- Abteilungen unter Admin: Ziel-OU, Standardgruppen, Mail-Domain, dazu die
  Namen, die bei Personen vorkommen, aber nicht gepflegt sind
- Geraete mit Suche ueber Hostname, Bezeichnung, Seriennummer und Tag-Code;
  CSV-Import mit einem Ergebnis je Zeile
- Neues Recht mitarbeiterBearbeiten; die Seitenleiste fuehrt Personen und
  Geraete unter einem Eintrag, im Modus Dienstleistung anders beschriftet

Der unabhaengige Review fand zwei Blocker. Der schwerere meldete Erfolg: ein
nicht geschlossenes Anfuehrungszeichen verschluckte im CSV-Import alle
Folgezeilen bis zum naechsten `"`, und der Import sagte „3 von 3 Zeilen
angelegt". Ein Wert reicht jetzt nicht mehr ueber die Zeile hinaus — fuer
Hostnamen und Seriennummern ist ein Umbruch praktisch immer ein vergessenes
Anfuehrungszeichen, und so endet der Schaden an seiner Zeile. Der zweite:
ungepflegte Abteilungen wurden ueber alle Kunden hinweg gruppiert.

Dazu sieben weitere Befunde, alle behoben — darunter eine Besitzerauswahl, die
Personen fremder Kunden anbot, und ein Techniker, der ein Geraet endgueltig
loeschen konnte.

610 Tests gruen, 18 E2E-Ablaeufe gegen den gebauten Stand.

Schliesst #011

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 12:51:54 +02:00
co-authored by Claude Opus 5
parent e3d80e5982
commit 86d55917f9
81 changed files with 6784 additions and 59 deletions
+268
View File
@@ -0,0 +1,268 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus } from "@/generated/prisma/enums";
import {
AbteilungsFehler,
abteilungAnlegen,
abteilungArchivieren,
abteilungBearbeiten,
} from "@/server/services/abteilungen";
import { abteilungsListe, ungepflegteAbteilungen } from "@/server/queries/abteilungen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Abteilungen (#011).
*
* An einer Abteilung haengen Ziel-OU und Standardgruppen — das, was die
* spaeteren PowerShell-Befehle in ein Verzeichnis schreiben (#012). Deshalb wird
* hier genauer geprueft als bei einer blossen Beschriftung.
*/
async function grundbestand() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const benutzer = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
return { eigene, admin: { id: benutzer.id, rolle: benutzer.rolle } };
}
describe.runIf(datenbankVerfuegbar)("Abteilungen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
describe("Anlegen", () => {
it("legt Name, Ziel-OU, Gruppen und Mail-Domain an", async () => {
const { admin } = await grundbestand();
const abteilung = await abteilungAnlegen(
{
name: " Buchhaltung ",
zielOu: "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de",
standardGruppen: ["G-Buchhaltung", "G-Alle"],
mailDomain: "firma.de",
},
admin,
);
expect(abteilung.name).toBe("Buchhaltung");
expect(abteilung.standardGruppen).toEqual(["G-Buchhaltung", "G-Alle"]);
expect(abteilung.mailDomain).toBe("firma.de");
});
it("weist denselben Namen ein zweites Mal ab, mit lesbarer Meldung", async () => {
const { admin } = await grundbestand();
await abteilungAnlegen({ name: "Buchhaltung" }, admin);
await expect(abteilungAnlegen({ name: "Buchhaltung" }, admin)).rejects.toThrow(/gibt es schon/);
});
it("räumt leere und doppelte Gruppen weg, behält die Reihenfolge", async () => {
// Die Reihenfolge landet spaeter im Add-ADGroupMember-Befehl; sie ist
// keine Menge, sondern eine Liste.
const { admin } = await grundbestand();
const abteilung = await abteilungAnlegen(
{ name: "Vertrieb", standardGruppen: ["G-Alle", " ", "G-Vertrieb", "G-Alle", ""] },
admin,
);
expect(abteilung.standardGruppen).toEqual(["G-Alle", "G-Vertrieb"]);
});
it("weist eine Mail-Adresse als Domain ab", async () => {
const { admin } = await grundbestand();
await expect(abteilungAnlegen({ name: "Vertrieb", mailDomain: "max@firma.de" }, admin)).rejects.toThrow(
/Mail-Domain/,
);
await expect(abteilungAnlegen({ name: "Einkauf", mailDomain: "firma" }, admin)).rejects.toThrow(
AbteilungsFehler,
);
});
it("nimmt eine Domain mit führendem @ an und normalisiert sie", async () => {
const { admin } = await grundbestand();
const abteilung = await abteilungAnlegen({ name: "IT", mailDomain: "@Firma.DE" }, admin);
expect(abteilung.mailDomain).toBe("firma.de");
});
it("weist einen leeren Namen ab", async () => {
const { admin } = await grundbestand();
await expect(abteilungAnlegen({ name: " " }, admin)).rejects.toThrow(AbteilungsFehler);
expect(await testPrisma.abteilung.count()).toBe(0);
});
});
describe("Bearbeiten", () => {
it("schreibt nichts, wenn sich nichts ändert", async () => {
const { admin } = await grundbestand();
const abteilung = await abteilungAnlegen({ name: "Buchhaltung" }, admin);
const vorher = await testPrisma.protokoll.count();
await abteilungBearbeiten(abteilung.id, { name: "Buchhaltung" }, admin);
expect(await testPrisma.protokoll.count()).toBe(vorher);
});
it("erkennt eine geänderte Gruppenreihenfolge als Änderung", async () => {
const { admin } = await grundbestand();
const abteilung = await abteilungAnlegen(
{ name: "Buchhaltung", standardGruppen: ["G-Alle", "G-Buchhaltung"] },
admin,
);
const nachher = await abteilungBearbeiten(
abteilung.id,
{ standardGruppen: ["G-Buchhaltung", "G-Alle"] },
admin,
);
expect(nachher.standardGruppen).toEqual(["G-Buchhaltung", "G-Alle"]);
expect(await testPrisma.protokoll.count({ where: { aktion: "Standardgruppen geändert" } })).toBe(1);
});
it("weist einen Namen ab, den es schon gibt", async () => {
const { admin } = await grundbestand();
await abteilungAnlegen({ name: "Buchhaltung" }, admin);
const vertrieb = await abteilungAnlegen({ name: "Vertrieb" }, admin);
await expect(abteilungBearbeiten(vertrieb.id, { name: "Buchhaltung" }, admin)).rejects.toThrow(
/gibt es schon/,
);
});
});
describe("Archivieren statt löschen", () => {
it("archiviert und gibt wieder frei", async () => {
const { admin } = await grundbestand();
const abteilung = await abteilungAnlegen({ name: "Buchhaltung" }, admin);
expect((await abteilungArchivieren(abteilung.id, true, admin)).archiviert).toBe(true);
expect((await abteilungArchivieren(abteilung.id, false, admin)).archiviert).toBe(false);
});
it("bietet gar keinen Löschweg an", async () => {
const dienst = await import("@/server/services/abteilungen");
expect(Object.keys(dienst).filter((name) => /loesch|entfern|delete/iu.test(name))).toEqual([]);
});
it("blendet archivierte auf Wunsch aus", async () => {
const { admin } = await grundbestand();
const abteilung = await abteilungAnlegen({ name: "Buchhaltung" }, admin);
await abteilungArchivieren(abteilung.id, true, admin);
expect(await abteilungsListe(admin, false)).toEqual([]);
expect(await abteilungsListe(admin, true)).toHaveLength(1);
});
});
describe("Zuordnung über den Namen", () => {
it("zählt nur aktive Personen mit passendem Abteilungsnamen", async () => {
const { admin, eigene } = await grundbestand();
await abteilungAnlegen({ name: "Buchhaltung" }, admin);
for (const [name, aktiv] of [
["Maria", true],
["Jens", true],
["Ex", false],
] as const) {
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: name,
nachname: "X",
anzeigename: name,
abteilung: "Buchhaltung",
aktiv,
},
});
}
const zeile = (await abteilungsListe(admin))[0]!;
expect(zeile.anzahlMitarbeiter).toBe(2);
});
it("meldet Abteilungsnamen, die es bei Personen gibt, aber nicht gepflegt sind", async () => {
// Das ist der Vorschlag nach einem Verzeichnisabgleich: der Name steht
// schon in den Daten, es fehlen nur Ziel-OU und Gruppen.
const { admin, eigene } = await grundbestand();
await abteilungAnlegen({ name: "Buchhaltung" }, admin);
for (const abteilung of ["Buchhaltung", "Vertrieb", "Vertrieb"]) {
await testPrisma.mitarbeiter.create({
data: { kundeId: eigene.id, vorname: "P", nachname: "X", anzeigename: "P X", abteilung },
});
}
expect(await ungepflegteAbteilungen(admin)).toEqual([
{ name: "Vertrieb", anzahl: 2, kundeId: eigene.id },
]);
});
});
describe("Mandantensicht", () => {
async function zweiKunden() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
await testPrisma.einstellung.create({
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
});
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({ data: { benutzerId: techniker.id, kundeId: meiner.id } });
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
await testPrisma.abteilung.create({ data: { kundeId: meiner.id, name: "Meine Abteilung" } });
const fremde = await testPrisma.abteilung.create({
data: { kundeId: fremder.id, name: "Fremde Abteilung", zielOu: "OU=Geheim" },
});
return { eigene, techniker: { id: techniker.id, rolle: techniker.rolle }, fremde };
}
it("zeigt einem gebundenen Techniker keine fremde Abteilung", async () => {
const { techniker } = await zweiKunden();
expect((await abteilungsListe(techniker)).map((z) => z.name)).toEqual(["Meine Abteilung"]);
});
it("lässt eine fremde Abteilung nicht bearbeiten", async () => {
const { techniker, fremde } = await zweiKunden();
await expect(abteilungBearbeiten(fremde.id, { zielOu: "OU=Anders" }, techniker)).rejects.toThrow(
/Berechtigung/,
);
});
it("hält bei ungepflegten Abteilungen die Kunden auseinander", async () => {
/*
* Der Blocker aus dem Review: gruppiert wurde nach Namen allein. Damit galt
* „Vertrieb“ als gepflegt, sobald **irgendein** Kunde eine Abteilung dieses
* Namens hatte — und beim Kunden ohne sie blieben `{{ou}}` und
* `{{gruppen}}` im Lauf leer, ohne dass die Seite es je sagte.
*/
const { eigene } = await zweiKunden();
const admin = await testPrisma.benutzer.create({
data: { email: "a@firma.example", name: "Admin", rolle: "ADMIN" },
});
const handelnder = { id: admin.id, rolle: admin.rolle };
const meiner = await testPrisma.kunde.findFirstOrThrow({ where: { kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.findFirstOrThrow({ where: { kuerzel: "FREMD" } });
// Nur der eine Kunde pflegt „Vertrieb“.
await testPrisma.abteilung.create({ data: { kundeId: meiner.id, name: "Vertrieb" } });
for (const kundeId of [meiner.id, fremder.id, fremder.id]) {
await testPrisma.mitarbeiter.create({
data: { kundeId, vorname: "P", nachname: "X", anzeigename: "P X", abteilung: "Vertrieb" },
});
}
const offen = await ungepflegteAbteilungen(handelnder);
expect(offen).toEqual([{ name: "Vertrieb", anzahl: 2, kundeId: fremder.id }]);
expect(offen.some((eintrag) => eintrag.kundeId === meiner.id)).toBe(false);
expect(eigene.id).toBeTruthy();
});
it("lässt eine fremde Abteilung nicht archivieren", async () => {
const { techniker, fremde } = await zweiKunden();
await expect(abteilungArchivieren(fremde.id, true, techniker)).rejects.toThrow(/Berechtigung/);
});
it("erlaubt denselben Abteilungsnamen bei zwei Kunden", async () => {
// Der Eindeutigkeitsindex geht ueber (kundeId, name) – „Buchhaltung“ gibt
// es bei jedem Kunden genau einmal, und das ist richtig so.
const { eigene } = await zweiKunden();
const zweiter = await testPrisma.kunde.findFirstOrThrow({ where: { kuerzel: "MEIN" } });
await testPrisma.abteilung.create({ data: { kundeId: eigene.id, name: "Buchhaltung" } });
await expect(
testPrisma.abteilung.create({ data: { kundeId: zweiter.id, name: "Buchhaltung" } }),
).resolves.toBeTruthy();
});
});
});
+254
View File
@@ -0,0 +1,254 @@
import { describe, expect, it } from "vitest";
import {
CsvFehler,
csvErzeugen,
csvZerlegen,
hatSpalte,
spaltenName,
trennzeichenErkennen,
wertAus,
} from "@/lib/csv";
/**
* CSV lesen und schreiben (#011).
*
* Reine Logik ohne Datenbank — und genau der Ort, an dem sich die Randfälle
* billig absichern lassen. Geprüft wird, was in echten Dateien steht: das
* Semikolon des deutschen Excel, ein BOM davor, CRLF, Werte in
* Anführungszeichen mit Trennzeichen und Umbruch darin, verdoppelte
* Anführungszeichen, Leerzeilen und Kopfzeilen in jeder Schreibweise.
*/
/** Kürzer als `zeilen.map(...)` in jedem Test. */
function werte(text: string, spalte: string): readonly string[] {
return csvZerlegen(text).zeilen.map((zeile) => wertAus(zeile, spalte));
}
describe("Spaltennamen normalisieren", () => {
it("macht Schreibweise, Leerraum, Unterstrich und Bindestrich unerheblich", () => {
for (const roh of ["serialNumber", "SerialNumber", " Serial Number ", "serial_number", "SERIAL-NUMBER"]) {
expect(spaltenName(roh), roh).toBe("serialnumber");
}
});
});
describe("Trennzeichen erkennen", () => {
it("erkennt das Semikolon, das deutsches Excel schreibt", () => {
expect(trennzeichenErkennen("hostname;label;type\na;b;c\n")).toBe(";");
});
it("erkennt das Komma", () => {
expect(trennzeichenErkennen("hostname,label,type\na,b,c\n")).toBe(",");
});
it("liest nur die Kopfzeile und lässt sich von Kommas in den Werten nicht täuschen", () => {
// Die Werte enthalten viermal so viele Kommas wie die Kopfzeile Semikolons.
const text = "hostname;label\nPC-1;Regal, Keller, hinten, links\nPC-2;Regal, Keller, vorn, rechts\n";
expect(trennzeichenErkennen(text)).toBe(";");
expect(werte(text, "label")).toEqual(["Regal, Keller, hinten, links", "Regal, Keller, vorn, rechts"]);
});
it("nimmt bei einer einspaltigen Datei das Komma – das Zeichen der Norm", () => {
expect(trennzeichenErkennen("hostname\nPC-1\n")).toBe(",");
});
it("lässt sich überstimmen", () => {
const tabelle = csvZerlegen("a;b\n1;2\n", { trennzeichen: "," });
expect(tabelle.spalten).toEqual(["a;b"]);
});
});
describe("Kopfzeile", () => {
it("ist unabhängig von Groß- und Kleinschreibung", () => {
const tabelle = csvZerlegen("HostName,LABEL,Serial Number\npc-1,Regal,SN1\n");
expect(tabelle.spalten).toEqual(["hostname", "label", "serialnumber"]);
expect(wertAus(tabelle.zeilen[0]!, "serialNumber")).toBe("SN1");
});
it("überspringt ein BOM am Dateianfang", () => {
const tabelle = csvZerlegen("hostname,label\npc-1,Regal\n");
expect(tabelle.spalten).toEqual(["hostname", "label"]);
});
it("nimmt bei einem doppelten Spaltennamen den ersten", () => {
const tabelle = csvZerlegen("hostname,label,hostname\npc-1,Regal,pc-2\n");
expect(wertAus(tabelle.zeilen[0]!, "hostname")).toBe("pc-1");
});
it("wirft bei einer leeren Datei", () => {
expect(() => csvZerlegen("")).toThrow(CsvFehler);
expect(() => csvZerlegen("\r\n")).toThrow(CsvFehler);
});
it("sagt, welche Spalten es gibt", () => {
const tabelle = csvZerlegen("hostname;typ\npc-1;PC\n");
expect(hatSpalte(tabelle, "hostname")).toBe(true);
expect(hatSpalte(tabelle, "label", "bezeichnung")).toBe(false);
expect(hatSpalte(tabelle, "type", "typ")).toBe(true);
});
});
describe("Werte in Anführungszeichen", () => {
it("nimmt Trennzeichen und verdoppelte Anführungszeichen auf", () => {
const text = 'hostname,label\npc-1,"Regal, Keller"\npc-2,"Der ""gute"" Platz"\n';
expect(werte(text, "label")).toEqual(["Regal, Keller", 'Der "gute" Platz']);
});
it("nimmt Umbrüche im Wert nur auf ausdrücklichen Wunsch auf", () => {
// Standard ist **nein**: ein Umbruch in einem Hostnamen kommt nicht vor, ein
// vergessenes schliessendes Anfuehrungszeichen staendig. Siehe
// `werteUeberZeilen` — ohne die Grenze verschluckt eine kaputte Zelle alle
// Folgezeilen, und der Import meldet dafuer Erfolg.
const text = 'hostname,label\npc-1,"Zeile 1\nZeile 2"\n';
const tabelle = csvZerlegen(text, { werteUeberZeilen: true });
expect(tabelle.zeilen.map((zeile) => wertAus(zeile, "label"))).toEqual(["Zeile 1\nZeile 2"]);
// Streng gelesen ist Zeile 2 kaputt, und Zeile 3 („Zeile 2\"“) ist nur noch
// der Rest davon – gemeldet wird beides, verschluckt nichts.
const streng = csvZerlegen(text);
expect(streng.zeilen[0]?.nummer).toBe(2);
expect(streng.zeilen[0]?.fehler).toMatch(/nicht geschlossen/);
});
it("normalisiert CRLF innerhalb eines Werts auf einen einzelnen Umbruch", () => {
const tabelle = csvZerlegen('hostname,label\r\npc-1,"oben\r\nunten"\r\n', {
werteUeberZeilen: true,
});
expect(tabelle.zeilen.map((zeile) => wertAus(zeile, "label"))).toEqual(["oben\nunten"]);
});
it("behält Leerraum innerhalb der Anführungszeichen, verwirft ihn außerhalb", () => {
const text = 'hostname , label\n pc-1 , " Regal " \n';
const tabelle = csvZerlegen(text);
expect(tabelle.spalten).toEqual(["hostname", "label"]);
expect(wertAus(tabelle.zeilen[0]!, "hostname")).toBe("pc-1");
// `wertAus` trimmt für die Anwendung; der rohe Wert bleibt erhalten.
expect(tabelle.zeilen[0]!.werte[1]).toBe(" Regal ");
});
it("behandelt ein Anführungszeichen mitten im Wert als gewöhnliches Zeichen", () => {
expect(werte('hostname,label\npc-1,24" Monitor\n', "label")).toEqual(['24" Monitor']);
});
it("meldet ein nicht geschlossenes Anführungszeichen als Zeilenfehler, statt zu werfen", () => {
/*
* Frueher warf das hier — und damit starb der ganze Import an einer einzigen
* kaputten Zelle. Das Akzeptanzkriterium verlangt das Gegenteil: die Zeile
* wird gemeldet, die anderen werden eingelesen.
*/
const tabelle = csvZerlegen('hostname,label\npc-1,"offen\npc-2,gut\n');
expect(tabelle.zeilen.map((zeile) => zeile.nummer)).toEqual([2, 3]);
expect(tabelle.zeilen[0]?.fehler).toMatch(/Anführungszeichen/);
expect(tabelle.zeilen[1]?.fehler).toBeUndefined();
expect(wertAus(tabelle.zeilen[1]!, "hostname")).toBe("pc-2");
});
it("meldet Text nach dem schließenden Anführungszeichen, statt ihn anzuhängen", () => {
// Vorher wanderte alles Folgende stillschweigend in diesen Wert – samt der
// naechsten Zeilen.
const tabelle = csvZerlegen('hostname,label\npc-1,"Regal" Keller,x\npc-2,gut\n');
expect(tabelle.zeilen[0]?.fehler).toMatch(/schließenden Anführungszeichen/);
expect(wertAus(tabelle.zeilen[1]!, "hostname")).toBe("pc-2");
});
it("wirft nur, wenn schon die Kopfzeile unlesbar ist", () => {
// Ohne Spaltennamen hat keine einzige Zeile eine Bedeutung – da hilft keine
// Zeilenmeldung.
expect(() => csvZerlegen('hostname,"label\npc-1,Regal\n')).toThrow(CsvFehler);
expect(() => csvZerlegen('hostname,"label\npc-1,Regal\n')).toThrow(/Kopfzeile/);
});
});
describe("Zeilen", () => {
it("überspringt Leerzeilen und Zeilen aus lauter Trennzeichen", () => {
const tabelle = csvZerlegen("hostname;label\n\npc-1;Regal\n;;\r\n\npc-2;Kiste\n");
expect(tabelle.zeilen.map((zeile) => wertAus(zeile, "hostname"))).toEqual(["pc-1", "pc-2"]);
});
it("zählt Zeilennummern wie ein Editor – über Leerzeilen und Umbrüche in Werten hinweg", () => {
const text = [
"hostname,label", // 1
"", // 2
"pc-1,Regal", // 3
'pc-2,"oben', // 4
'unten"', // 5
"pc-3,Kiste", // 6
].join("\n");
const tabelle = csvZerlegen(text, { werteUeberZeilen: true });
expect(tabelle.zeilen.map((zeile) => zeile.nummer)).toEqual([3, 4, 6]);
});
it("kommt ohne abschließenden Umbruch aus", () => {
expect(werte("hostname,label\npc-1,Regal", "hostname")).toEqual(["pc-1"]);
});
it("verträgt fehlende und zusätzliche Spalten", () => {
const tabelle = csvZerlegen("hostname,label,type\npc-1\npc-2,Regal,PC,zuviel,noch mehr\n");
const [kurz, lang] = tabelle.zeilen;
// Fehlende Spalte: kein Eintrag, also auch kein leerer String.
expect(kurz!.felder.has("label")).toBe(false);
expect(wertAus(kurz!, "label")).toBe("");
expect(wertAus(lang!, "type")).toBe("PC");
// Ueberzaehlige Werte bleiben erreichbar, ohne eine Spalte zu belegen.
expect(lang!.werte).toHaveLength(5);
});
it("bricht bei zu vielen Zeilen ab und nennt die Grenze", () => {
const viele = ["hostname", ...Array.from({ length: 5 }, (_, i) => `pc-${i}`)].join("\n");
expect(() => csvZerlegen(viele, { hoechstZeilen: 3 })).toThrow(/mehr als 3 Zeilen/);
// Genau an der Grenze geht es noch.
expect(csvZerlegen(viele, { hoechstZeilen: 5 }).zeilen).toHaveLength(5);
});
});
describe("Mehrere Namen für dieselbe Spalte", () => {
it("nimmt den ersten gesetzten Wert", () => {
const tabelle = csvZerlegen("label;bezeichnung\n;Regal\nKiste;\n");
expect(tabelle.zeilen.map((zeile) => wertAus(zeile, "label", "bezeichnung"))).toEqual(["Regal", "Kiste"]);
});
});
describe("Erzeugen", () => {
it("maskiert Trennzeichen, Anführungszeichen, Umbrüche und Randleerraum", () => {
const text = csvErzeugen(
["a", "b"],
[
{ a: 'x;y "z"', b: "oben\nunten" },
{ a: " Rand ", b: "" },
],
{
mitBom: false,
},
);
expect(text).toBe('a;b\r\n"x;y ""z""";"oben\nunten"\r\n" Rand ";\r\n');
});
it("stellt standardmäßig ein BOM voran und trennt mit Semikolon – für deutsches Excel", () => {
const text = csvErzeugen(["hostname"], [{ hostname: "pc-1" }]);
expect(text.startsWith("")).toBe(true);
expect(text.endsWith("\r\n")).toBe(true);
});
it("schreibt fehlende Werte als leere Zelle", () => {
expect(csvErzeugen(["a", "b"], [{ a: null }, { b: 7 }], { mitBom: false })).toBe("a;b\r\n;\r\n;7\r\n");
});
it("liest zurück, was es geschrieben hat", () => {
const spalten = ["hostname", "label", "type"];
const zeilen = [
{ hostname: "pc-1", label: 'Regal, "Keller"', type: "PC" },
{ hostname: "", label: "oben\nunten", type: "RAUM" },
{ hostname: " Rand ", label: "a;b,c", type: "" },
];
for (const trennzeichen of [",", ";"] as const) {
const text = csvErzeugen(spalten, zeilen, { trennzeichen });
// Der Rundlauf liest eine **selbst erzeugte** Datei – dort sind Umbrueche
// in Werten gewollt und korrekt maskiert.
const tabelle = csvZerlegen(text, { werteUeberZeilen: true });
expect(tabelle.trennzeichen, trennzeichen).toBe(trennzeichen);
expect(tabelle.zeilen.map((zeile) => zeile.werte)).toEqual(
zeilen.map((zeile) => spalten.map((spalte) => zeile[spalte as keyof typeof zeile] ?? "")),
);
}
});
});
+198
View File
@@ -0,0 +1,198 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Die Geraeteliste durch die Oberflaeche (#011).
*
* Diese Datei gibt es aus demselben Grund wie tests/e2e/vorlagen-editor.spec.ts:
* ein Screenshot sagt in dieser Umgebung **nichts** ueber Bedienbarkeit, weil
* `next dev` nicht hydriert (siehe CLAUDE.md, „Oberflaeche pruefen“). Geprueft
* wird deshalb, was nur mit angehaengtem React gelingt — anlegen, suchen,
* einlesen, loeschen — und anschliessend, ob es wirklich in der Datenbank steht:
* durch Neuladen.
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt.
*
* Der Ablauf raeumt hinter sich auf: an den angelegten Geraeten haengt kein Lauf,
* sie lassen sich also loeschen. Ihre Tag-Codes bleiben vergeben, und genau das
* ist die Zusage aus #011 — ein Aufkleber zeigt nie auf ein zweites Geraet.
*/
const MARKE = `ZZ-E2E-${Date.now()}`;
async function anmelden(seite: Page) {
const email = process.env["SEED_ADMIN_EMAIL"];
const passwort = process.env["SEED_ADMIN_PASSWORD"];
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
await seite.goto("/anmelden");
await seite.fill('input[name="email"]', email);
await seite.fill('input[name="passwort"]', passwort);
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
}
/** Der Abschnitt „Gerät anlegen“ – eindeutig ueber seinen eigenen Knopf. */
function anlegenFormular(seite: Page) {
return seite.locator("section", { has: seite.getByRole("button", { name: "Gerät anlegen" }) });
}
function karte(seite: Page, name: string) {
return seite.locator("li").filter({ hasText: name });
}
/** Loescht alle Geraete, deren Name mit der Marke dieses Durchlaufs beginnt. */
async function aufraeumen(seite: Page) {
await seite.goto(`/geraete?suche=${MARKE}`);
for (let versuch = 0; versuch < 10; versuch += 1) {
const karten = seite.locator("li").filter({ hasText: MARKE });
if ((await karten.count()) === 0) return;
const erste = karten.first();
await erste.getByRole("button", { name: "löschen", exact: true }).click();
await erste.getByRole("button", { name: "ja, löschen" }).click();
await expect(karten).toHaveCount(await karten.count().then((anzahl) => anzahl - 1));
}
}
test.describe("Geräte", () => {
test("legt ein Gerät an, findet es in der Liste und sucht es", async ({ page }) => {
test.slow();
const name = `${MARKE}-PC-01`;
await anmelden(page);
await page.goto("/geraete");
// Der Reiter fuehrt zwischen Personen und Geraeten hin und her.
await expect(page.getByRole("link", { name: "Geräte", exact: true })).toHaveAttribute(
"aria-current",
"page",
);
const formular = anlegenFormular(page);
await formular.getByLabel("Hostname").fill(name);
await formular.getByLabel("Seriennummer").fill(`${MARKE}-SN`);
await formular.getByLabel("Typ").selectOption("NOTEBOOK");
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
// Der Tag-Code steht in der Meldung – er kommt gleich auf den Aufkleber.
const meldung = formular.getByRole("status");
await expect(meldung).toContainText(/wurde angelegt, Tag-Code LZ-[A-Z2-9]{6}\./);
const tagCode = (await meldung.textContent())?.match(/LZ-[A-Z2-9]{6}/)?.[0];
expect(tagCode).toBeTruthy();
// Der Beweis, dass es nicht nur im Browser steht: neu laden.
await page.goto("/geraete");
await expect(karte(page, name)).toContainText("Notebook");
await expect(karte(page, name)).toContainText(tagCode!);
// Suche über den Hostname …
await page.locator('input[name="suche"]').fill(name);
await page.getByRole("button", { name: "Filtern" }).click();
await expect(page.locator("ul > li").filter({ hasText: MARKE })).toHaveCount(1);
// … und über den abgetippten Tag-Code ohne Präfix.
await page.locator('input[name="suche"]').fill(tagCode!.slice(3));
await page.getByRole("button", { name: "Filtern" }).click();
await expect(karte(page, name)).toBeVisible();
// Ein Suchbegriff, der zu nichts passt, sagt das auch.
await page.locator('input[name="suche"]').fill(`${MARKE}-gibt-es-nicht`);
await page.getByRole("button", { name: "Filtern" }).click();
await expect(page.getByText("Kein Gerät passt zu diesen Filtern.")).toBeVisible();
await aufraeumen(page);
});
test("ändert Stammdaten und archiviert statt zu löschen", async ({ page }) => {
test.slow();
const name = `${MARKE}-PC-02`;
await anmelden(page);
await page.goto("/geraete");
const formular = anlegenFormular(page);
await formular.getByLabel("Hostname").fill(name);
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
await expect(formular.getByRole("status")).toContainText("wurde angelegt");
await page.goto(`/geraete?suche=${MARKE}`);
const meine = karte(page, name);
await meine.getByText("Stammdaten bearbeiten").click();
await meine.getByLabel("Seriennummer").fill(`${MARKE}-SN-NEU`);
await meine.getByRole("button", { name: "Speichern" }).click();
await expect(meine.getByRole("status")).toContainText("Gespeichert.");
// Und wieder der Beweis, dass es in der Datenbank steht.
await page.reload();
await expect(karte(page, name)).toContainText(`S/N ${MARKE}-SN-NEU`);
// Archivieren nimmt das Gerät aus der Liste, ohne es zu entfernen.
await karte(page, name).getByRole("button", { name: "archivieren" }).click();
await expect(karte(page, name)).toHaveCount(0);
await page.goto(`/geraete?suche=${MARKE}&archiviert=1`);
await expect(karte(page, name)).toContainText("archiviert");
await karte(page, name).getByRole("button", { name: "wieder freigeben" }).click();
await expect(karte(page, name).getByRole("button", { name: "archivieren" })).toBeVisible();
await aufraeumen(page);
});
test("liest eine CSV-Datei ein und meldet die kaputte Zeile einzeln", async ({ page }) => {
test.slow();
await anmelden(page);
await page.goto("/geraete");
const bereich = page.locator("details", { hasText: "Geräte aus einer CSV-Datei einlesen" });
await bereich.getByText("Geräte aus einer CSV-Datei einlesen").click();
await bereich
.locator('textarea[name="inhalt"]')
.fill(
[
"hostname;label;serialNumber;inventoryNumber;type;model",
`${MARKE}-CSV-GUT;;SN-GUT;INV-1;PC;OptiPlex`,
`${MARKE}-CSV-BOESE;;SN-BOESE;INV-2;Tablet;`,
].join("\r\n"),
);
await bereich.getByRole("button", { name: "Einlesen" }).click();
await expect(bereich.getByRole("status")).toContainText("1 von 2 Zeilen angelegt, 1 Zeile abgewiesen.");
// Mit Zeilennummer, damit die Datei an genau dieser Stelle zu ändern ist.
await expect(bereich.getByText("Zeile 3")).toBeVisible();
await expect(bereich.getByText(/kein bekannter Gerätetyp/)).toBeVisible();
// Die gute Zeile steht in der Datenbank, die kaputte nicht.
await page.goto(`/geraete?suche=${MARKE}-CSV`);
await expect(karte(page, `${MARKE}-CSV-GUT`)).toBeVisible();
await expect(page.locator("ul > li").filter({ hasText: MARKE })).toHaveCount(1);
await aufraeumen(page);
});
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile, viewport }) => {
test.skip(!isMobile, "Nur im Projekt „handy“.");
test.slow();
const name = `${MARKE}-RAUM`;
await anmelden(page);
await page.goto("/geraete");
const formular = anlegenFormular(page);
await formular.getByLabel("Bezeichnung").fill(name);
await formular.getByLabel("Typ").selectOption("RAUM");
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
await expect(formular.getByRole("status")).toContainText("wurde angelegt");
await page.goto(`/geraete?suche=${MARKE}`);
await expect(karte(page, name)).toBeVisible();
const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
}));
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
expect(viewport?.width).toBeLessThanOrEqual(430);
// Die Bedienelemente sind am Handy mindestens 44 px hoch.
const knopf = karte(page, name).getByRole("button", { name: "archivieren" });
const kasten = await knopf.boundingBox();
expect(kasten?.height ?? 0).toBeGreaterThanOrEqual(44);
await aufraeumen(page);
});
});
+133
View File
@@ -0,0 +1,133 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
/**
* Personen und Abteilungen durch die Oberflaeche (#011).
*
* Geprueft wird, was nur mit angehaengtem React gelingt — und danach, ob es
* wirklich in der Datenbank steht: durch Neuladen. Ein Screenshot sagt darueber
* nichts (siehe CLAUDE.md, „Oberflaeche pruefen“).
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`).
*
* Aufgeraeumt wird am Ende ueber die Datenbank und nicht ueber die Oberflaeche:
* die Anwendung kennt bewusst keinen Loeschweg fuer Personen und Abteilungen
* (deaktivieren und archivieren sind das Gegenteil von Aufraeumen). Ohne diesen
* Nachlauf saehe jede Datenbank, gegen die diese Tests laufen, nach ein paar
* Durchlaeufen aus wie ein Messie-Keller — in der Entwicklungsdatenbank faellt
* das sofort auf den Screenshots auf.
*/
const STEMPEL = Date.now();
async function anmelden(seite: Page) {
const email = process.env["SEED_ADMIN_EMAIL"];
const passwort = process.env["SEED_ADMIN_PASSWORD"];
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
await seite.goto("/anmelden");
await seite.fill('input[name="email"]', email);
await seite.fill('input[name="passwort"]', passwort);
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
}
test.describe("Personen", () => {
test("legt an, findet über die Suche und deaktiviert", async ({ page }) => {
test.slow();
const nachname = `ZZPruef${STEMPEL}`;
await anmelden(page);
await page.goto("/mitarbeiter");
// `summary` ist in Chromium kein `button`, sondern ein Aufklapp-Element.
await page.locator("summary", { hasText: "Person von Hand anlegen" }).click();
await page.getByLabel("Vorname").fill("Testa");
await page.getByLabel("Nachname").fill(nachname);
await page.getByLabel("Mail-Adresse (optional)").fill(`testa.${STEMPEL}@firma.de`);
await page.getByRole("button", { name: "Person anlegen" }).click();
await expect(page.getByText(`„Testa ${nachname}“ wurde angelegt.`)).toBeVisible();
// Über die Suche finden – das ist der Weg, den ein Techniker geht.
await page.goto(`/mitarbeiter?suche=${nachname}`);
const treffer = page.getByRole("link", { name: new RegExp(nachname) });
await expect(treffer).toHaveCount(1);
await treffer.click();
await page.waitForURL(/\/mitarbeiter\/[^/]+$/);
// Deaktivieren – der einzige Weg hinaus, geloescht wird nichts.
await page.getByRole("button", { name: "deaktivieren" }).click();
await expect(page.getByText("deaktiviert")).toBeVisible();
// Und damit aus der Standardliste verschwinden, aber nicht aus der Welt.
await page.goto(`/mitarbeiter?suche=${nachname}`);
await expect(page.getByRole("link", { name: new RegExp(nachname) })).toHaveCount(0);
await page.goto(`/mitarbeiter?suche=${nachname}&deaktivierte=1`);
await expect(page.getByRole("link", { name: new RegExp(nachname) })).toHaveCount(1);
});
test("sperrt die Stammdaten einer Person aus dem Verzeichnis", async ({ page }) => {
// Vorausgesetzt wird die Beispielperson aus `pnpm beispiel:stammdaten`.
test.slow();
await anmelden(page);
await page.goto("/mitarbeiter?suche=kwinter");
const treffer = page.getByRole("link", { name: /Katrin Winter/ });
test.skip((await treffer.count()) === 0, "Beispiel-Stammdaten fehlen (pnpm beispiel:stammdaten).");
await treffer.click();
await page.waitForURL(/\/mitarbeiter\/[^/]+$/);
await expect(page.getByText(/kommt aus dem Verzeichnis/)).toBeVisible();
await expect(page.getByLabel("Vorname")).toHaveAttribute("readonly", "");
await expect(page.getByRole("button", { name: "Stammdaten speichern" })).toHaveCount(0);
// Deaktivieren muss trotzdem gehen: das Offboarding wartet nicht auf den
// naechsten Abgleich.
await expect(page.getByRole("button", { name: "deaktivieren" })).toBeVisible();
});
});
test.describe("Abteilungen", () => {
test("legt eine Abteilung mit Ziel-OU und Gruppen an", async ({ page }) => {
test.slow();
const name = `ZZ Abteilung ${STEMPEL}`;
await anmelden(page);
await page.goto("/admin/abteilungen");
const formular = page.locator("section", { hasText: "Abteilung anlegen" });
await formular.getByLabel("Name").fill(name);
await formular.getByLabel("Ziel-OU (optional)").fill("OU=Test,DC=firma,DC=de");
await formular.getByLabel("Standardgruppen, mit Komma getrennt").fill("G-Test, G-Alle");
await formular.getByRole("button", { name: "Abteilung anlegen" }).click();
await expect(page.getByText(`„${name}“ wurde angelegt.`)).toBeVisible();
await page.reload();
const eintrag = page.locator("li", { hasText: name }).first();
await expect(eintrag).toContainText("OU=Test,DC=firma,DC=de");
await expect(eintrag).toContainText("G-Test, G-Alle");
// Denselben Namen ein zweites Mal – mit lesbarer Meldung, nicht mit einem
// Datenbankfehler.
await formular.getByLabel("Name").fill(name);
await formular.getByRole("button", { name: "Abteilung anlegen" }).click();
await expect(page.getByText(/gibt es schon/)).toBeVisible();
// Aufräumen: archivieren, denn geloescht wird nichts.
await eintrag.getByRole("button", { name: "archivieren" }).click();
await expect(eintrag.getByRole("button", { name: "wieder freigeben" })).toBeVisible();
});
});
test.afterAll(async () => {
const personen = await prisma.mitarbeiter.findMany({
where: { nachname: { startsWith: "ZZPruef" } },
select: { id: true },
});
const abteilungen = await prisma.abteilung.findMany({
where: { name: { startsWith: "ZZ Abteilung" } },
select: { id: true },
});
const kennungen = [...personen, ...abteilungen].map((zeile) => zeile.id);
if (kennungen.length > 0) {
await prisma.protokoll.deleteMany({ where: { entitaetId: { in: kennungen } } });
await prisma.mitarbeiter.deleteMany({ where: { id: { in: personen.map((z) => z.id) } } });
await prisma.abteilung.deleteMany({ where: { id: { in: abteilungen.map((z) => z.id) } } });
}
await prisma.$disconnect();
});
+559
View File
@@ -0,0 +1,559 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, GeraeteTyp, Rolle } from "@/generated/prisma/enums";
import { istTagCode } from "@/lib/tagcode";
import {
GERAETE_IMPORT_HOECHSTZEILEN,
GeraeteFehler,
geraeteImportieren,
geraetAnlegen,
geraetArchivieren,
geraetBearbeiten,
geraetLoeschen,
} from "@/server/services/geraete";
import { besitzerKandidaten, geraetHolen, geraeteListe } from "@/server/queries/geraete";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Geräte, Tag-Codes und der CSV-Import (#011).
*
* Festgehalten werden die Zusagen aus dem Auftrag: ein registriertes Gerät hat
* einen Namen, ein `tagCode` ändert sich nie und kommt nie zurück, an einem
* Gerät mit Läufen wird nichts gelöscht, und eine kaputte CSV-Zeile bricht den
* Import nicht ab. Dazu die Mandantensicht des Lesemodells — im Review von #009
* war genau dort ein Suchfilter, der die Kundenbedingung überschrieben hat.
*/
async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus } });
const benutzer = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Administrator", rolle: Rolle.ADMIN },
});
return { eigene, admin: { id: benutzer.id, rolle: Rolle.ADMIN } };
}
/** Ein Lauf an einem Gerät – mehr braucht es dafür nicht als eine veröffentlichte Version. */
async function laufAnlegen(kundeId: string, geraetId: string) {
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC-Neuinstallation" } });
const version = await testPrisma.vorlagenVersion.create({
data: {
vorlageId: vorlage.id,
version: 1,
status: "VEROEFFENTLICHT",
veroeffentlichtAm: new Date(),
},
});
return testPrisma.lauf.create({
data: { vorlagenVersionId: version.id, titel: "Neuinstallation", kundeId, geraetId },
});
}
describe.runIf(datenbankVerfuegbar)("Geräte", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
describe("Anlegen", () => {
it("erzeugt einen Tag-Code und schreibt ihn ins Vergabebuch", async () => {
const { admin, eigene } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-VERTRIEB-07", typ: GeraeteTyp.PC }, admin);
expect(istTagCode(geraet.tagCode)).toBe(true);
expect(geraet.kundeId).toBe(eigene.id);
expect(geraet.registriertAm).not.toBeNull();
// Beides in einer Transaktion: ein Code ohne Eintrag wäre nach dem Löschen
// des Geräts wieder frei.
await expect(
testPrisma.tagCodeVergabe.findUnique({ where: { tagCode: geraet.tagCode } }),
).resolves.not.toBeNull();
});
it("lehnt ein registriertes Gerät ohne Hostname und ohne Bezeichnung ab", async () => {
const { admin } = await grundbestand();
await expect(geraetAnlegen({ seriennummer: "SN-1" }, admin)).rejects.toThrow(GeraeteFehler);
await expect(geraetAnlegen({ hostname: " ", bezeichnung: "" }, admin)).rejects.toThrow(
/Hostname oder eine Bezeichnung/,
);
// Und es ist auch kein Tag-Code verbraucht worden.
expect(await testPrisma.tagCodeVergabe.count()).toBe(0);
expect(await testPrisma.geraet.count()).toBe(0);
});
it("erlaubt einen vorgedruckten Tag ohne jeden Namen", async () => {
const { admin } = await grundbestand();
const tag = await geraetAnlegen({ registrieren: false }, admin);
expect(tag.registriertAm).toBeNull();
expect(tag.hostname).toBeNull();
expect(tag.bezeichnung).toBeNull();
});
it("nimmt einen Besitzer nur, wenn er zum selben Kunden gehört", async () => {
const { admin, eigene } = await grundbestand();
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const eigenePerson = await testPrisma.mitarbeiter.create({
data: { kundeId: eigene.id, vorname: "Ada", nachname: "Lovelace", anzeigename: "Ada Lovelace" },
});
const fremdePerson = await testPrisma.mitarbeiter.create({
data: { kundeId: fremder.id, vorname: "Max", nachname: "Fremd", anzeigename: "Max Fremd" },
});
const geraet = await geraetAnlegen(
{ hostname: "NB-ADA", typ: GeraeteTyp.NOTEBOOK, mitarbeiterId: eigenePerson.id },
admin,
);
expect(geraet.mitarbeiterId).toBe(eigenePerson.id);
await expect(
geraetAnlegen({ hostname: "NB-MAX", mitarbeiterId: fremdePerson.id }, admin),
).rejects.toThrow(/anderen Kunden/);
});
it("protokolliert das Anlegen", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ bezeichnung: "Serverschrank Keller" }, admin);
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaet: "Geraet", entitaetId: geraet.id },
});
expect(eintraege).toHaveLength(1);
expect(eintraege[0]?.aktion).toBe("Gerät angelegt");
expect(eintraege[0]?.nachher).toMatchObject({ tagCode: geraet.tagCode });
});
});
describe("Bearbeiten", () => {
it("weist jeden Änderungsversuch am Tag-Code ab", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-1" }, admin);
await expect(
geraetBearbeiten(geraet.id, { tagCode: "LZ-ZZZZZZ", hostname: "PC-2" }, admin),
).rejects.toThrow(/bleibt, wie er ist/);
// Nichts ist passiert – auch nicht der Hostname, der im selben Aufruf stand.
const frisch = await testPrisma.geraet.findUniqueOrThrow({ where: { id: geraet.id } });
expect(frisch.tagCode).toBe(geraet.tagCode);
expect(frisch.hostname).toBe("PC-1");
});
it("lässt den unveränderten Tag-Code mitkommen, ohne sich zu beschweren", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-1" }, admin);
const nachher = await geraetBearbeiten(geraet.id, { tagCode: geraet.tagCode, hostname: "PC-2" }, admin);
expect(nachher.hostname).toBe("PC-2");
});
it("lässt den Hostname nicht leeren, wenn dann kein Name übrig bleibt", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-1" }, admin);
await expect(geraetBearbeiten(geraet.id, { hostname: "" }, admin)).rejects.toThrow(
/Hostname oder eine Bezeichnung/,
);
// Mit einer Bezeichnung als Ersatz geht es.
const nachher = await geraetBearbeiten(
geraet.id,
{ hostname: "", bezeichnung: "Kiste im Regal" },
admin,
);
expect(nachher.hostname).toBeNull();
expect(nachher.bezeichnung).toBe("Kiste im Regal");
});
it("protokolliert nur, was sich geändert hat", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-1", seriennummer: "SN-1" }, admin);
await geraetBearbeiten(geraet.id, { hostname: "PC-2", seriennummer: "SN-1" }, admin);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaet: "Geraet", entitaetId: geraet.id, aktion: { contains: "geändert" } },
});
expect(eintrag.aktion).toBe("Hostname geändert");
expect(eintrag.vorher).toMatchObject({ Hostname: "PC-1" });
expect(eintrag.nachher).toMatchObject({ Hostname: "PC-2" });
expect(eintrag.nachher).not.toHaveProperty("Seriennummer");
});
it("schreibt gar nichts, wenn sich nichts geändert hat", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-1" }, admin);
await geraetBearbeiten(geraet.id, { hostname: "PC-1" }, admin);
expect(await testPrisma.protokoll.count({ where: { aktion: { contains: "geändert" } } })).toBe(0);
});
it("registriert einen vorgedruckten Tag, sobald er einen Namen bekommt", async () => {
const { admin } = await grundbestand();
const tag = await geraetAnlegen({ registrieren: false }, admin);
await expect(geraetBearbeiten(tag.id, { registrieren: true }, admin)).rejects.toThrow(
/Hostname oder eine Bezeichnung/,
);
const nachher = await geraetBearbeiten(tag.id, { registrieren: true, hostname: "PC-NEU" }, admin);
expect(nachher.registriertAm).not.toBeNull();
expect(nachher.tagCode).toBe(tag.tagCode);
});
});
describe("Archivieren und Löschen", () => {
it("lässt ein Gerät mit Läufen nicht löschen, nur archivieren", async () => {
const { admin, eigene } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-1" }, admin);
await laufAnlegen(eigene.id, geraet.id);
await expect(geraetLoeschen(geraet.id, admin)).rejects.toThrow(/nur archivieren/);
expect(await testPrisma.geraet.count()).toBe(1);
await geraetArchivieren(geraet.id, true, admin);
const frisch = await testPrisma.geraet.findUniqueOrThrow({ where: { id: geraet.id } });
expect(frisch.archiviert).toBe(true);
// Und zurück.
await geraetArchivieren(geraet.id, false, admin);
await expect(testPrisma.geraet.findUniqueOrThrow({ where: { id: geraet.id } })).resolves.toMatchObject({
archiviert: false,
});
});
it("gibt einen Tag-Code auch nach dem Löschen nicht zurück", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-WEG" }, admin);
await geraetLoeschen(geraet.id, admin);
expect(await testPrisma.geraet.count()).toBe(0);
// Das Vergabebuch behält ihn: ein übrig gebliebener Aufkleber darf nie auf
// ein zweites Objekt zeigen.
await expect(
testPrisma.tagCodeVergabe.findUnique({ where: { tagCode: geraet.tagCode } }),
).resolves.not.toBeNull();
});
it("protokolliert das Löschen, und der Eintrag überlebt das Gerät", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-WEG" }, admin);
await geraetLoeschen(geraet.id, admin);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaetId: geraet.id, aktion: "Gerät gelöscht" },
});
expect(eintrag.vorher).toMatchObject({ tagCode: geraet.tagCode, hostname: "PC-WEG" });
});
});
describe("CSV-Import", () => {
it("verliert keine Zeile an einem kaputten Anführungszeichen", async () => {
/*
* Der Blocker aus dem Review: ein Anführungszeichen, das in einer Zelle
* beginnt und nicht schliesst, verschluckte alle Folgezeilen bis zum
* naechsten `"`. Der Import meldete dafuer **Erfolg** — drei von drei
* Zeilen angelegt —, und in einer Bezeichnung stand der Text der
* verschluckten Zeilen. Jetzt endet der Schaden an seiner Zeile.
*/
const { admin } = await grundbestand();
const text = [
"hostname,label,serialNumber",
"PC-1,,SN-1",
'PC-2,"Regal,SN-2',
"PC-3,,SN-3",
'PC-4,"Kiste",SN-4',
"PC-5,,SN-5",
].join("\n");
const ergebnis = await geraeteImportieren(text, admin);
expect(ergebnis.angelegt).toBe(4);
expect(ergebnis.abgewiesen).toHaveLength(1);
expect(ergebnis.abgewiesen[0]?.zeile).toBe(3);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/Anführungszeichen/);
const namen = (await testPrisma.geraet.findMany({ select: { hostname: true } }))
.map((zeile) => zeile.hostname)
.sort();
expect(namen).toEqual(["PC-1", "PC-3", "PC-4", "PC-5"]);
});
it("weist eine doppelte Zeile ab, statt den Bestand zu verdoppeln", async () => {
// Dieselbe Datei zweimal eingelesen verdoppelte sonst alles – und weil ein
// tagCode nie zurueckkommt, waeren die Aufkleber dafuer schon gedruckt.
const { admin } = await grundbestand();
const text = ["hostname", "PC-DOPPELT", "PC-DOPPELT", "PC-EINMAL"].join("\n");
const erster = await geraeteImportieren(text, admin);
expect(erster.angelegt).toBe(2);
expect(erster.abgewiesen[0]?.grund).toMatch(/schon weiter oben/);
const zweiter = await geraeteImportieren(text, admin);
expect(zweiter.angelegt).toBe(0);
expect(zweiter.abgewiesen).toHaveLength(3);
expect(zweiter.abgewiesen[0]?.grund).toMatch(/gibt es schon/);
expect(await testPrisma.geraet.count()).toBe(2);
});
const KOPF = "hostname,label,serialNumber,inventoryNumber,type,model";
it("legt die guten Zeilen an und meldet die kaputten einzeln", async () => {
const { admin } = await grundbestand();
const text = [
KOPF,
"PC-BUERO-01,,SN-1,INV-1,PC,OptiPlex",
",Serverschrank Keller,,,SONSTIGES,",
"PC-KAPUTT,,SN-3,,Tablet,", // unbekannter Typ
",,SN-4,,PC,", // weder Hostname noch Bezeichnung
"NB-05,,SN-5,,notebook,Latitude", // Schreibweise unerheblich
].join("\r\n");
const ergebnis = await geraeteImportieren(text, admin);
expect(ergebnis.gesamt).toBe(5);
expect(ergebnis.angelegt).toBe(3);
// Zeilennummern wie in der Datei: Kopfzeile ist 1.
expect(ergebnis.abgewiesen.map((a) => a.zeile)).toEqual([4, 5]);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/kein bekannter Gerätetyp/);
expect(ergebnis.abgewiesen[1]?.grund).toMatch(/Weder ein Hostname noch eine Bezeichnung/);
const geraete = await testPrisma.geraet.findMany({ orderBy: { tagCode: "asc" } });
expect(geraete.map((g) => g.hostname ?? g.bezeichnung).sort()).toEqual([
"NB-05",
"PC-BUERO-01",
"Serverschrank Keller",
]);
expect(geraete.find((g) => g.hostname === "NB-05")?.typ).toBe(GeraeteTyp.NOTEBOOK);
});
it("erzeugt je Zeile einen eigenen Tag-Code und trägt jeden ein", async () => {
const { admin } = await grundbestand();
const text = [KOPF, "PC-1,,,,PC,", "PC-2,,,,PC,", "PC-3,,,,PC,"].join("\n");
await geraeteImportieren(text, admin);
const codes = (await testPrisma.geraet.findMany({ select: { tagCode: true } })).map((g) => g.tagCode);
expect(codes).toHaveLength(3);
expect(new Set(codes).size).toBe(3);
expect(codes.every(istTagCode)).toBe(true);
expect(await testPrisma.tagCodeVergabe.count()).toBe(3);
});
it("versteht deutsches Excel: Semikolon, BOM und deutsche Spaltennamen", async () => {
const { admin } = await grundbestand();
const text =
"Hostname;Bezeichnung;Seriennummer;Inventarnummer;Typ;Modell\r\n" +
'PC-EXCEL;;"SN;mit;Semikolon";INV-9;Drucker;"Kyocera ""P3260"""\r\n';
const ergebnis = await geraeteImportieren(text, admin);
expect(ergebnis.angelegt).toBe(1);
const geraet = await testPrisma.geraet.findFirstOrThrow();
expect(geraet.seriennummer).toBe("SN;mit;Semikolon");
expect(geraet.modell).toBe('Kyocera "P3260"');
expect(geraet.typ).toBe(GeraeteTyp.DRUCKER);
});
it("setzt ohne Typspalte den Standard, ohne etwas zu erfinden", async () => {
const { admin } = await grundbestand();
const ergebnis = await geraeteImportieren("hostname\nPC-OHNE-TYP\n", admin);
expect(ergebnis.angelegt).toBe(1);
await expect(testPrisma.geraet.findFirstOrThrow()).resolves.toMatchObject({
typ: GeraeteTyp.SONSTIGES,
});
});
it("weist eine Datei ohne Namensspalte als Ganzes ab", async () => {
const { admin } = await grundbestand();
await expect(geraeteImportieren("serialNumber,type\nSN-1,PC\n", admin)).rejects.toThrow(/hostname/);
expect(await testPrisma.geraet.count()).toBe(0);
});
it("bricht bei zu vielen Zeilen ab, bevor eine einzige geschrieben ist", async () => {
const { admin } = await grundbestand();
const zeilen = Array.from({ length: GERAETE_IMPORT_HOECHSTZEILEN + 1 }, (_, i) => `PC-${i},,,,PC,`);
await expect(geraeteImportieren([KOPF, ...zeilen].join("\n"), admin)).rejects.toThrow(
new RegExp(`mehr als ${GERAETE_IMPORT_HOECHSTZEILEN} Zeilen`),
);
expect(await testPrisma.geraet.count()).toBe(0);
});
it("protokolliert jede eingelesene Zeile mit ihrer Nummer", async () => {
const { admin } = await grundbestand();
await geraeteImportieren([KOPF, "PC-1,,,,PC,"].join("\n"), admin);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { aktion: "Gerät aus CSV angelegt" },
});
expect(eintrag.nachher).toMatchObject({ hostname: "PC-1", zeile: 2 });
});
});
describe("Lesemodell und Filter", () => {
it("findet über Hostname, Bezeichnung, Seriennummer und Tag-Code", async () => {
const { admin } = await grundbestand();
const pc = await geraetAnlegen(
{ hostname: "PC-BUERO-01", seriennummer: "SN-4711", typ: GeraeteTyp.PC },
admin,
);
await geraetAnlegen({ bezeichnung: "Serverschrank Keller", typ: GeraeteTyp.SERVER }, admin);
const namen = async (suche: string) =>
(await geraeteListe(admin, { suche })).map((zeile) => zeile.anzeige);
expect(await namen("buero")).toEqual(["PC-BUERO-01"]);
expect(await namen("keller")).toEqual(["Serverschrank Keller"]);
expect(await namen("4711")).toEqual(["PC-BUERO-01"]);
// Die sechs Zeichen ohne das Präfix genügen – abgetippt vom Aufkleber.
expect(await namen(pc.tagCode.slice(3))).toEqual(["PC-BUERO-01"]);
});
it("filtert nach Typ und zeigt Archivierte nur auf Wunsch", async () => {
const { admin } = await grundbestand();
await geraetAnlegen({ hostname: "PC-1", typ: GeraeteTyp.PC }, admin);
const drucker = await geraetAnlegen({ bezeichnung: "Drucker Flur", typ: GeraeteTyp.DRUCKER }, admin);
await geraetArchivieren(drucker.id, true, admin);
expect((await geraeteListe(admin)).map((z) => z.anzeige)).toEqual(["PC-1"]);
expect((await geraeteListe(admin, { auchArchivierte: true })).map((z) => z.anzeige)).toEqual([
"PC-1",
"Drucker Flur",
]);
expect(
(await geraeteListe(admin, { typ: GeraeteTyp.DRUCKER, auchArchivierte: true })).map((z) => z.anzeige),
).toEqual(["Drucker Flur"]);
expect(await geraeteListe(admin, { typ: GeraeteTyp.FIREWALL })).toEqual([]);
});
it("zeigt Besitzer und Anzahl der Läufe", async () => {
const { admin, eigene } = await grundbestand();
const person = await testPrisma.mitarbeiter.create({
data: { kundeId: eigene.id, vorname: "Ada", nachname: "Lovelace", anzeigename: "Ada Lovelace" },
});
const geraet = await geraetAnlegen({ hostname: "NB-ADA", mitarbeiterId: person.id }, admin);
await laufAnlegen(eigene.id, geraet.id);
const zeile = await geraetHolen(geraet.id, admin);
expect(zeile?.besitzerName).toBe("Ada Lovelace");
expect(zeile?.besitzerAktiv).toBe(true);
expect(zeile?.anzahlLaeufe).toBe(1);
});
});
/**
* Die Mandantensicht. Der wichtigste Test dieser Datei: ein Suchbegriff darf
* die Kundenbedingung nicht aushebeln. Vor #009 tat er das, weil zwei `OR` im
* selben Objektliteral standen und das zweite gewann.
*/
describe("Mandantensicht", () => {
async function zweiKunden() {
const { eigene } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: Rolle.TECHNIKER },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: meiner.id },
});
await testPrisma.geraet.create({
data: {
kundeId: meiner.id,
tagCode: "LZ-AAAAAA",
hostname: "GEHEIM-MEIN",
registriertAm: new Date(),
},
});
await testPrisma.geraet.create({
data: {
kundeId: fremder.id,
tagCode: "LZ-BBBBBB",
hostname: "GEHEIM-FREMD",
registriertAm: new Date(),
},
});
return { eigene, meiner, fremder, techniker: { id: techniker.id, rolle: Rolle.TECHNIKER } };
}
it("zeigt einem gebundenen Techniker nur die Geräte seiner Kunden", async () => {
const { techniker } = await zweiKunden();
expect((await geraeteListe(techniker)).map((z) => z.anzeige)).toEqual(["GEHEIM-MEIN"]);
});
it("lässt sich die Kundengrenze nicht durch einen Suchbegriff aushebeln", async () => {
const { techniker } = await zweiKunden();
// Genau der Fall aus dem Review: die Suche bringt ein eigenes `OR` mit.
expect(await geraeteListe(techniker, { suche: "GEHEIM-FREMD" })).toEqual([]);
expect(await geraeteListe(techniker, { suche: "GEHEIM" })).toHaveLength(1);
expect(await geraeteListe(techniker, { suche: "LZ-BBBBBB" })).toEqual([]);
// Und auch nicht mit Typ- und Archivfilter zusammen.
expect(
await geraeteListe(techniker, { suche: "GEHEIM", auchArchivierte: true, typ: GeraeteTyp.SONSTIGES }),
).toHaveLength(1);
});
it("gibt ein fremdes Gerät auch beim Einzelabruf nicht heraus", async () => {
const { techniker, fremder } = await zweiKunden();
const fremdesGeraet = await testPrisma.geraet.findFirstOrThrow({
where: { kundeId: fremder.id },
});
expect(await geraetHolen(fremdesGeraet.id, techniker)).toBeUndefined();
});
it("schlägt als Besitzer nur aktive Personen sichtbarer Kunden vor", async () => {
const { techniker, meiner, fremder } = await zweiKunden();
await testPrisma.mitarbeiter.createMany({
data: [
{ kundeId: meiner.id, vorname: "A", nachname: "Aktiv", anzeigename: "Anna Aktiv" },
{
kundeId: meiner.id,
vorname: "D",
nachname: "Weg",
anzeigename: "Dieter Weg",
aktiv: false,
},
{ kundeId: fremder.id, vorname: "F", nachname: "Fremd", anzeigename: "Frida Fremd" },
],
});
const auswahl = await besitzerKandidaten(techniker);
expect(auswahl.kandidaten.map((k) => k.anzeigename)).toEqual(["Anna Aktiv"]);
expect(auswahl.gekuerzt).toBe(false);
});
it("nennt bei jedem Kandidaten den Kunden, damit die Auswahl ihn filtern kann", async () => {
/*
* Der Befund aus dem Review: die Seite holt die Kandidaten **einmal** und
* gab sie in jede Karte. Ein Administrator sieht alle Kunden — die
* Auswahlliste bot damit Personen an, die der Dienst danach abweist, weil
* ein Geraet nur einer Person desselben Kunden gehoeren kann. Ohne
* `kundeId` am Kandidaten laesst sich das in der Oberflaeche nicht
* verhindern.
*/
const { meiner, fremder } = await zweiKunden();
const admin = await testPrisma.benutzer.create({
data: { email: "a2@firma.example", name: "Admin", rolle: Rolle.ADMIN },
});
await testPrisma.mitarbeiter.createMany({
data: [
{ kundeId: meiner.id, vorname: "A", nachname: "A", anzeigename: "Anna A" },
{ kundeId: fremder.id, vorname: "B", nachname: "B", anzeigename: "Bert B" },
],
});
const auswahl = await besitzerKandidaten({ id: admin.id, rolle: admin.rolle });
expect(auswahl.kandidaten.map((k) => k.anzeigename)).toEqual(["Anna A", "Bert B"]);
expect(auswahl.kandidaten.map((k) => k.kundeId).sort()).toEqual([meiner.id, fremder.id].sort());
// Was die Oberflaeche daraus macht: nur die des eigenen Kunden.
expect(auswahl.kandidaten.filter((k) => k.kundeId === meiner.id).map((k) => k.anzeigename)).toEqual([
"Anna A",
]);
});
it("sagt, wenn die Besitzerauswahl gekürzt wurde", async () => {
const { techniker, meiner } = await zweiKunden();
await testPrisma.mitarbeiter.createMany({
data: Array.from({ length: 3 }, (_, i) => ({
kundeId: meiner.id,
vorname: "P",
nachname: `Nummer ${i}`,
anzeigename: `Person ${i}`,
})),
});
const auswahl = await besitzerKandidaten(techniker, 2);
expect(auswahl.kandidaten).toHaveLength(2);
expect(auswahl.gekuerzt).toBe(true);
});
});
});
+409
View File
@@ -0,0 +1,409 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import {
ABGEGLICHENE_FELDER,
MitarbeiterFehler,
mitarbeiterAktivSetzen,
mitarbeiterAnlegen,
mitarbeiterBearbeiten,
} from "@/server/services/mitarbeiter";
import {
abteilungsNamen,
geraeteDesMitarbeiters,
mitarbeiterHolen,
mitarbeiterListe,
} from "@/server/queries/mitarbeiter";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Mitarbeiter (#011).
*
* Zwei Regeln tragen das Ganze: nichts wird geloescht, und was aus dem
* Verzeichnis kommt, wird nicht von Hand ueberschrieben. Alles hier dreht sich
* darum — dazu die Mandantensicht, die im Review von #009 dreimal die Fundstelle
* war.
*/
async function grundbestand() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
return { eigene, admin: { id: admin.id, rolle: admin.rolle } };
}
describe.runIf(datenbankVerfuegbar)("Mitarbeiter", () => {
beforeEach(async () => {
await datenbankLeeren();
});
describe("Anlegen", () => {
it("bildet den Anzeigenamen aus Vor- und Nachname, wenn keiner angegeben ist", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: " Maria ", nachname: " Huber " }, admin);
expect(person.anzeigename).toBe("Maria Huber");
expect(person.herkunft).toBe(Herkunft.MANUELL);
expect(person.aktiv).toBe(true);
});
it("nimmt einen eigenen Anzeigenamen an", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen(
{ vorname: "Maria", nachname: "Huber", anzeigename: "Huber, Maria" },
admin,
);
expect(person.anzeigename).toBe("Huber, Maria");
});
it("weist einen leeren Namen ab", async () => {
const { admin } = await grundbestand();
await expect(mitarbeiterAnlegen({ vorname: " ", nachname: "Huber" }, admin)).rejects.toThrow(
MitarbeiterFehler,
);
expect(await testPrisma.mitarbeiter.count()).toBe(0);
});
it("weist einen sAMAccountName über 20 Zeichen ab", async () => {
// Das Active Directory kuerzt nicht, es lehnt ab — und zwar erst beim
// New-ADUser vor dem Kunden.
const { admin } = await grundbestand();
await expect(
mitarbeiterAnlegen({ vorname: "Jörg", nachname: "Müller", sam: "j".repeat(21) }, admin),
).rejects.toThrow(/höchstens 20/);
await expect(
mitarbeiterAnlegen({ vorname: "Jörg", nachname: "Müller", sam: "j".repeat(20) }, admin),
).resolves.toBeTruthy();
});
it("schreibt einen Protokolleintrag", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaetId: person.id },
});
expect(eintrag.aktion).toBe("Mitarbeiter angelegt");
expect(eintrag.benutzerId).toBe(admin.id);
});
});
describe("Aus dem Verzeichnis abgeglichene Personen", () => {
async function abgeglichen(kundeId: string) {
return testPrisma.mitarbeiter.create({
data: {
kundeId,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
mail: "maria.huber@firma.example",
abteilung: "Buchhaltung",
herkunft: Herkunft.ABGLEICH,
},
});
}
it("lässt abgeglichene Felder nicht von Hand ändern", async () => {
// Der naechste Abgleich wuerde die Aenderung ohnehin zurueckdrehen. Ein
// Wert, der zwischen zwei Laeufen hin- und herspringt, ist schlimmer als
// ein falscher, weil niemand ihm mehr traut.
const { admin, eigene } = await grundbestand();
const person = await abgeglichen(eigene.id);
for (const aenderung of [
{ vorname: "Marie" },
{ nachname: "Hubert" },
{ mail: "anders@firma.example" },
{ abteilung: "Vertrieb" },
]) {
await expect(
mitarbeiterBearbeiten(person.id, aenderung, admin),
JSON.stringify(aenderung),
).rejects.toThrow(/Verzeichnis/);
}
});
it("stört sich nicht an unverändert mitgeschickten Feldern", async () => {
// Das Formular schickt alle Felder mit. Wuerde schon das Mitschicken
// scheitern, liefe der Benutzer gegen eine Fehlermeldung, ohne etwas
// geaendert zu haben. Abgewiesen wird nur, was sich wirklich aendern soll.
const { admin, eigene } = await grundbestand();
const person = await abgeglichen(eigene.id);
const vorherProtokoll = await testPrisma.protokoll.count();
const nachher = await mitarbeiterBearbeiten(
person.id,
{
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
mail: "maria.huber@firma.example",
abteilung: "Buchhaltung",
},
admin,
);
expect(nachher.anzeigename).toBe("Maria Huber");
expect(await testPrisma.protokoll.count()).toBe(vorherProtokoll);
});
it("sperrt bei ABGLEICH jedes gepflegte Feld, nicht nur einige", async () => {
// Alle Stammdaten einer abgeglichenen Person kommen aus dem Verzeichnis —
// auch der Titel. Uebrig bleibt genau ein Weg: aktivieren und
// deaktivieren. Das ist Absicht und keine Luecke.
const { admin, eigene } = await grundbestand();
const person = await abgeglichen(eigene.id);
for (const feld of ABGEGLICHENE_FELDER) {
await expect(
mitarbeiterBearbeiten(person.id, { [feld]: `anders-${feld}` }, admin),
feld,
).rejects.toThrow(/Verzeichnis/);
}
});
it("lässt eine manuell angelegte Person vollständig ändern", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const nachher = await mitarbeiterBearbeiten(
person.id,
{ vorname: "Marie", mail: "marie@firma.example" },
admin,
);
expect(nachher.vorname).toBe("Marie");
expect(nachher.mail).toBe("marie@firma.example");
});
it("lässt sich auch bei Herkunft ABGLEICH deaktivieren", async () => {
// Das Offboarding muss auch dann gehen, wenn das Verzeichnis noch nicht
// nachgezogen hat.
const { admin, eigene } = await grundbestand();
const person = await abgeglichen(eigene.id);
const nachher = await mitarbeiterAktivSetzen(person.id, false, admin);
expect(nachher.aktiv).toBe(false);
});
});
describe("Bearbeiten", () => {
it("schreibt nichts, wenn sich nichts ändert", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const vorher = await testPrisma.protokoll.count();
await mitarbeiterBearbeiten(person.id, { vorname: "Maria", nachname: "Huber" }, admin);
expect(await testPrisma.protokoll.count()).toBe(vorher);
});
it("nennt im Protokoll, was sich geändert hat", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
await mitarbeiterBearbeiten(person.id, { mail: "m.huber@firma.example" }, admin);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaetId: person.id, aktion: { contains: "geändert" } },
});
expect(eintrag.aktion).toBe("Mail-Adresse geändert");
expect(eintrag.vorher).toEqual({ mail: null });
expect(eintrag.nachher).toEqual({ mail: "m.huber@firma.example" });
});
});
describe("Deaktivieren statt löschen", () => {
it("setzt aktiv auf false und protokolliert es", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
await mitarbeiterAktivSetzen(person.id, false, admin);
expect((await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id: person.id } })).aktiv).toBe(
false,
);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaetId: person.id, aktion: "Mitarbeiter deaktiviert" },
});
expect(eintrag).toBeTruthy();
});
it("bietet gar keinen Löschweg an", async () => {
// Waere Loeschen manchmal moeglich, muesste jeder Aufrufer die Bedingung
// kennen — und beim Offboarding entscheidet sich niemand bewusst zwischen
// zwei Wegen. Geprueft wird der Dienst selbst: was er nicht anbietet, kann
// auch niemand versehentlich rufen.
const dienst = await import("@/server/services/mitarbeiter");
const verdaechtig = Object.keys(dienst).filter((name) => /loesch|entfern|delete/iu.test(name));
expect(verdaechtig).toEqual([]);
expect(Object.keys(dienst)).toContain("mitarbeiterAktivSetzen");
});
});
describe("Liste und Suche", () => {
async function bestand() {
const { admin, eigene } = await grundbestand();
await mitarbeiterAnlegen(
{
vorname: "Maria",
nachname: "Huber",
mail: "maria.huber@firma.example",
upn: "maria.huber@firma.example",
sam: "mhuber",
abteilung: "Buchhaltung",
},
admin,
);
const jens = await mitarbeiterAnlegen(
{ vorname: "Jens", nachname: "Ex", sam: "jex", abteilung: "Vertrieb" },
admin,
);
await mitarbeiterAktivSetzen(jens.id, false, admin);
return { admin, eigene, jens };
}
it("zeigt standardmäßig nur aktive Personen", async () => {
const { admin } = await bestand();
const namen = (await mitarbeiterListe(admin)).map((z) => z.anzeigename);
expect(namen).toEqual(["Maria Huber"]);
});
it("zeigt mit Umschalter auch deaktivierte", async () => {
const { admin } = await bestand();
const namen = (await mitarbeiterListe(admin, { auchDeaktivierte: true })).map((z) => z.anzeigename);
expect(namen.sort()).toEqual(["Jens Ex", "Maria Huber"]);
});
it("sucht über Anzeigename, Mail, UPN und sam", async () => {
const { admin } = await bestand();
for (const suche of ["huber", "MARIA.HUBER@firma", "mhuber"]) {
const treffer = await mitarbeiterListe(admin, { suche });
expect(
treffer.map((z) => z.anzeigename),
suche,
).toEqual(["Maria Huber"]);
}
});
it("lässt den Aktiv-Filter auch beim Suchen stehen", async () => {
// Genau der Blocker aus #009 in neuem Gewand: der Suchfilter bringt ein
// eigenes `OR` mit. Liegt es nicht in einem `AND`, gewinnt es.
const { admin } = await bestand();
const treffer = await mitarbeiterListe(admin, { suche: "e" });
expect(treffer.map((z) => z.anzeigename)).toEqual(["Maria Huber"]);
});
it("filtert nach Abteilung", async () => {
const { admin } = await bestand();
expect((await mitarbeiterListe(admin, { abteilung: "Buchhaltung" })).length).toBe(1);
expect((await mitarbeiterListe(admin, { abteilung: "Vertrieb" })).length).toBe(0);
});
it("liefert die vorkommenden Abteilungsnamen", async () => {
const { admin } = await bestand();
expect(await abteilungsNamen(admin)).toEqual(["Buchhaltung", "Vertrieb"]);
});
it("zählt Geräte und Läufe", async () => {
const { admin, eigene } = await bestand();
const person = (await mitarbeiterListe(admin))[0]!;
await testPrisma.geraet.create({
data: { kundeId: eigene.id, mitarbeiterId: person.id, tagCode: "LZ-7K3F9Q", hostname: "PC-001" },
});
const frisch = (await mitarbeiterListe(admin))[0]!;
expect(frisch.anzahlGeraete).toBe(1);
expect(frisch.anzahlLaeufe).toBe(0);
});
it("gibt die Geräte einer Person zurück, Hostname vor Bezeichnung", async () => {
const { admin, eigene } = await bestand();
const person = (await mitarbeiterListe(admin))[0]!;
await testPrisma.geraet.create({
data: { kundeId: eigene.id, mitarbeiterId: person.id, tagCode: "LZ-7K3F9Q", hostname: "PC-001" },
});
await testPrisma.geraet.create({
data: {
kundeId: eigene.id,
mitarbeiterId: person.id,
tagCode: "LZ-8M4G2R",
bezeichnung: "Monitor rechts",
},
});
const geraete = await geraeteDesMitarbeiters(person.id, admin);
expect(geraete.map((g) => g.name).sort()).toEqual(["Monitor rechts", "PC-001"]);
});
});
describe("Mandantensicht", () => {
async function zweiKunden() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
await testPrisma.einstellung.create({
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
});
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: meiner.id },
});
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
for (const [kundeId, name] of [
[meiner.id, "Meine Person"],
[fremder.id, "Fremde Person"],
] as const) {
await testPrisma.mitarbeiter.create({
data: { kundeId, vorname: name, nachname: "X", anzeigename: name, abteilung: "Geheim" },
});
}
return {
eigene,
techniker: { id: techniker.id, rolle: techniker.rolle },
fremdePerson: await testPrisma.mitarbeiter.findFirstOrThrow({ where: { kundeId: fremder.id } }),
};
}
it("zeigt einem gebundenen Techniker keine fremde Person", async () => {
const { techniker } = await zweiKunden();
expect((await mitarbeiterListe(techniker)).map((z) => z.anzeigename)).toEqual(["Meine Person"]);
});
it("hält die Grenze auch beim Suchen", async () => {
const { techniker } = await zweiKunden();
expect((await mitarbeiterListe(techniker, { suche: "Person" })).map((z) => z.anzeigename)).toEqual([
"Meine Person",
]);
});
it("gibt eine fremde Person nicht einzeln heraus", async () => {
const { techniker, fremdePerson } = await zweiKunden();
expect(await mitarbeiterHolen(fremdePerson.id, techniker)).toBeUndefined();
});
it("zeigt die Geräte einer fremden Person nicht", async () => {
const { techniker, fremdePerson } = await zweiKunden();
await testPrisma.geraet.create({
data: { kundeId: fremdePerson.kundeId, mitarbeiterId: fremdePerson.id, tagCode: "LZ-7K3F9Q" },
});
expect(await geraeteDesMitarbeiters(fremdePerson.id, techniker)).toEqual([]);
});
it("blendet fremde Abteilungsnamen aus dem Filter aus", async () => {
const { techniker } = await zweiKunden();
await testPrisma.mitarbeiter.updateMany({
where: { anzeigename: "Fremde Person" },
data: { abteilung: "Nur-fuer-Fremde" },
});
expect(await abteilungsNamen(techniker)).not.toContain("Nur-fuer-Fremde");
});
it("lässt eine fremde Person nicht deaktivieren", async () => {
const { techniker, fremdePerson } = await zweiKunden();
await expect(mitarbeiterAktivSetzen(fremdePerson.id, false, techniker)).rejects.toThrow(/Berechtigung/);
});
it("lässt eine fremde Person nicht bearbeiten", async () => {
const { techniker, fremdePerson } = await zweiKunden();
await expect(mitarbeiterBearbeiten(fremdePerson.id, { titel: "Chef" }, techniker)).rejects.toThrow(
/Berechtigung/,
);
});
});
});
+11 -2
View File
@@ -17,10 +17,19 @@ describe("Seitenleiste nach Betriebsmodus", () => {
expect(pfade).toContain("/mitarbeiter");
});
it("zeigt im Modus Dienstleistung Kunden statt Mitarbeiter & Geräte", () => {
it("zeigt im Modus Dienstleistung zusätzlich die Kunden", () => {
const pfade = pfadeFuer("DIENSTLEISTUNG", "ADMIN");
expect(pfade).toContain("/kunden");
expect(pfade).not.toContain("/mitarbeiter");
});
it("nennt dieselbe Seite je Modus anders", () => {
// Es sind die Leute des Kunden, nicht die eigenen Angestellten. Die Seite
// bleibt dieselbe — sie ist in beiden Modi erreichbar, sonst kaeme man im
// Modus Dienstleistung gar nicht an die Personen eines Kunden (#011).
const beschriftung = (modus: "INTERN" | "DIENSTLEISTUNG") =>
navigationFuer(modus).find((e) => e.pfad === "/mitarbeiter")?.beschriftung;
expect(beschriftung("INTERN")).toBe("Mitarbeiter & Geräte");
expect(beschriftung("DIENSTLEISTUNG")).toBe("Personen & Geräte");
});
});
+4 -3
View File
@@ -14,6 +14,7 @@ const MATRIX: Record<Recht, Record<Rolle, boolean>> = {
lesen: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: true },
laeufeBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
geraeteBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
mitarbeiterBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false },
archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false },
verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false },
@@ -25,10 +26,10 @@ describe("Rechtematrix", () => {
);
it("prueft jede Rolle gegen jedes Recht", () => {
// Haelt fest, dass die Tabelle unten wirklich vollstaendig ist: vier Rollen, sechs Rechte.
// Haelt fest, dass die Tabelle unten wirklich vollstaendig ist: vier Rollen, sieben Rechte.
expect(ALLE_ROLLEN).toHaveLength(4);
expect(ALLE_RECHTE).toHaveLength(6);
expect(faelle).toHaveLength(24);
expect(ALLE_RECHTE).toHaveLength(7);
expect(faelle).toHaveLength(28);
});
it.each(faelle)("%s: %s -> %s", (recht, rolle, erwartet) => {