Data model - employee_assignments records org placement over time (valid_from/valid_to), written by a trigger on `employees` rather than inside each RPC: ~70 `update employees` statements spread over fifteen migrations mean per-call bookkeeping would miss paths today and again with every future RPC. A partial unique index enforces the one-open-interval invariant the trigger relies on when closing the current row. - The Organigramm gains a Stichtag (default today). Membership comes from entry/exit/karenz, past placement from the new history, future placement projected from pending_org_changes. Placements predating the migration are backfilled with today's values and flagged as such in the UI, since employee_history only ever stored free text and cannot be reconstructed. Correctness - Reports and exports silently truncated at PostgREST's 1000-row cap (db.max_rows); employee_history is already past it at ~800 staff. Every whole-table read now pages explicitly. - XLSX date cells were a day early: ExcelJS converts a Date to an Excel serial straight off getTime(), so a Date built at local midnight lands on the previous day's serial in any positive-offset zone. - Date handling is pinned to Europe/Vienna throughout, and date-only strings are formatted without a Date round-trip. The dashboard's YTD window was built by round-tripping a local Date through toISOString(), which shifted it a day early and dropped 31 December entirely. - Export routes parsed measure/group/split/eventType with unchecked `as` casts, so an unknown value reached column headers as `undefined` and the Content-Disposition filename. Parsed against the label maps now, with the filename slugged as a backstop. - toXlsx keyed columns by header text, silently dropping the second of any two columns sharing a name — split columns take their header from data. - The org chart tree walks had no cycle guard; nothing in the schema forbids a manager_id cycle, and one would hang the tab rather than misreport. - The login page reflected ?error= verbatim, letting anyone put arbitrary text on the real sign-in screen; messages are looked up by code now. - React Flow needs elementsSelectable on, or it sets pointer-events:none on the whole node and the expand control stops responding. UI - Mobile: the shell was unusable below lg — a fixed 236px margin pushed content off-screen with no mobile navigation at all. The sidebar is now a drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so iOS stops zooming on focus, and form grids stack. - Org chart nodes redesigned: per-kind accent stripes and icons, vacant roles called out, expand control moved to the bottom edge carrying the child count. - Pagination is windowed; it previously rendered one link per page (54 for the employee list, unbounded for the audit log). - Positions page reduced to open positions with a single "Besetzen" action. - The employee Organisation tab links into the org chart focused on that person, reusing the chart's existing search-match highlighting. Also included, uncommitted until now - Dependants, HR notes, academic titles, split address fields, position validity and role/employment fields, with their migrations and UI. - Docker/compose deployment setup, data-model and security-review docs.
173 lines
7.3 KiB
TypeScript
173 lines
7.3 KiB
TypeScript
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
|
import { randomUUID } from "node:crypto";
|
|
import type { Database, EmploymentStatus } from "@/lib/supabase/types";
|
|
|
|
const URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
const ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
|
|
if (!URL || !ANON_KEY || !SERVICE_ROLE_KEY) {
|
|
throw new Error(
|
|
"Missing Supabase env vars for integration tests. Start local Supabase (`npx supabase start`) and run " +
|
|
"`npm run test:integration`, which loads .env.test.local automatically. See README.md."
|
|
);
|
|
}
|
|
|
|
export const adminClient: SupabaseClient<Database> = createClient(URL, SERVICE_ROLE_KEY, {
|
|
auth: { autoRefreshToken: false, persistSession: false },
|
|
});
|
|
|
|
export function anonClient(): SupabaseClient<Database> {
|
|
return createClient(URL!, ANON_KEY!, { auth: { autoRefreshToken: false, persistSession: false } });
|
|
}
|
|
|
|
export type TestUser = { id: string; email: string; password: string };
|
|
|
|
// Creates a real auth.users row (via the admin API) with no profiles row —
|
|
// this is exactly the "signed up but never provisioned" state §2.3
|
|
// describes: authenticated, but not HR.
|
|
export async function createBareAuthUser(): Promise<TestUser> {
|
|
const email = `test-${randomUUID()}@example.test`;
|
|
const password = `Test-${randomUUID()}!`;
|
|
const { data, error } = await adminClient.auth.admin.createUser({ email, password, email_confirm: true });
|
|
if (error || !data.user) throw new Error(`createBareAuthUser failed: ${error?.message}`);
|
|
return { id: data.user.id, email, password };
|
|
}
|
|
|
|
export async function createHrUser(opts: { active: boolean }): Promise<TestUser> {
|
|
const user = await createBareAuthUser();
|
|
const { error } = await adminClient
|
|
.from("profiles")
|
|
.insert({ id: user.id, email: user.email, full_name: "Integrationstest HR", role: "hr", is_active: opts.active });
|
|
if (error) throw new Error(`createHrUser profile insert failed: ${error.message}`);
|
|
return user;
|
|
}
|
|
|
|
export async function deleteTestUser(user: TestUser): Promise<void> {
|
|
await adminClient.from("profiles").delete().eq("id", user.id);
|
|
await adminClient.auth.admin.deleteUser(user.id);
|
|
}
|
|
|
|
export async function signInAs(user: TestUser): Promise<SupabaseClient<Database>> {
|
|
const client = anonClient();
|
|
const { error } = await client.auth.signInWithPassword({ email: user.email, password: user.password });
|
|
if (error) throw new Error(`signInAs(${user.email}) failed: ${error.message}`);
|
|
return client;
|
|
}
|
|
|
|
// Pulled from the seeded dataset (supabase/seed.ts) — any active, non-lead
|
|
// employee works for read/mutation tests that don't care which one.
|
|
export async function pickSeededEmployee(
|
|
filter: Partial<{ status: EmploymentStatus; is_lead: boolean }> = {}
|
|
): Promise<{
|
|
id: string;
|
|
team_id: string | null;
|
|
division_id: string;
|
|
manager_id: string | null;
|
|
status: string;
|
|
}> {
|
|
let q = adminClient.from("employees").select("id, team_id, division_id, manager_id, status").limit(1);
|
|
if (filter.status) q = q.eq("status", filter.status);
|
|
if (filter.is_lead !== undefined) q = q.eq("is_lead", filter.is_lead);
|
|
const { data, error } = await q.maybeSingle();
|
|
if (error || !data) throw new Error(`pickSeededEmployee failed: ${error?.message ?? "no matching row"}`);
|
|
return data;
|
|
}
|
|
|
|
export async function pickSeededTeam(excludeTeamId?: string): Promise<{ id: string }> {
|
|
const q = adminClient.from("teams").select("id").limit(2);
|
|
const { data, error } = await q;
|
|
if (error || !data?.length) throw new Error(`pickSeededTeam failed: ${error?.message}`);
|
|
const match = data.find((t) => t.id !== excludeTeamId) ?? data[0];
|
|
return match;
|
|
}
|
|
|
|
export async function pickSeededLocation(): Promise<{ id: string }> {
|
|
const { data, error } = await adminClient.from("locations").select("id").limit(1).maybeSingle();
|
|
if (error || !data) throw new Error(`pickSeededLocation failed: ${error?.message ?? "no rows"}`);
|
|
return data;
|
|
}
|
|
|
|
// The seeded org guarantees exactly one active team lead per team (§2's
|
|
// "reports-to" rule) — resolve_manager_for() relies on the same query.
|
|
export async function teamLeadId(teamId: string): Promise<string | null> {
|
|
const { data, error } = await adminClient
|
|
.from("employees")
|
|
.select("id")
|
|
.eq("team_id", teamId)
|
|
.eq("is_lead", true)
|
|
.neq("status", "Ausgetreten")
|
|
.maybeSingle();
|
|
if (error) throw new Error(`teamLeadId(${teamId}) failed: ${error.message}`);
|
|
return data?.id ?? null;
|
|
}
|
|
|
|
// YYYY-MM-DD, offset from today — for building "wirksam ab" test payloads
|
|
// without hardcoding dates that eventually go stale.
|
|
export function isoDateOffset(days: number): string {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + days);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
// Hires a throwaway employee into `teamId` via the real hire_employee RPC
|
|
// (not a raw insert) so every mutation test starts from a state the app
|
|
// itself can produce. Caller must clean up with deleteTestEmployee.
|
|
export async function hireTestEmployee(
|
|
hrClient: SupabaseClient<Database>,
|
|
teamId: string,
|
|
overrides: Partial<Record<string, unknown>> = {}
|
|
): Promise<string> {
|
|
const location = await pickSeededLocation();
|
|
const payload = {
|
|
first_name: "Integrationstest",
|
|
last_name: `Person-${randomUUID().slice(0, 8)}`,
|
|
gender: "w",
|
|
birth_date: "1990-01-01",
|
|
location_id: location.id,
|
|
team_id: teamId,
|
|
job_title: "Integrationstest-Rolle",
|
|
entry_date: isoDateOffset(-30),
|
|
source: "Extern",
|
|
...overrides,
|
|
};
|
|
const { data, error } = await hrClient.rpc("hire_employee", { payload });
|
|
if (error || !data) throw new Error(`hireTestEmployee failed: ${error?.message ?? "no id returned"}`);
|
|
return data;
|
|
}
|
|
|
|
// employees has no cascade from audit_log (target_employee_id is a plain
|
|
// FK, immutable log by design) — clear those rows first so the employee
|
|
// delete itself doesn't fail with a foreign key violation. employee_history
|
|
// and pending_org_changes do cascade on employee_id.
|
|
export async function deleteTestEmployee(employeeId: string): Promise<void> {
|
|
await adminClient.from("audit_log").delete().eq("target_employee_id", employeeId);
|
|
await adminClient.from("employees").delete().eq("id", employeeId);
|
|
}
|
|
|
|
// Creates a throwaway open position via the real create_position RPC.
|
|
// Defaults to a non-lead position reporting to `superiorEmployeeId` (its
|
|
// team is derived from that employee's own team, same as the app does).
|
|
// Caller must clean up with deleteTestPosition — and, since positions.
|
|
// reports_to_employee_id / filled_by_employee_id reference employees(id)
|
|
// with no cascade, delete positions before the employees they point to.
|
|
export async function createTestPosition(
|
|
hrClient: SupabaseClient<Database>,
|
|
superiorEmployeeId: string,
|
|
overrides: Partial<Record<string, unknown>> = {}
|
|
): Promise<string> {
|
|
const payload = {
|
|
title: `Integrationstest-Position-${randomUUID().slice(0, 8)}`,
|
|
superior_employee_id: superiorEmployeeId,
|
|
is_lead: false,
|
|
...overrides,
|
|
};
|
|
const { data, error } = await hrClient.rpc("create_position", { payload });
|
|
if (error || !data) throw new Error(`createTestPosition failed: ${error?.message ?? "no id returned"}`);
|
|
return data;
|
|
}
|
|
|
|
export async function deleteTestPosition(positionId: string): Promise<void> {
|
|
await adminClient.from("positions").delete().eq("id", positionId);
|
|
}
|