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.
Files changed (27) hide show
  1. package/.github/workflows/create-pr.yml +26 -5
  2. package/.github/workflows/umino-project.yml +4 -4
  3. package/CHANGELOG.md +19 -0
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -3
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  6. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +30 -7
  7. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  8. package/bin/domain/usecases/ClearPastNextActionDateHourUseCase.js +59 -0
  9. package/bin/domain/usecases/ClearPastNextActionDateHourUseCase.js.map +1 -0
  10. package/bin/domain/usecases/HandleScheduledEventUseCase.js +3 -3
  11. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +8 -3
  14. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +3 -3
  15. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +21 -0
  16. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +175 -3
  17. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +73 -37
  18. package/src/domain/usecases/ClearPastNextActionDateHourUseCase.test.ts +325 -0
  19. package/src/domain/usecases/ClearPastNextActionDateHourUseCase.ts +88 -0
  20. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +4 -3
  21. package/src/domain/usecases/HandleScheduledEventUseCase.ts +3 -3
  22. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  23. package/types/domain/usecases/ClearPastNextActionDateHourUseCase.d.ts +14 -0
  24. package/types/domain/usecases/ClearPastNextActionDateHourUseCase.d.ts.map +1 -0
  25. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -3
  26. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  27. 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 = (hasNextPage: boolean, endCursor: string) =>
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: 2,
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 (!response.data) {
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 response.data;
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
- }[] = data.node.items.nodes;
328
- projectItems
329
- // .filter(item => item.content.repository !== undefined)
330
- .forEach((item) => {
331
- if (!item || !item.content || !item.content.repository) {
332
- return;
333
- }
334
- issues.push({
335
- id: item.id,
336
- nameWithOwner: item.content.repository.nameWithOwner,
337
- number: item.content.number,
338
- title: item.content.title,
339
- state: this.convertStrToState(item.content.state),
340
- url: item.content.url,
341
- body: item.content.body,
342
- labels: item.content.labels?.nodes?.map((l) => l.name) || [],
343
- assignees: item.content.assignees?.nodes?.map((a) => a.login) || [],
344
- createdAt: item.content.createdAt || new Date().toISOString(),
345
- customFields: item.fieldValues.nodes
346
- .filter((field) => !!field.field)
347
- .map((field) => {
348
- return {
349
- name: field.field.name,
350
- value:
351
- field.name ??
352
- field.text ??
353
- field.number?.toString() ??
354
- field.date ??
355
- null,
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
- const pageInfo = data.node.items.pageInfo;
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
+ });