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:
29
tests/unit/colors.test.ts
Normal file
29
tests/unit/colors.test.ts
Normal 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
78
tests/unit/format.test.ts
Normal 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
52
tests/unit/org.test.ts
Normal 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
164
tests/unit/reports.test.ts
Normal 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 },
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user