487 lines
13 KiB
TypeScript
487 lines
13 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { buildResourceMonthTemplateCompleteness } from "../router/report-blueprints-support.js";
|
|
import {
|
|
DeleteReportTemplateInputSchema,
|
|
deleteReportTemplate,
|
|
listReportTemplates,
|
|
SaveReportTemplateInputSchema,
|
|
saveReportTemplate,
|
|
} from "../router/report-template-procedure-support.js";
|
|
|
|
function createContext(reportTemplate: Record<string, unknown>) {
|
|
return {
|
|
db: {
|
|
reportTemplate,
|
|
},
|
|
dbUser: {
|
|
id: "user_controller",
|
|
systemRole: "CONTROLLER",
|
|
permissionOverrides: null,
|
|
},
|
|
} as const;
|
|
}
|
|
|
|
describe("report template procedure support", () => {
|
|
it("reuses the shared resource month completeness basis", () => {
|
|
expect(buildResourceMonthTemplateCompleteness([
|
|
"monthKey",
|
|
"displayName",
|
|
"countryName",
|
|
"federalState",
|
|
"metroCityName",
|
|
"monthlyPublicHolidayCount",
|
|
"monthlyPublicHolidayHoursDeduction",
|
|
"monthlyAbsenceDayEquivalent",
|
|
"monthlyAbsenceHoursDeduction",
|
|
"monthlySahHours",
|
|
"monthlyTargetHours",
|
|
"monthlyActualBookedHours",
|
|
"monthlyUnassignedHours",
|
|
])).toMatchObject({
|
|
scope: "resource_month",
|
|
isAuditReady: true,
|
|
isRecommendedComplete: false,
|
|
minimumAuditColumnCount: 13,
|
|
selectedMinimumAuditColumnCount: 13,
|
|
missingMinimumAuditColumns: [],
|
|
missingRecommendedColumns: expect.arrayContaining([
|
|
"eid",
|
|
"chapter",
|
|
"monthlyExpectedBookedHours",
|
|
]),
|
|
});
|
|
});
|
|
|
|
it("lists shared and owned templates with parsed config and ownership flags", async () => {
|
|
const updatedAt = new Date("2026-03-31T10:00:00.000Z");
|
|
const ctx = createContext({
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "tpl_1",
|
|
name: "Owned resource month",
|
|
description: "Monthly default",
|
|
entity: "RESOURCE_MONTH",
|
|
config: {
|
|
entity: "resource_month",
|
|
columns: ["displayName", "monthlySahHours"],
|
|
filters: [],
|
|
periodMonth: "2026-03",
|
|
},
|
|
isShared: false,
|
|
ownerId: "user_controller",
|
|
updatedAt,
|
|
},
|
|
{
|
|
id: "tpl_2",
|
|
name: "Shared project",
|
|
description: null,
|
|
entity: "PROJECT",
|
|
config: {
|
|
entity: "project",
|
|
columns: ["name"],
|
|
filters: [],
|
|
},
|
|
isShared: true,
|
|
ownerId: "user_other",
|
|
updatedAt,
|
|
},
|
|
]),
|
|
});
|
|
|
|
const result = await listReportTemplates(ctx);
|
|
|
|
expect(ctx.db.reportTemplate.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
OR: [
|
|
{ ownerId: "user_controller" },
|
|
{ isShared: true },
|
|
],
|
|
},
|
|
orderBy: [{ name: "asc" }],
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
description: true,
|
|
entity: true,
|
|
config: true,
|
|
isShared: true,
|
|
ownerId: true,
|
|
updatedAt: true,
|
|
},
|
|
});
|
|
expect(result).toEqual([
|
|
expect.objectContaining({
|
|
id: "tpl_1",
|
|
name: "Owned resource month",
|
|
description: "Monthly default",
|
|
entity: "resource_month",
|
|
config: {
|
|
entity: "resource_month",
|
|
columns: ["displayName", "monthlySahHours"],
|
|
filters: [],
|
|
periodMonth: "2026-03",
|
|
sortDir: "asc",
|
|
},
|
|
isShared: false,
|
|
isOwner: true,
|
|
completeness: expect.objectContaining({
|
|
scope: "resource_month",
|
|
isAuditReady: false,
|
|
isRecommendedComplete: false,
|
|
recommendedColumnCount: 26,
|
|
selectedRecommendedColumnCount: 2,
|
|
minimumAuditColumnCount: 13,
|
|
selectedMinimumAuditColumnCount: 2,
|
|
missingRecommendedColumns: expect.arrayContaining([
|
|
"monthKey",
|
|
"monthlyTargetHours",
|
|
"monthlyUnassignedHours",
|
|
]),
|
|
missingMinimumAuditColumns: expect.arrayContaining([
|
|
"monthKey",
|
|
"countryName",
|
|
"monthlyTargetHours",
|
|
]),
|
|
}),
|
|
updatedAt,
|
|
}),
|
|
expect.objectContaining({
|
|
id: "tpl_2",
|
|
name: "Shared project",
|
|
description: null,
|
|
entity: "project",
|
|
config: {
|
|
entity: "project",
|
|
columns: ["name"],
|
|
filters: [],
|
|
sortDir: "asc",
|
|
},
|
|
isShared: true,
|
|
isOwner: false,
|
|
completeness: null,
|
|
updatedAt,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("marks resource month templates audit-ready once the minimum audit basis is present", async () => {
|
|
const updatedAt = new Date("2026-03-31T10:30:00.000Z");
|
|
const ctx = createContext({
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "tpl_audit",
|
|
name: "Audit-ready resource month",
|
|
description: null,
|
|
entity: "RESOURCE_MONTH",
|
|
config: {
|
|
entity: "resource_month",
|
|
columns: [
|
|
"monthKey",
|
|
"displayName",
|
|
"countryName",
|
|
"federalState",
|
|
"metroCityName",
|
|
"monthlyPublicHolidayCount",
|
|
"monthlyPublicHolidayHoursDeduction",
|
|
"monthlyAbsenceDayEquivalent",
|
|
"monthlyAbsenceHoursDeduction",
|
|
"monthlySahHours",
|
|
"monthlyTargetHours",
|
|
"monthlyActualBookedHours",
|
|
"monthlyUnassignedHours",
|
|
],
|
|
filters: [],
|
|
periodMonth: "2026-03",
|
|
},
|
|
isShared: false,
|
|
ownerId: "user_controller",
|
|
updatedAt,
|
|
},
|
|
]),
|
|
});
|
|
|
|
const [result] = await listReportTemplates(ctx);
|
|
|
|
expect(result?.completeness).toMatchObject({
|
|
scope: "resource_month",
|
|
isAuditReady: true,
|
|
isRecommendedComplete: false,
|
|
minimumAuditColumnCount: 13,
|
|
selectedMinimumAuditColumnCount: 13,
|
|
missingMinimumAuditColumns: [],
|
|
missingRecommendedColumns: expect.arrayContaining([
|
|
"eid",
|
|
"chapter",
|
|
"monthlyExpectedBookedHours",
|
|
]),
|
|
});
|
|
});
|
|
|
|
it("upserts new templates under the current owner with converted entity values", async () => {
|
|
const upsert = vi.fn().mockResolvedValue({
|
|
id: "tpl_new",
|
|
updatedAt: new Date("2026-03-31T11:00:00.000Z"),
|
|
});
|
|
const ctx = createContext({
|
|
upsert,
|
|
});
|
|
const input = SaveReportTemplateInputSchema.parse({
|
|
name: "Monthly default",
|
|
description: "Team-wide default",
|
|
isShared: true,
|
|
config: {
|
|
entity: "resource_month",
|
|
columns: ["displayName", "monthlyTargetHours"],
|
|
filters: [],
|
|
periodMonth: "2026-03",
|
|
},
|
|
});
|
|
|
|
const result = await saveReportTemplate(ctx, input);
|
|
|
|
expect(result).toEqual({
|
|
id: "tpl_new",
|
|
updatedAt: new Date("2026-03-31T11:00:00.000Z"),
|
|
});
|
|
expect(upsert).toHaveBeenCalledWith({
|
|
where: {
|
|
ownerId_name: {
|
|
ownerId: "user_controller",
|
|
name: "Monthly default",
|
|
},
|
|
},
|
|
update: {
|
|
description: "Team-wide default",
|
|
entity: "RESOURCE_MONTH",
|
|
config: {
|
|
entity: "resource_month",
|
|
columns: ["displayName", "monthlyTargetHours"],
|
|
filters: [],
|
|
periodMonth: "2026-03",
|
|
sortDir: "asc",
|
|
},
|
|
isShared: true,
|
|
},
|
|
create: {
|
|
ownerId: "user_controller",
|
|
name: "Monthly default",
|
|
description: "Team-wide default",
|
|
entity: "RESOURCE_MONTH",
|
|
config: {
|
|
entity: "resource_month",
|
|
columns: ["displayName", "monthlyTargetHours"],
|
|
filters: [],
|
|
periodMonth: "2026-03",
|
|
sortDir: "asc",
|
|
},
|
|
isShared: true,
|
|
},
|
|
select: { id: true, updatedAt: true },
|
|
});
|
|
});
|
|
|
|
it("updates owned templates and rejects updates for foreign templates", async () => {
|
|
const findUnique = vi.fn()
|
|
.mockResolvedValueOnce({ ownerId: "user_controller" })
|
|
.mockResolvedValueOnce({ ownerId: "user_other" });
|
|
const update = vi.fn().mockResolvedValue({
|
|
id: "tpl_1",
|
|
updatedAt: new Date("2026-03-31T12:00:00.000Z"),
|
|
});
|
|
const ctx = createContext({
|
|
findUnique,
|
|
update,
|
|
});
|
|
|
|
const ownedResult = await saveReportTemplate(ctx, SaveReportTemplateInputSchema.parse({
|
|
id: "tpl_1",
|
|
name: "Owned template",
|
|
description: "Updated",
|
|
config: {
|
|
entity: "project",
|
|
columns: ["name"],
|
|
filters: [],
|
|
},
|
|
}));
|
|
|
|
expect(ownedResult).toEqual({
|
|
id: "tpl_1",
|
|
updatedAt: new Date("2026-03-31T12:00:00.000Z"),
|
|
});
|
|
expect(update).toHaveBeenCalledWith({
|
|
where: { id: "tpl_1" },
|
|
data: {
|
|
name: "Owned template",
|
|
description: "Updated",
|
|
entity: "PROJECT",
|
|
config: {
|
|
entity: "project",
|
|
columns: ["name"],
|
|
filters: [],
|
|
sortDir: "asc",
|
|
},
|
|
isShared: false,
|
|
},
|
|
select: { id: true, updatedAt: true },
|
|
});
|
|
|
|
await expect(saveReportTemplate(ctx, SaveReportTemplateInputSchema.parse({
|
|
id: "tpl_2",
|
|
name: "Foreign template",
|
|
config: {
|
|
entity: "resource",
|
|
columns: ["displayName"],
|
|
filters: [],
|
|
},
|
|
}))).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "Template cannot be updated",
|
|
});
|
|
});
|
|
|
|
it("clears an existing description when the template is saved with a blank description", async () => {
|
|
const findUnique = vi.fn().mockResolvedValue({ ownerId: "user_controller" });
|
|
const update = vi.fn().mockResolvedValue({
|
|
id: "tpl_1",
|
|
updatedAt: new Date("2026-03-31T13:00:00.000Z"),
|
|
});
|
|
const ctx = createContext({
|
|
findUnique,
|
|
update,
|
|
});
|
|
|
|
const result = await saveReportTemplate(ctx, SaveReportTemplateInputSchema.parse({
|
|
id: "tpl_1",
|
|
name: "Owned template",
|
|
description: " ",
|
|
config: {
|
|
entity: "project",
|
|
columns: ["name"],
|
|
filters: [],
|
|
},
|
|
}));
|
|
|
|
expect(result).toEqual({
|
|
id: "tpl_1",
|
|
updatedAt: new Date("2026-03-31T13:00:00.000Z"),
|
|
});
|
|
expect(update).toHaveBeenCalledWith({
|
|
where: { id: "tpl_1" },
|
|
data: {
|
|
name: "Owned template",
|
|
description: null,
|
|
entity: "PROJECT",
|
|
config: {
|
|
entity: "project",
|
|
columns: ["name"],
|
|
filters: [],
|
|
sortDir: "asc",
|
|
},
|
|
isShared: false,
|
|
},
|
|
select: { id: true, updatedAt: true },
|
|
});
|
|
});
|
|
|
|
it("stores blank descriptions as null for new templates", async () => {
|
|
const upsert = vi.fn().mockResolvedValue({
|
|
id: "tpl_new",
|
|
updatedAt: new Date("2026-03-31T14:00:00.000Z"),
|
|
});
|
|
const ctx = createContext({
|
|
upsert,
|
|
});
|
|
|
|
await saveReportTemplate(ctx, SaveReportTemplateInputSchema.parse({
|
|
name: "Monthly default",
|
|
description: " ",
|
|
isShared: true,
|
|
config: {
|
|
entity: "resource_month",
|
|
columns: ["displayName", "monthlyTargetHours"],
|
|
filters: [],
|
|
periodMonth: "2026-03",
|
|
},
|
|
}));
|
|
|
|
expect(upsert).toHaveBeenCalledWith({
|
|
where: {
|
|
ownerId_name: {
|
|
ownerId: "user_controller",
|
|
name: "Monthly default",
|
|
},
|
|
},
|
|
update: {
|
|
description: null,
|
|
entity: "RESOURCE_MONTH",
|
|
config: {
|
|
entity: "resource_month",
|
|
columns: ["displayName", "monthlyTargetHours"],
|
|
filters: [],
|
|
periodMonth: "2026-03",
|
|
sortDir: "asc",
|
|
},
|
|
isShared: true,
|
|
},
|
|
create: {
|
|
ownerId: "user_controller",
|
|
name: "Monthly default",
|
|
description: null,
|
|
entity: "RESOURCE_MONTH",
|
|
config: {
|
|
entity: "resource_month",
|
|
columns: ["displayName", "monthlyTargetHours"],
|
|
filters: [],
|
|
periodMonth: "2026-03",
|
|
sortDir: "asc",
|
|
},
|
|
isShared: true,
|
|
},
|
|
select: { id: true, updatedAt: true },
|
|
});
|
|
});
|
|
|
|
it("maps duplicate template names to a conflict error instead of leaking a raw database error", async () => {
|
|
const update = vi.fn().mockRejectedValue({ code: "P2002" });
|
|
const findUnique = vi.fn().mockResolvedValue({ ownerId: "user_controller" });
|
|
const ctx = createContext({
|
|
findUnique,
|
|
update,
|
|
});
|
|
|
|
await expect(saveReportTemplate(ctx, SaveReportTemplateInputSchema.parse({
|
|
id: "tpl_1",
|
|
name: "Monthly default",
|
|
config: {
|
|
entity: "project",
|
|
columns: ["name"],
|
|
filters: [],
|
|
},
|
|
}))).rejects.toMatchObject({
|
|
code: "CONFLICT",
|
|
message: 'A report template named "Monthly default" already exists',
|
|
});
|
|
});
|
|
|
|
it("deletes only owned templates", async () => {
|
|
const findUnique = vi.fn()
|
|
.mockResolvedValueOnce({ ownerId: "user_controller" })
|
|
.mockResolvedValueOnce({ ownerId: "user_other" });
|
|
const del = vi.fn().mockResolvedValue(undefined);
|
|
const ctx = createContext({
|
|
findUnique,
|
|
delete: del,
|
|
});
|
|
|
|
const result = await deleteReportTemplate(ctx, DeleteReportTemplateInputSchema.parse({ id: "tpl_1" }));
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
expect(del).toHaveBeenCalledWith({ where: { id: "tpl_1" } });
|
|
|
|
await expect(deleteReportTemplate(ctx, DeleteReportTemplateInputSchema.parse({ id: "tpl_2" }))).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
message: "Template cannot be deleted",
|
|
});
|
|
});
|
|
});
|