import { beforeEach, describe, expect, it } from "vitest"; import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums"; import { MITARBEITER_IMPORT_HOECHSTZEILEN, mitarbeiterImportieren, type MitarbeiterImportErgebnis, } from "@/server/services/mitarbeiter"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * CSV-Import der Personen (#016). * * Festgehalten wird vor allem, was in #011 am Geräteimport schiefging: der * Import meldete „3 von 3 angelegt“, obwohl Zeilen verschluckt worden waren. * Deshalb prüft hier fast jeder Test die **Summe** mit — je Zeile genau ein Fach, * und die Datenbank danach genauso, wie die Zahlen es behaupten. * * Dazu die zweite Regel des Hauses: was aus dem Verzeichnis kommt, wird nicht von * Hand überschrieben. */ const KOPF = "givenName,sn,displayName,mail,upn,sam,department,title"; 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: "ADMIN" }, }); return { eigene, admin: { id: benutzer.id, rolle: benutzer.rolle } }; } /** Jede Zeile landet in genau einem Fach – sonst lügt die Erfolgsmeldung. */ function summePruefen(ergebnis: MitarbeiterImportErgebnis): void { expect(ergebnis.angelegt + ergebnis.aktualisiert + ergebnis.unveraendert + ergebnis.abgewiesen.length).toBe( ergebnis.gesamt, ); } describe.runIf(datenbankVerfuegbar)("Personen aus einer CSV-Datei", () => { beforeEach(async () => { await datenbankLeeren(); delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; }); describe("Zeile für Zeile", () => { it("legt die guten Zeilen an und meldet die kaputte mit ihrer Zeilennummer", async () => { const { admin } = await grundbestand(); const text = [ KOPF, "Anna,Muster,Anna Muster,anna@firma.example,anna@firma.example,amuster,Vertrieb,Innendienst", 'Bert,"Berg,Bert Berg,bert@firma.example,,bberg,Vertrieb,', // Anführungszeichen nicht geschlossen "Clara,Cloud,Clara Cloud,clara@firma.example,,ccloud,Technik,", ",,,dora@firma.example,,dfehlt,,", // kein Name in der Zeile "Emil,Eck,Emil Eck,,,eeck,,", ].join("\n"); const ergebnis = await mitarbeiterImportieren(text, admin); expect(ergebnis.gesamt).toBe(5); expect(ergebnis.angelegt).toBe(3); summePruefen(ergebnis); // Zeilennummern wie in der Datei: die Kopfzeile ist 1. expect(ergebnis.abgewiesen.map((abweisung) => abweisung.zeile)).toEqual([3, 5]); expect(ergebnis.abgewiesen[0]?.grund).toMatch(/Anführungszeichen/); expect(ergebnis.abgewiesen[1]?.grund).toMatch(/fehlt ein Name/); const namen = (await testPrisma.mitarbeiter.findMany({ select: { anzeigename: true } })) .map((zeile) => zeile.anzeigename) .sort(); expect(namen).toEqual(["Anna Muster", "Clara Cloud", "Emil Eck"]); }); it("legt als MANUELL an und protokolliert die Zeilennummer", async () => { // MANUELL, weil niemand aus dieser Datei im Verzeichnis steht — und weil // eine so angelegte Person änderbar bleiben muss. const { admin, eigene } = await grundbestand(); await mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"), admin); const person = await testPrisma.mitarbeiter.findFirstOrThrow(); expect(person.herkunft).toBe(Herkunft.MANUELL); expect(person.kundeId).toBe(eigene.id); expect(person.anzeigename).toBe("Anna Muster"); expect(person.abteilung).toBe("Vertrieb"); const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { aktion: "Mitarbeiter aus CSV angelegt" }, }); expect(eintrag.nachher).toMatchObject({ anzeigename: "Anna Muster", zeile: 2 }); }); it("versteht deutsches Excel: Semikolon, BOM und deutsche Spaltennamen", async () => { const { admin } = await grundbestand(); const text = "Vorname;Nachname;Anzeigename;Mail;UPN;sAMAccountName;Abteilung;Titel\r\n" + 'Jörg;Öz;"Öz, Jörg";joerg@firma.example;joerg@firma.example;joez;Technik;"Leiter ""IT"""\r\n'; const ergebnis = await mitarbeiterImportieren(text, admin); expect(ergebnis.angelegt).toBe(1); summePruefen(ergebnis); await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({ vorname: "Jörg", nachname: "Öz", anzeigename: "Öz, Jörg", titel: 'Leiter "IT"', }); }); it("teilt displayName am letzten Leerzeichen, lässt den Anzeigenamen aber stehen", async () => { const { admin } = await grundbestand(); const ergebnis = await mitarbeiterImportieren( ["displayName,sam", "Anna van der Berg,aberg"].join("\n"), admin, ); expect(ergebnis.angelegt).toBe(1); summePruefen(ergebnis); // Die Teilung ist eine Annahme; der Anzeigename ist es nicht — und nur er // steht später in der Oberfläche und im Laufzettel. await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({ vorname: "Anna van der", nachname: "Berg", anzeigename: "Anna van der Berg", }); }); it("gibt die Regel des Dienstes auch zeilenweise weiter", async () => { // Die 20-Zeichen-Grenze des sAMAccountName steht im Dienst und nirgends // sonst. Der Import geht durch denselben Weg, also gilt sie hier auch. const { admin } = await grundbestand(); const ergebnis = await mitarbeiterImportieren( [KOPF, "Anna,Muster,,,,viel-zu-langer-anmeldename,,", "Bert,Berg,,,,bberg,,"].join("\n"), admin, ); expect(ergebnis.angelegt).toBe(1); summePruefen(ergebnis); expect(ergebnis.abgewiesen[0]).toMatchObject({ zeile: 2 }); expect(ergebnis.abgewiesen[0]?.grund).toMatch(/höchstens 20/); }); it("weist eine Datei ohne Namensspalte als Ganzes ab", async () => { const { admin } = await grundbestand(); await expect(mitarbeiterImportieren("mail,sam\nanna@firma.example,amuster\n", admin)).rejects.toThrow( /displayName/, ); expect(await testPrisma.mitarbeiter.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: MITARBEITER_IMPORT_HOECHSTZEILEN + 1 }, (_, index) => `Anna,Muster${index},,,,a${index},,`, ); await expect(mitarbeiterImportieren([KOPF, ...zeilen].join("\n"), admin)).rejects.toThrow( new RegExp(`mehr als ${MITARBEITER_IMPORT_HOECHSTZEILEN} Zeilen`), ); expect(await testPrisma.mitarbeiter.count()).toBe(0); }); }); describe("Wiedererkennen statt verdoppeln", () => { it("erkennt ein Duplikat innerhalb derselben Datei und nennt die erste Zeile", async () => { const { admin } = await grundbestand(); const ergebnis = await mitarbeiterImportieren( [ KOPF, "Anna,Muster,,anna@firma.example,,amuster,Vertrieb,", "Bert,Berg,,bert@firma.example,,bberg,Vertrieb,", "Anna,Muster-Neu,,anna@firma.example,,AMUSTER,Vertrieb,", // dieselbe Kennung ].join("\n"), admin, ); expect(ergebnis.angelegt).toBe(2); summePruefen(ergebnis); expect(ergebnis.abgewiesen[0]?.zeile).toBe(4); expect(ergebnis.abgewiesen[0]?.grund).toMatch(/schon in Zeile 2/); expect(await testPrisma.mitarbeiter.count()).toBe(2); // Und die erste Zeile gilt, nicht die dritte. await expect( testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "amuster" } }), ).resolves.toMatchObject({ nachname: "Muster" }); }); it("legt dieselbe Datei beim zweiten Einlesen nicht erneut an", async () => { // Der eigentliche Zweck der Wiedererkennung: eine Onboarding-Liste wandert // mehrfach durch dieses Feld, und niemand zählt vorher nach. const { admin } = await grundbestand(); const text = [ KOPF, "Anna,Muster,Anna Muster,anna@firma.example,anna@firma.example,amuster,Vertrieb,Innendienst", "Bert,Berg,Bert Berg,bert@firma.example,bert@firma.example,bberg,Technik,", ].join("\n"); const erster = await mitarbeiterImportieren(text, admin); expect(erster.angelegt).toBe(2); const zweiter = await mitarbeiterImportieren(text, admin); expect(zweiter).toMatchObject({ gesamt: 2, angelegt: 0, aktualisiert: 0, unveraendert: 2 }); summePruefen(zweiter); expect(await testPrisma.mitarbeiter.count()).toBe(2); }); it("erkennt über den UPN, wenn kein sam in der Datei steht", async () => { const { admin, eigene } = await grundbestand(); await testPrisma.mitarbeiter.create({ data: { kundeId: eigene.id, vorname: "Anna", nachname: "Muster", anzeigename: "Anna Muster", upn: "anna.muster@firma.example", sam: "amuster", }, }); const ergebnis = await mitarbeiterImportieren( ["givenName,sn,upn,department", "Anna,Muster,ANNA.MUSTER@firma.example,Vertrieb"].join("\n"), admin, ); expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 1 }); summePruefen(ergebnis); const person = await testPrisma.mitarbeiter.findFirstOrThrow(); expect(person.abteilung).toBe("Vertrieb"); // Gross- und Kleinschreibung unterscheidet keine Personen, deshalb bleibt // der vorhandene Wert stehen, statt ihn zu überschreiben. expect(person.upn).toBe("anna.muster@firma.example"); }); it("erkennt ohne sam und ohne UPN über den Anzeigenamen", async () => { // Die Onboarding-Liste mit Name und Abteilung und sonst nichts. Ohne diesen // Rückfall stünde jede Person nach dem zweiten Einlesen doppelt da. const { admin } = await grundbestand(); const text = ["displayName,department", "Anna Muster,Vertrieb"].join("\n"); expect(await mitarbeiterImportieren(text, admin)).toMatchObject({ angelegt: 1 }); expect(await mitarbeiterImportieren(text, admin)).toMatchObject({ angelegt: 0, unveraendert: 1 }); expect(await testPrisma.mitarbeiter.count()).toBe(1); }); it("weist eine Zeile ab, die auf zwei Personen passt", async () => { // Hier zu raten hieße, die Stammdaten der falschen Person zu überschreiben. const { admin, eigene } = await grundbestand(); for (const [vorname, sam, upn] of [ ["Anna", "amuster", "a.muster@firma.example"], ["Anneliese", "amuster2", "anna@firma.example"], ] as const) { await testPrisma.mitarbeiter.create({ data: { kundeId: eigene.id, vorname, nachname: "Muster", anzeigename: vorname, sam, upn }, }); } const ergebnis = await mitarbeiterImportieren( [KOPF, "Anna,Muster,,,anna@firma.example,amuster,Vertrieb,"].join("\n"), admin, ); expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 0, unveraendert: 0 }); summePruefen(ergebnis); expect(ergebnis.abgewiesen[0]?.grund).toMatch(/passt auf/); expect(await testPrisma.mitarbeiter.count()).toBe(2); }); it("aktiviert eine deaktivierte Person nicht wieder", async () => { // Das Wiedereinstellen ist eine Entscheidung und keine Zeile in einer // Datei. Die Oberfläche sagt es genauso — also muss es hier auch gelten. const { admin, eigene } = await grundbestand(); await testPrisma.mitarbeiter.create({ data: { kundeId: eigene.id, vorname: "Anna", nachname: "Muster", anzeigename: "Anna Muster", sam: "amuster", aktiv: false, }, }); const ergebnis = await mitarbeiterImportieren( [KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"), admin, ); expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 1 }); summePruefen(ergebnis); await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({ aktiv: false, abteilung: "Vertrieb", }); }); it("lässt eine leere Zelle nichts löschen", async () => { const { admin, eigene } = await grundbestand(); await testPrisma.mitarbeiter.create({ data: { kundeId: eigene.id, vorname: "Anna", nachname: "Muster", anzeigename: "Muster, Anna", sam: "amuster", titel: "Innendienst", mail: "anna@firma.example", }, }); const ergebnis = await mitarbeiterImportieren( [KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"), admin, ); expect(ergebnis).toMatchObject({ aktualisiert: 1 }); summePruefen(ergebnis); await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({ abteilung: "Vertrieb", titel: "Innendienst", mail: "anna@firma.example", // Kein displayName in der Zeile heißt: der vorhandene bleibt, auch wenn // er anders lautet als „Vorname Nachname“. anzeigename: "Muster, Anna", }); }); }); describe("Herkunft ABGLEICH", () => { it("überschreibt eine Person aus dem Verzeichnis nicht, sondern meldet die Zeile", async () => { // Der nächste Abgleich drehte die Änderung ohnehin zurück. Stillschweigend // zu überspringen wäre das Schlimmere: der Import meldete dann einen // Erfolg, den es nicht gab. const { admin, eigene } = await grundbestand(); await testPrisma.mitarbeiter.create({ data: { kundeId: eigene.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber", sam: "mhuber", abteilung: "Buchhaltung", titel: "Leitung", herkunft: Herkunft.ABGLEICH, }, }); const protokollVorher = await testPrisma.protokoll.count(); const ergebnis = await mitarbeiterImportieren( [KOPF, "Maria,Huber-Neu,,neu@firma.example,,mhuber,Vertrieb,Sachbearbeitung"].join("\n"), admin, ); expect(ergebnis).toMatchObject({ gesamt: 1, angelegt: 0, aktualisiert: 0, unveraendert: 0 }); summePruefen(ergebnis); expect(ergebnis.abgewiesen[0]?.zeile).toBe(2); expect(ergebnis.abgewiesen[0]?.grund).toMatch(/kommt aus dem Verzeichnis/); await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({ nachname: "Huber", abteilung: "Buchhaltung", titel: "Leitung", mail: null, }); expect(await testPrisma.protokoll.count()).toBe(protokollVorher); }); }); describe("Mandantengrenze", () => { it("verwirft im Modus INTERN eine mitgeschickte Kundenkennung", async () => { const { admin, eigene } = await grundbestand(); const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } }); await mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,,"].join("\n"), admin, fremder.id); await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({ kundeId: eigene.id, }); }); it("erkennt nur innerhalb desselben Kunden – und schreibt nicht für einen fremden", async () => { await grundbestand(Betriebsmodus.DIENSTLEISTUNG); 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: "TECHNIKER" }, }); await testPrisma.benutzerKunde.create({ data: { benutzerId: techniker.id, kundeId: meiner.id } }); process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-"; const handelnder = { id: techniker.id, rolle: techniker.rolle }; // Dieselbe Kennung gibt es beim fremden Kunden schon. Sie darf hier nichts // treffen: sonst schriebe dieser Import in fremde Stammdaten. await testPrisma.mitarbeiter.create({ data: { kundeId: fremder.id, vorname: "Anna", nachname: "Fremd", anzeigename: "Anna Fremd", sam: "amuster", }, }); const ergebnis = await mitarbeiterImportieren( [KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"), handelnder, meiner.id, ); expect(ergebnis).toMatchObject({ angelegt: 1 }); summePruefen(ergebnis); await expect( testPrisma.mitarbeiter.findFirstOrThrow({ where: { kundeId: fremder.id } }), ).resolves.toMatchObject({ nachname: "Fremd", abteilung: null }); // Und für einen Kunden, der diesem Techniker nicht gehört, geht gar nichts. await expect( mitarbeiterImportieren([KOPF, "Bert,Berg,,,,bberg,,"].join("\n"), handelnder, fremder.id), ).rejects.toThrow(/Berechtigung/); expect(await testPrisma.mitarbeiter.count({ where: { kundeId: fremder.id } })).toBe(1); }); it("verlangt im Modus DIENSTLEISTUNG eine Kundenwahl", async () => { const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG); await expect( mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,,"].join("\n"), admin), ).rejects.toThrow(/Kunde/); expect(await testPrisma.mitarbeiter.count()).toBe(0); }); }); });