github-issue-tower-defence-management 1.36.3 → 1.37.1
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/.github/workflows/create-pr.yml +26 -5
- package/.github/workflows/umino-project.yml +4 -4
- package/CHANGELOG.md +19 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -3
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +30 -7
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
- package/bin/domain/usecases/ClearPastNextActionDateHourUseCase.js +59 -0
- package/bin/domain/usecases/ClearPastNextActionDateHourUseCase.js.map +1 -0
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +3 -3
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +8 -3
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +3 -3
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +21 -0
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +175 -3
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +73 -37
- package/src/domain/usecases/ClearPastNextActionDateHourUseCase.test.ts +325 -0
- package/src/domain/usecases/ClearPastNextActionDateHourUseCase.ts +88 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +4 -3
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +3 -3
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
- package/types/domain/usecases/ClearPastNextActionDateHourUseCase.d.ts +14 -0
- package/types/domain/usecases/ClearPastNextActionDateHourUseCase.d.ts.map +1 -0
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -3
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/src/domain/usecases/ClearNextActionHourUseCase.ts +0 -61
|
@@ -25,12 +25,16 @@ const mockJsonResponse = <T>(data: T) => ({
|
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
describe('GraphqlProjectItemRepository', () => {
|
|
28
|
-
const makePageResponse = (
|
|
28
|
+
const makePageResponse = (
|
|
29
|
+
hasNextPage: boolean,
|
|
30
|
+
endCursor: string,
|
|
31
|
+
totalCount = 2,
|
|
32
|
+
) =>
|
|
29
33
|
mockJsonResponse({
|
|
30
34
|
data: {
|
|
31
35
|
node: {
|
|
32
36
|
items: {
|
|
33
|
-
totalCount
|
|
37
|
+
totalCount,
|
|
34
38
|
pageInfo: {
|
|
35
39
|
endCursor,
|
|
36
40
|
startCursor: 'cursor-start',
|
|
@@ -101,6 +105,174 @@ describe('GraphqlProjectItemRepository', () => {
|
|
|
101
105
|
setTimeoutSpy.mockRestore();
|
|
102
106
|
});
|
|
103
107
|
|
|
108
|
+
it('should throw when response contains GraphQL errors alongside partial data', async () => {
|
|
109
|
+
const localStorageRepository = new LocalStorageRepository();
|
|
110
|
+
const repository = new GraphqlProjectItemRepository(
|
|
111
|
+
localStorageRepository,
|
|
112
|
+
'',
|
|
113
|
+
'dummy-token',
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
mockPost.mockReturnValueOnce(
|
|
117
|
+
mockJsonResponse({
|
|
118
|
+
data: {
|
|
119
|
+
node: {
|
|
120
|
+
items: {
|
|
121
|
+
totalCount: 1,
|
|
122
|
+
pageInfo: {
|
|
123
|
+
endCursor: 'cursor-1',
|
|
124
|
+
startCursor: 'cursor-start',
|
|
125
|
+
hasNextPage: false,
|
|
126
|
+
},
|
|
127
|
+
nodes: [
|
|
128
|
+
{
|
|
129
|
+
id: 'item-partial',
|
|
130
|
+
fieldValues: { nodes: [] },
|
|
131
|
+
content: {
|
|
132
|
+
repository: { nameWithOwner: 'owner/repo' },
|
|
133
|
+
number: 1,
|
|
134
|
+
title: 'Partial Issue',
|
|
135
|
+
state: 'OPEN',
|
|
136
|
+
url: 'https://github.com/owner/repo/issues/1',
|
|
137
|
+
body: 'body',
|
|
138
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
139
|
+
labels: { nodes: [] },
|
|
140
|
+
assignees: { nodes: [] },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
errors: [{ message: 'RATE_LIMITED' }],
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
await expect(
|
|
152
|
+
repository.fetchProjectItems('test-project-id'),
|
|
153
|
+
).rejects.toThrow('GitHub GraphQL errors: RATE_LIMITED');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should throw when data is null in response', async () => {
|
|
157
|
+
const localStorageRepository = new LocalStorageRepository();
|
|
158
|
+
const repository = new GraphqlProjectItemRepository(
|
|
159
|
+
localStorageRepository,
|
|
160
|
+
'',
|
|
161
|
+
'dummy-token',
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
mockPost.mockReturnValueOnce(
|
|
165
|
+
mockJsonResponse({
|
|
166
|
+
data: null,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
await expect(
|
|
171
|
+
repository.fetchProjectItems('test-project-id'),
|
|
172
|
+
).rejects.toThrow('No data returned from GitHub API');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should throw when node is null in response', async () => {
|
|
176
|
+
const localStorageRepository = new LocalStorageRepository();
|
|
177
|
+
const repository = new GraphqlProjectItemRepository(
|
|
178
|
+
localStorageRepository,
|
|
179
|
+
'',
|
|
180
|
+
'dummy-token',
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
mockPost.mockReturnValueOnce(
|
|
184
|
+
mockJsonResponse({
|
|
185
|
+
data: { node: null },
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
await expect(
|
|
190
|
+
repository.fetchProjectItems('test-project-id'),
|
|
191
|
+
).rejects.toThrow('No data returned from GitHub API');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should throw when accumulated nodes count does not match totalCount', async () => {
|
|
195
|
+
const localStorageRepository = new LocalStorageRepository();
|
|
196
|
+
const repository = new GraphqlProjectItemRepository(
|
|
197
|
+
localStorageRepository,
|
|
198
|
+
'',
|
|
199
|
+
'dummy-token',
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
mockPost.mockReturnValueOnce(
|
|
203
|
+
mockJsonResponse({
|
|
204
|
+
data: {
|
|
205
|
+
node: {
|
|
206
|
+
items: {
|
|
207
|
+
totalCount: 5,
|
|
208
|
+
pageInfo: {
|
|
209
|
+
endCursor: 'cursor-1',
|
|
210
|
+
startCursor: 'cursor-start',
|
|
211
|
+
hasNextPage: false,
|
|
212
|
+
},
|
|
213
|
+
nodes: [
|
|
214
|
+
{
|
|
215
|
+
id: 'item-1',
|
|
216
|
+
fieldValues: { nodes: [] },
|
|
217
|
+
content: {
|
|
218
|
+
repository: { nameWithOwner: 'owner/repo' },
|
|
219
|
+
number: 1,
|
|
220
|
+
title: 'Test Issue',
|
|
221
|
+
state: 'OPEN',
|
|
222
|
+
url: 'https://github.com/owner/repo/issues/1',
|
|
223
|
+
body: 'body',
|
|
224
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
225
|
+
labels: { nodes: [] },
|
|
226
|
+
assignees: { nodes: [] },
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
await expect(
|
|
237
|
+
repository.fetchProjectItems('test-project-id'),
|
|
238
|
+
).rejects.toThrow(
|
|
239
|
+
'fetchProjectItems: expected 5 items but accumulated 1',
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should throw when page has no nodes but totalCount is positive', async () => {
|
|
244
|
+
const localStorageRepository = new LocalStorageRepository();
|
|
245
|
+
const repository = new GraphqlProjectItemRepository(
|
|
246
|
+
localStorageRepository,
|
|
247
|
+
'',
|
|
248
|
+
'dummy-token',
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
mockPost.mockReturnValueOnce(
|
|
252
|
+
mockJsonResponse({
|
|
253
|
+
data: {
|
|
254
|
+
node: {
|
|
255
|
+
items: {
|
|
256
|
+
totalCount: 2,
|
|
257
|
+
pageInfo: {
|
|
258
|
+
endCursor: 'cursor-1',
|
|
259
|
+
startCursor: 'cursor-start',
|
|
260
|
+
hasNextPage: false,
|
|
261
|
+
},
|
|
262
|
+
nodes: [],
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await expect(
|
|
270
|
+
repository.fetchProjectItems('test-project-id'),
|
|
271
|
+
).rejects.toThrow(
|
|
272
|
+
'fetchProjectItems: expected 2 items but accumulated 0',
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
104
276
|
it('should not sleep on first request when there is only one page', async () => {
|
|
105
277
|
const localStorageRepository = new LocalStorageRepository();
|
|
106
278
|
const repository = new GraphqlProjectItemRepository(
|
|
@@ -110,7 +282,7 @@ describe('GraphqlProjectItemRepository', () => {
|
|
|
110
282
|
);
|
|
111
283
|
|
|
112
284
|
const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
|
|
113
|
-
mockPost.mockReturnValueOnce(makePageResponse(false, 'cursor-1'));
|
|
285
|
+
mockPost.mockReturnValueOnce(makePageResponse(false, 'cursor-1', 1));
|
|
114
286
|
|
|
115
287
|
const result = await repository.fetchProjectItems('test-project-id');
|
|
116
288
|
|
|
@@ -281,17 +281,31 @@ query GetProjectItems($projectId: ID!, $after: String) {
|
|
|
281
281
|
};
|
|
282
282
|
}[];
|
|
283
283
|
};
|
|
284
|
-
};
|
|
285
|
-
};
|
|
284
|
+
} | null;
|
|
285
|
+
} | null;
|
|
286
|
+
errors?: { message: string }[];
|
|
286
287
|
}>();
|
|
287
|
-
if (
|
|
288
|
+
if (response.errors && response.errors.length > 0) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`GitHub GraphQL errors: ${response.errors.map((e) => e.message).join('; ')}`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const rawData = response.data;
|
|
294
|
+
if (!rawData) {
|
|
295
|
+
throw new Error('No data returned from GitHub API');
|
|
296
|
+
}
|
|
297
|
+
const rawNode = rawData.node;
|
|
298
|
+
if (rawNode === null) {
|
|
288
299
|
throw new Error('No data returned from GitHub API');
|
|
289
300
|
}
|
|
290
|
-
return
|
|
301
|
+
return { node: rawNode };
|
|
291
302
|
};
|
|
292
303
|
const issues: ProjectItem[] = [];
|
|
293
304
|
let after: string | null = null;
|
|
294
305
|
let hasNextPage = true;
|
|
306
|
+
let totalCount = 0;
|
|
307
|
+
let cumulativeRawNodes = 0;
|
|
308
|
+
let pageIndex = 0;
|
|
295
309
|
|
|
296
310
|
while (hasNextPage) {
|
|
297
311
|
if (after !== null) {
|
|
@@ -300,6 +314,14 @@ query GetProjectItems($projectId: ID!, $after: String) {
|
|
|
300
314
|
);
|
|
301
315
|
}
|
|
302
316
|
const data = await callGraphql(projectId, after);
|
|
317
|
+
const pageNodes = data.node.items.nodes;
|
|
318
|
+
const pageInfo = data.node.items.pageInfo;
|
|
319
|
+
totalCount = data.node.items.totalCount;
|
|
320
|
+
cumulativeRawNodes += pageNodes.length;
|
|
321
|
+
pageIndex++;
|
|
322
|
+
console.log(
|
|
323
|
+
`fetchProjectItems: page ${pageIndex}, nodes: ${pageNodes.length}, cumulative: ${cumulativeRawNodes}/${totalCount}`,
|
|
324
|
+
);
|
|
303
325
|
const projectItems: {
|
|
304
326
|
id: string;
|
|
305
327
|
fieldValues: {
|
|
@@ -324,43 +346,57 @@ query GetProjectItems($projectId: ID!, $after: String) {
|
|
|
324
346
|
labels: { nodes: { name: string }[] };
|
|
325
347
|
assignees: { nodes: { login: string }[] };
|
|
326
348
|
};
|
|
327
|
-
}[] =
|
|
328
|
-
projectItems
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
};
|
|
357
|
-
}),
|
|
358
|
-
});
|
|
349
|
+
}[] = pageNodes;
|
|
350
|
+
projectItems.forEach((item) => {
|
|
351
|
+
if (!item || !item.content || !item.content.repository) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
issues.push({
|
|
355
|
+
id: item.id,
|
|
356
|
+
nameWithOwner: item.content.repository.nameWithOwner,
|
|
357
|
+
number: item.content.number,
|
|
358
|
+
title: item.content.title,
|
|
359
|
+
state: this.convertStrToState(item.content.state),
|
|
360
|
+
url: item.content.url,
|
|
361
|
+
body: item.content.body,
|
|
362
|
+
labels: item.content.labels?.nodes?.map((l) => l.name) || [],
|
|
363
|
+
assignees: item.content.assignees?.nodes?.map((a) => a.login) || [],
|
|
364
|
+
createdAt: item.content.createdAt || new Date().toISOString(),
|
|
365
|
+
customFields: item.fieldValues.nodes
|
|
366
|
+
.filter((field) => !!field.field)
|
|
367
|
+
.map((field) => {
|
|
368
|
+
return {
|
|
369
|
+
name: field.field.name,
|
|
370
|
+
value:
|
|
371
|
+
field.name ??
|
|
372
|
+
field.text ??
|
|
373
|
+
field.number?.toString() ??
|
|
374
|
+
field.date ??
|
|
375
|
+
null,
|
|
376
|
+
};
|
|
377
|
+
}),
|
|
359
378
|
});
|
|
360
|
-
|
|
379
|
+
});
|
|
380
|
+
if (
|
|
381
|
+
pageNodes.length === 100 &&
|
|
382
|
+
!pageInfo.hasNextPage &&
|
|
383
|
+
cumulativeRawNodes < totalCount
|
|
384
|
+
) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`fetchProjectItems: page ${pageIndex} has ${pageNodes.length} nodes with hasNextPage=false but only ${cumulativeRawNodes}/${totalCount} items accumulated`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
361
389
|
hasNextPage = pageInfo.hasNextPage;
|
|
362
390
|
after = pageInfo.endCursor;
|
|
363
391
|
}
|
|
392
|
+
console.log(
|
|
393
|
+
`fetchProjectItems: completed, totalCount: ${totalCount}, cumulativeRawNodes: ${cumulativeRawNodes}, issues: ${issues.length}`,
|
|
394
|
+
);
|
|
395
|
+
if (cumulativeRawNodes !== totalCount) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`fetchProjectItems: expected ${totalCount} items but accumulated ${cumulativeRawNodes}`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
364
400
|
return issues;
|
|
365
401
|
};
|
|
366
402
|
getProjectItemFieldsFromIssueUrl = async (
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { mock } from 'jest-mock-extended';
|
|
2
|
+
import { IssueRepository } from './adapter-interfaces/IssueRepository';
|
|
3
|
+
import { ClearPastNextActionDateHourUseCase } from './ClearPastNextActionDateHourUseCase';
|
|
4
|
+
import { Project } from '../entities/Project';
|
|
5
|
+
import { Issue } from '../entities/Issue';
|
|
6
|
+
|
|
7
|
+
describe('ClearPastNextActionDateHourUseCase', () => {
|
|
8
|
+
jest.setTimeout(60 * 1000);
|
|
9
|
+
const mockIssueRepository = mock<IssueRepository>();
|
|
10
|
+
|
|
11
|
+
const nextActionHourField = {
|
|
12
|
+
name: 'Next Action Hour',
|
|
13
|
+
fieldId: 'hourFieldId',
|
|
14
|
+
};
|
|
15
|
+
const nextActionDateField = {
|
|
16
|
+
name: 'Next Action Date',
|
|
17
|
+
fieldId: 'dateFieldId',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const basicProject = {
|
|
21
|
+
...mock<Project>(),
|
|
22
|
+
nextActionHour: nextActionHourField,
|
|
23
|
+
nextActionDate: nextActionDateField,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const openIssueWithHour = {
|
|
27
|
+
...mock<Issue>(),
|
|
28
|
+
state: 'OPEN' as const,
|
|
29
|
+
nextActionHour: 10,
|
|
30
|
+
nextActionDate: null,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const openIssueWithDateOnly = {
|
|
34
|
+
...mock<Issue>(),
|
|
35
|
+
state: 'OPEN' as const,
|
|
36
|
+
nextActionHour: null,
|
|
37
|
+
nextActionDate: new Date('2026-04-01T00:00:00'),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe('run', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
jest.clearAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const testCases: {
|
|
46
|
+
name: string;
|
|
47
|
+
input: {
|
|
48
|
+
targetDates: Date[];
|
|
49
|
+
project: Project;
|
|
50
|
+
issues: Issue[];
|
|
51
|
+
cacheUsed: boolean;
|
|
52
|
+
};
|
|
53
|
+
expectedClearProjectFieldCalls: [Project, string, Issue][];
|
|
54
|
+
}[] = [
|
|
55
|
+
{
|
|
56
|
+
name: 'should not clear anything when targetDates is empty',
|
|
57
|
+
input: {
|
|
58
|
+
targetDates: [],
|
|
59
|
+
project: basicProject,
|
|
60
|
+
issues: [openIssueWithDateOnly],
|
|
61
|
+
cacheUsed: false,
|
|
62
|
+
},
|
|
63
|
+
expectedClearProjectFieldCalls: [],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'should not clear anything when project has no nextActionDate and no nextActionHour',
|
|
67
|
+
input: {
|
|
68
|
+
targetDates: [new Date('2026-04-02T10:00:00')],
|
|
69
|
+
project: {
|
|
70
|
+
...basicProject,
|
|
71
|
+
nextActionHour: null,
|
|
72
|
+
nextActionDate: null,
|
|
73
|
+
},
|
|
74
|
+
issues: [openIssueWithDateOnly],
|
|
75
|
+
cacheUsed: false,
|
|
76
|
+
},
|
|
77
|
+
expectedClearProjectFieldCalls: [],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'should clear nextActionDate for issue with only nextActionDate set and date is before today',
|
|
81
|
+
input: {
|
|
82
|
+
targetDates: [new Date('2026-04-02T10:00:00')],
|
|
83
|
+
project: {
|
|
84
|
+
...basicProject,
|
|
85
|
+
nextActionHour: null,
|
|
86
|
+
nextActionDate: nextActionDateField,
|
|
87
|
+
},
|
|
88
|
+
issues: [
|
|
89
|
+
{
|
|
90
|
+
...openIssueWithDateOnly,
|
|
91
|
+
nextActionDate: new Date('2026-04-01T00:00:00'),
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
cacheUsed: false,
|
|
95
|
+
},
|
|
96
|
+
expectedClearProjectFieldCalls: [
|
|
97
|
+
[
|
|
98
|
+
{
|
|
99
|
+
...basicProject,
|
|
100
|
+
nextActionHour: null,
|
|
101
|
+
nextActionDate: nextActionDateField,
|
|
102
|
+
},
|
|
103
|
+
'dateFieldId',
|
|
104
|
+
{
|
|
105
|
+
...openIssueWithDateOnly,
|
|
106
|
+
nextActionDate: new Date('2026-04-01T00:00:00'),
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'should clear nextActionDate when date is today',
|
|
113
|
+
input: {
|
|
114
|
+
targetDates: [new Date('2026-04-02T10:00:00')],
|
|
115
|
+
project: {
|
|
116
|
+
...basicProject,
|
|
117
|
+
nextActionHour: null,
|
|
118
|
+
nextActionDate: nextActionDateField,
|
|
119
|
+
},
|
|
120
|
+
issues: [
|
|
121
|
+
{
|
|
122
|
+
...openIssueWithDateOnly,
|
|
123
|
+
nextActionDate: new Date('2026-04-02T00:00:00'),
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
cacheUsed: false,
|
|
127
|
+
},
|
|
128
|
+
expectedClearProjectFieldCalls: [
|
|
129
|
+
[
|
|
130
|
+
{
|
|
131
|
+
...basicProject,
|
|
132
|
+
nextActionHour: null,
|
|
133
|
+
nextActionDate: nextActionDateField,
|
|
134
|
+
},
|
|
135
|
+
'dateFieldId',
|
|
136
|
+
{
|
|
137
|
+
...openIssueWithDateOnly,
|
|
138
|
+
nextActionDate: new Date('2026-04-02T00:00:00'),
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'should not clear nextActionDate when date is in the future',
|
|
145
|
+
input: {
|
|
146
|
+
targetDates: [new Date('2026-04-02T10:00:00')],
|
|
147
|
+
project: {
|
|
148
|
+
...basicProject,
|
|
149
|
+
nextActionHour: null,
|
|
150
|
+
nextActionDate: nextActionDateField,
|
|
151
|
+
},
|
|
152
|
+
issues: [
|
|
153
|
+
{
|
|
154
|
+
...openIssueWithDateOnly,
|
|
155
|
+
nextActionDate: new Date('2026-04-03T00:00:00'),
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
cacheUsed: false,
|
|
159
|
+
},
|
|
160
|
+
expectedClearProjectFieldCalls: [],
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'should not clear nextActionDate when issue has nextActionHour set (handled by hour path)',
|
|
164
|
+
input: {
|
|
165
|
+
targetDates: [new Date('2026-04-02T10:00:00')],
|
|
166
|
+
project: {
|
|
167
|
+
...basicProject,
|
|
168
|
+
nextActionHour: null,
|
|
169
|
+
nextActionDate: nextActionDateField,
|
|
170
|
+
},
|
|
171
|
+
issues: [
|
|
172
|
+
{
|
|
173
|
+
...mock<Issue>(),
|
|
174
|
+
state: 'OPEN' as const,
|
|
175
|
+
nextActionHour: 9,
|
|
176
|
+
nextActionDate: new Date('2026-04-01T00:00:00'),
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
cacheUsed: false,
|
|
180
|
+
},
|
|
181
|
+
expectedClearProjectFieldCalls: [],
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'should not clear nextActionDate when issue state is not OPEN',
|
|
185
|
+
input: {
|
|
186
|
+
targetDates: [new Date('2026-04-02T10:00:00')],
|
|
187
|
+
project: {
|
|
188
|
+
...basicProject,
|
|
189
|
+
nextActionHour: null,
|
|
190
|
+
nextActionDate: nextActionDateField,
|
|
191
|
+
},
|
|
192
|
+
issues: [
|
|
193
|
+
{
|
|
194
|
+
...openIssueWithDateOnly,
|
|
195
|
+
state: 'CLOSED' as const,
|
|
196
|
+
nextActionDate: new Date('2026-04-01T00:00:00'),
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
cacheUsed: false,
|
|
200
|
+
},
|
|
201
|
+
expectedClearProjectFieldCalls: [],
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: 'should clear nextActionHour and nextActionDate for issue with past nextActionHour at HH:45 target',
|
|
205
|
+
input: {
|
|
206
|
+
targetDates: [
|
|
207
|
+
new Date('2026-04-02T09:45:00'),
|
|
208
|
+
new Date('2026-04-02T09:46:00'),
|
|
209
|
+
],
|
|
210
|
+
project: basicProject,
|
|
211
|
+
issues: [
|
|
212
|
+
{
|
|
213
|
+
...openIssueWithHour,
|
|
214
|
+
nextActionHour: 10,
|
|
215
|
+
nextActionDate: null,
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
cacheUsed: false,
|
|
219
|
+
},
|
|
220
|
+
expectedClearProjectFieldCalls: [
|
|
221
|
+
[
|
|
222
|
+
basicProject,
|
|
223
|
+
'hourFieldId',
|
|
224
|
+
{
|
|
225
|
+
...openIssueWithHour,
|
|
226
|
+
nextActionHour: 10,
|
|
227
|
+
nextActionDate: null,
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
[
|
|
231
|
+
basicProject,
|
|
232
|
+
'dateFieldId',
|
|
233
|
+
{
|
|
234
|
+
...openIssueWithHour,
|
|
235
|
+
nextActionHour: 10,
|
|
236
|
+
nextActionDate: null,
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: 'should not clear nextActionHour when no HH:45 in targetDates',
|
|
243
|
+
input: {
|
|
244
|
+
targetDates: [
|
|
245
|
+
new Date('2026-04-02T09:00:00'),
|
|
246
|
+
new Date('2026-04-02T09:01:00'),
|
|
247
|
+
],
|
|
248
|
+
project: basicProject,
|
|
249
|
+
issues: [
|
|
250
|
+
{
|
|
251
|
+
...openIssueWithHour,
|
|
252
|
+
nextActionHour: 10,
|
|
253
|
+
nextActionDate: null,
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
cacheUsed: false,
|
|
257
|
+
},
|
|
258
|
+
expectedClearProjectFieldCalls: [],
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: 'should clear both nextActionDate-only issues and nextActionHour issues in same run',
|
|
262
|
+
input: {
|
|
263
|
+
targetDates: [
|
|
264
|
+
new Date('2026-04-02T09:45:00'),
|
|
265
|
+
new Date('2026-04-02T09:46:00'),
|
|
266
|
+
],
|
|
267
|
+
project: basicProject,
|
|
268
|
+
issues: [
|
|
269
|
+
{
|
|
270
|
+
...openIssueWithHour,
|
|
271
|
+
nextActionHour: 10,
|
|
272
|
+
nextActionDate: null,
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
...openIssueWithDateOnly,
|
|
276
|
+
nextActionDate: new Date('2026-04-01T00:00:00'),
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
cacheUsed: false,
|
|
280
|
+
},
|
|
281
|
+
expectedClearProjectFieldCalls: [
|
|
282
|
+
[
|
|
283
|
+
basicProject,
|
|
284
|
+
'hourFieldId',
|
|
285
|
+
{
|
|
286
|
+
...openIssueWithHour,
|
|
287
|
+
nextActionHour: 10,
|
|
288
|
+
nextActionDate: null,
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
[
|
|
292
|
+
basicProject,
|
|
293
|
+
'dateFieldId',
|
|
294
|
+
{
|
|
295
|
+
...openIssueWithHour,
|
|
296
|
+
nextActionHour: 10,
|
|
297
|
+
nextActionDate: null,
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
[
|
|
301
|
+
basicProject,
|
|
302
|
+
'dateFieldId',
|
|
303
|
+
{
|
|
304
|
+
...openIssueWithDateOnly,
|
|
305
|
+
nextActionDate: new Date('2026-04-01T00:00:00'),
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
],
|
|
309
|
+
},
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
testCases.forEach(({ name, input, expectedClearProjectFieldCalls }) => {
|
|
313
|
+
it(name, async () => {
|
|
314
|
+
jest.clearAllMocks();
|
|
315
|
+
const useCase = new ClearPastNextActionDateHourUseCase(
|
|
316
|
+
mockIssueRepository,
|
|
317
|
+
);
|
|
318
|
+
await useCase.run(input);
|
|
319
|
+
expect(mockIssueRepository.clearProjectField.mock.calls).toEqual(
|
|
320
|
+
expectedClearProjectFieldCalls,
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|