import type { SupabaseClient } from "@supabase/supabase-js"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Database } from "@/lib/supabase/types"; import { adminClient, createHrUser, createTestPosition, deleteTestEmployee, deleteTestPosition, deleteTestUser, hireTestEmployee, isoDateOffset, pickSeededUnit, signInAs, type TestUser, } from "./helpers"; // Die Besetzungshistorie (A008). Im Altmodell wurde sie von einem Trigger in // eine eigene Tabelle mitgeschrieben; jetzt *ist* position_assignments die // Historie — dieselben Zeilen, aus denen auch der heutige Stand kommt. Damit // gibt es nichts mehr, was auseinanderlaufen könnte, aber die Invarianten // müssen umso mehr halten: keine Lücke, keine Überschneidung, höchstens eine // laufende Besetzung. // // Die Tests treiben die echten RPCs, nicht Inserts. describe("position_assignments als Besetzungshistorie", () => { let hrUser: TestUser; let hrClient: SupabaseClient; let unitA: { id: string }; let unitB: { id: string }; const employeeIds: string[] = []; const positionIds: string[] = []; beforeAll(async () => { hrUser = await createHrUser({ active: true }); hrClient = await signInAs(hrUser); unitA = await pickSeededUnit(); unitB = await pickSeededUnit(unitA.id); }); afterAll(async () => { // Planstellen vor den Personen: die Besetzungen hängen an beiden. for (const id of positionIds) await deleteTestPosition(id); for (const id of employeeIds) await deleteTestEmployee(id); await deleteTestUser(hrUser); }); async function freshPosition(orgUnitId: string): Promise { const id = await createTestPosition(hrClient, orgUnitId); positionIds.push(id); return id; } async function freshEmployee(positionId: string): Promise { const id = await hireTestEmployee(hrClient, positionId); employeeIds.push(id); return id; } async function assignmentsFor(employeeId: string) { const { data } = await adminClient .from("position_assignments") .select("position_id, valid_from, valid_to") .eq("employee_id", employeeId) .order("valid_from"); return data ?? []; } it("öffnet eine Besetzung bei der Einstellung", async () => { const positionId = await freshPosition(unitA.id); const employeeId = await freshEmployee(positionId); const rows = await assignmentsFor(employeeId); expect(rows).toHaveLength(1); expect(rows[0].position_id).toBe(positionId); expect(rows[0].valid_to).toBeNull(); }); it("übernimmt die Tätigkeit aus dem Job der Planstelle", async () => { // Sie wird bei der Einstellung nicht mitgegeben — sonst könnten die // Tätigkeit auf der Person und die der Planstelle auseinanderlaufen. const positionId = await freshPosition(unitA.id); const employeeId = await freshEmployee(positionId); const { data: position } = await adminClient .from("om_positions") .select("jobs!inner(title)") .eq("id", positionId) .single(); const { data: employee } = await adminClient.from("employees").select("job_title").eq("id", employeeId).single(); expect(employee?.job_title).toBe((position as unknown as { jobs: { title: string } }).jobs.title); }); it("weist eine Einstellung auf eine bereits besetzte Planstelle zurück", async () => { // Der Unique-Index fängt das ebenfalls ab; die RPC soll es vorher mit // einer Meldung tun, die in der Oberfläche etwas erklärt. const positionId = await freshPosition(unitA.id); await freshEmployee(positionId); await expect(hireTestEmployee(hrClient, positionId)).rejects.toThrow(/bereits besetzt/i); }); it("schliesst die alte Besetzung und öffnet die neue bei einer Versetzung", async () => { const from = await freshPosition(unitA.id); const to = await freshPosition(unitB.id); const employeeId = await freshEmployee(from); const { error } = await hrClient.rpc("transfer_employee", { payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: to }, }); expect(error).toBeNull(); const rows = await assignmentsFor(employeeId); expect(rows).toHaveLength(2); expect(rows[0].position_id).toBe(from); expect(rows[0].valid_to).toBe(isoDateOffset(0)); expect(rows[1].position_id).toBe(to); expect(rows[1].valid_to).toBeNull(); // Die Intervalle müssen exakt aneinanderstossen, sonst landet eine // Stichtagsabfrage in einer Lücke. expect(rows[1].valid_from).toBe(rows[0].valid_to); }); it("hält höchstens eine laufende Besetzung je Person", async () => { const from = await freshPosition(unitA.id); const to = await freshPosition(unitB.id); const employeeId = await freshEmployee(from); await hrClient.rpc("transfer_employee", { payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: to }, }); const rows = await assignmentsFor(employeeId); expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1); }); it("weist eine Versetzung auf eine besetzte Zielplanstelle zurück", async () => { const besetzt = await freshPosition(unitA.id); await freshEmployee(besetzt); const andere = await freshPosition(unitB.id); const employeeId = await freshEmployee(andere); const { error } = await hrClient.rpc("transfer_employee", { payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: besetzt }, }); expect(error?.message).toMatch(/bereits besetzt/i); }); it("merkt eine Versetzung in der Zukunft vor, statt sie sofort zu schreiben", async () => { const from = await freshPosition(unitA.id); const to = await freshPosition(unitB.id); const employeeId = await freshEmployee(from); await hrClient.rpc("transfer_employee", { payload: { employee_id: employeeId, effective_date: isoDateOffset(30), target_position_id: to }, }); const rows = await assignmentsFor(employeeId); expect(rows).toHaveLength(1); expect(rows[0].position_id).toBe(from); const { data: pending } = await adminClient .from("pending_org_changes") .select("change_type, effective_date, payload, status") .eq("employee_id", employeeId); expect(pending).toHaveLength(1); expect(pending?.[0].status).toBe("pending"); expect((pending?.[0].payload as { target_position_id: string }).target_position_id).toBe(to); }); it("gibt die Planstelle beim Austritt frei", async () => { const positionId = await freshPosition(unitA.id); const employeeId = await freshEmployee(positionId); const { error } = await hrClient.rpc("terminate_employee", { payload: { employee_id: employeeId, exit_date: isoDateOffset(0), exit_reason: "Kündigung AN" }, }); expect(error).toBeNull(); const rows = await assignmentsFor(employeeId); expect(rows.filter((r) => r.valid_to === null)).toHaveLength(0); expect(rows.at(-1)?.valid_to).toBe(isoDateOffset(0)); }); it("hinterlässt keine überschneidenden Besetzungen auf einer Planstelle", async () => { // Der Unique-Index deckt nur die *laufende* Besetzung ab; die Historie // könnte sich unbemerkt überschneiden. const positionId = await freshPosition(unitA.id); const ersteR = await freshEmployee(positionId); await hrClient.rpc("terminate_employee", { payload: { employee_id: ersteR, exit_date: isoDateOffset(0), exit_reason: "Kündigung AN" }, }); await freshEmployee(positionId, ); const { data } = await adminClient .from("position_assignments") .select("valid_from, valid_to") .eq("position_id", positionId) .order("valid_from"); const rows = data ?? []; for (let i = 1; i < rows.length; i++) { const vorher = rows[i - 1]; expect(vorher.valid_to === null || vorher.valid_to <= rows[i].valid_from, `Besetzung ${i} überschneidet`).toBe(true); } }); it("ist ohne aktive HR-Sitzung nicht lesbar", async () => { const outsider = await createHrUser({ active: false }); const outsiderClient = await signInAs(outsider); const { data } = await outsiderClient.from("position_assignments").select("id").limit(1); expect(data ?? []).toHaveLength(0); await deleteTestUser(outsider); }); });