import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import request from 'supertest'; import { createHmac } from 'node:crypto'; import app from '../app.js'; import { configureQueue, drain } from '../lib/queue.js'; vi.mock('../db/ingest_events.dao.js', () => ({ recordDelivery: vi.fn(), markProcessing: vi.fn(), markDone: vi.fn(), markFailed: vi.fn(), resetStaleProcessing: vi.fn(), })); vi.mock('../db/notes.dao.js', () => ({ createNotes: vi.fn(), getAllNotes: vi.fn(), streamAllNotes: vi.fn(), })); vi.mock('../lib/httpClient.js', () => ({ requestJson: vi.fn(), })); import { recordDelivery, markDone, markFailed, markProcessing, } from '../db/ingest_events.dao.js'; import { createNotes } from '../db/notes.dao.js'; import { requestJson } from '../lib/httpClient.js'; import { resetSlackCaches } from '../services/slack.service.js'; import type { NoteInput } from '../types/domain.js'; const mockRecordDelivery = vi.mocked(recordDelivery); const mockCreateNotes = vi.mocked(createNotes); const mockMarkDone = vi.mocked(markDone); const mockMarkFailed = vi.mocked(markFailed); const mockMarkProcessing = vi.mocked(markProcessing); const mockRequestJson = vi.mocked(requestJson); const SIGNING_SECRET = 'slack-signing-secret'; const BOT_TOKEN = 'xoxb-test-token'; const reactionEvent = (overrides: Record = {}): string => JSON.stringify({ type: 'event_callback', event_id: 'Ev0PIN1', team_id: 'T999', event: { type: 'reaction_added', user: 'U_REACTOR', reaction: 'pushpin', item_user: 'U_AUTHOR', item: { type: 'message', channel: 'C555', ts: '1700000000.000100' }, ...overrides, }, }); const sign = (body: string, timestamp: string, secret = SIGNING_SECRET): string => `v0=${createHmac('sha256', secret).update(`v0:${timestamp}:${body}`).digest('hex')}`; const postEvent = (body: string) => { const timestamp = Math.floor(Date.now() / 1000).toString(); return request(app) .post('/v1/webhooks/slack') .set('Content-Type', 'application/json') .set('x-slack-request-timestamp', timestamp) .set('x-slack-signature', sign(body, timestamp)) .send(body); }; /** Routes a Slack Web API call by method name to a canned response. */ const slackApi = (responses: Record): void => { mockRequestJson.mockImplementation(async (url: string) => { const method = url.split('/api/')[1] ?? ''; if (!(method in responses)) { throw new Error(`Unexpected Slack method: ${method}`); } return responses[method]; }); }; const historyWith = (text: string, user = 'U_AUTHOR') => ({ ok: true, messages: [{ text, user, ts: '1700000000.000100' }], }); const userNamed = (displayName: string) => ({ ok: true, user: { id: 'U_AUTHOR', name: 'fallback', profile: { display_name: displayName } }, }); const notesWritten = (): NoteInput[] => { const call = mockCreateNotes.mock.calls[0]; return call ? call[0] : []; }; /** * Exercises the seam the unit tests cannot: the provider registry wiring that * carries a Slack reaction from the signed request through normalization, the * ack, the queue, and enrichment into an actual note insert. */ describe('Slack reaction capture, end to end', () => { const originalSecret = process.env.SLACK_SIGNING_SECRET; const originalToken = process.env.SLACK_BOT_TOKEN; const originalReaction = process.env.SLACK_CAPTURE_REACTION; beforeEach(() => { vi.clearAllMocks(); resetSlackCaches(); process.env.SLACK_SIGNING_SECRET = SIGNING_SECRET; process.env.SLACK_BOT_TOKEN = BOT_TOKEN; delete process.env.SLACK_CAPTURE_REACTION; configureQueue({ baseDelayMs: 0, maxAttempts: 2 }); mockRecordDelivery.mockResolvedValue(42); mockCreateNotes.mockResolvedValue([]); }); afterEach(async () => { await drain(); const restore = (key: string, value: string | undefined): void => { if (value === undefined) delete process.env[key]; else process.env[key] = value; }; restore('SLACK_SIGNING_SECRET', originalSecret); restore('SLACK_BOT_TOKEN', originalToken); restore('SLACK_CAPTURE_REACTION', originalReaction); }); it('should turn a pinned message into a note with fetched text and a resolved author', async () => { slackApi({ 'conversations.history': historyWith('Deploys are '), 'users.info': userNamed('Jane Doe'), }); const res = await postEvent(reactionEvent()); expect(res.status).toBe(200); await drain(); const notes = notesWritten(); expect(notes).toHaveLength(1); expect(notes[0].text).toBe('Deploys are scary'); expect(notes[0].author).toBe('Jane Doe'); expect(notes[0].sourceMeta).toMatchObject({ provider: 'slack', externalId: 'Ev0PIN1', channelId: 'C555', authorHandle: 'Jane Doe', }); expect(mockMarkProcessing).toHaveBeenCalledWith(42); expect(mockMarkDone).toHaveBeenCalledWith(42); }); /** A placeholder reaching the database would be a visible bug on a sticky. */ it('should never persist the placeholder text', async () => { slackApi({ 'conversations.history': historyWith('real message body'), 'users.info': userNamed('Jane Doe'), }); await postEvent(reactionEvent()); await drain(); expect(notesWritten()[0].text).not.toMatch(/pending/i); expect(notesWritten()[0].sourceMeta).not.toHaveProperty('needsMessageText'); }); it('should drop the note when the message cannot be fetched', async () => { slackApi({ 'conversations.history': { ok: true, messages: [] }, 'conversations.replies': { ok: true, messages: [] }, }); await postEvent(reactionEvent()); await drain(); expect(notesWritten()).toEqual([]); expect(mockMarkDone).toHaveBeenCalledWith(42); }); it('should omit the note count from the ack because enrichment runs after it', async () => { slackApi({ 'conversations.history': historyWith('anything'), 'users.info': userNamed('Jane Doe'), }); const res = await postEvent(reactionEvent()); expect(res.body).toEqual({ accepted: true }); }); it('should ignore a reaction that is not the configured one', async () => { const res = await postEvent(reactionEvent({ reaction: 'tada' })); expect(res.status).toBe(200); await drain(); expect(mockRequestJson).not.toHaveBeenCalled(); expect(notesWritten()).toEqual([]); }); it('should honor a custom capture reaction from the environment', async () => { process.env.SLACK_CAPTURE_REACTION = 'sticky'; slackApi({ 'conversations.history': historyWith('captured by custom emoji'), 'users.info': userNamed('Jane Doe'), }); await postEvent(reactionEvent({ reaction: 'sticky' })); await drain(); expect(notesWritten()[0].text).toBe('captured by custom emoji'); }); it('should answer the url_verification handshake', async () => { const res = await postEvent( JSON.stringify({ type: 'url_verification', challenge: 'abc123' }) ); expect(res.status).toBe(200); expect(res.body).toEqual({ challenge: 'abc123' }); }); it('should reject a delivery whose signature does not match', async () => { const body = reactionEvent(); const timestamp = Math.floor(Date.now() / 1000).toString(); const res = await request(app) .post('/v1/webhooks/slack') .set('Content-Type', 'application/json') .set('x-slack-request-timestamp', timestamp) .set('x-slack-signature', sign(body, timestamp, 'wrong-secret')) .send(body); expect(res.status).toBe(401); expect(mockRecordDelivery).not.toHaveBeenCalled(); }); it('should stop at the dedup layer when Slack redelivers an event', async () => { mockRecordDelivery.mockResolvedValue(null); const res = await postEvent(reactionEvent()); expect(res.status).toBe(200); expect(res.body).toEqual({ duplicate: true }); await drain(); expect(mockCreateNotes).not.toHaveBeenCalled(); }); /** A failed enrichment must leave the delivery failed, not silently done. */ it('should mark the delivery failed when the Slack API rejects the fetch', async () => { mockRequestJson.mockResolvedValue({ ok: false, error: 'channel_not_found' }); await postEvent(reactionEvent()); await drain(); expect(mockCreateNotes).not.toHaveBeenCalled(); expect(mockMarkFailed).toHaveBeenCalledWith(42, expect.stringContaining('channel_not_found')); expect(mockMarkDone).not.toHaveBeenCalled(); }); /** A missing channel is settled; retrying it only delays the queue. */ it('should not retry a permanent Slack failure', async () => { mockRequestJson.mockResolvedValue({ ok: false, error: 'channel_not_found' }); await postEvent(reactionEvent()); await drain(); expect(mockRequestJson).toHaveBeenCalledTimes(1); }); it('should retry a rate-limited Slack failure until the attempt cap', async () => { mockRequestJson.mockResolvedValue({ ok: false, error: 'ratelimited' }); await postEvent(reactionEvent()); await drain(); expect(mockRequestJson).toHaveBeenCalledTimes(2); }); });