Building Slack integration
This commit is contained in:
283
backend/tests/slack.integration.test.ts
Normal file
283
backend/tests/slack.integration.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user