Files
kongruity/backend/tests/slack.integration.test.ts
2026-08-02 06:13:01 -04:00

284 lines
9.0 KiB
TypeScript

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, unknown> = {}): 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<string, unknown>): 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 <https://ex.co|scary>'),
'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);
});
});