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:
@@ -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 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<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user