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`).
147 lines
6.1 KiB
TypeScript
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);
|
|
}
|