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 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 16:45:26 +02:00
parent 378ed61002
commit 45cf7b8c29
2 changed files with 958 additions and 0 deletions
@@ -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> = {}): 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 2050% 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<Partial<Anomaly>>({
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<Partial<Anomaly>>({
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<Partial<Anomaly>>({
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<Partial<Anomaly>>({
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<Partial<Anomaly>>({
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<Partial<Anomaly>>({
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<Partial<Anomaly>>({
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<Partial<Anomaly>>({
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);
});
});
});
@@ -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<string, unknown>), _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<string, unknown>) {
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);
});
});