@hed-hog/operations 0.0.332 → 0.0.347
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/controllers/operations-collaborators.controller.d.ts +55 -36
- package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
- package/dist/controllers/operations-projects.controller.d.ts +3 -0
- package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
- package/dist/operations.service.d.ts +58 -36
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +34 -34
- package/dist/operations.service.js.map +1 -1
- package/dist/operations.service.spec.js +6 -0
- package/dist/operations.service.spec.js.map +1 -1
- package/hedhog/data/menu.yaml +5 -3
- package/hedhog/data/route.yaml +7 -7
- package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +476 -0
- package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +3 -1
- package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +261 -0
- package/hedhog/frontend/app/_components/collaborator-tasks-tab.tsx.ejs +358 -358
- package/hedhog/frontend/app/_components/collaborator-timesheets-tab.tsx.ejs +6 -6
- package/hedhog/frontend/app/_components/contract-content-editor.tsx.ejs +258 -0
- package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +5 -4
- package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +1 -0
- package/hedhog/frontend/app/_components/project-assignments-tab.tsx.ejs +0 -6
- package/hedhog/frontend/app/_components/project-cost-report-screen.tsx.ejs +23 -23
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +23 -50
- package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +62 -28
- package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +23 -6
- package/hedhog/frontend/app/_components/task-form-sheet.tsx.ejs +629 -629
- package/hedhog/frontend/app/_lib/api.ts.ejs +2 -2
- package/hedhog/frontend/app/_lib/utils/task-ui.ts.ejs +1 -1
- package/hedhog/frontend/app/my-projects/page.tsx.ejs +2 -16
- package/hedhog/frontend/app/my-tasks/page.tsx.ejs +86 -24
- package/hedhog/frontend/app/projects/page.tsx.ejs +6 -42
- package/hedhog/frontend/messages/operations/operations/en.json +2100 -0
- package/hedhog/frontend/messages/operations/operations/pt.json +2111 -0
- package/hedhog/frontend/widgets/capacity-distribution.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/effort-by-project.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/headcount-by-area.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/index.ts.ejs +25 -25
- package/hedhog/frontend/widgets/managed-projects-status.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-hours-period-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-open-requests-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-pending-requests-list.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-project-allocations-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-quick-actions.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-relevant-deadlines.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-timesheet-status-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-weekly-journey.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/portfolio-costs-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/portfolio-effort-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/portfolio-projects-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/portfolio-risk-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/project-status-overview.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/shared-operations-widget.tsx.ejs +169 -169
- package/hedhog/frontend/widgets/strategic-deadlines.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-approval-queue.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-capacity-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-headcount-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-hours-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-pending-approvals-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-utilization-overview.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-workload-alerts.tsx.ejs +16 -16
- package/hedhog/table/operations_collaborator.yaml +8 -8
- package/hedhog/table/operations_task.yaml +76 -76
- package/hedhog/table/operations_task_activity.yaml +51 -51
- package/package.json +6 -6
- package/src/controllers/operations-collaborators.controller.ts +9 -9
- package/src/controllers/operations-tasks.controller.ts +156 -156
- package/src/dashboard/widgets/MyQuickActions.tsx +22 -22
- package/src/dto/create-collaborator.dto.ts +4 -4
- package/src/operations.service.spec.ts +1006 -988
- package/src/operations.service.ts +40 -42
|
@@ -1,988 +1,1006 @@
|
|
|
1
|
-
/// <reference types="jest" />
|
|
2
|
-
|
|
3
|
-
import { BadRequestException } from "@nestjs/common";
|
|
4
|
-
import { OperationsService } from "./operations.service";
|
|
5
|
-
import { OperationsAccessService } from "./services/shared/operations-access.service";
|
|
6
|
-
|
|
7
|
-
describe("OperationsService proposal integration", () => {
|
|
8
|
-
let service: OperationsService;
|
|
9
|
-
let prisma: { $transaction: jest.Mock };
|
|
10
|
-
let integrationApi: { publishEvent: jest.Mock };
|
|
11
|
-
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
prisma = {
|
|
14
|
-
$transaction: jest.fn(),
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
integrationApi = {
|
|
18
|
-
publishEvent: jest.fn().mockResolvedValue(undefined),
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
service = new OperationsService(
|
|
22
|
-
prisma as any,
|
|
23
|
-
{} as any,
|
|
24
|
-
integrationApi as any,
|
|
25
|
-
{} as any,
|
|
26
|
-
{} as any,
|
|
27
|
-
new OperationsAccessService(),
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
jest
|
|
31
|
-
.spyOn(service as any, "generateContractCode")
|
|
32
|
-
.mockResolvedValue("CTR-001");
|
|
33
|
-
jest
|
|
34
|
-
.spyOn(service as any, "replaceContractParties")
|
|
35
|
-
.mockResolvedValue(undefined);
|
|
36
|
-
jest
|
|
37
|
-
.spyOn(service as any, "insertContractHistory")
|
|
38
|
-
.mockResolvedValue(undefined);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
afterEach(() => {
|
|
42
|
-
jest.restoreAllMocks();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("throws when proposalId is missing", async () => {
|
|
46
|
-
await expect(
|
|
47
|
-
service.createContractFromProposalIntegration({}),
|
|
48
|
-
).rejects.toThrow(BadRequestException);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("returns the existing contract when the CRM proposal was already converted", async () => {
|
|
52
|
-
const tx = {
|
|
53
|
-
$queryRawUnsafe: jest
|
|
54
|
-
.fn()
|
|
55
|
-
.mockResolvedValueOnce([])
|
|
56
|
-
.mockResolvedValueOnce([{ id: 42, code: "CTR-EXISTING" }]),
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
prisma.$transaction.mockImplementation(
|
|
60
|
-
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
const result = await service.createContractFromProposalIntegration({
|
|
64
|
-
proposalId: 1001,
|
|
65
|
-
title: "Already converted proposal",
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
expect(result).toEqual({
|
|
69
|
-
id: 42,
|
|
70
|
-
code: "CTR-EXISTING",
|
|
71
|
-
});
|
|
72
|
-
expect((service as any).generateContractCode).not.toHaveBeenCalled();
|
|
73
|
-
expect((service as any).replaceContractParties).not.toHaveBeenCalled();
|
|
74
|
-
expect(integrationApi.publishEvent).not.toHaveBeenCalled();
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("creates a draft contract with CRM origin metadata and emits an event", async () => {
|
|
78
|
-
const tx = {
|
|
79
|
-
$queryRawUnsafe: jest
|
|
80
|
-
.fn()
|
|
81
|
-
.mockResolvedValueOnce([])
|
|
82
|
-
.mockResolvedValueOnce([])
|
|
83
|
-
.mockResolvedValueOnce([{ id: 77 }]),
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
prisma.$transaction.mockImplementation(
|
|
87
|
-
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
const payload = {
|
|
91
|
-
proposalId: 1001,
|
|
92
|
-
proposalRevisionId: 21,
|
|
93
|
-
personId: 5,
|
|
94
|
-
approvedByUserId: 9,
|
|
95
|
-
correlationId: "proposal:1001",
|
|
96
|
-
code: "P-1001",
|
|
97
|
-
title: "Implementation Proposal",
|
|
98
|
-
total: 1234,
|
|
99
|
-
currency: "USD",
|
|
100
|
-
locale: "en",
|
|
101
|
-
commercialTerms: {
|
|
102
|
-
contractCategory: "client" as const,
|
|
103
|
-
contractType: "service_agreement" as const,
|
|
104
|
-
billingModel: "monthly_retainer" as const,
|
|
105
|
-
validFrom: "2025-01-10",
|
|
106
|
-
validUntil: "2025-12-31",
|
|
107
|
-
notes: "Annual support agreement",
|
|
108
|
-
},
|
|
109
|
-
person: {
|
|
110
|
-
id: 5,
|
|
111
|
-
name: "Acme Contact",
|
|
112
|
-
tradeName: "Acme Ltd",
|
|
113
|
-
email: "ops@acme.test",
|
|
114
|
-
phone: "555-0100",
|
|
115
|
-
document: "12.345.678/0001-90",
|
|
116
|
-
},
|
|
117
|
-
revision: {
|
|
118
|
-
id: 21,
|
|
119
|
-
title: "Revision 1",
|
|
120
|
-
summary: "Approved commercial terms",
|
|
121
|
-
contentHtml: "<p>draft</p>",
|
|
122
|
-
},
|
|
123
|
-
items: [
|
|
124
|
-
{
|
|
125
|
-
name: "Monthly retainer",
|
|
126
|
-
description: "Support hours",
|
|
127
|
-
amount: 1000,
|
|
128
|
-
recurrence: "monthly" as const,
|
|
129
|
-
},
|
|
130
|
-
{
|
|
131
|
-
name: "Setup fee",
|
|
132
|
-
amount: 234,
|
|
133
|
-
recurrence: "one_time" as const,
|
|
134
|
-
},
|
|
135
|
-
],
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
const result = await service.createContractFromProposalIntegration(payload);
|
|
139
|
-
|
|
140
|
-
expect(result).toEqual({
|
|
141
|
-
id: 77,
|
|
142
|
-
code: "CTR-001",
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
const insertCall = tx.$queryRawUnsafe.mock.calls[2];
|
|
146
|
-
expect(insertCall[0]).toContain("INSERT INTO operations_contract");
|
|
147
|
-
expect(insertCall).toContain("crm_proposal");
|
|
148
|
-
expect(insertCall).toContain("1001");
|
|
149
|
-
|
|
150
|
-
expect((service as any).replaceContractParties).toHaveBeenCalledWith(
|
|
151
|
-
tx,
|
|
152
|
-
77,
|
|
153
|
-
[
|
|
154
|
-
expect.objectContaining({
|
|
155
|
-
partyRole: "client",
|
|
156
|
-
displayName: "Acme Ltd",
|
|
157
|
-
isPrimary: true,
|
|
158
|
-
}),
|
|
159
|
-
],
|
|
160
|
-
);
|
|
161
|
-
expect((service as any).insertContractHistory).toHaveBeenCalledWith(
|
|
162
|
-
tx,
|
|
163
|
-
77,
|
|
164
|
-
9,
|
|
165
|
-
"created",
|
|
166
|
-
"Draft contract generated from CRM proposal P-1001.",
|
|
167
|
-
expect.any(String),
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
expect(integrationApi.publishEvent).toHaveBeenCalledWith(
|
|
171
|
-
expect.objectContaining({
|
|
172
|
-
eventName: "operations.contract.created",
|
|
173
|
-
payload: expect.objectContaining({
|
|
174
|
-
proposalId: 1001,
|
|
175
|
-
contract: expect.objectContaining({
|
|
176
|
-
id: 77,
|
|
177
|
-
originType: "crm_proposal",
|
|
178
|
-
originId: "1001",
|
|
179
|
-
contractType: "service_agreement",
|
|
180
|
-
}),
|
|
181
|
-
}),
|
|
182
|
-
}),
|
|
183
|
-
expect.objectContaining({
|
|
184
|
-
persistenceClient: tx,
|
|
185
|
-
}),
|
|
186
|
-
);
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
describe("OperationsService quick-entry timesheets", () => {
|
|
191
|
-
let service: OperationsService;
|
|
192
|
-
|
|
193
|
-
beforeEach(() => {
|
|
194
|
-
service = new OperationsService(
|
|
195
|
-
{
|
|
196
|
-
$transaction: jest.fn(),
|
|
197
|
-
} as any,
|
|
198
|
-
{} as any,
|
|
199
|
-
{
|
|
200
|
-
publishEvent: jest.fn().mockResolvedValue(undefined),
|
|
201
|
-
} as any,
|
|
202
|
-
{} as any,
|
|
203
|
-
{} as any,
|
|
204
|
-
new OperationsAccessService(),
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
jest.spyOn(service as any, "getActorContext").mockResolvedValue({
|
|
208
|
-
userId: 15,
|
|
209
|
-
roleSlugs: ["operations-collaborator"],
|
|
210
|
-
collaboratorId: 7,
|
|
211
|
-
collaboratorName: "Taylor Tester",
|
|
212
|
-
isDirector: false,
|
|
213
|
-
isSupervisor: false,
|
|
214
|
-
isCollaborator: true,
|
|
215
|
-
teamCollaboratorIds: [],
|
|
216
|
-
visibleCollaboratorIds: [7],
|
|
217
|
-
visibleProjectIds: [11],
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
afterEach(() => {
|
|
222
|
-
jest.restoreAllMocks();
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it("rejects quick entries with non-positive duration", async () => {
|
|
226
|
-
await expect(
|
|
227
|
-
service.createTimesheetEntry(15, {
|
|
228
|
-
projectId: 11,
|
|
229
|
-
workDate: "2026-04-09",
|
|
230
|
-
duration: 0,
|
|
231
|
-
unit: "minutes",
|
|
232
|
-
} as any),
|
|
233
|
-
).rejects.toThrow(BadRequestException);
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it("rejects quick entries without a work date", async () => {
|
|
237
|
-
await expect(
|
|
238
|
-
service.createTimesheetEntry(15, {
|
|
239
|
-
projectId: 11,
|
|
240
|
-
duration: 30,
|
|
241
|
-
unit: "minutes",
|
|
242
|
-
} as any),
|
|
243
|
-
).rejects.toThrow(BadRequestException);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it("stores quick-entry duration in minutes and keeps hours compatible", async () => {
|
|
247
|
-
const tx = {
|
|
248
|
-
$queryRawUnsafe: jest.fn().mockResolvedValue([{ id: 91 }]),
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
(service as any).prisma.$transaction.mockImplementation(
|
|
252
|
-
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
253
|
-
);
|
|
254
|
-
|
|
255
|
-
jest
|
|
256
|
-
.spyOn(service as any, "resolveOwnedProjectAssignment")
|
|
257
|
-
.mockResolvedValue({
|
|
258
|
-
id: 33,
|
|
259
|
-
projectId: 11,
|
|
260
|
-
projectName: "Project Atlas",
|
|
261
|
-
projectCode: "OPS-11",
|
|
262
|
-
roleLabel: "Engineer",
|
|
263
|
-
});
|
|
264
|
-
jest.spyOn(service as any, "getOwnedTaskRecord").mockResolvedValue({
|
|
265
|
-
id: 44,
|
|
266
|
-
name: "Implement API",
|
|
267
|
-
projectAssignmentId: 33,
|
|
268
|
-
projectId: 11,
|
|
269
|
-
projectName: "Project Atlas",
|
|
270
|
-
projectCode: "OPS-11",
|
|
271
|
-
});
|
|
272
|
-
jest
|
|
273
|
-
.spyOn(service as any, "getOrCreateTimesheetForWorkDate")
|
|
274
|
-
.mockResolvedValue(55);
|
|
275
|
-
jest
|
|
276
|
-
.spyOn(service as any, "refreshTimesheetTotal")
|
|
277
|
-
.mockResolvedValue(undefined);
|
|
278
|
-
jest
|
|
279
|
-
.spyOn(service as any, "submitTimesheetForApproval")
|
|
280
|
-
.mockResolvedValue(undefined);
|
|
281
|
-
jest
|
|
282
|
-
.spyOn(service as any, "getTimesheetEntryByIdForActor")
|
|
283
|
-
.mockResolvedValue({
|
|
284
|
-
id: 91,
|
|
285
|
-
durationMinutes: 90,
|
|
286
|
-
hours: 1.5,
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
const result = await service.createTimesheetEntry(15, {
|
|
290
|
-
projectId: 11,
|
|
291
|
-
taskId: 44,
|
|
292
|
-
workDate: "2026-04-09",
|
|
293
|
-
duration: 1.5,
|
|
294
|
-
unit: "hours",
|
|
295
|
-
description: "Worked on the API layer",
|
|
296
|
-
} as any);
|
|
297
|
-
|
|
298
|
-
expect(result).toEqual(
|
|
299
|
-
expect.objectContaining({
|
|
300
|
-
id: 91,
|
|
301
|
-
durationMinutes: 90,
|
|
302
|
-
hours: 1.5,
|
|
303
|
-
}),
|
|
304
|
-
);
|
|
305
|
-
expect(tx.$queryRawUnsafe).toHaveBeenCalledWith(
|
|
306
|
-
expect.stringContaining("INSERT INTO operations_timesheet_entry"),
|
|
307
|
-
55,
|
|
308
|
-
33,
|
|
309
|
-
44,
|
|
310
|
-
"Implement API",
|
|
311
|
-
"2026-04-09",
|
|
312
|
-
90,
|
|
313
|
-
1.5,
|
|
314
|
-
"Worked on the API layer",
|
|
315
|
-
);
|
|
316
|
-
expect((service as any).submitTimesheetForApproval).toHaveBeenCalledWith(
|
|
317
|
-
tx,
|
|
318
|
-
55,
|
|
319
|
-
7,
|
|
320
|
-
);
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it("uses the task assignment when full timesheet entries omit projectAssignmentId", async () => {
|
|
324
|
-
const tx = {
|
|
325
|
-
$queryRawUnsafe: jest.fn().mockResolvedValue([{ id: 55 }]),
|
|
326
|
-
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
(service as any).prisma.$transaction.mockImplementation(
|
|
330
|
-
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
331
|
-
);
|
|
332
|
-
|
|
333
|
-
jest.spyOn(service as any, "getCollaboratorById").mockResolvedValue({
|
|
334
|
-
id: 7,
|
|
335
|
-
supervisorId: 18,
|
|
336
|
-
});
|
|
337
|
-
jest.spyOn(service as any, "getOwnedTaskRecord").mockResolvedValue({
|
|
338
|
-
id: 44,
|
|
339
|
-
name: "Implement API",
|
|
340
|
-
projectAssignmentId: 33,
|
|
341
|
-
projectId: 11,
|
|
342
|
-
projectName: "Project Atlas",
|
|
343
|
-
projectCode: "OPS-11",
|
|
344
|
-
});
|
|
345
|
-
jest
|
|
346
|
-
.spyOn(service as any, "refreshTimesheetTotal")
|
|
347
|
-
.mockResolvedValue(undefined);
|
|
348
|
-
jest.spyOn(service as any, "listSingleTimesheet").mockResolvedValue({
|
|
349
|
-
id: 55,
|
|
350
|
-
status: "draft",
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
await service.createTimesheet(15, {
|
|
354
|
-
weekStartDate: "2026-04-06",
|
|
355
|
-
weekEndDate: "2026-04-12",
|
|
356
|
-
entries: [
|
|
357
|
-
{
|
|
358
|
-
taskId: 44,
|
|
359
|
-
workDate: "2026-04-09",
|
|
360
|
-
duration: 2,
|
|
361
|
-
unit: "hours",
|
|
362
|
-
description: "Worked on the API layer",
|
|
363
|
-
},
|
|
364
|
-
],
|
|
365
|
-
} as any);
|
|
366
|
-
|
|
367
|
-
expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
|
|
368
|
-
expect.stringContaining("INSERT INTO operations_timesheet_entry"),
|
|
369
|
-
55,
|
|
370
|
-
33,
|
|
371
|
-
44,
|
|
372
|
-
"Implement API",
|
|
373
|
-
"2026-04-09",
|
|
374
|
-
120,
|
|
375
|
-
2,
|
|
376
|
-
"Worked on the API layer",
|
|
377
|
-
);
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
it("rejects submitting a timesheet without a resolved approver", async () => {
|
|
381
|
-
const tx = {
|
|
382
|
-
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
383
|
-
$queryRawUnsafe: jest
|
|
384
|
-
.fn()
|
|
385
|
-
.mockResolvedValueOnce([])
|
|
386
|
-
.mockResolvedValueOnce([{ exists: true }]),
|
|
387
|
-
};
|
|
388
|
-
|
|
389
|
-
(service as any).prisma.$transaction.mockImplementation(
|
|
390
|
-
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
391
|
-
);
|
|
392
|
-
|
|
393
|
-
jest.spyOn(service as any, "getTimesheetById").mockResolvedValue({
|
|
394
|
-
id: 55,
|
|
395
|
-
collaboratorId: 7,
|
|
396
|
-
approverCollaboratorId: null,
|
|
397
|
-
status: "draft",
|
|
398
|
-
});
|
|
399
|
-
jest.spyOn(service as any, "getCollaboratorById").mockResolvedValue({
|
|
400
|
-
id: 7,
|
|
401
|
-
supervisorId: null,
|
|
402
|
-
});
|
|
403
|
-
jest.spyOn(service as any, "listSingleTimesheet").mockResolvedValue({
|
|
404
|
-
id: 55,
|
|
405
|
-
status: "submitted",
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
await expect(service.submitTimesheet(15, 55)).rejects.toThrow(
|
|
409
|
-
BadRequestException,
|
|
410
|
-
);
|
|
411
|
-
expect(tx.$executeRawUnsafe).not.toHaveBeenCalled();
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
it("rejects submitting a timesheet without active entries", async () => {
|
|
415
|
-
const tx = {
|
|
416
|
-
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
417
|
-
$queryRawUnsafe: jest
|
|
418
|
-
.fn()
|
|
419
|
-
.mockResolvedValueOnce([{ managerCollaboratorId: 18 }])
|
|
420
|
-
.mockResolvedValueOnce([{ exists: false }]),
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
(service as any).prisma.$transaction.mockImplementation(
|
|
424
|
-
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
425
|
-
);
|
|
426
|
-
|
|
427
|
-
jest.spyOn(service as any, "getTimesheetById").mockResolvedValue({
|
|
428
|
-
id: 56,
|
|
429
|
-
collaboratorId: 7,
|
|
430
|
-
approverCollaboratorId: 18,
|
|
431
|
-
status: "draft",
|
|
432
|
-
});
|
|
433
|
-
jest.spyOn(service as any, "getCollaboratorById").mockResolvedValue({
|
|
434
|
-
id: 7,
|
|
435
|
-
supervisorId: 18,
|
|
436
|
-
});
|
|
437
|
-
jest.spyOn(service as any, "listSingleTimesheet").mockResolvedValue({
|
|
438
|
-
id: 56,
|
|
439
|
-
status: "submitted",
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
await expect(service.submitTimesheet(15, 56)).rejects.toThrow(
|
|
443
|
-
BadRequestException,
|
|
444
|
-
);
|
|
445
|
-
expect(tx.$executeRawUnsafe).not.toHaveBeenCalled();
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
it("deletes only draft quick entries and refreshes the weekly total", async () => {
|
|
449
|
-
const tx = {
|
|
450
|
-
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
451
|
-
$queryRawUnsafe: jest.fn().mockResolvedValue([]),
|
|
452
|
-
};
|
|
453
|
-
|
|
454
|
-
(service as any).prisma.$transaction.mockImplementation(
|
|
455
|
-
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
456
|
-
);
|
|
457
|
-
|
|
458
|
-
jest
|
|
459
|
-
.spyOn(service as any, "getTimesheetEntryByIdForActor")
|
|
460
|
-
.mockResolvedValue({
|
|
461
|
-
id: 91,
|
|
462
|
-
timesheetId: 55,
|
|
463
|
-
collaboratorId: 7,
|
|
464
|
-
status: "draft",
|
|
465
|
-
});
|
|
466
|
-
jest
|
|
467
|
-
.spyOn(service as any, "refreshTimesheetTotal")
|
|
468
|
-
.mockResolvedValue(undefined);
|
|
469
|
-
|
|
470
|
-
await expect(service.removeTimesheetEntry(15, 91)).resolves.toEqual({
|
|
471
|
-
success: true,
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
|
|
475
|
-
expect.stringContaining("UPDATE operations_timesheet_entry"),
|
|
476
|
-
91,
|
|
477
|
-
);
|
|
478
|
-
expect((service as any).refreshTimesheetTotal).toHaveBeenCalledWith(tx, 55);
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
it("updates quick entries and moves them to the correct weekly timesheet", async () => {
|
|
482
|
-
const tx = {
|
|
483
|
-
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
484
|
-
$queryRawUnsafe: jest.fn().mockResolvedValue([]),
|
|
485
|
-
};
|
|
486
|
-
|
|
487
|
-
(service as any).prisma.$transaction.mockImplementation(
|
|
488
|
-
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
489
|
-
);
|
|
490
|
-
|
|
491
|
-
jest
|
|
492
|
-
.spyOn(service as any, "getTimesheetEntryByIdForActor")
|
|
493
|
-
.mockResolvedValueOnce({
|
|
494
|
-
id: 93,
|
|
495
|
-
timesheetId: 55,
|
|
496
|
-
collaboratorId: 7,
|
|
497
|
-
projectId: 11,
|
|
498
|
-
projectAssignmentId: 33,
|
|
499
|
-
taskId: 44,
|
|
500
|
-
status: "draft",
|
|
501
|
-
weekStartDate: "2026-04-06",
|
|
502
|
-
weekEndDate: "2026-04-12",
|
|
503
|
-
workDate: "2026-04-09",
|
|
504
|
-
hours: 1,
|
|
505
|
-
durationMinutes: 60,
|
|
506
|
-
})
|
|
507
|
-
.mockResolvedValueOnce({
|
|
508
|
-
id: 93,
|
|
509
|
-
timesheetId: 77,
|
|
510
|
-
collaboratorId: 7,
|
|
511
|
-
projectId: 12,
|
|
512
|
-
projectAssignmentId: 34,
|
|
513
|
-
taskId: 45,
|
|
514
|
-
status: "draft",
|
|
515
|
-
weekStartDate: "2026-04-13",
|
|
516
|
-
weekEndDate: "2026-04-19",
|
|
517
|
-
workDate: "2026-04-14",
|
|
518
|
-
hours: 2,
|
|
519
|
-
durationMinutes: 120,
|
|
520
|
-
taskName: "Review backlog",
|
|
521
|
-
});
|
|
522
|
-
jest
|
|
523
|
-
.spyOn(service as any, "resolveOwnedProjectAssignment")
|
|
524
|
-
.mockResolvedValue({
|
|
525
|
-
id: 34,
|
|
526
|
-
projectId: 12,
|
|
527
|
-
projectName: "Project Boreal",
|
|
528
|
-
projectCode: "OPS-12",
|
|
529
|
-
roleLabel: "Engineer",
|
|
530
|
-
});
|
|
531
|
-
jest.spyOn(service as any, "getOwnedTaskRecord").mockResolvedValue({
|
|
532
|
-
id: 45,
|
|
533
|
-
name: "Review backlog",
|
|
534
|
-
projectAssignmentId: 34,
|
|
535
|
-
projectId: 12,
|
|
536
|
-
projectName: "Project Boreal",
|
|
537
|
-
projectCode: "OPS-12",
|
|
538
|
-
});
|
|
539
|
-
jest
|
|
540
|
-
.spyOn(service as any, "getOrCreateTimesheetForWorkDate")
|
|
541
|
-
.mockResolvedValue(77);
|
|
542
|
-
jest
|
|
543
|
-
.spyOn(service as any, "refreshTimesheetTotal")
|
|
544
|
-
.mockResolvedValue(undefined);
|
|
545
|
-
jest
|
|
546
|
-
.spyOn(service as any, "cleanupEmptyEditableTimesheet")
|
|
547
|
-
.mockResolvedValue(undefined);
|
|
548
|
-
jest
|
|
549
|
-
.spyOn(service as any, "submitTimesheetForApproval")
|
|
550
|
-
.mockResolvedValue(undefined);
|
|
551
|
-
|
|
552
|
-
const result = await service.updateTimesheetEntry(15, 93, {
|
|
553
|
-
projectId: 12,
|
|
554
|
-
projectAssignmentId: 34,
|
|
555
|
-
taskId: 45,
|
|
556
|
-
workDate: "2026-04-14",
|
|
557
|
-
duration: 2,
|
|
558
|
-
unit: "hours",
|
|
559
|
-
description: "Backlog refinement",
|
|
560
|
-
} as any);
|
|
561
|
-
|
|
562
|
-
expect(result).toEqual(
|
|
563
|
-
expect.objectContaining({
|
|
564
|
-
id: 93,
|
|
565
|
-
timesheetId: 77,
|
|
566
|
-
}),
|
|
567
|
-
);
|
|
568
|
-
expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
|
|
569
|
-
expect.stringContaining("UPDATE operations_timesheet_entry"),
|
|
570
|
-
77,
|
|
571
|
-
34,
|
|
572
|
-
45,
|
|
573
|
-
"Review backlog",
|
|
574
|
-
"2026-04-14",
|
|
575
|
-
120,
|
|
576
|
-
2,
|
|
577
|
-
"Backlog refinement",
|
|
578
|
-
93,
|
|
579
|
-
);
|
|
580
|
-
expect((service as any).refreshTimesheetTotal).toHaveBeenCalledWith(tx, 55);
|
|
581
|
-
expect((service as any).refreshTimesheetTotal).toHaveBeenCalledWith(tx, 77);
|
|
582
|
-
expect((service as any).cleanupEmptyEditableTimesheet).toHaveBeenCalledWith(
|
|
583
|
-
tx,
|
|
584
|
-
55,
|
|
585
|
-
);
|
|
586
|
-
expect((service as any).submitTimesheetForApproval).toHaveBeenCalledWith(
|
|
587
|
-
tx,
|
|
588
|
-
77,
|
|
589
|
-
7,
|
|
590
|
-
);
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
it("allows deleting entries from submitted timesheets until approval", async () => {
|
|
594
|
-
const tx = {
|
|
595
|
-
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
596
|
-
};
|
|
597
|
-
|
|
598
|
-
(service as any).prisma.$transaction.mockImplementation(
|
|
599
|
-
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
600
|
-
);
|
|
601
|
-
|
|
602
|
-
jest
|
|
603
|
-
.spyOn(service as any, "getTimesheetEntryByIdForActor")
|
|
604
|
-
.mockResolvedValue({
|
|
605
|
-
id: 92,
|
|
606
|
-
timesheetId: 56,
|
|
607
|
-
collaboratorId: 7,
|
|
608
|
-
status: "submitted",
|
|
609
|
-
});
|
|
610
|
-
jest
|
|
611
|
-
.spyOn(service as any, "refreshTimesheetTotal")
|
|
612
|
-
.mockResolvedValue(undefined);
|
|
613
|
-
jest
|
|
614
|
-
.spyOn(service as any, "cleanupEmptyEditableTimesheet")
|
|
615
|
-
.mockResolvedValue(undefined);
|
|
616
|
-
|
|
617
|
-
await expect(service.removeTimesheetEntry(15, 92)).resolves.toEqual({
|
|
618
|
-
success: true,
|
|
619
|
-
});
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
it("rejects deleting entries from approved timesheets", async () => {
|
|
623
|
-
jest
|
|
624
|
-
.spyOn(service as any, "getTimesheetEntryByIdForActor")
|
|
625
|
-
.mockResolvedValue({
|
|
626
|
-
id: 94,
|
|
627
|
-
timesheetId: 57,
|
|
628
|
-
collaboratorId: 7,
|
|
629
|
-
status: "approved",
|
|
630
|
-
});
|
|
631
|
-
|
|
632
|
-
await expect(service.removeTimesheetEntry(15, 94)).rejects.toThrow(
|
|
633
|
-
BadRequestException,
|
|
634
|
-
);
|
|
635
|
-
});
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
describe("OperationsService task column activity", () => {
|
|
639
|
-
let service: OperationsService;
|
|
640
|
-
let prisma: { $transaction: jest.Mock };
|
|
641
|
-
let integrationApi: { publishEvent: jest.Mock };
|
|
642
|
-
|
|
643
|
-
const baseTask = {
|
|
644
|
-
id: 44,
|
|
645
|
-
name: "Implement board",
|
|
646
|
-
description: null,
|
|
647
|
-
priority: "medium",
|
|
648
|
-
status: "todo",
|
|
649
|
-
dueDate: null,
|
|
650
|
-
estimateHours: null,
|
|
651
|
-
position: 0,
|
|
652
|
-
tags: null,
|
|
653
|
-
assigneeCollaboratorId: null,
|
|
654
|
-
projectAssignmentId: null,
|
|
655
|
-
projectId: 11,
|
|
656
|
-
projectName: "Project Atlas",
|
|
657
|
-
projectCode: "OPS-11",
|
|
658
|
-
doingStartedAt: null,
|
|
659
|
-
totalDoingMinutes: 0,
|
|
660
|
-
deletedAt: null,
|
|
661
|
-
};
|
|
662
|
-
|
|
663
|
-
beforeEach(() => {
|
|
664
|
-
prisma = {
|
|
665
|
-
$transaction: jest.fn(),
|
|
666
|
-
};
|
|
667
|
-
integrationApi = {
|
|
668
|
-
publishEvent: jest.fn().mockResolvedValue(undefined),
|
|
669
|
-
};
|
|
670
|
-
service = new OperationsService(
|
|
671
|
-
prisma as any,
|
|
672
|
-
{} as any,
|
|
673
|
-
integrationApi as any,
|
|
674
|
-
{} as any,
|
|
675
|
-
{} as any,
|
|
676
|
-
new OperationsAccessService(),
|
|
677
|
-
);
|
|
678
|
-
|
|
679
|
-
jest.spyOn(service as any, "getActorContext").mockResolvedValue({
|
|
680
|
-
userId: 15,
|
|
681
|
-
roleSlugs: ["operations-collaborator"],
|
|
682
|
-
collaboratorId: 7,
|
|
683
|
-
collaboratorName: "Taylor Tester",
|
|
684
|
-
isDirector: false,
|
|
685
|
-
isSupervisor: false,
|
|
686
|
-
isCollaborator: true,
|
|
687
|
-
teamCollaboratorIds: [],
|
|
688
|
-
visibleCollaboratorIds: [7],
|
|
689
|
-
visibleProjectIds: [11],
|
|
690
|
-
});
|
|
691
|
-
jest
|
|
692
|
-
.spyOn(service as any, "assertProjectAccess")
|
|
693
|
-
.mockResolvedValue(undefined);
|
|
694
|
-
jest.spyOn(service as any, "getProjectBoardTask").mockResolvedValue({
|
|
695
|
-
...baseTask,
|
|
696
|
-
assigneeCollaboratorId: 7,
|
|
697
|
-
status: "doing",
|
|
698
|
-
});
|
|
699
|
-
});
|
|
700
|
-
|
|
701
|
-
afterEach(() => {
|
|
702
|
-
jest.restoreAllMocks();
|
|
703
|
-
});
|
|
704
|
-
|
|
705
|
-
function mockTransaction() {
|
|
706
|
-
const tx = {
|
|
707
|
-
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
708
|
-
};
|
|
709
|
-
prisma.$transaction.mockImplementation(
|
|
710
|
-
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
711
|
-
);
|
|
712
|
-
return tx;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
it("records todo to doing and auto-assigns the actor", async () => {
|
|
716
|
-
const tx = mockTransaction();
|
|
717
|
-
jest
|
|
718
|
-
.spyOn(service as any, "getTaskRecordForActor")
|
|
719
|
-
.mockResolvedValue(baseTask);
|
|
720
|
-
|
|
721
|
-
await service.updateTask(15, 44, { status: "doing" } as any);
|
|
722
|
-
|
|
723
|
-
expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(2);
|
|
724
|
-
expect(tx.$executeRawUnsafe.mock.calls[0][0]).toContain(
|
|
725
|
-
"doing_started_at = CASE",
|
|
726
|
-
);
|
|
727
|
-
expect(tx.$executeRawUnsafe.mock.calls[0][3]).toBe(7);
|
|
728
|
-
expect(tx.$executeRawUnsafe.mock.calls[0][15]).toBe(true);
|
|
729
|
-
expect(tx.$executeRawUnsafe.mock.calls[0][16]).toBe(false);
|
|
730
|
-
expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
|
|
731
|
-
"INSERT INTO operations_task_activity",
|
|
732
|
-
);
|
|
733
|
-
expect(tx.$executeRawUnsafe.mock.calls[1]
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
"
|
|
738
|
-
|
|
739
|
-
])
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
]);
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
).
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
jest
|
|
894
|
-
.spyOn(service as any, "
|
|
895
|
-
.
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
jest
|
|
976
|
-
.spyOn(service as any, "
|
|
977
|
-
.
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1
|
+
/// <reference types="jest" />
|
|
2
|
+
|
|
3
|
+
import { BadRequestException } from "@nestjs/common";
|
|
4
|
+
import { OperationsService } from "./operations.service";
|
|
5
|
+
import { OperationsAccessService } from "./services/shared/operations-access.service";
|
|
6
|
+
|
|
7
|
+
describe("OperationsService proposal integration", () => {
|
|
8
|
+
let service: OperationsService;
|
|
9
|
+
let prisma: { $transaction: jest.Mock };
|
|
10
|
+
let integrationApi: { publishEvent: jest.Mock };
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
prisma = {
|
|
14
|
+
$transaction: jest.fn(),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
integrationApi = {
|
|
18
|
+
publishEvent: jest.fn().mockResolvedValue(undefined),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
service = new OperationsService(
|
|
22
|
+
prisma as any,
|
|
23
|
+
{} as any,
|
|
24
|
+
integrationApi as any,
|
|
25
|
+
{} as any,
|
|
26
|
+
{} as any,
|
|
27
|
+
new OperationsAccessService(),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
jest
|
|
31
|
+
.spyOn(service as any, "generateContractCode")
|
|
32
|
+
.mockResolvedValue("CTR-001");
|
|
33
|
+
jest
|
|
34
|
+
.spyOn(service as any, "replaceContractParties")
|
|
35
|
+
.mockResolvedValue(undefined);
|
|
36
|
+
jest
|
|
37
|
+
.spyOn(service as any, "insertContractHistory")
|
|
38
|
+
.mockResolvedValue(undefined);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
jest.restoreAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("throws when proposalId is missing", async () => {
|
|
46
|
+
await expect(
|
|
47
|
+
service.createContractFromProposalIntegration({}),
|
|
48
|
+
).rejects.toThrow(BadRequestException);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns the existing contract when the CRM proposal was already converted", async () => {
|
|
52
|
+
const tx = {
|
|
53
|
+
$queryRawUnsafe: jest
|
|
54
|
+
.fn()
|
|
55
|
+
.mockResolvedValueOnce([])
|
|
56
|
+
.mockResolvedValueOnce([{ id: 42, code: "CTR-EXISTING" }]),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
prisma.$transaction.mockImplementation(
|
|
60
|
+
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const result = await service.createContractFromProposalIntegration({
|
|
64
|
+
proposalId: 1001,
|
|
65
|
+
title: "Already converted proposal",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(result).toEqual({
|
|
69
|
+
id: 42,
|
|
70
|
+
code: "CTR-EXISTING",
|
|
71
|
+
});
|
|
72
|
+
expect((service as any).generateContractCode).not.toHaveBeenCalled();
|
|
73
|
+
expect((service as any).replaceContractParties).not.toHaveBeenCalled();
|
|
74
|
+
expect(integrationApi.publishEvent).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("creates a draft contract with CRM origin metadata and emits an event", async () => {
|
|
78
|
+
const tx = {
|
|
79
|
+
$queryRawUnsafe: jest
|
|
80
|
+
.fn()
|
|
81
|
+
.mockResolvedValueOnce([])
|
|
82
|
+
.mockResolvedValueOnce([])
|
|
83
|
+
.mockResolvedValueOnce([{ id: 77 }]),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
prisma.$transaction.mockImplementation(
|
|
87
|
+
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const payload = {
|
|
91
|
+
proposalId: 1001,
|
|
92
|
+
proposalRevisionId: 21,
|
|
93
|
+
personId: 5,
|
|
94
|
+
approvedByUserId: 9,
|
|
95
|
+
correlationId: "proposal:1001",
|
|
96
|
+
code: "P-1001",
|
|
97
|
+
title: "Implementation Proposal",
|
|
98
|
+
total: 1234,
|
|
99
|
+
currency: "USD",
|
|
100
|
+
locale: "en",
|
|
101
|
+
commercialTerms: {
|
|
102
|
+
contractCategory: "client" as const,
|
|
103
|
+
contractType: "service_agreement" as const,
|
|
104
|
+
billingModel: "monthly_retainer" as const,
|
|
105
|
+
validFrom: "2025-01-10",
|
|
106
|
+
validUntil: "2025-12-31",
|
|
107
|
+
notes: "Annual support agreement",
|
|
108
|
+
},
|
|
109
|
+
person: {
|
|
110
|
+
id: 5,
|
|
111
|
+
name: "Acme Contact",
|
|
112
|
+
tradeName: "Acme Ltd",
|
|
113
|
+
email: "ops@acme.test",
|
|
114
|
+
phone: "555-0100",
|
|
115
|
+
document: "12.345.678/0001-90",
|
|
116
|
+
},
|
|
117
|
+
revision: {
|
|
118
|
+
id: 21,
|
|
119
|
+
title: "Revision 1",
|
|
120
|
+
summary: "Approved commercial terms",
|
|
121
|
+
contentHtml: "<p>draft</p>",
|
|
122
|
+
},
|
|
123
|
+
items: [
|
|
124
|
+
{
|
|
125
|
+
name: "Monthly retainer",
|
|
126
|
+
description: "Support hours",
|
|
127
|
+
amount: 1000,
|
|
128
|
+
recurrence: "monthly" as const,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "Setup fee",
|
|
132
|
+
amount: 234,
|
|
133
|
+
recurrence: "one_time" as const,
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const result = await service.createContractFromProposalIntegration(payload);
|
|
139
|
+
|
|
140
|
+
expect(result).toEqual({
|
|
141
|
+
id: 77,
|
|
142
|
+
code: "CTR-001",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const insertCall = tx.$queryRawUnsafe.mock.calls[2];
|
|
146
|
+
expect(insertCall[0]).toContain("INSERT INTO operations_contract");
|
|
147
|
+
expect(insertCall).toContain("crm_proposal");
|
|
148
|
+
expect(insertCall).toContain("1001");
|
|
149
|
+
|
|
150
|
+
expect((service as any).replaceContractParties).toHaveBeenCalledWith(
|
|
151
|
+
tx,
|
|
152
|
+
77,
|
|
153
|
+
[
|
|
154
|
+
expect.objectContaining({
|
|
155
|
+
partyRole: "client",
|
|
156
|
+
displayName: "Acme Ltd",
|
|
157
|
+
isPrimary: true,
|
|
158
|
+
}),
|
|
159
|
+
],
|
|
160
|
+
);
|
|
161
|
+
expect((service as any).insertContractHistory).toHaveBeenCalledWith(
|
|
162
|
+
tx,
|
|
163
|
+
77,
|
|
164
|
+
9,
|
|
165
|
+
"created",
|
|
166
|
+
"Draft contract generated from CRM proposal P-1001.",
|
|
167
|
+
expect.any(String),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(integrationApi.publishEvent).toHaveBeenCalledWith(
|
|
171
|
+
expect.objectContaining({
|
|
172
|
+
eventName: "operations.contract.created",
|
|
173
|
+
payload: expect.objectContaining({
|
|
174
|
+
proposalId: 1001,
|
|
175
|
+
contract: expect.objectContaining({
|
|
176
|
+
id: 77,
|
|
177
|
+
originType: "crm_proposal",
|
|
178
|
+
originId: "1001",
|
|
179
|
+
contractType: "service_agreement",
|
|
180
|
+
}),
|
|
181
|
+
}),
|
|
182
|
+
}),
|
|
183
|
+
expect.objectContaining({
|
|
184
|
+
persistenceClient: tx,
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("OperationsService quick-entry timesheets", () => {
|
|
191
|
+
let service: OperationsService;
|
|
192
|
+
|
|
193
|
+
beforeEach(() => {
|
|
194
|
+
service = new OperationsService(
|
|
195
|
+
{
|
|
196
|
+
$transaction: jest.fn(),
|
|
197
|
+
} as any,
|
|
198
|
+
{} as any,
|
|
199
|
+
{
|
|
200
|
+
publishEvent: jest.fn().mockResolvedValue(undefined),
|
|
201
|
+
} as any,
|
|
202
|
+
{} as any,
|
|
203
|
+
{} as any,
|
|
204
|
+
new OperationsAccessService(),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
jest.spyOn(service as any, "getActorContext").mockResolvedValue({
|
|
208
|
+
userId: 15,
|
|
209
|
+
roleSlugs: ["operations-collaborator"],
|
|
210
|
+
collaboratorId: 7,
|
|
211
|
+
collaboratorName: "Taylor Tester",
|
|
212
|
+
isDirector: false,
|
|
213
|
+
isSupervisor: false,
|
|
214
|
+
isCollaborator: true,
|
|
215
|
+
teamCollaboratorIds: [],
|
|
216
|
+
visibleCollaboratorIds: [7],
|
|
217
|
+
visibleProjectIds: [11],
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
afterEach(() => {
|
|
222
|
+
jest.restoreAllMocks();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("rejects quick entries with non-positive duration", async () => {
|
|
226
|
+
await expect(
|
|
227
|
+
service.createTimesheetEntry(15, {
|
|
228
|
+
projectId: 11,
|
|
229
|
+
workDate: "2026-04-09",
|
|
230
|
+
duration: 0,
|
|
231
|
+
unit: "minutes",
|
|
232
|
+
} as any),
|
|
233
|
+
).rejects.toThrow(BadRequestException);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("rejects quick entries without a work date", async () => {
|
|
237
|
+
await expect(
|
|
238
|
+
service.createTimesheetEntry(15, {
|
|
239
|
+
projectId: 11,
|
|
240
|
+
duration: 30,
|
|
241
|
+
unit: "minutes",
|
|
242
|
+
} as any),
|
|
243
|
+
).rejects.toThrow(BadRequestException);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("stores quick-entry duration in minutes and keeps hours compatible", async () => {
|
|
247
|
+
const tx = {
|
|
248
|
+
$queryRawUnsafe: jest.fn().mockResolvedValue([{ id: 91 }]),
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
(service as any).prisma.$transaction.mockImplementation(
|
|
252
|
+
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
jest
|
|
256
|
+
.spyOn(service as any, "resolveOwnedProjectAssignment")
|
|
257
|
+
.mockResolvedValue({
|
|
258
|
+
id: 33,
|
|
259
|
+
projectId: 11,
|
|
260
|
+
projectName: "Project Atlas",
|
|
261
|
+
projectCode: "OPS-11",
|
|
262
|
+
roleLabel: "Engineer",
|
|
263
|
+
});
|
|
264
|
+
jest.spyOn(service as any, "getOwnedTaskRecord").mockResolvedValue({
|
|
265
|
+
id: 44,
|
|
266
|
+
name: "Implement API",
|
|
267
|
+
projectAssignmentId: 33,
|
|
268
|
+
projectId: 11,
|
|
269
|
+
projectName: "Project Atlas",
|
|
270
|
+
projectCode: "OPS-11",
|
|
271
|
+
});
|
|
272
|
+
jest
|
|
273
|
+
.spyOn(service as any, "getOrCreateTimesheetForWorkDate")
|
|
274
|
+
.mockResolvedValue(55);
|
|
275
|
+
jest
|
|
276
|
+
.spyOn(service as any, "refreshTimesheetTotal")
|
|
277
|
+
.mockResolvedValue(undefined);
|
|
278
|
+
jest
|
|
279
|
+
.spyOn(service as any, "submitTimesheetForApproval")
|
|
280
|
+
.mockResolvedValue(undefined);
|
|
281
|
+
jest
|
|
282
|
+
.spyOn(service as any, "getTimesheetEntryByIdForActor")
|
|
283
|
+
.mockResolvedValue({
|
|
284
|
+
id: 91,
|
|
285
|
+
durationMinutes: 90,
|
|
286
|
+
hours: 1.5,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const result = await service.createTimesheetEntry(15, {
|
|
290
|
+
projectId: 11,
|
|
291
|
+
taskId: 44,
|
|
292
|
+
workDate: "2026-04-09",
|
|
293
|
+
duration: 1.5,
|
|
294
|
+
unit: "hours",
|
|
295
|
+
description: "Worked on the API layer",
|
|
296
|
+
} as any);
|
|
297
|
+
|
|
298
|
+
expect(result).toEqual(
|
|
299
|
+
expect.objectContaining({
|
|
300
|
+
id: 91,
|
|
301
|
+
durationMinutes: 90,
|
|
302
|
+
hours: 1.5,
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
expect(tx.$queryRawUnsafe).toHaveBeenCalledWith(
|
|
306
|
+
expect.stringContaining("INSERT INTO operations_timesheet_entry"),
|
|
307
|
+
55,
|
|
308
|
+
33,
|
|
309
|
+
44,
|
|
310
|
+
"Implement API",
|
|
311
|
+
"2026-04-09",
|
|
312
|
+
90,
|
|
313
|
+
1.5,
|
|
314
|
+
"Worked on the API layer",
|
|
315
|
+
);
|
|
316
|
+
expect((service as any).submitTimesheetForApproval).toHaveBeenCalledWith(
|
|
317
|
+
tx,
|
|
318
|
+
55,
|
|
319
|
+
7,
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("uses the task assignment when full timesheet entries omit projectAssignmentId", async () => {
|
|
324
|
+
const tx = {
|
|
325
|
+
$queryRawUnsafe: jest.fn().mockResolvedValue([{ id: 55 }]),
|
|
326
|
+
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
(service as any).prisma.$transaction.mockImplementation(
|
|
330
|
+
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
jest.spyOn(service as any, "getCollaboratorById").mockResolvedValue({
|
|
334
|
+
id: 7,
|
|
335
|
+
supervisorId: 18,
|
|
336
|
+
});
|
|
337
|
+
jest.spyOn(service as any, "getOwnedTaskRecord").mockResolvedValue({
|
|
338
|
+
id: 44,
|
|
339
|
+
name: "Implement API",
|
|
340
|
+
projectAssignmentId: 33,
|
|
341
|
+
projectId: 11,
|
|
342
|
+
projectName: "Project Atlas",
|
|
343
|
+
projectCode: "OPS-11",
|
|
344
|
+
});
|
|
345
|
+
jest
|
|
346
|
+
.spyOn(service as any, "refreshTimesheetTotal")
|
|
347
|
+
.mockResolvedValue(undefined);
|
|
348
|
+
jest.spyOn(service as any, "listSingleTimesheet").mockResolvedValue({
|
|
349
|
+
id: 55,
|
|
350
|
+
status: "draft",
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await service.createTimesheet(15, {
|
|
354
|
+
weekStartDate: "2026-04-06",
|
|
355
|
+
weekEndDate: "2026-04-12",
|
|
356
|
+
entries: [
|
|
357
|
+
{
|
|
358
|
+
taskId: 44,
|
|
359
|
+
workDate: "2026-04-09",
|
|
360
|
+
duration: 2,
|
|
361
|
+
unit: "hours",
|
|
362
|
+
description: "Worked on the API layer",
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
} as any);
|
|
366
|
+
|
|
367
|
+
expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
|
|
368
|
+
expect.stringContaining("INSERT INTO operations_timesheet_entry"),
|
|
369
|
+
55,
|
|
370
|
+
33,
|
|
371
|
+
44,
|
|
372
|
+
"Implement API",
|
|
373
|
+
"2026-04-09",
|
|
374
|
+
120,
|
|
375
|
+
2,
|
|
376
|
+
"Worked on the API layer",
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("rejects submitting a timesheet without a resolved approver", async () => {
|
|
381
|
+
const tx = {
|
|
382
|
+
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
383
|
+
$queryRawUnsafe: jest
|
|
384
|
+
.fn()
|
|
385
|
+
.mockResolvedValueOnce([])
|
|
386
|
+
.mockResolvedValueOnce([{ exists: true }]),
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
(service as any).prisma.$transaction.mockImplementation(
|
|
390
|
+
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
jest.spyOn(service as any, "getTimesheetById").mockResolvedValue({
|
|
394
|
+
id: 55,
|
|
395
|
+
collaboratorId: 7,
|
|
396
|
+
approverCollaboratorId: null,
|
|
397
|
+
status: "draft",
|
|
398
|
+
});
|
|
399
|
+
jest.spyOn(service as any, "getCollaboratorById").mockResolvedValue({
|
|
400
|
+
id: 7,
|
|
401
|
+
supervisorId: null,
|
|
402
|
+
});
|
|
403
|
+
jest.spyOn(service as any, "listSingleTimesheet").mockResolvedValue({
|
|
404
|
+
id: 55,
|
|
405
|
+
status: "submitted",
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
await expect(service.submitTimesheet(15, 55)).rejects.toThrow(
|
|
409
|
+
BadRequestException,
|
|
410
|
+
);
|
|
411
|
+
expect(tx.$executeRawUnsafe).not.toHaveBeenCalled();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("rejects submitting a timesheet without active entries", async () => {
|
|
415
|
+
const tx = {
|
|
416
|
+
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
417
|
+
$queryRawUnsafe: jest
|
|
418
|
+
.fn()
|
|
419
|
+
.mockResolvedValueOnce([{ managerCollaboratorId: 18 }])
|
|
420
|
+
.mockResolvedValueOnce([{ exists: false }]),
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
(service as any).prisma.$transaction.mockImplementation(
|
|
424
|
+
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
jest.spyOn(service as any, "getTimesheetById").mockResolvedValue({
|
|
428
|
+
id: 56,
|
|
429
|
+
collaboratorId: 7,
|
|
430
|
+
approverCollaboratorId: 18,
|
|
431
|
+
status: "draft",
|
|
432
|
+
});
|
|
433
|
+
jest.spyOn(service as any, "getCollaboratorById").mockResolvedValue({
|
|
434
|
+
id: 7,
|
|
435
|
+
supervisorId: 18,
|
|
436
|
+
});
|
|
437
|
+
jest.spyOn(service as any, "listSingleTimesheet").mockResolvedValue({
|
|
438
|
+
id: 56,
|
|
439
|
+
status: "submitted",
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
await expect(service.submitTimesheet(15, 56)).rejects.toThrow(
|
|
443
|
+
BadRequestException,
|
|
444
|
+
);
|
|
445
|
+
expect(tx.$executeRawUnsafe).not.toHaveBeenCalled();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("deletes only draft quick entries and refreshes the weekly total", async () => {
|
|
449
|
+
const tx = {
|
|
450
|
+
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
451
|
+
$queryRawUnsafe: jest.fn().mockResolvedValue([]),
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
(service as any).prisma.$transaction.mockImplementation(
|
|
455
|
+
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
jest
|
|
459
|
+
.spyOn(service as any, "getTimesheetEntryByIdForActor")
|
|
460
|
+
.mockResolvedValue({
|
|
461
|
+
id: 91,
|
|
462
|
+
timesheetId: 55,
|
|
463
|
+
collaboratorId: 7,
|
|
464
|
+
status: "draft",
|
|
465
|
+
});
|
|
466
|
+
jest
|
|
467
|
+
.spyOn(service as any, "refreshTimesheetTotal")
|
|
468
|
+
.mockResolvedValue(undefined);
|
|
469
|
+
|
|
470
|
+
await expect(service.removeTimesheetEntry(15, 91)).resolves.toEqual({
|
|
471
|
+
success: true,
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
|
|
475
|
+
expect.stringContaining("UPDATE operations_timesheet_entry"),
|
|
476
|
+
91,
|
|
477
|
+
);
|
|
478
|
+
expect((service as any).refreshTimesheetTotal).toHaveBeenCalledWith(tx, 55);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("updates quick entries and moves them to the correct weekly timesheet", async () => {
|
|
482
|
+
const tx = {
|
|
483
|
+
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
484
|
+
$queryRawUnsafe: jest.fn().mockResolvedValue([]),
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
(service as any).prisma.$transaction.mockImplementation(
|
|
488
|
+
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
jest
|
|
492
|
+
.spyOn(service as any, "getTimesheetEntryByIdForActor")
|
|
493
|
+
.mockResolvedValueOnce({
|
|
494
|
+
id: 93,
|
|
495
|
+
timesheetId: 55,
|
|
496
|
+
collaboratorId: 7,
|
|
497
|
+
projectId: 11,
|
|
498
|
+
projectAssignmentId: 33,
|
|
499
|
+
taskId: 44,
|
|
500
|
+
status: "draft",
|
|
501
|
+
weekStartDate: "2026-04-06",
|
|
502
|
+
weekEndDate: "2026-04-12",
|
|
503
|
+
workDate: "2026-04-09",
|
|
504
|
+
hours: 1,
|
|
505
|
+
durationMinutes: 60,
|
|
506
|
+
})
|
|
507
|
+
.mockResolvedValueOnce({
|
|
508
|
+
id: 93,
|
|
509
|
+
timesheetId: 77,
|
|
510
|
+
collaboratorId: 7,
|
|
511
|
+
projectId: 12,
|
|
512
|
+
projectAssignmentId: 34,
|
|
513
|
+
taskId: 45,
|
|
514
|
+
status: "draft",
|
|
515
|
+
weekStartDate: "2026-04-13",
|
|
516
|
+
weekEndDate: "2026-04-19",
|
|
517
|
+
workDate: "2026-04-14",
|
|
518
|
+
hours: 2,
|
|
519
|
+
durationMinutes: 120,
|
|
520
|
+
taskName: "Review backlog",
|
|
521
|
+
});
|
|
522
|
+
jest
|
|
523
|
+
.spyOn(service as any, "resolveOwnedProjectAssignment")
|
|
524
|
+
.mockResolvedValue({
|
|
525
|
+
id: 34,
|
|
526
|
+
projectId: 12,
|
|
527
|
+
projectName: "Project Boreal",
|
|
528
|
+
projectCode: "OPS-12",
|
|
529
|
+
roleLabel: "Engineer",
|
|
530
|
+
});
|
|
531
|
+
jest.spyOn(service as any, "getOwnedTaskRecord").mockResolvedValue({
|
|
532
|
+
id: 45,
|
|
533
|
+
name: "Review backlog",
|
|
534
|
+
projectAssignmentId: 34,
|
|
535
|
+
projectId: 12,
|
|
536
|
+
projectName: "Project Boreal",
|
|
537
|
+
projectCode: "OPS-12",
|
|
538
|
+
});
|
|
539
|
+
jest
|
|
540
|
+
.spyOn(service as any, "getOrCreateTimesheetForWorkDate")
|
|
541
|
+
.mockResolvedValue(77);
|
|
542
|
+
jest
|
|
543
|
+
.spyOn(service as any, "refreshTimesheetTotal")
|
|
544
|
+
.mockResolvedValue(undefined);
|
|
545
|
+
jest
|
|
546
|
+
.spyOn(service as any, "cleanupEmptyEditableTimesheet")
|
|
547
|
+
.mockResolvedValue(undefined);
|
|
548
|
+
jest
|
|
549
|
+
.spyOn(service as any, "submitTimesheetForApproval")
|
|
550
|
+
.mockResolvedValue(undefined);
|
|
551
|
+
|
|
552
|
+
const result = await service.updateTimesheetEntry(15, 93, {
|
|
553
|
+
projectId: 12,
|
|
554
|
+
projectAssignmentId: 34,
|
|
555
|
+
taskId: 45,
|
|
556
|
+
workDate: "2026-04-14",
|
|
557
|
+
duration: 2,
|
|
558
|
+
unit: "hours",
|
|
559
|
+
description: "Backlog refinement",
|
|
560
|
+
} as any);
|
|
561
|
+
|
|
562
|
+
expect(result).toEqual(
|
|
563
|
+
expect.objectContaining({
|
|
564
|
+
id: 93,
|
|
565
|
+
timesheetId: 77,
|
|
566
|
+
}),
|
|
567
|
+
);
|
|
568
|
+
expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
|
|
569
|
+
expect.stringContaining("UPDATE operations_timesheet_entry"),
|
|
570
|
+
77,
|
|
571
|
+
34,
|
|
572
|
+
45,
|
|
573
|
+
"Review backlog",
|
|
574
|
+
"2026-04-14",
|
|
575
|
+
120,
|
|
576
|
+
2,
|
|
577
|
+
"Backlog refinement",
|
|
578
|
+
93,
|
|
579
|
+
);
|
|
580
|
+
expect((service as any).refreshTimesheetTotal).toHaveBeenCalledWith(tx, 55);
|
|
581
|
+
expect((service as any).refreshTimesheetTotal).toHaveBeenCalledWith(tx, 77);
|
|
582
|
+
expect((service as any).cleanupEmptyEditableTimesheet).toHaveBeenCalledWith(
|
|
583
|
+
tx,
|
|
584
|
+
55,
|
|
585
|
+
);
|
|
586
|
+
expect((service as any).submitTimesheetForApproval).toHaveBeenCalledWith(
|
|
587
|
+
tx,
|
|
588
|
+
77,
|
|
589
|
+
7,
|
|
590
|
+
);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("allows deleting entries from submitted timesheets until approval", async () => {
|
|
594
|
+
const tx = {
|
|
595
|
+
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
(service as any).prisma.$transaction.mockImplementation(
|
|
599
|
+
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
jest
|
|
603
|
+
.spyOn(service as any, "getTimesheetEntryByIdForActor")
|
|
604
|
+
.mockResolvedValue({
|
|
605
|
+
id: 92,
|
|
606
|
+
timesheetId: 56,
|
|
607
|
+
collaboratorId: 7,
|
|
608
|
+
status: "submitted",
|
|
609
|
+
});
|
|
610
|
+
jest
|
|
611
|
+
.spyOn(service as any, "refreshTimesheetTotal")
|
|
612
|
+
.mockResolvedValue(undefined);
|
|
613
|
+
jest
|
|
614
|
+
.spyOn(service as any, "cleanupEmptyEditableTimesheet")
|
|
615
|
+
.mockResolvedValue(undefined);
|
|
616
|
+
|
|
617
|
+
await expect(service.removeTimesheetEntry(15, 92)).resolves.toEqual({
|
|
618
|
+
success: true,
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("rejects deleting entries from approved timesheets", async () => {
|
|
623
|
+
jest
|
|
624
|
+
.spyOn(service as any, "getTimesheetEntryByIdForActor")
|
|
625
|
+
.mockResolvedValue({
|
|
626
|
+
id: 94,
|
|
627
|
+
timesheetId: 57,
|
|
628
|
+
collaboratorId: 7,
|
|
629
|
+
status: "approved",
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
await expect(service.removeTimesheetEntry(15, 94)).rejects.toThrow(
|
|
633
|
+
BadRequestException,
|
|
634
|
+
);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
describe("OperationsService task column activity", () => {
|
|
639
|
+
let service: OperationsService;
|
|
640
|
+
let prisma: { $transaction: jest.Mock };
|
|
641
|
+
let integrationApi: { publishEvent: jest.Mock };
|
|
642
|
+
|
|
643
|
+
const baseTask = {
|
|
644
|
+
id: 44,
|
|
645
|
+
name: "Implement board",
|
|
646
|
+
description: null,
|
|
647
|
+
priority: "medium",
|
|
648
|
+
status: "todo",
|
|
649
|
+
dueDate: null,
|
|
650
|
+
estimateHours: null,
|
|
651
|
+
position: 0,
|
|
652
|
+
tags: null,
|
|
653
|
+
assigneeCollaboratorId: null,
|
|
654
|
+
projectAssignmentId: null,
|
|
655
|
+
projectId: 11,
|
|
656
|
+
projectName: "Project Atlas",
|
|
657
|
+
projectCode: "OPS-11",
|
|
658
|
+
doingStartedAt: null,
|
|
659
|
+
totalDoingMinutes: 0,
|
|
660
|
+
deletedAt: null,
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
beforeEach(() => {
|
|
664
|
+
prisma = {
|
|
665
|
+
$transaction: jest.fn(),
|
|
666
|
+
};
|
|
667
|
+
integrationApi = {
|
|
668
|
+
publishEvent: jest.fn().mockResolvedValue(undefined),
|
|
669
|
+
};
|
|
670
|
+
service = new OperationsService(
|
|
671
|
+
prisma as any,
|
|
672
|
+
{} as any,
|
|
673
|
+
integrationApi as any,
|
|
674
|
+
{} as any,
|
|
675
|
+
{} as any,
|
|
676
|
+
new OperationsAccessService(),
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
jest.spyOn(service as any, "getActorContext").mockResolvedValue({
|
|
680
|
+
userId: 15,
|
|
681
|
+
roleSlugs: ["operations-collaborator"],
|
|
682
|
+
collaboratorId: 7,
|
|
683
|
+
collaboratorName: "Taylor Tester",
|
|
684
|
+
isDirector: false,
|
|
685
|
+
isSupervisor: false,
|
|
686
|
+
isCollaborator: true,
|
|
687
|
+
teamCollaboratorIds: [],
|
|
688
|
+
visibleCollaboratorIds: [7],
|
|
689
|
+
visibleProjectIds: [11],
|
|
690
|
+
});
|
|
691
|
+
jest
|
|
692
|
+
.spyOn(service as any, "assertProjectAccess")
|
|
693
|
+
.mockResolvedValue(undefined);
|
|
694
|
+
jest.spyOn(service as any, "getProjectBoardTask").mockResolvedValue({
|
|
695
|
+
...baseTask,
|
|
696
|
+
assigneeCollaboratorId: 7,
|
|
697
|
+
status: "doing",
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
afterEach(() => {
|
|
702
|
+
jest.restoreAllMocks();
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
function mockTransaction() {
|
|
706
|
+
const tx = {
|
|
707
|
+
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
708
|
+
};
|
|
709
|
+
prisma.$transaction.mockImplementation(
|
|
710
|
+
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
711
|
+
);
|
|
712
|
+
return tx;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
it("records todo to doing and auto-assigns the actor", async () => {
|
|
716
|
+
const tx = mockTransaction();
|
|
717
|
+
jest
|
|
718
|
+
.spyOn(service as any, "getTaskRecordForActor")
|
|
719
|
+
.mockResolvedValue(baseTask);
|
|
720
|
+
|
|
721
|
+
await service.updateTask(15, 44, { status: "doing" } as any);
|
|
722
|
+
|
|
723
|
+
expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(2);
|
|
724
|
+
expect(tx.$executeRawUnsafe.mock.calls[0][0]).toContain(
|
|
725
|
+
"doing_started_at = CASE",
|
|
726
|
+
);
|
|
727
|
+
expect(tx.$executeRawUnsafe.mock.calls[0][3]).toBe(7);
|
|
728
|
+
expect(tx.$executeRawUnsafe.mock.calls[0][15]).toBe(true);
|
|
729
|
+
expect(tx.$executeRawUnsafe.mock.calls[0][16]).toBe(false);
|
|
730
|
+
expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
|
|
731
|
+
"INSERT INTO operations_task_activity",
|
|
732
|
+
);
|
|
733
|
+
expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
|
|
734
|
+
'$3::"operations_task_activity_action_bd50457330_enum"',
|
|
735
|
+
);
|
|
736
|
+
expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
|
|
737
|
+
'$4::"operations_task_activity_from_status_3a69262de6_enum"',
|
|
738
|
+
);
|
|
739
|
+
expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
|
|
740
|
+
'$5::"operations_task_activity_to_status_1fd99321c8_enum"',
|
|
741
|
+
);
|
|
742
|
+
expect(tx.$executeRawUnsafe.mock.calls[1].slice(1)).toEqual([
|
|
743
|
+
44,
|
|
744
|
+
7,
|
|
745
|
+
"status_changed",
|
|
746
|
+
"todo",
|
|
747
|
+
"doing",
|
|
748
|
+
]);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it("records doing to review and closes accumulated doing minutes", async () => {
|
|
752
|
+
const tx = mockTransaction();
|
|
753
|
+
jest.spyOn(service as any, "getTaskRecordForActor").mockResolvedValue({
|
|
754
|
+
...baseTask,
|
|
755
|
+
status: "doing",
|
|
756
|
+
assigneeCollaboratorId: 9,
|
|
757
|
+
doingStartedAt: "2026-05-16T10:00:00.000Z",
|
|
758
|
+
totalDoingMinutes: 15,
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
await service.updateTask(15, 44, { status: "review" } as any);
|
|
762
|
+
|
|
763
|
+
expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(2);
|
|
764
|
+
expect(tx.$executeRawUnsafe.mock.calls[0][3]).toBe(9);
|
|
765
|
+
expect(tx.$executeRawUnsafe.mock.calls[0][15]).toBe(false);
|
|
766
|
+
expect(tx.$executeRawUnsafe.mock.calls[0][16]).toBe(true);
|
|
767
|
+
expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
|
|
768
|
+
'$3::"operations_task_activity_action_bd50457330_enum"',
|
|
769
|
+
);
|
|
770
|
+
expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
|
|
771
|
+
'$4::"operations_task_activity_from_status_3a69262de6_enum"',
|
|
772
|
+
);
|
|
773
|
+
expect(tx.$executeRawUnsafe.mock.calls[1][0]).toContain(
|
|
774
|
+
'$5::"operations_task_activity_to_status_1fd99321c8_enum"',
|
|
775
|
+
);
|
|
776
|
+
expect(tx.$executeRawUnsafe.mock.calls[1].slice(1)).toEqual([
|
|
777
|
+
44,
|
|
778
|
+
7,
|
|
779
|
+
"status_changed",
|
|
780
|
+
"doing",
|
|
781
|
+
"review",
|
|
782
|
+
]);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it("blocks moving advanced without a linked collaborator when no assignee exists", async () => {
|
|
786
|
+
prisma.$transaction.mockImplementation(
|
|
787
|
+
async (callback: (client: unknown) => unknown) =>
|
|
788
|
+
callback({ $executeRawUnsafe: jest.fn() }),
|
|
789
|
+
);
|
|
790
|
+
jest.spyOn(service as any, "getActorContext").mockResolvedValue({
|
|
791
|
+
userId: 15,
|
|
792
|
+
roleSlugs: ["admin"],
|
|
793
|
+
collaboratorId: null,
|
|
794
|
+
collaboratorName: null,
|
|
795
|
+
isDirector: true,
|
|
796
|
+
isSupervisor: true,
|
|
797
|
+
isCollaborator: true,
|
|
798
|
+
teamCollaboratorIds: [],
|
|
799
|
+
visibleCollaboratorIds: [],
|
|
800
|
+
visibleProjectIds: [],
|
|
801
|
+
});
|
|
802
|
+
jest
|
|
803
|
+
.spyOn(service as any, "getTaskRecordForActor")
|
|
804
|
+
.mockResolvedValue(baseTask);
|
|
805
|
+
|
|
806
|
+
await expect(
|
|
807
|
+
service.updateTask(15, 44, { status: "review" } as any),
|
|
808
|
+
).rejects.toThrow(BadRequestException);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it("does not create activity when the status does not change", async () => {
|
|
812
|
+
const tx = mockTransaction();
|
|
813
|
+
jest
|
|
814
|
+
.spyOn(service as any, "getTaskRecordForActor")
|
|
815
|
+
.mockResolvedValue(baseTask);
|
|
816
|
+
|
|
817
|
+
await service.updateTask(15, 44, { status: "todo" } as any);
|
|
818
|
+
|
|
819
|
+
expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(1);
|
|
820
|
+
expect(tx.$executeRawUnsafe.mock.calls[0][15]).toBe(false);
|
|
821
|
+
expect(tx.$executeRawUnsafe.mock.calls[0][16]).toBe(false);
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
describe("OperationsService approval side effects", () => {
|
|
826
|
+
let service: OperationsService;
|
|
827
|
+
|
|
828
|
+
beforeEach(() => {
|
|
829
|
+
service = new OperationsService(
|
|
830
|
+
{
|
|
831
|
+
$transaction: jest.fn(),
|
|
832
|
+
} as any,
|
|
833
|
+
{} as any,
|
|
834
|
+
{
|
|
835
|
+
publishEvent: jest.fn().mockResolvedValue(undefined),
|
|
836
|
+
} as any,
|
|
837
|
+
{} as any,
|
|
838
|
+
{} as any,
|
|
839
|
+
new OperationsAccessService(),
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
jest.spyOn(service as any, "getActorContext").mockResolvedValue({
|
|
843
|
+
userId: 22,
|
|
844
|
+
roleSlugs: ["admin-operations-supervisor"],
|
|
845
|
+
collaboratorId: 18,
|
|
846
|
+
collaboratorName: "Sam Supervisor",
|
|
847
|
+
isDirector: false,
|
|
848
|
+
isSupervisor: true,
|
|
849
|
+
isCollaborator: false,
|
|
850
|
+
teamCollaboratorIds: [7],
|
|
851
|
+
visibleCollaboratorIds: [7, 18],
|
|
852
|
+
visibleProjectIds: [11],
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
afterEach(() => {
|
|
857
|
+
jest.restoreAllMocks();
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
it("applies approved permanent schedule adjustments to the active collaborator weekly schedule", async () => {
|
|
861
|
+
const tx = {
|
|
862
|
+
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
863
|
+
$queryRawUnsafe: jest
|
|
864
|
+
.fn()
|
|
865
|
+
.mockResolvedValueOnce([
|
|
866
|
+
{
|
|
867
|
+
collaboratorId: 7,
|
|
868
|
+
requestScope: "permanent",
|
|
869
|
+
},
|
|
870
|
+
])
|
|
871
|
+
.mockResolvedValueOnce([
|
|
872
|
+
{
|
|
873
|
+
weekday: "monday",
|
|
874
|
+
isWorkingDay: true,
|
|
875
|
+
startTime: "08:00",
|
|
876
|
+
endTime: "17:00",
|
|
877
|
+
breakMinutes: 45,
|
|
878
|
+
},
|
|
879
|
+
{
|
|
880
|
+
weekday: "friday",
|
|
881
|
+
isWorkingDay: false,
|
|
882
|
+
startTime: null,
|
|
883
|
+
endTime: null,
|
|
884
|
+
breakMinutes: 0,
|
|
885
|
+
},
|
|
886
|
+
]),
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
(service as any).prisma.$transaction.mockImplementation(
|
|
890
|
+
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
jest
|
|
894
|
+
.spyOn(service as any, "querySingle")
|
|
895
|
+
.mockResolvedValueOnce({
|
|
896
|
+
id: 81,
|
|
897
|
+
targetType: "schedule_adjustment_request",
|
|
898
|
+
targetId: 400,
|
|
899
|
+
requesterCollaboratorId: 7,
|
|
900
|
+
approverCollaboratorId: 18,
|
|
901
|
+
status: "pending",
|
|
902
|
+
})
|
|
903
|
+
.mockResolvedValueOnce({
|
|
904
|
+
id: 81,
|
|
905
|
+
targetType: "schedule_adjustment_request",
|
|
906
|
+
targetId: 400,
|
|
907
|
+
status: "approved",
|
|
908
|
+
decidedAt: "2026-04-11T12:00:00.000Z",
|
|
909
|
+
decisionNote: "Approved for the new weekly schedule.",
|
|
910
|
+
});
|
|
911
|
+
jest
|
|
912
|
+
.spyOn(service as any, "insertApprovalHistory")
|
|
913
|
+
.mockResolvedValue(undefined);
|
|
914
|
+
|
|
915
|
+
await expect(
|
|
916
|
+
service.approve(22, 81, {
|
|
917
|
+
note: "Approved for the new weekly schedule.",
|
|
918
|
+
}),
|
|
919
|
+
).resolves.toEqual(
|
|
920
|
+
expect.objectContaining({
|
|
921
|
+
id: 81,
|
|
922
|
+
status: "approved",
|
|
923
|
+
}),
|
|
924
|
+
);
|
|
925
|
+
|
|
926
|
+
expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
|
|
927
|
+
expect.stringContaining("UPDATE operations_schedule_adjustment_request"),
|
|
928
|
+
"approved",
|
|
929
|
+
18,
|
|
930
|
+
400,
|
|
931
|
+
);
|
|
932
|
+
expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
|
|
933
|
+
expect.stringContaining("UPDATE operations_collaborator_schedule_day"),
|
|
934
|
+
7,
|
|
935
|
+
);
|
|
936
|
+
expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
|
|
937
|
+
expect.stringContaining(
|
|
938
|
+
"INSERT INTO operations_collaborator_schedule_day",
|
|
939
|
+
),
|
|
940
|
+
7,
|
|
941
|
+
"monday",
|
|
942
|
+
true,
|
|
943
|
+
"08:00",
|
|
944
|
+
"17:00",
|
|
945
|
+
45,
|
|
946
|
+
);
|
|
947
|
+
expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
|
|
948
|
+
expect.stringContaining(
|
|
949
|
+
"INSERT INTO operations_collaborator_schedule_day",
|
|
950
|
+
),
|
|
951
|
+
7,
|
|
952
|
+
"friday",
|
|
953
|
+
false,
|
|
954
|
+
null,
|
|
955
|
+
null,
|
|
956
|
+
0,
|
|
957
|
+
);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it("keeps the collaborator weekly schedule unchanged for approved temporary adjustments", async () => {
|
|
961
|
+
const tx = {
|
|
962
|
+
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
|
|
963
|
+
$queryRawUnsafe: jest.fn().mockResolvedValueOnce([
|
|
964
|
+
{
|
|
965
|
+
collaboratorId: 7,
|
|
966
|
+
requestScope: "temporary",
|
|
967
|
+
},
|
|
968
|
+
]),
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
(service as any).prisma.$transaction.mockImplementation(
|
|
972
|
+
async (callback: (client: unknown) => unknown) => callback(tx),
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
jest
|
|
976
|
+
.spyOn(service as any, "querySingle")
|
|
977
|
+
.mockResolvedValueOnce({
|
|
978
|
+
id: 82,
|
|
979
|
+
targetType: "schedule_adjustment_request",
|
|
980
|
+
targetId: 401,
|
|
981
|
+
requesterCollaboratorId: 7,
|
|
982
|
+
approverCollaboratorId: 18,
|
|
983
|
+
status: "pending",
|
|
984
|
+
})
|
|
985
|
+
.mockResolvedValueOnce({
|
|
986
|
+
id: 82,
|
|
987
|
+
targetType: "schedule_adjustment_request",
|
|
988
|
+
targetId: 401,
|
|
989
|
+
status: "approved",
|
|
990
|
+
decidedAt: "2026-04-11T12:05:00.000Z",
|
|
991
|
+
decisionNote: "Approved as a temporary exception.",
|
|
992
|
+
});
|
|
993
|
+
jest
|
|
994
|
+
.spyOn(service as any, "insertApprovalHistory")
|
|
995
|
+
.mockResolvedValue(undefined);
|
|
996
|
+
|
|
997
|
+
await service.approve(22, 82, {
|
|
998
|
+
note: "Approved as a temporary exception.",
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
expect(tx.$executeRawUnsafe).not.toHaveBeenCalledWith(
|
|
1002
|
+
expect.stringContaining("UPDATE operations_collaborator_schedule_day"),
|
|
1003
|
+
7,
|
|
1004
|
+
);
|
|
1005
|
+
});
|
|
1006
|
+
});
|