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
+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] ?? "")),
);
}
});
});