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/, ); }); }); });