Files
alpenwerk-hr/tests/integration/helpers.ts
Maximilian Stubhan 79f0e19bf8 Org assignment history, mobile support, and a correctness pass
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.
2026-07-24 23:38:10 +02:00

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);
}