import { beforeEach, describe, expect, it } from "vitest"; import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums"; import { ABGEGLICHENE_FELDER, MitarbeiterFehler, mitarbeiterAktivSetzen, mitarbeiterAnlegen, mitarbeiterBearbeiten, vorgesetztenSetzen, } from "@/server/services/mitarbeiter"; import { abteilungsNamen, geraeteDesMitarbeiters, mitarbeiterHolen, mitarbeiterListe, moeglicheVorgesetzte, vorgesetzterVon, } 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("Vorgesetzter", () => { it("laesst sich fuer eine von Hand angelegte Person setzen", async () => { // Der Fall, der vorher gar nicht ging: ohne Verzeichnisquelle gab es kein // Feld fuer die Zuordnung (#020). const { admin } = await grundbestand(); const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin); const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin); expect(maria.herkunft).toBe(Herkunft.MANUELL); await vorgesetztenSetzen(maria.id, petra.id, admin); expect(await vorgesetzterVon(maria.id, admin)).toEqual({ id: petra.id, anzeigename: "Petra Klein", }); }); it("entfernt die Zuordnung bei leerer Angabe", async () => { const { admin } = await grundbestand(); const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin); const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin); await vorgesetztenSetzen(maria.id, petra.id, admin); await vorgesetztenSetzen(maria.id, "", admin); expect(await vorgesetzterVon(maria.id, admin)).toBeUndefined(); }); it("laesst niemanden sein eigener Vorgesetzter sein", async () => { const { admin } = await grundbestand(); const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin); await expect(vorgesetztenSetzen(maria.id, maria.id, admin)).rejects.toThrow(/eigener Vorgesetzter/); }); it("weist eine Schleife ueber zwei Personen ab", async () => { const { admin } = await grundbestand(); const a = await mitarbeiterAnlegen({ vorname: "A", nachname: "Eins" }, admin); const b = await mitarbeiterAnlegen({ vorname: "B", nachname: "Zwei" }, admin); await vorgesetztenSetzen(b.id, a.id, admin); await expect(vorgesetztenSetzen(a.id, b.id, admin)).rejects.toThrow(/Schleife/); }); it("weist eine Schleife ueber drei Personen ab", async () => { // Die Datenbank faengt nur die kuerzeste ab; diese braucht den Gang nach // oben im Dienst. const { admin } = await grundbestand(); const a = await mitarbeiterAnlegen({ vorname: "A", nachname: "Eins" }, admin); const b = await mitarbeiterAnlegen({ vorname: "B", nachname: "Zwei" }, admin); const c = await mitarbeiterAnlegen({ vorname: "C", nachname: "Drei" }, admin); await vorgesetztenSetzen(b.id, a.id, admin); await vorgesetztenSetzen(c.id, b.id, admin); await expect(vorgesetztenSetzen(a.id, c.id, admin)).rejects.toThrow(/Schleife/); }); it("gilt nur innerhalb desselben Kunden", async () => { const { eigene, admin } = await grundbestand(); const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } }); const hier = await testPrisma.mitarbeiter.create({ data: { kundeId: eigene.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" }, }); const dort = await testPrisma.mitarbeiter.create({ data: { kundeId: fremd.id, vorname: "Petra", nachname: "Klein", anzeigename: "Petra Klein" }, }); await expect(vorgesetztenSetzen(hier.id, dort.id, admin)).rejects.toThrow(/selben Kunden/); // Und auch an der Service-Schicht vorbei nicht: der zusammengesetzte // Fremdschluessel haelt dieselbe Grenze. await expect( testPrisma.mitarbeiter.update({ where: { id: hier.id }, data: { vorgesetzterId: dort.id }, }), ).rejects.toThrow(/Mitarbeiter_vorgesetzter_gleicher_kunde/); }); it("bietet zur Auswahl nur aktive Kollegen desselben Kunden, ohne die Person selbst", async () => { const { eigene, admin } = await grundbestand(); const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } }); const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin); const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin); const weg = await mitarbeiterAnlegen({ vorname: "Alt", nachname: "Kollege" }, admin); await mitarbeiterAktivSetzen(weg.id, false, admin); await testPrisma.mitarbeiter.create({ data: { kundeId: fremd.id, vorname: "Fremd", nachname: "Person", anzeigename: "Fremd Person" }, }); expect(eigene.id).toBe(maria.kundeId); const auswahl = await moeglicheVorgesetzte(maria.id, admin); expect(auswahl.map((eintrag) => eintrag.id)).toEqual([petra.id]); }); it("schreibt die Aenderung ins Protokoll, mit Namen", async () => { const { admin } = await grundbestand(); const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin); const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin); await vorgesetztenSetzen(maria.id, petra.id, admin); await vorgesetztenSetzen(maria.id, null, admin); const eintraege = await testPrisma.protokoll.findMany({ where: { entitaet: "Mitarbeiter", entitaetId: maria.id }, orderBy: { erstelltAm: "asc" }, select: { aktion: true }, }); // Das Anlegen protokolliert selbst - hier zaehlt, was danach kam. expect(eintraege.map((e) => e.aktion).slice(1)).toEqual([ "Vorgesetzter auf Petra Klein gesetzt", "Vorgesetzter entfernt", ]); }); }); 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/, ); }); }); });