Files
alpenwerk-hr/tests/integration/helpers.ts
Maximilian Stubhan 901c5c426e 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`).
2026-07-14 20:32:20 +02:00

147 lines
6.1 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);
}