import { describe, it, expect, afterEach } from 'vitest'; import { NormalizationError, SLACK_PENDING_TEXT, isNoteInputArray, normalizeSlack, } from '../config/normalizers.js'; import type { NoteInput } from '../types/domain.js'; const sourceMeta = (input: NoteInput): Record => { expect(input.sourceMeta).toBeDefined(); return input.sourceMeta as Record; }; const slackPayload = ( event: Record = {}, envelope: Record = {} ): Record => ({ type: 'event_callback', event_id: 'Ev08K1QR2X', team_id: 'T0123', event: { type: 'reaction_added', user: 'U_REACTOR', reaction: 'pushpin', item_user: 'U_AUTHOR', item: { type: 'message', channel: 'C0123', ts: '1700000000.000100' }, event_ts: '1700000001.000200', ...event, }, ...envelope, }); const item = (overrides: Record = {}): Record => ({ type: 'message', channel: 'C0123', ts: '1700000000.000100', ...overrides, }); const savedReaction = process.env.SLACK_CAPTURE_REACTION; const setCaptureReaction = (value: string | undefined): void => { if (value === undefined) { delete process.env.SLACK_CAPTURE_REACTION; return; } process.env.SLACK_CAPTURE_REACTION = value; }; afterEach(() => { setCaptureReaction(savedReaction); }); describe('normalizeSlack', () => { it('should map a captured reaction to a single placeholder note', () => { const result = normalizeSlack(slackPayload()); expect(result.externalId).toBe('Ev08K1QR2X'); expect(result.notes).toHaveLength(1); expect(result.notes[0].id).toBe('slack_Ev08K1QR2X'); expect(result.notes[0].text).toBe(SLACK_PENDING_TEXT); expect(result.notes[0].author).toBe('U_AUTHOR'); }); it('should export the placeholder text used for the pending message body', () => { expect(SLACK_PENDING_TEXT).toBe('(pending Slack message text)'); }); it('should attach provenance plus the identifiers the enrichment hook needs', () => { const meta = sourceMeta(normalizeSlack(slackPayload()).notes[0]); expect(meta).toMatchObject({ provider: 'slack', externalId: 'Ev08K1QR2X', needsMessageText: true, channelId: 'C0123', messageTs: '1700000000.000100', teamId: 'T0123', reaction: 'pushpin', reactedBy: 'U_REACTOR', authorUserId: 'U_AUTHOR', }); expect(typeof meta.receivedAt).toBe('string'); }); it('should mark needsMessageText as exactly true', () => { const meta = sourceMeta(normalizeSlack(slackPayload()).notes[0]); expect(meta.needsMessageText).toBe(true); }); it('should record an ISO receivedAt timestamp in provenance', () => { const receivedAt = String(sourceMeta(normalizeSlack(slackPayload()).notes[0]).receivedAt); expect(new Date(receivedAt).toISOString()).toBe(receivedAt); }); it('should fall back to the reacting user when item_user is absent', () => { expect(normalizeSlack(slackPayload({ item_user: undefined })).notes[0].author).toBe( 'U_REACTOR' ); expect(normalizeSlack(slackPayload({ item_user: '' })).notes[0].author).toBe('U_REACTOR'); }); it('should fall back to an unknown author when neither user is present', () => { const result = normalizeSlack(slackPayload({ item_user: undefined, user: undefined })); expect(result.notes[0].author).toBe('unknown'); expect(sourceMeta(result.notes[0]).reactedBy).toBeUndefined(); expect(sourceMeta(result.notes[0]).authorUserId).toBeUndefined(); }); it('should omit teamId when the envelope carries none', () => { const payload = slackPayload({}, { team_id: undefined }); expect(sourceMeta(normalizeSlack(payload).notes[0]).teamId).toBeUndefined(); }); it('should return zero notes for a reaction other than the configured one', () => { expect(normalizeSlack(slackPayload({ reaction: 'eyes' }))).toEqual({ externalId: 'Ev08K1QR2X', notes: [], }); }); it('should honour a custom capture reaction from the environment', () => { setCaptureReaction('thumbsup'); expect(normalizeSlack(slackPayload({ reaction: 'thumbsup' })).notes).toHaveLength(1); expect(normalizeSlack(slackPayload({ reaction: 'pushpin' })).notes).toHaveLength(0); }); it('should read the capture reaction at call time rather than at module load', () => { setCaptureReaction('eyes'); expect(normalizeSlack(slackPayload({ reaction: 'eyes' })).notes).toHaveLength(1); setCaptureReaction('rocket'); expect(normalizeSlack(slackPayload({ reaction: 'eyes' })).notes).toHaveLength(0); expect(normalizeSlack(slackPayload({ reaction: 'rocket' })).notes).toHaveLength(1); }); it('should match a reaction carrying a skin-tone modifier', () => { setCaptureReaction('thumbsup'); const result = normalizeSlack(slackPayload({ reaction: 'thumbsup::skin-tone-3' })); expect(result.notes).toHaveLength(1); expect(sourceMeta(result.notes[0]).reaction).toBe('thumbsup::skin-tone-3'); }); it('should fall back to pushpin when no capture reaction is configured', () => { setCaptureReaction(undefined); expect(normalizeSlack(slackPayload()).notes).toHaveLength(1); expect(normalizeSlack(slackPayload({ reaction: 'pushpin::skin-tone-5' })).notes).toHaveLength( 1 ); }); it('should return zero notes when the reaction is missing or not a string', () => { expect(normalizeSlack(slackPayload({ reaction: undefined })).notes).toHaveLength(0); expect(normalizeSlack(slackPayload({ reaction: 7 })).notes).toHaveLength(0); expect(normalizeSlack(slackPayload({ reaction: '' })).notes).toHaveLength(0); }); it('should return zero notes for an event type we do not capture', () => { expect(normalizeSlack(slackPayload({ type: 'message' }))).toEqual({ externalId: 'Ev08K1QR2X', notes: [], }); expect(normalizeSlack(slackPayload({ type: 'reaction_removed' })).notes).toHaveLength(0); }); it('should return zero notes when the event is missing or not an object', () => { expect(normalizeSlack({ type: 'event_callback', event_id: 'Ev1' })).toEqual({ externalId: 'Ev1', notes: [], }); expect(normalizeSlack({ type: 'event_callback', event_id: 'Ev1', event: 'nope' })).toEqual({ externalId: 'Ev1', notes: [], }); }); it('should return zero notes when the reacted item is not a message', () => { expect(normalizeSlack(slackPayload({ item: item({ type: 'file' }) }))).toEqual({ externalId: 'Ev08K1QR2X', notes: [], }); }); it('should return zero notes when the item channel is missing', () => { expect(normalizeSlack(slackPayload({ item: item({ channel: undefined }) })).notes).toHaveLength( 0 ); expect(normalizeSlack(slackPayload({ item: item({ channel: '' }) })).notes).toHaveLength(0); }); it('should return zero notes when the item timestamp is missing', () => { expect(normalizeSlack(slackPayload({ item: item({ ts: undefined }) })).notes).toHaveLength(0); expect(normalizeSlack(slackPayload({ item: item({ ts: 1700000000 }) })).notes).toHaveLength(0); }); it('should return zero notes when the item itself is missing', () => { expect(normalizeSlack(slackPayload({ item: undefined })).notes).toHaveLength(0); }); it('should throw when event_id is missing or empty', () => { expect(() => normalizeSlack(slackPayload({}, { event_id: undefined }))).toThrow( NormalizationError ); expect(() => normalizeSlack(slackPayload({}, { event_id: '' }))).toThrow(/missing event_id/); expect(() => normalizeSlack(slackPayload({}, { event_id: 42 }))).toThrow(NormalizationError); }); it('should throw for an envelope type other than event_callback', () => { expect(() => normalizeSlack(slackPayload({}, { type: 'url_verification' }))).toThrow( NormalizationError ); expect(() => normalizeSlack(slackPayload({}, { type: undefined }))).toThrow( /not an event_callback/ ); }); it('should throw for a non-object payload', () => { expect(() => normalizeSlack(null)).toThrow(/not an object/); expect(() => normalizeSlack(undefined)).toThrow(NormalizationError); expect(() => normalizeSlack('event_callback')).toThrow(NormalizationError); expect(() => normalizeSlack([slackPayload()])).toThrow(/not an object/); }); it('should truncate a generated note id to 64 characters', () => { const longId = 'E'.repeat(400); const result = normalizeSlack(slackPayload({}, { event_id: longId })); expect(result.externalId).toBe(longId); expect(result.notes[0].id).toHaveLength(64); expect(result.notes[0].id).toBe(`slack_${longId}`.slice(0, 64)); expect(sourceMeta(result.notes[0]).externalId).toBe(longId); }); it('should produce notes accepted by the note input guard', () => { expect(isNoteInputArray(normalizeSlack(slackPayload()).notes)).toBe(true); }); });