import { describe, expect, it } from "vitest"; import { baueCornerstoneZeile, benutzername, beschaeftigungsStatus, CORNERSTONE_CSV, CORNERSTONE_SPALTEN, cornerstoneSpalten, csDatum, dienstmonate, geschlecht, kontoStatus, type CornerstoneKontext, type CornerstoneQuelle, } from "@/lib/cornerstone"; import { toCsv } from "@/lib/export"; import { buildOrgMaps, type OrgUnit } from "@/lib/org"; // Die Datei geht in ein fremdes System, das sie über eine im Portal // eingerichtete Zuordnung einliest. Eine verschobene Spalte oder ein Datum in // der falschen Reihenfolge fällt nicht hier auf, sondern dort — und das // Datum fällt womöglich gar nicht auf, weil 09.03. und 03.09. beide gültig // sind. Deshalb steht die Form hier fest. // Die Kopfzeile aus der Vorgabe, Zeichen für Zeichen. const VORGABE = "User ID,Local System ID,Prefix,First Name,Middle Name,Last Name,Suffix,Username,Approver,Manager,Absent,Allow Reconciliation,Email,Required Training Approvals,Gender,Exempt,Ethnicity,Months of Service,Compensation Currency,Address Line 1,Address Line 2,City,State,Zip,Mailstop,Country,Original Hire Date,Last Hire Date,Phone,Mobile,Home Phone,Fax,Status,User activation date,User de-activation date,Personal Email,Language,Time Zone,User Type,Employment Status,Leave Reason,Termination Type,Termination Reason,Termination Date,Eligible For Rehire,Rehired Employee,Division ID,Position ID,Grade ID,Cost Center ID,Location ID,Retention Rules,Organisationsstufe,Customfield ID AD,Customfield ID Doxis,Customfield ID Interflex,Customfield ID LGVplus"; const units: OrgUnit[] = [ { id: "g", org_number: "1", name: "Manner AG", parent_id: null, unit_type: "Gesellschaft" }, { id: "b", org_number: "2", name: "Produktion", parent_id: "g", unit_type: "Bereich" }, { id: "t", org_number: "3", name: "Linie 1", parent_id: "b", unit_type: "Team" }, ]; const kontext: CornerstoneKontext = { orgMaps: buildOrgMaps(units, []), standortName: new Map([["wien", "Wien"]]), managerNummer: new Map([["chef", 1000]]), kostenstelle: new Map([["pos-1", { id: "k", code: "4711", name: "Waffelproduktion" }]]), wiedereingestellt: new Set(["zurueck"]), stichtag: "2026-09-25", }; function person(teil: Partial = {}): CornerstoneQuelle { return { id: "e1", personnel_number: 9001, first_name: "Testa", last_name: "Musterfrau", title_prefix: [], title_suffix: [], gender: "w", company_email: "testa.musterfrau@example.test", address: "Teststrasse 1", postal_code: "1010", city: "Wien", address_country: "AT", entry_date: "2026-09-15", exit_date: null, exit_reason: null, austrittsart: null, absence_type: null, status: "Aktiv", paygrade: "B", location_id: "wien", org_unit_id: "t", position_id: "pos-1", position_number: "S-0001", manager_id: "chef", ...teil, }; } const zeile = (teil: Partial = {}) => baueCornerstoneZeile(person(teil), kontext); describe("Spalten", () => { it("stehen genau so und in genau der Reihenfolge wie vorgegeben", () => { expect(CORNERSTONE_SPALTEN.join(",")).toBe(VORGABE); }); it("sind 57", () => { expect(CORNERSTONE_SPALTEN).toHaveLength(57); }); it("schreibt die Datei mit Komma und ohne BOM", () => { // Die Route nimmt genau diese Form. Stünden die Vorgaben von toCsv drin // (Semikolon fürs deutsche Excel, BOM gegen Buchstabensalat), käme in // Cornerstone eine einspaltige Datei an, deren Spalte auch noch ein // unsichtbares Zeichen im Namen trägt. expect(CORNERSTONE_CSV).toEqual({ trenner: ",", bom: false }); }); it("decken sich mit den Feldern, die eine Zeile liefert", () => { // Ein Feld, das die Zeile setzt, das aber in keiner Spalte steht, ginge // stillschweigend verloren. expect(Object.keys(zeile()).sort()).toEqual([...CORNERSTONE_SPALTEN].sort()); }); }); describe("Einzelne Umsetzungen", () => { it("schreibt das Datum mit dem Tag zuerst", () => { // Die Einleseeinstellung steht auf „Tag zuerst". Ein ISO-Datum würde // stumm falsch gelesen. expect(csDatum("2026-09-15")).toBe("15.09.2026"); expect(csDatum(null)).toBe(""); }); it("übersetzt das Geschlecht in Cornerstones Werte", () => { expect(geschlecht("w")).toBe("female"); expect(geschlecht("m")).toBe("male"); // Alles andere ist nicht „leer", sondern ein gültiger Wert — sonst weist // Cornerstone die ganze Zeile ab. expect(geschlecht("x")).toBe("not specified"); }); it("baut den Anmeldenamen ohne Umlaute und Sonderzeichen", () => { expect(benutzername("Jörg", "Müller-Groß")).toBe("joerg.muellergross"); expect(benutzername("Anna Maria", "Berger")).toBe("annamaria.berger"); }); it("zählt volle Dienstmonate", () => { expect(dienstmonate("2026-09-15", "2026-09-25")).toBe(0); expect(dienstmonate("2025-09-30", "2026-09-25")).toBe(11); expect(dienstmonate("2025-09-15", "2026-09-25")).toBe(12); }); it("trennt Konto von Beschäftigung", () => { // Wer in Karenz ist, hat ein aktives Konto und arbeitet nicht. Aus einem // Wert gefüllt wäre entweder das eine oder das andere falsch. expect(kontoStatus("Karenz")).toBe("Aktiv"); expect(beschaeftigungsStatus("Karenz")).toBe("Abwesend"); expect(kontoStatus("Ausgetreten")).toBe("Inaktiv"); }); }); describe("Eine Zeile", () => { it("entspricht der Beispielzeile in den tragenden Feldern", () => { const z = zeile(); expect(z).toMatchObject({ "Local System ID": "9001", "First Name": "Testa", "Last Name": "Musterfrau", Username: "testa.musterfrau", Gender: "female", "Compensation Currency": "EUR", "Address Line 1": "Teststrasse 1", City: "Wien", Zip: "1010", Country: "AT", "Original Hire Date": "15.09.2026", "Last Hire Date": "15.09.2026", Status: "Aktiv", Language: "de-DE", "Time Zone": "CET", "User Type": "Mitarbeiter", "Employment Status": "Arbeitend", "Customfield ID AD": "testa.musterfrau", "Customfield ID Doxis": "DOX-9001", "Customfield ID Interflex": "9001", "Customfield ID LGVplus": "LGV-9001", }); }); it("verweist auf die vorgesetzte Person über deren Personalnummer", () => { // Cornerstone verknüpft über die User ID, nicht über den Namen. expect(zeile().Manager).toBe("1000"); expect(zeile({ manager_id: null }).Manager).toBe(""); }); it("nimmt die dienstliche Adresse und lässt die privaten Felder leer", () => { const z = zeile(); expect(z.Email).toBe("testa.musterfrau@example.test"); expect(z["Personal Email"]).toBe(""); expect(z["Home Phone"]).toBe(""); }); it("setzt die Kostenstelle der Planstelle, nicht der Person", () => { expect(zeile()["Cost Center ID"]).toBe("4711"); expect(zeile({ position_id: null })["Cost Center ID"]).toBe(""); }); it("nennt als Organisationsstufe die Ebene, nicht den Namen der Einheit", () => { expect(zeile().Organisationsstufe).toBe("Team"); expect(zeile({ org_unit_id: "b" }).Organisationsstufe).toBe("Bereich"); expect(zeile({ org_unit_id: null }).Organisationsstufe).toBe(""); }); it("füllt den Austritt nur, wenn es einen gibt", () => { const aktiv = zeile(); expect(aktiv["Termination Date"]).toBe(""); expect(aktiv["Termination Type"]).toBe(""); const weg = zeile({ status: "Ausgetreten", exit_date: "2026-12-31", exit_reason: "Kündigung", austrittsart: "freiwillig" }); expect(weg["Termination Date"]).toBe("31.12.2026"); expect(weg["Termination Type"]).toBe("freiwillig"); expect(weg["Termination Reason"]).toBe("Kündigung"); expect(weg.Status).toBe("Inaktiv"); }); it("nennt den Abwesenheitsgrund nur bei Karenz", () => { expect(zeile({ absence_type: "Elternkarenz" })["Leave Reason"]).toBe(""); expect(zeile({ status: "Karenz", absence_type: "Elternkarenz" })["Leave Reason"]).toBe("Elternkarenz"); }); it("kennzeichnet Wiedereingestellte", () => { expect(zeile()["Rehired Employee"]).toBe(""); expect(zeile({ id: "zurueck" })["Rehired Employee"]).toBe("Ja"); }); it("trägt die Platzhalter sichtbar ein", () => { // Sie müssen vor einem echten Lauf ersetzt werden — ein leeres Feld // sähe aus wie „nichts zu tun". expect(zeile()["Division ID"]).toBe("DIVISION-ID-HIER"); }); }); describe("als CSV", () => { it("schreibt die Kopfzeile Zeichen für Zeichen wie vorgegeben", () => { // Komma statt Semikolon und kein BOM — sonst hiesse die erste Spalte für // Cornerstone nicht „User ID", sondern trüge das unsichtbare Zeichen im // Namen und käme in keiner Zuordnung vor. const csv = toCsv([zeile()], cornerstoneSpalten(), CORNERSTONE_CSV); expect(csv.startsWith("User ID,")).toBe(true); expect(csv.split(/\r?\n/)[0]).toBe(VORGABE); }); it("maskiert ein Komma im Wert, statt die Zeile zu zerreissen", () => { const csv = toCsv([zeile({ address: "Teststrasse 1, Stiege 2" })], cornerstoneSpalten(), CORNERSTONE_CSV); expect(csv).toContain('"Teststrasse 1, Stiege 2"'); // Felder zählen, nicht Kommas: das maskierte Komma steht ja in der Zeile. // Beide Zeilen müssen gleich viele Felder haben, sonst verrutscht ab der // Adresse jede weitere Spalte um eins. const felder = (zeileText: string) => zeileText.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).length; const [kopf, daten] = csv.split(/\r?\n/); expect(felder(daten)).toBe(felder(kopf)); expect(felder(kopf)).toBe(57); }); it("lässt die private Adresse nirgends durch", () => { const csv = toCsv([zeile({ company_email: null })], cornerstoneSpalten(), CORNERSTONE_CSV); expect(csv).not.toContain("@"); }); });