Skip to content

Commit 7b9b68f

Browse files
author
bgagent
committed
test(fanout): conditional UpdateItem race + dup-delete coverage (aws-samples#79 test gap)
Adds 4 tests covering the lifecycle-persist conditional path that review fix #1 introduced and review fix aws-samples#6 hardened. Pre-PR-aws-samples#79 the only ConditionalCheckFailed coverage was the terminal-dedup path; the new lifecycle-persist + dup-delete code lacked direct assertions and was flagged 9/10 criticality by the reviewer. - task_created persist ConditionalCheckFailed → posts duplicate then deletes it: pins the cleanup behaviour that prevents ghost task_created posts in the channel - session_started persist ConditionalCheckFailed → posts duplicate then deletes it: parallel coverage for the other lifecycle attribute (slack_session_msg_ts) - dup-delete failure emits fanout.slack.dup_delete_failed with error_id: pins the operator-alarm signal added in review fix aws-samples#6; asserts both the event key and the FANOUT_SLACK_DUP_DELETE_FAILED error_id propagate - chat.delete returning message_not_found is treated as success (no dup_delete_failed): negative-class assertion. Prevents false-positive alarms when the race resolves cleanly (the duplicate was already deleted by a prior retry). The ghost / message_not_found tests use ``fetchMock.mockImplementation`` URL-routing rather than ``.mockResolvedValueOnce`` chains because ``updateReaction`` issues 2-3 reaction-API fetches between chat.postMessage and chat.delete; routing by URL keeps the test focused on the load-bearing chat.delete behaviour without coupling to reaction call order.
1 parent 5c42d2c commit 7b9b68f

1 file changed

Lines changed: 171 additions & 0 deletions

File tree

cdk/test/handlers/slack-notify.test.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,4 +309,175 @@ describe('dispatchSlackEvent', () => {
309309
const postBody = JSON.parse((fetchMock.mock.calls[0][1] as { body: string }).body);
310310
expect(postBody.text).toContain('org/repo');
311311
});
312+
313+
// ---------------------------------------------------------------------
314+
// PR #79 test gap #31 — conditional UpdateItem race + dup-delete
315+
// ---------------------------------------------------------------------
316+
317+
test('task_created persist ConditionalCheckFailed → posts duplicate then deletes it', async () => {
318+
// Race: a sibling retry already wrote ``slack_created_msg_ts``.
319+
// Our POST landed in Slack first, the conditional UpdateItem
320+
// failed, and we must clean up the duplicate root message via
321+
// ``chat.delete``. Without this, the channel accumulates ghost
322+
// task_created posts on every retry-after-success-write race.
323+
ddbSend
324+
.mockResolvedValueOnce({
325+
Item: {
326+
task_id: 't1',
327+
channel_source: 'slack',
328+
channel_metadata: { slack_team_id: 'T1', slack_channel_id: 'C1' },
329+
repo: 'org/repo',
330+
},
331+
})
332+
.mockRejectedValueOnce(
333+
Object.assign(new Error('cond fail'), { name: 'ConditionalCheckFailedException' }),
334+
);
335+
// Default fetchMock returns ok=true with ts; fine for the post +
336+
// best-effort reaction calls. The chat.delete call uses the same
337+
// default which is also ``ok: true`` — perfect for the success path.
338+
339+
await dispatchSlackEvent(mkEvent('t1', 'task_created'), ddb);
340+
341+
// Find the chat.delete invocation by URL — the duplicate cleanup
342+
// is the load-bearing assertion. Reaction add/remove calls fall
343+
// through to the default mock and don't carry test signal here.
344+
const deleteCall = fetchMock.mock.calls.find(
345+
([url]) => url === 'https://slack.com/api/chat.delete',
346+
);
347+
expect(deleteCall).toBeDefined();
348+
const deleteBody = JSON.parse((deleteCall![1] as { body: string }).body);
349+
expect(deleteBody.ts).toBe('1234.0001');
350+
});
351+
352+
test('session_started persist ConditionalCheckFailed → posts duplicate then deletes it', async () => {
353+
// Same race as task_created but for session_started; uses
354+
// ``slack_session_msg_ts`` as the conditional attribute. Without
355+
// delete, terminal cleanup would orphan the duplicate session
356+
// message (terminal cleanup deletes a single session_msg_ts, not
357+
// the duplicate that was never persisted).
358+
ddbSend
359+
.mockResolvedValueOnce({
360+
Item: {
361+
task_id: 't1',
362+
channel_source: 'slack',
363+
channel_metadata: {
364+
slack_team_id: 'T1',
365+
slack_channel_id: 'C1',
366+
slack_thread_ts: '999.000', // session_started threads under task_created
367+
},
368+
repo: 'org/repo',
369+
},
370+
})
371+
.mockRejectedValueOnce(
372+
Object.assign(new Error('cond fail'), { name: 'ConditionalCheckFailedException' }),
373+
);
374+
375+
await dispatchSlackEvent(mkEvent('t1', 'session_started'), ddb);
376+
377+
const deleteCall = fetchMock.mock.calls.find(
378+
([url]) => url === 'https://slack.com/api/chat.delete',
379+
);
380+
expect(deleteCall).toBeDefined();
381+
const deleteBody = JSON.parse((deleteCall![1] as { body: string }).body);
382+
expect(deleteBody.ts).toBe('1234.0001');
383+
});
384+
385+
test('dup-delete failure emits fanout.slack.dup_delete_failed with error_id (PR #79 review #6)', async () => {
386+
// The conditional persist hit a sibling retry; we tried to delete
387+
// the duplicate Slack message but ``chat.delete`` failed. The
388+
// duplicate is now permanent in the thread — operators need a
389+
// dedicated alarmable signal so they can detect ghost-message
390+
// accumulation. We override the default mock for chat.delete
391+
// specifically by URL-routing the fetch implementation rather
392+
// than relying on call-order, so reactions can fall through to
393+
// the default without consuming our scripted responses.
394+
ddbSend
395+
.mockResolvedValueOnce({
396+
Item: {
397+
task_id: 't1',
398+
channel_source: 'slack',
399+
channel_metadata: { slack_team_id: 'T1', slack_channel_id: 'C1' },
400+
repo: 'org/repo',
401+
},
402+
})
403+
.mockRejectedValueOnce(
404+
Object.assign(new Error('cond fail'), { name: 'ConditionalCheckFailedException' }),
405+
);
406+
fetchMock.mockImplementation(async (url: string) => {
407+
if (url === 'https://slack.com/api/chat.delete') {
408+
return {
409+
ok: true,
410+
json: () => Promise.resolve({ ok: false, error: 'cant_delete_message' }),
411+
};
412+
}
413+
// Default: chat.postMessage / reactions.* succeed.
414+
return {
415+
ok: true,
416+
headers: { get: () => null },
417+
json: () => Promise.resolve({ ok: true, ts: '1234.0001' }),
418+
};
419+
});
420+
421+
const loggerModule = await import('../../src/handlers/shared/logger');
422+
const errorSpy = jest.spyOn(loggerModule.logger, 'error').mockImplementation(() => undefined);
423+
try {
424+
await dispatchSlackEvent(mkEvent('t1', 'task_created'), ddb);
425+
426+
const ghostError = errorSpy.mock.calls.find(
427+
c => (c[1] as Record<string, unknown> | undefined)?.event === 'fanout.slack.dup_delete_failed',
428+
);
429+
expect(ghostError).toBeDefined();
430+
expect((ghostError?.[1] as Record<string, unknown>).error_id).toBe('FANOUT_SLACK_DUP_DELETE_FAILED');
431+
expect((ghostError?.[1] as Record<string, unknown>).duplicate_ts).toBe('1234.0001');
432+
expect((ghostError?.[1] as Record<string, unknown>).event_type).toBe('task_created');
433+
} finally {
434+
errorSpy.mockRestore();
435+
}
436+
});
437+
438+
test('chat.delete returning message_not_found is treated as success (no dup_delete_failed)', async () => {
439+
// ``message_not_found`` means the duplicate already got cleaned
440+
// up by something else (e.g. a previous retry's delete). The
441+
// dup_delete_failed alarm must NOT fire on this benign case, or
442+
// operators will see false positives whenever the race resolves
443+
// cleanly.
444+
ddbSend
445+
.mockResolvedValueOnce({
446+
Item: {
447+
task_id: 't1',
448+
channel_source: 'slack',
449+
channel_metadata: { slack_team_id: 'T1', slack_channel_id: 'C1' },
450+
repo: 'org/repo',
451+
},
452+
})
453+
.mockRejectedValueOnce(
454+
Object.assign(new Error('cond fail'), { name: 'ConditionalCheckFailedException' }),
455+
);
456+
fetchMock.mockImplementation(async (url: string) => {
457+
if (url === 'https://slack.com/api/chat.delete') {
458+
return {
459+
ok: true,
460+
json: () => Promise.resolve({ ok: false, error: 'message_not_found' }),
461+
};
462+
}
463+
return {
464+
ok: true,
465+
headers: { get: () => null },
466+
json: () => Promise.resolve({ ok: true, ts: '1234.0001' }),
467+
};
468+
});
469+
470+
const loggerModule = await import('../../src/handlers/shared/logger');
471+
const errorSpy = jest.spyOn(loggerModule.logger, 'error').mockImplementation(() => undefined);
472+
try {
473+
await dispatchSlackEvent(mkEvent('t1', 'task_created'), ddb);
474+
475+
const ghostError = errorSpy.mock.calls.find(
476+
c => (c[1] as Record<string, unknown> | undefined)?.event === 'fanout.slack.dup_delete_failed',
477+
);
478+
expect(ghostError).toBeUndefined();
479+
} finally {
480+
errorSpy.mockRestore();
481+
}
482+
});
312483
});

0 commit comments

Comments
 (0)