Consolidation pass: HR-only access, effective-dated mutations, data integrity guards, test suite

Reworks the app from a two-role (hr_admin/manager) model to a single
HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/
reorg RPCs to actually defer future-dated changes via a new
pending_org_changes table instead of writing them immediately (applied
by a daily Vercel Cron route), makes reorg undo append-only instead of
deleting history, adds Karenz-return and history-date integrity guards,
deprecates the salary column, and adds explicit schema grants + perf
indexes needed to run against a fresh (non-hosted) Postgres instance.

Adds vitest unit + integration test suites (the latter against a real
local Supabase instance) covering all of the above, plus lint/typecheck/
build wiring (`npm run check`).
This commit is contained in:
2026-07-14 20:32:20 +02:00
parent 4299277af0
commit 901c5c426e
67 changed files with 4765 additions and 286 deletions

29
tests/unit/colors.test.ts Normal file
View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { actionBadgeStyle, avatarColorFor, STATUS_STYLES } from "@/lib/colors";
describe("avatarColorFor", () => {
it("is deterministic for the same seed", () => {
expect(avatarColorFor("Maria Gruber")).toBe(avatarColorFor("Maria Gruber"));
});
it("returns a value from the fixed palette (a hex color)", () => {
expect(avatarColorFor("Anna Huber")).toMatch(/^#[0-9a-f]{6}$/i);
});
});
describe("STATUS_STYLES", () => {
it("has an entry for every employment status", () => {
expect(Object.keys(STATUS_STYLES).sort()).toEqual(["Aktiv", "Ausgetreten", "Geplant", "Karenz"].sort());
});
});
describe("actionBadgeStyle", () => {
it("maps known audit actions to their category style", () => {
expect(actionBadgeStyle("Austritt")).toBe(STATUS_STYLES.Ausgetreten);
expect(actionBadgeStyle("Neueinstellung")).toBe(STATUS_STYLES.Aktiv);
});
it("falls back to the brand style for an unknown action", () => {
expect(actionBadgeStyle("Irgendwas Unbekanntes")).toContain("bg-brand-100");
});
});

78
tests/unit/format.test.ts Normal file
View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { daysBetween, fmtAge, fmtDate, initials, tenure } from "@/lib/format";
describe("fmtDate", () => {
it("formats an ISO date string in de-AT order", () => {
expect(fmtDate("2026-03-05")).toBe("05.03.2026");
});
it("returns an em dash for null/undefined/empty input", () => {
expect(fmtDate(null)).toBe("");
expect(fmtDate(undefined)).toBe("");
expect(fmtDate("")).toBe("");
});
it("returns an em dash for an invalid date string", () => {
expect(fmtDate("not-a-date")).toBe("");
});
});
describe("initials", () => {
it("uppercases the first letter of each name", () => {
expect(initials("maria", "gruber")).toBe("MG");
});
it("trims surrounding whitespace before taking the first letter", () => {
expect(initials(" Anna", "Huber ")).toBe("AH");
});
});
describe("fmtAge", () => {
it("computes age correctly when the birthday has already passed this year", () => {
const today = new Date();
const birthDate = new Date(today.getFullYear() - 30, 0, 1); // Jan 1, definitely passed
expect(fmtAge(birthDate)).toBe(30);
});
it("subtracts one year when the birthday has not yet occurred this year", () => {
const today = new Date();
const future = new Date(today.getFullYear() - 30, 11, 31); // Dec 31, likely not yet passed
if (today.getMonth() === 11 && today.getDate() === 31) return; // skip on the one day this'd be flaky
expect(fmtAge(future)).toBe(29);
});
});
describe("tenure", () => {
it("formats whole years and months since entry", () => {
const start = new Date(2020, 0, 15);
const end = new Date(2023, 2, 15); // exactly 3 years, 2 months later
expect(tenure(start, end)).toBe("3 Jahre, 2 Monate");
});
it("uses singular Jahr/Monat for exactly 1", () => {
const start = new Date(2020, 0, 1);
const end = new Date(2021, 1, 1); // 1 year, 1 month
expect(tenure(start, end)).toBe("1 Jahr, 1 Monat");
});
it("falls back to 'unter 1 Monat' for less than a month", () => {
const start = new Date(2024, 0, 1);
const end = new Date(2024, 0, 15);
expect(tenure(start, end)).toBe("unter 1 Monat");
});
it("defaults the end date to today when no end date is given", () => {
const start = new Date();
expect(tenure(start)).toBe("unter 1 Monat");
});
});
describe("daysBetween", () => {
it("computes whole days between two dates", () => {
expect(daysBetween("2026-01-01", "2026-01-11")).toBe(10);
});
it("returns a negative number when the second date precedes the first", () => {
expect(daysBetween("2026-01-11", "2026-01-01")).toBe(-10);
});
});

52
tests/unit/org.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { breadcrumbFor, breadcrumbLabel, type OrgMaps } from "@/lib/org";
import type { Database } from "@/lib/supabase/types";
type Division = Database["public"]["Tables"]["divisions"]["Row"];
type Department = Database["public"]["Tables"]["departments"]["Row"];
type Team = Database["public"]["Tables"]["teams"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"];
const division: Division = { id: "div-1", org_number: "20100000", name: "Produktion" };
const department: Department = { id: "dept-1", org_number: "21100000", name: "Fertigung", division_id: "div-1" };
const team: Team = { id: "team-1", org_number: "22010000", name: "Montage", department_id: "dept-1" };
const location: Location = { id: "loc-1", name: "Wien-Hernals", country: "Österreich" };
const orgMaps: OrgMaps = {
divisions: new Map([[division.id, division]]),
departments: new Map([[department.id, department]]),
teams: new Map([[team.id, team]]),
locations: new Map([[location.id, location]]),
divisionList: [division],
locationList: [location],
};
describe("breadcrumbFor", () => {
it("resolves division, department (via team), and team from ids", () => {
const result = breadcrumbFor(orgMaps, division.id, team.id);
expect(result.division?.name).toBe("Produktion");
expect(result.department?.name).toBe("Fertigung");
expect(result.team?.name).toBe("Montage");
});
it("has no team/department for a division head with no team (team_id null)", () => {
const result = breadcrumbFor(orgMaps, division.id, null);
expect(result.division?.name).toBe("Produktion");
expect(result.team).toBeUndefined();
expect(result.department).toBeUndefined();
});
});
describe("breadcrumbLabel", () => {
it("joins division department team with the SAP-OM breadcrumb separator", () => {
expect(breadcrumbLabel(orgMaps, division.id, team.id)).toBe("Produktion Fertigung Montage");
});
it("omits missing segments instead of producing empty separators", () => {
expect(breadcrumbLabel(orgMaps, division.id, null)).toBe("Produktion");
});
it("falls back to a dash when nothing resolves", () => {
expect(breadcrumbLabel(orgMaps, null, null)).toBe("");
});
});

164
tests/unit/reports.test.ts Normal file
View File

@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";
import {
aggregateReport,
groupKeyFor,
measureValue,
MEASURE_LABELS,
type Measure,
type OrgLookups,
type ReportEmployee,
} from "@/lib/reports";
function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
return {
id: "1",
first_name: "Maria",
last_name: "Gruber",
job_title: "Maschinenbediener:in",
division_id: "div-1",
team_id: "team-1",
location_id: "loc-1",
status: "Aktiv",
employment_type: "Vollzeit",
contract_type: "unbefristet",
entry_date: "2020-01-01",
exit_date: null,
weekly_hours: 38.5,
source: "Extern",
paygrade: "B",
birth_date: "1990-01-01",
gender: "w",
...overrides,
};
}
const lookups: OrgLookups = {
divisionName: new Map([["div-1", "Produktion"]]),
departmentNameByTeam: new Map([["team-1", "Fertigung"]]),
teamName: new Map([["team-1", "Montage"]]),
locationName: new Map([["loc-1", "Wien-Hernals"]]),
};
describe("no salary measure in scope (consolidation §4/§8)", () => {
it("does not expose an avg_salary measure", () => {
expect(Object.keys(MEASURE_LABELS)).not.toContain("avg_salary");
});
});
describe("groupKeyFor", () => {
it("resolves division/department/team/location names via lookups", () => {
const e = emp();
expect(groupKeyFor(e, "division", lookups)).toBe("Produktion");
expect(groupKeyFor(e, "department", lookups)).toBe("Fertigung");
expect(groupKeyFor(e, "team", lookups)).toBe("Montage");
expect(groupKeyFor(e, "location", lookups)).toBe("Wien-Hernals");
});
it("falls back to a dash for department/team when the employee has no team", () => {
const e = emp({ team_id: null });
expect(groupKeyFor(e, "department", lookups)).toBe("");
expect(groupKeyFor(e, "team", lookups)).toBe("");
});
it("falls back to 'Unbekannt' for an unresolvable division/location id", () => {
const e = emp({ division_id: "ghost", location_id: "ghost" });
expect(groupKeyFor(e, "division", lookups)).toBe("Unbekannt");
expect(groupKeyFor(e, "location", lookups)).toBe("Unbekannt");
});
it("derives entry_year from entry_date", () => {
expect(groupKeyFor(emp({ entry_date: "2019-06-01" }), "entry_year", lookups)).toBe("2019");
});
});
describe("measureValue", () => {
it("returns 0 for an empty row set regardless of measure", () => {
expect(measureValue([], "headcount")).toBe(0);
});
it("counts headcount/hires/exits as row length", () => {
const rows = [emp(), emp({ id: "2" }), emp({ id: "3" })];
(["headcount", "hires", "exits"] as Measure[]).forEach((m) => expect(measureValue(rows, m)).toBe(3));
});
it("sums FTE as weekly_hours / 38.5", () => {
const rows = [emp({ weekly_hours: 38.5 }), emp({ weekly_hours: 19.25 })];
expect(measureValue(rows, "fte")).toBeCloseTo(1.5, 5);
});
it("computes parttime_rate as a percentage of Teilzeit employees", () => {
const rows = [
emp({ employment_type: "Vollzeit" }),
emp({ employment_type: "Teilzeit" }),
emp({ employment_type: "Teilzeit" }),
emp({ employment_type: "Teilzeit" }),
];
expect(measureValue(rows, "parttime_rate")).toBe(75);
});
it("computes female_share as a percentage", () => {
const rows = [emp({ gender: "w" }), emp({ gender: "w" }), emp({ gender: "m" })];
expect(measureValue(rows, "female_share")).toBeCloseTo(66.67, 1);
});
it("computes avg_age from birth_date", () => {
const today = new Date();
const thirtyYearsAgo = `${today.getFullYear() - 30}-01-01`;
const fortyYearsAgo = `${today.getFullYear() - 40}-01-01`;
const rows = [emp({ birth_date: thirtyYearsAgo }), emp({ birth_date: fortyYearsAgo })];
expect(measureValue(rows, "avg_age")).toBeCloseTo(35, 0);
});
it("computes avg_tenure in years using exit_date when present (deterministic)", () => {
const rows = [
emp({ entry_date: "2020-01-01", exit_date: "2022-01-01" }), // ~2 years
emp({ entry_date: "2020-01-01", exit_date: "2024-01-01" }), // ~4 years
];
expect(measureValue(rows, "avg_tenure")).toBeCloseTo(3, 0);
});
});
describe("aggregateReport", () => {
it("groups rows, sorts groups descending by value, and never includes a salary field", () => {
const employees = [
emp({ id: "1", division_id: "div-1" }),
emp({ id: "2", division_id: "div-1" }),
emp({ id: "3", division_id: "div-2" }),
];
const multiDivisionLookups: OrgLookups = {
...lookups,
divisionName: new Map([
["div-1", "Produktion"],
["div-2", "IT"],
]),
};
const rows = aggregateReport(employees, "headcount", "division", null, multiDivisionLookups);
expect(rows[0]).toMatchObject({ key: "Produktion", value: 2, count: 2 });
expect(rows[1]).toMatchObject({ key: "IT", value: 1, count: 1 });
for (const row of rows) {
for (const person of row.people) {
expect(person).not.toHaveProperty("monthly_salary_gross");
}
}
});
it("caps drilldown people list source data at the full group (UI caps display, not aggregation)", () => {
const employees = Array.from({ length: 15 }, (_, i) => emp({ id: String(i) }));
const rows = aggregateReport(employees, "headcount", "division", null, lookups);
expect(rows[0].people).toHaveLength(15);
});
it("adds a split breakdown per group when a split dimension is given", () => {
const employees = [
emp({ id: "1", employment_type: "Vollzeit" }),
emp({ id: "2", employment_type: "Teilzeit" }),
];
const rows = aggregateReport(employees, "headcount", "division", "employment_type", lookups);
expect(rows[0].split).toEqual(
expect.arrayContaining([
{ key: "Vollzeit", value: 1, count: 1 },
{ key: "Teilzeit", value: 1, count: 1 },
])
);
});
});