From 45cf7b8c29479b22a5f93a84143eba20560fc61a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 10 Apr 2026 16:45:26 +0200 Subject: [PATCH] test(api): add 36 tests for insights anomalies and resource identifier read Insights: budget burn rate, staffing gaps, timeline overruns, utilization thresholds, summary counts, sorting. Resource: resolveByIdentifier, getHoverCard, getById, getByEid with alias fallback, getByIdentifierDetail mapping. Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/insights-anomalies.test.ts | 633 ++++++++++++++++++ .../resource-identifier-read.test.ts | 325 +++++++++ 2 files changed, 958 insertions(+) create mode 100644 packages/api/src/__tests__/insights-anomalies.test.ts create mode 100644 packages/api/src/__tests__/resource-identifier-read.test.ts diff --git a/packages/api/src/__tests__/insights-anomalies.test.ts b/packages/api/src/__tests__/insights-anomalies.test.ts new file mode 100644 index 0000000..dfe9ba0 --- /dev/null +++ b/packages/api/src/__tests__/insights-anomalies.test.ts @@ -0,0 +1,633 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildInsightSnapshot, + type Anomaly, + type InsightsDbAccess, + type InsightSnapshot, +} from "../router/insights-anomalies.js"; + +// Fixed reference date for all deterministic tests. +// now = 2026-04-10 (Friday) +// periodStart = 2026-04-01, periodEnd = 2026-04-30 +// twoWeeksFromNow = 2026-04-24 +// countBusinessDays(2026-01-01, 2026-12-31) = 261 +// countBusinessDays(2026-01-01, 2026-04-10) = 72 +// expectedBurnRate = 72 / 261 ≈ 0.2759 (≈28%) +const NOW = new Date("2026-04-10"); + +// --------------------------------------------------------------------------- +// Shared fixture shapes +// --------------------------------------------------------------------------- + +interface InsightProjectRecord { + id: string; + name: string; + budgetCents: number; + startDate: Date; + endDate: Date; + demandRequirements: { + headcount: number; + startDate: Date; + endDate: Date; + _count: { assignments: number }; + }[]; + assignments: { + resourceId: string; + startDate: Date; + endDate: Date; + hoursPerDay: number; + dailyCostCents: number; + status: string; + }[]; +} + +interface InsightResourceRecord { + id: string; + displayName: string; + availability: unknown; +} + +interface InsightAssignmentLoadRecord { + resourceId: string; + hoursPerDay: number; +} + +// --------------------------------------------------------------------------- +// DB mock factory +// --------------------------------------------------------------------------- + +function makeDb( + projects: InsightProjectRecord[] = [], + resources: InsightResourceRecord[] = [], + assignments: InsightAssignmentLoadRecord[] = [], +): InsightsDbAccess { + return { + project: { findMany: vi.fn().mockResolvedValue(projects) }, + resource: { findMany: vi.fn().mockResolvedValue(resources) }, + assignment: { findMany: vi.fn().mockResolvedValue(assignments) }, + }; +} + +// --------------------------------------------------------------------------- +// Reusable project factories +// --------------------------------------------------------------------------- + +/** Base project with no anomaly indicators (zero budget, no demands, no assignments). */ +function baseProject(overrides: Partial = {}): InsightProjectRecord { + return { + id: "p1", + name: "Base Project", + budgetCents: 0, + startDate: new Date("2026-01-01"), + endDate: new Date("2026-12-31"), + demandRequirements: [], + assignments: [], + ...overrides, + }; +} + +/** Project fixture designed to trigger a budget WARNING. + * + * Budget = 1,000,000 cents. One assignment: dailyCostCents=5000 from 2026-01-01 to 2026-04-10. + * assignmentDays = countBizDays(2026-01-01, min(2026-04-10, now)) = 72 + * totalCostCents = 5000 * 72 = 360,000 + * actualBurnRate = 360,000 / 1,000,000 = 0.36 + * expectedBurnRate ≈ 0.2759 + * 0.36 > 0.2759 * 1.2 = 0.3311 → WARNING + * 0.36 > 0.2759 * 1.5 = 0.4138 → NO (not critical) + */ +const budgetWarningProject: InsightProjectRecord = { + id: "p-budget-warn", + name: "Budget Warning", + budgetCents: 1_000_000, + startDate: new Date("2026-01-01"), + endDate: new Date("2026-12-31"), + demandRequirements: [], + assignments: [ + { + resourceId: "r1", + startDate: new Date("2026-01-01"), + endDate: new Date("2026-04-10"), + hoursPerDay: 8, + dailyCostCents: 5_000, + status: "ACTIVE", + }, + ], +}; + +/** Project fixture designed to trigger a budget CRITICAL. + * + * dailyCostCents=6000; actualBurnRate = 6000*72/1,000,000 = 0.432 + * 0.432 > 0.2759 * 1.5 = 0.4138 → CRITICAL + */ +const budgetCriticalProject: InsightProjectRecord = { + id: "p-budget-crit", + name: "Budget Critical", + budgetCents: 1_000_000, + startDate: new Date("2026-01-01"), + endDate: new Date("2026-12-31"), + demandRequirements: [], + assignments: [ + { + resourceId: "r1", + startDate: new Date("2026-01-01"), + endDate: new Date("2026-04-10"), + hoursPerDay: 8, + dailyCostCents: 6_000, + status: "ACTIVE", + }, + ], +}; + +/** Project fixture with on-track burn rate (no budget anomaly). + * + * dailyCostCents=3000; actualBurnRate = 3000*72/1,000,000 = 0.216 + * 0.216 ≤ 0.2759 * 1.2 = 0.3311 → NO anomaly + */ +const budgetOnTrackProject: InsightProjectRecord = { + id: "p-budget-ok", + name: "Budget OK", + budgetCents: 1_000_000, + startDate: new Date("2026-01-01"), + endDate: new Date("2026-12-31"), + demandRequirements: [], + assignments: [ + { + resourceId: "r1", + startDate: new Date("2026-01-01"), + endDate: new Date("2026-04-10"), + hoursPerDay: 8, + dailyCostCents: 3_000, + status: "ACTIVE", + }, + ], +}; + +/** Standard resource with 8 h/day availability (5×8 = 40 h/week). */ +const resource8h: InsightResourceRecord = { + id: "r1", + displayName: "Busy Dev", + availability: { mon: 8, tue: 8, wed: 8, thu: 8, fri: 8 }, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("buildInsightSnapshot", () => { + // ------------------------------------------------------------------------- + // 1. Empty data + // ------------------------------------------------------------------------- + + describe("empty data", () => { + it("returns empty anomalies and a zero summary when there is no data", async () => { + const snapshot = await buildInsightSnapshot(makeDb(), NOW); + + expect(snapshot.anomalies).toHaveLength(0); + expect(snapshot.summary).toEqual({ + total: 0, + criticalCount: 0, + budget: 0, + staffing: 0, + timeline: 0, + utilization: 0, + }); + }); + }); + + // ------------------------------------------------------------------------- + // 2. Budget anomalies + // ------------------------------------------------------------------------- + + describe("budget anomalies", () => { + it("produces no anomaly when the burn rate is on track (actual <= expected * 1.2)", async () => { + const snapshot = await buildInsightSnapshot(makeDb([budgetOnTrackProject]), NOW); + expect(snapshot.anomalies.filter((a) => a.type === "budget")).toHaveLength(0); + }); + + it("produces a warning anomaly when burn rate is 20–50% over expected", async () => { + const snapshot = await buildInsightSnapshot(makeDb([budgetWarningProject]), NOW); + const budgetAnomalies = snapshot.anomalies.filter((a) => a.type === "budget"); + + expect(budgetAnomalies).toHaveLength(1); + expect(budgetAnomalies[0]).toMatchObject>({ + type: "budget", + severity: "warning", + entityId: "p-budget-warn", + entityName: "Budget Warning", + }); + expect(budgetAnomalies[0]?.message).toContain("36% spent at 28% timeline"); + }); + + it("produces a critical anomaly when burn rate is >50% over expected", async () => { + const snapshot = await buildInsightSnapshot(makeDb([budgetCriticalProject]), NOW); + const budgetAnomalies = snapshot.anomalies.filter((a) => a.type === "budget"); + + expect(budgetAnomalies).toHaveLength(1); + expect(budgetAnomalies[0]).toMatchObject>({ + type: "budget", + severity: "critical", + entityId: "p-budget-crit", + }); + }); + + it("produces no budget anomaly when budgetCents is 0", async () => { + const project: InsightProjectRecord = { + ...budgetWarningProject, + id: "p-zero-budget", + budgetCents: 0, + }; + const snapshot = await buildInsightSnapshot(makeDb([project]), NOW); + expect(snapshot.anomalies.filter((a) => a.type === "budget")).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // 3. Staffing anomalies + // ------------------------------------------------------------------------- + + describe("staffing anomalies", () => { + it("produces a warning when >30% of positions are unfilled and demand starts within 2 weeks", async () => { + // headcount=4, assignments=2 → unfilled=2 → 50% > 30%, ≤60% → warning + const project = baseProject({ + id: "p-staff-warn", + name: "Staffing Warning", + demandRequirements: [ + { + headcount: 4, + startDate: new Date("2026-04-15"), // within 2 weeks (≤ 2026-04-24) + endDate: new Date("2026-06-01"), // after now (≥ 2026-04-10) + _count: { assignments: 2 }, + }, + ], + }); + + const snapshot = await buildInsightSnapshot(makeDb([project]), NOW); + const staffingAnomalies = snapshot.anomalies.filter((a) => a.type === "staffing"); + + expect(staffingAnomalies).toHaveLength(1); + expect(staffingAnomalies[0]).toMatchObject>({ + type: "staffing", + severity: "warning", + entityId: "p-staff-warn", + entityName: "Staffing Warning", + }); + expect(staffingAnomalies[0]?.message).toContain("2 of 4 positions unfilled"); + }); + + it("produces a critical anomaly when >60% of positions are unfilled", async () => { + // headcount=5, assignments=1 → unfilled=4 → 80% > 60% → critical + const project = baseProject({ + id: "p-staff-crit", + name: "Staffing Critical", + demandRequirements: [ + { + headcount: 5, + startDate: new Date("2026-04-15"), + endDate: new Date("2026-06-01"), + _count: { assignments: 1 }, + }, + ], + }); + + const snapshot = await buildInsightSnapshot(makeDb([project]), NOW); + const staffingAnomalies = snapshot.anomalies.filter((a) => a.type === "staffing"); + + expect(staffingAnomalies).toHaveLength(1); + expect(staffingAnomalies[0]).toMatchObject>({ + type: "staffing", + severity: "critical", + entityId: "p-staff-crit", + }); + }); + + it("produces no staffing anomaly when demand starts beyond 2 weeks", async () => { + // startDate=2026-05-01 > twoWeeksFromNow(2026-04-24) → demand excluded + const project = baseProject({ + id: "p-staff-future", + demandRequirements: [ + { + headcount: 5, + startDate: new Date("2026-05-01"), + endDate: new Date("2026-06-01"), + _count: { assignments: 0 }, + }, + ], + }); + + const snapshot = await buildInsightSnapshot(makeDb([project]), NOW); + expect(snapshot.anomalies.filter((a) => a.type === "staffing")).toHaveLength(0); + }); + + it("produces no staffing anomaly when unfill rate is ≤30%", async () => { + // headcount=4, assignments=3 → unfilled=1 → 25% ≤ 30% → no anomaly + const project = baseProject({ + id: "p-staff-ok", + demandRequirements: [ + { + headcount: 4, + startDate: new Date("2026-04-15"), + endDate: new Date("2026-06-01"), + _count: { assignments: 3 }, + }, + ], + }); + + const snapshot = await buildInsightSnapshot(makeDb([project]), NOW); + expect(snapshot.anomalies.filter((a) => a.type === "staffing")).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // 4. Timeline anomalies + // ------------------------------------------------------------------------- + + describe("timeline anomalies", () => { + it("produces a warning when an ACTIVE/CONFIRMED assignment extends past the project end date", async () => { + const project = baseProject({ + id: "p-timeline-warn", + name: "Timeline Warning", + endDate: new Date("2026-06-30"), + assignments: [ + { + resourceId: "r1", + startDate: new Date("2026-01-01"), + endDate: new Date("2026-07-31"), // past project end + hoursPerDay: 8, + dailyCostCents: 0, + status: "ACTIVE", + }, + ], + }); + + const snapshot = await buildInsightSnapshot(makeDb([project]), NOW); + const timelineAnomalies = snapshot.anomalies.filter((a) => a.type === "timeline"); + + expect(timelineAnomalies).toHaveLength(1); + expect(timelineAnomalies[0]).toMatchObject>({ + type: "timeline", + severity: "warning", + entityId: "p-timeline-warn", + entityName: "Timeline Warning", + }); + expect(timelineAnomalies[0]?.message).toContain( + "1 assignment(s) extend beyond the project end date", + ); + expect(timelineAnomalies[0]?.message).toContain("2026-06-30"); + }); + + it("produces no timeline anomaly when all assignments end before the project end date", async () => { + const project = baseProject({ + id: "p-timeline-ok", + endDate: new Date("2026-12-31"), + assignments: [ + { + resourceId: "r1", + startDate: new Date("2026-01-01"), + endDate: new Date("2026-06-30"), // before project end + hoursPerDay: 8, + dailyCostCents: 0, + status: "ACTIVE", + }, + ], + }); + + const snapshot = await buildInsightSnapshot(makeDb([project]), NOW); + expect(snapshot.anomalies.filter((a) => a.type === "timeline")).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // 5. Utilization anomalies + // ------------------------------------------------------------------------- + + describe("utilization anomalies", () => { + // The utilization query window is 2026-04-01 to 2026-04-30. + // Any assignment mocked in the `assignments` array is treated as active in that window. + + it("produces a warning when utilization is between 110% and 130%", async () => { + // dailyAvailableHours = 40/5 = 8 h/day + // bookedHours = 9 → utilization = Math.round(9/8*100) = 113% → warning + const snapshot = await buildInsightSnapshot( + makeDb([], [resource8h], [{ resourceId: "r1", hoursPerDay: 9 }]), + NOW, + ); + const utilAnomalies = snapshot.anomalies.filter((a) => a.type === "utilization"); + + expect(utilAnomalies).toHaveLength(1); + expect(utilAnomalies[0]).toMatchObject>({ + type: "utilization", + severity: "warning", + entityId: "r1", + entityName: "Busy Dev", + }); + expect(utilAnomalies[0]?.message).toContain("113% utilization"); + }); + + it("produces a critical anomaly when utilization exceeds 130%", async () => { + // bookedHours = 11 → utilization = Math.round(11/8*100) = 138% → critical + const snapshot = await buildInsightSnapshot( + makeDb([], [resource8h], [{ resourceId: "r1", hoursPerDay: 11 }]), + NOW, + ); + const utilAnomalies = snapshot.anomalies.filter((a) => a.type === "utilization"); + + expect(utilAnomalies).toHaveLength(1); + expect(utilAnomalies[0]).toMatchObject>({ + type: "utilization", + severity: "critical", + entityId: "r1", + }); + expect(utilAnomalies[0]?.message).toContain("138% utilization"); + }); + + it("produces a warning when utilization is under 40% but has some bookings", async () => { + // bookedHours = 2 → utilization = Math.round(2/8*100) = 25% < 40% → underutil warning + const snapshot = await buildInsightSnapshot( + makeDb([], [resource8h], [{ resourceId: "r1", hoursPerDay: 2 }]), + NOW, + ); + const utilAnomalies = snapshot.anomalies.filter((a) => a.type === "utilization"); + + expect(utilAnomalies).toHaveLength(1); + expect(utilAnomalies[0]).toMatchObject>({ + type: "utilization", + severity: "warning", + entityId: "r1", + }); + expect(utilAnomalies[0]?.message).toContain("25% utilization"); + }); + + it("produces no anomaly when resource availability is null", async () => { + const nullAvailResource: InsightResourceRecord = { + id: "r-null", + displayName: "Null Avail", + availability: null, + }; + const snapshot = await buildInsightSnapshot( + makeDb([], [nullAvailResource], [{ resourceId: "r-null", hoursPerDay: 15 }]), + NOW, + ); + expect(snapshot.anomalies.filter((a) => a.type === "utilization")).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // 6. Summary + // ------------------------------------------------------------------------- + + describe("summary counts", () => { + it("counts anomalies by type correctly", async () => { + // Trigger: 1 budget warning + 1 timeline warning. + // Keep endDate=2026-12-31 so the budget math still yields a warning + // (same as budgetWarningProject), then add a separate assignment that + // extends past that same end date to create the timeline warning. + const project: InsightProjectRecord = { + ...budgetWarningProject, + id: "p-summary", + name: "Summary Project", + // endDate stays 2026-12-31 (inherited from budgetWarningProject spread) + assignments: [ + ...budgetWarningProject.assignments, + { + resourceId: "r2", + startDate: new Date("2026-01-01"), + endDate: new Date("2027-01-31"), // extends past 2026-12-31 + hoursPerDay: 8, + dailyCostCents: 0, + status: "ACTIVE", + }, + ], + }; + + const snapshot = await buildInsightSnapshot(makeDb([project]), NOW); + + expect(snapshot.summary.budget).toBe(1); + expect(snapshot.summary.timeline).toBe(1); + expect(snapshot.summary.staffing).toBe(0); + expect(snapshot.summary.utilization).toBe(0); + expect(snapshot.summary.total).toBe(2); + }); + + it("counts only critical-severity anomalies in criticalCount", async () => { + // 1 budget warning + 1 budget critical from two separate projects + const snapshot = await buildInsightSnapshot( + makeDb([budgetWarningProject, budgetCriticalProject]), + NOW, + ); + + expect(snapshot.summary.budget).toBe(2); + expect(snapshot.summary.criticalCount).toBe(1); + }); + }); + + // ------------------------------------------------------------------------- + // 7. Sorting + // ------------------------------------------------------------------------- + + describe("sorting", () => { + it("places critical anomalies before warning anomalies regardless of insertion order", async () => { + // budgetWarningProject triggers a warning; budgetCriticalProject triggers a critical + const snapshot = await buildInsightSnapshot( + makeDb([budgetWarningProject, budgetCriticalProject]), + NOW, + ); + + const anomalies = snapshot.anomalies; + expect(anomalies.length).toBeGreaterThanOrEqual(2); + + const firstCriticalIdx = anomalies.findIndex((a) => a.severity === "critical"); + const lastWarningIdx = anomalies.map((a) => a.severity).lastIndexOf("warning"); + + expect(firstCriticalIdx).toBeLessThan(lastWarningIdx); + }); + }); + + // ------------------------------------------------------------------------- + // 8. Combined scenarios + // ------------------------------------------------------------------------- + + describe("combined scenarios", () => { + it("detects budget, timeline, and utilization anomalies in a single call", async () => { + // Keep endDate=2026-12-31 so the budget warn math holds, and add a + // timeline-overrun assignment that ends in 2027. + const combinedProject: InsightProjectRecord = { + ...budgetWarningProject, + id: "p-combined", + name: "Combined Project", + assignments: [ + // Budget-triggering assignment (same as budgetWarningProject) + ...budgetWarningProject.assignments, + // Timeline-overrun assignment (past 2026-12-31) + { + resourceId: "r1", + startDate: new Date("2026-01-01"), + endDate: new Date("2027-01-31"), + hoursPerDay: 8, + dailyCostCents: 0, + status: "CONFIRMED", + }, + ], + }; + + const snapshot = await buildInsightSnapshot( + makeDb( + [combinedProject], + [resource8h], + [{ resourceId: "r1", hoursPerDay: 11 }], // utilization critical + ), + NOW, + ); + + const types = snapshot.anomalies.map((a) => a.type); + expect(types).toContain("budget"); + expect(types).toContain("timeline"); + expect(types).toContain("utilization"); + expect(snapshot.summary.total).toBeGreaterThanOrEqual(3); + }); + + it("detects staffing, timeline, and utilization anomalies simultaneously for different entities", async () => { + const staffingProject = baseProject({ + id: "p-multi", + name: "Multi Anomaly Project", + endDate: new Date("2026-06-30"), + demandRequirements: [ + { + headcount: 5, + startDate: new Date("2026-04-15"), + endDate: new Date("2026-06-01"), + _count: { assignments: 1 }, // 80% unfilled → critical + }, + ], + assignments: [ + { + resourceId: "r1", + startDate: new Date("2026-01-01"), + endDate: new Date("2026-07-31"), // extends past project end + hoursPerDay: 8, + dailyCostCents: 0, + status: "ACTIVE", + }, + ], + }); + + const snapshot = await buildInsightSnapshot( + makeDb( + [staffingProject], + [resource8h], + [{ resourceId: "r1", hoursPerDay: 9 }], // 113% → utilization warning + ), + NOW, + ); + + expect(snapshot.summary.staffing).toBeGreaterThanOrEqual(1); + expect(snapshot.summary.timeline).toBeGreaterThanOrEqual(1); + expect(snapshot.summary.utilization).toBeGreaterThanOrEqual(1); + expect(snapshot.summary.total).toBeGreaterThanOrEqual(3); + + // Critical staffing anomaly must come before warnings in sorted output + const criticalIdx = snapshot.anomalies.findIndex((a) => a.severity === "critical"); + expect(criticalIdx).toBe(0); + }); + }); +}); diff --git a/packages/api/src/__tests__/resource-identifier-read.test.ts b/packages/api/src/__tests__/resource-identifier-read.test.ts new file mode 100644 index 0000000..e49536e --- /dev/null +++ b/packages/api/src/__tests__/resource-identifier-read.test.ts @@ -0,0 +1,325 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// Mock resource read shared +const resolveResourceIdentifierSnapshotMock = vi.fn(); +const readResourceByIdentifierDetailSnapshotMock = vi.fn(); + +vi.mock("../router/resource-read-shared.js", () => ({ + resolveResourceIdentifierSnapshot: (...args: unknown[]) => + resolveResourceIdentifierSnapshotMock(...args), + readResourceByIdentifierDetailSnapshot: (...args: unknown[]) => + readResourceByIdentifierDetailSnapshotMock(...args), + ResourceDirectoryQuerySchema: {}, + ResourceListQuerySchema: {}, +})); + +// Mock resource read models +vi.mock("../router/resource-read-models.js", () => ({ + mapResourceDetail: (r: unknown) => ({ ...(r as Record), _mapped: true }), +})); + +// Mock anonymization +const anonymizeResourceMock = vi.fn().mockImplementation((r: unknown) => r); +const getAnonymizationDirectoryMock = vi.fn().mockResolvedValue(null); + +vi.mock("../lib/anonymization.js", () => ({ + anonymizeResource: (...args: unknown[]) => anonymizeResourceMock(...args), + getAnonymizationDirectory: (...args: unknown[]) => getAnonymizationDirectoryMock(...args), +})); + +// Mock resource access +vi.mock("../lib/resource-access.js", () => ({ + assertCanReadResource: vi.fn().mockResolvedValue(undefined), +})); + +// Mock selects +vi.mock("../db/selects.js", () => ({ + ROLE_BRIEF_SELECT: { id: true, name: true }, +})); + +import { createCallerFactory, createTRPCRouter } from "../trpc.js"; +import { resourceIdentifierReadProcedures } from "../router/resource-identifier-read.js"; + +const router = createTRPCRouter(resourceIdentifierReadProcedures); +const createCaller = createCallerFactory(router); + +function createProtectedCaller(db: Record) { + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { id: "user_1", systemRole: SystemRole.MANAGER, permissionOverrides: null }, + }); +} + +const mockResource = { + id: "resource_1", + displayName: "Test User", + eid: "E001", + email: "test@example.com", + chapter: "Engineering", + lcrCents: 5000, + ucrCents: 7500, + currency: "EUR", + chargeabilityTarget: 80, + skills: [], + availability: { mon: 8, tue: 8, wed: 8, thu: 8, fri: 8 }, + isActive: true, + resourceType: "INTERNAL", + areaRole: { id: "role_1", name: "Developer" }, + country: { name: "Germany", code: "DE" }, + managementLevel: { name: "Senior" }, + userId: "user_1", + blueprint: null, + resourceRoles: [], +}; + +beforeEach(() => { + vi.clearAllMocks(); + anonymizeResourceMock.mockImplementation((r: unknown) => r); + getAnonymizationDirectoryMock.mockResolvedValue(null); +}); + +// ─── resolveByIdentifier ────────────────────────────────────────────────────── + +describe("resourceIdentifierRead.resolveByIdentifier", () => { + it("returns resource when resolved successfully", async () => { + resolveResourceIdentifierSnapshotMock.mockResolvedValue(mockResource); + const caller = createProtectedCaller({}); + + const result = await caller.resolveByIdentifier({ identifier: "E001" }); + + expect(result).toEqual(mockResource); + }); + + it("throws NOT_FOUND when result has 'error' key", async () => { + resolveResourceIdentifierSnapshotMock.mockResolvedValue({ error: "Resource not found: E999" }); + const caller = createProtectedCaller({}); + + await expect(caller.resolveByIdentifier({ identifier: "E999" })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Resource not found", + }); + }); + + it("passes identifier to resolveResourceIdentifierSnapshot", async () => { + resolveResourceIdentifierSnapshotMock.mockResolvedValue(mockResource); + const caller = createProtectedCaller({}); + + await caller.resolveByIdentifier({ identifier: "Test User" }); + + expect(resolveResourceIdentifierSnapshotMock).toHaveBeenCalledWith( + expect.objectContaining({ dbUser: expect.any(Object) }), + "Test User", + expect.any(String), + ); + }); +}); + +// ─── getHoverCard ───────────────────────────────────────────────────────────── + +describe("resourceIdentifierRead.getHoverCard", () => { + it("returns selected fields for a found resource", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(mockResource), + }, + }; + const caller = createProtectedCaller(db); + + const result = await caller.getHoverCard({ id: "resource_1" }); + + expect(result).toMatchObject({ + id: mockResource.id, + displayName: mockResource.displayName, + eid: mockResource.eid, + chapter: mockResource.chapter, + lcrCents: mockResource.lcrCents, + ucrCents: mockResource.ucrCents, + currency: mockResource.currency, + chargeabilityTarget: mockResource.chargeabilityTarget, + isActive: mockResource.isActive, + resourceType: mockResource.resourceType, + areaRole: mockResource.areaRole, + country: mockResource.country, + managementLevel: mockResource.managementLevel, + }); + }); + + it("throws NOT_FOUND when findUnique returns null", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + const caller = createProtectedCaller(db); + + await expect(caller.getHoverCard({ id: "nonexistent" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); + + it("anonymizes the resource before returning", async () => { + const anonymized = { ...mockResource, displayName: "Anonymous", eid: "ANON" }; + anonymizeResourceMock.mockReturnValue(anonymized); + + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(mockResource), + }, + }; + const caller = createProtectedCaller(db); + + const result = await caller.getHoverCard({ id: "resource_1" }); + + expect(anonymizeResourceMock).toHaveBeenCalledWith(mockResource, null); + expect(result.displayName).toBe("Anonymous"); + expect(result.eid).toBe("ANON"); + }); +}); + +// ─── getByIdentifierDetail ──────────────────────────────────────────────────── + +describe("resourceIdentifierRead.getByIdentifierDetail", () => { + it("maps resource detail when found (adds _mapped: true)", async () => { + readResourceByIdentifierDetailSnapshotMock.mockResolvedValue(mockResource); + const caller = createProtectedCaller({}); + + const result = await caller.getByIdentifierDetail({ identifier: "E001" }); + + expect(result).toMatchObject({ ...mockResource, _mapped: true }); + }); + + it("returns error result directly when has 'error' key", async () => { + const errorResult = { error: "Resource not found: E999", suggestions: [] }; + readResourceByIdentifierDetailSnapshotMock.mockResolvedValue(errorResult); + const caller = createProtectedCaller({}); + + const result = await caller.getByIdentifierDetail({ identifier: "E999" }); + + expect(result).toEqual(errorResult); + }); +}); + +// ─── getById ────────────────────────────────────────────────────────────────── + +describe("resourceIdentifierRead.getById", () => { + it("returns resource with isOwnedByCurrentUser=true when userId matches", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(mockResource), + }, + }; + const caller = createProtectedCaller(db); + + const result = await caller.getById({ id: "resource_1" }); + + expect(result).toMatchObject({ ...mockResource, isOwnedByCurrentUser: true }); + }); + + it("returns isOwnedByCurrentUser=false when userId doesn't match", async () => { + const otherResource = { ...mockResource, userId: "other_user" }; + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(otherResource), + }, + }; + const caller = createProtectedCaller(db); + + const result = await caller.getById({ id: "resource_1" }); + + expect(result).toMatchObject({ isOwnedByCurrentUser: false }); + }); + + it("throws NOT_FOUND when not found", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + const caller = createProtectedCaller(db); + + await expect(caller.getById({ id: "nonexistent" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); + +// ─── getByEid ───────────────────────────────────────────────────────────────── + +describe("resourceIdentifierRead.getByEid", () => { + it("returns resource when found by eid", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(mockResource), + }, + }; + const caller = createProtectedCaller(db); + + const result = await caller.getByEid({ eid: "E001" }); + + expect(result).toEqual(mockResource); + expect(db.resource.findUnique).toHaveBeenCalledWith({ where: { eid: "E001" } }); + }); + + it("falls back to anonymization directory alias when eid not found directly", async () => { + const aliasMap = new Map([["e001-alias", "resource_1"]]); + const directory = { byAliasEid: aliasMap, byResourceId: new Map() }; + getAnonymizationDirectoryMock.mockResolvedValue(directory); + + const db = { + resource: { + findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(mockResource), + }, + }; + const caller = createProtectedCaller(db); + + const result = await caller.getByEid({ eid: "E001-Alias" }); + + expect(result).toEqual(mockResource); + expect(db.resource.findUnique).toHaveBeenNthCalledWith(2, { where: { id: "resource_1" } }); + }); + + it("throws NOT_FOUND when both lookups fail", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + const caller = createProtectedCaller(db); + + await expect(caller.getByEid({ eid: "UNKNOWN" })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Resource not found", + }); + }); +}); + +// ─── getByIdentifier ────────────────────────────────────────────────────────── + +describe("resourceIdentifierRead.getByIdentifier", () => { + it("delegates to resolveResourceIdentifierSnapshot", async () => { + resolveResourceIdentifierSnapshotMock.mockResolvedValue(mockResource); + const caller = createProtectedCaller({}); + + await caller.getByIdentifier({ identifier: "E001" }); + + expect(resolveResourceIdentifierSnapshotMock).toHaveBeenCalledWith( + expect.objectContaining({ dbUser: expect.any(Object) }), + "E001", + ); + }); + + it("returns result directly", async () => { + const errorResult = { error: "Resource not found: E999" }; + resolveResourceIdentifierSnapshotMock.mockResolvedValue(errorResult); + const caller = createProtectedCaller({}); + + const result = await caller.getByIdentifier({ identifier: "E999" }); + + expect(result).toEqual(errorResult); + }); +});