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:
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