Building Slack integration

This commit is contained in:
KS Jannette
2026-08-02 06:13:01 -04:00
parent dae256f6c6
commit b607ee9121
16 changed files with 2351 additions and 8 deletions

View File

@@ -322,7 +322,6 @@ describe('normalize', () => {
expect(() => normalize('github', {})).toThrow(NormalizationError);
expect(() => normalize('github', {})).toThrow(/No normalizer registered/);
expect(() => normalize('jira', {})).toThrow(/No normalizer registered/);
expect(() => normalize('slack', {})).toThrow(/No normalizer registered/);
});
});
@@ -330,11 +329,11 @@ describe('hasNormalizer', () => {
it('should be true for providers with a normalizer', () => {
expect(hasNormalizer('rest')).toBe(true);
expect(hasNormalizer('linear')).toBe(true);
expect(hasNormalizer('slack')).toBe(true);
});
it('should be false for providers awaiting an integration', () => {
expect(hasNormalizer('github')).toBe(false);
expect(hasNormalizer('jira')).toBe(false);
expect(hasNormalizer('slack')).toBe(false);
});
});

View File

@@ -64,6 +64,40 @@ describe('queue', () => {
expect(errors).toHaveBeenCalled();
});
/**
* The whole point of classifying a failure as permanent: a revoked token or
* a deleted channel cannot be fixed by trying again, and retrying it holds
* up every job behind it.
*/
it('should abandon a job immediately when the error is marked permanent', async () => {
configureQueue({ maxAttempts: 4 });
let attempts = 0;
enqueue('permanently-failing', async () => {
attempts += 1;
throw Object.assign(new Error('token_revoked'), { permanent: true });
});
await drain();
expect(attempts).toBe(1);
expect(String(errors.mock.calls[0]?.[0])).toContain('abandoned as permanent');
});
it('should still retry a job whose error carries a falsy permanent flag', async () => {
configureQueue({ maxAttempts: 3 });
let attempts = 0;
enqueue('transiently-failing', async () => {
attempts += 1;
throw Object.assign(new Error('ratelimited'), { permanent: false });
});
await drain();
expect(attempts).toBe(3);
});
it('should retry a failing job up to the configured cap and then give up', async () => {
configureQueue({ maxAttempts: 4 });
let attempts = 0;

View 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);
});
});

View File

@@ -0,0 +1,245 @@
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<string, unknown> => {
expect(input.sourceMeta).toBeDefined();
return input.sourceMeta as Record<string, unknown>;
};
const slackPayload = (
event: Record<string, unknown> = {},
envelope: Record<string, unknown> = {}
): Record<string, unknown> => ({
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<string, unknown> = {}): Record<string, unknown> => ({
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);
});
});

View File

@@ -0,0 +1,402 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { createHmac } from 'node:crypto';
import express, { type NextFunction, type Request, type Response } from 'express';
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(),
}));
import {
recordDelivery,
markDone,
markFailed,
markProcessing,
} from '../db/ingest_events.dao.js';
import { createNotes } from '../db/notes.dao.js';
import slackRouter from '../routes/slack.routes.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 SECRET = 'slack-test-secret';
// Mirrors app.ts: the raw parser must claim the body so the bytes Slack signed
// survive to verifySignature.
const app = express();
app.use('/v1/slack', express.raw({ type: '*/*', limit: '2mb' }), slackRouter);
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error(`Unhandled error: ${err.message}`);
if (res.headersSent) return;
res.status(500).json({ error: 'Internal server error' });
});
const FIELDS: Record<string, string> = {
command: '/sticky',
text: 'Deploys are scary',
user_id: 'U123',
user_name: 'jane',
team_id: 'T999',
channel_id: 'C555',
channel_name: 'retro',
trigger_id: 'trg_1',
};
const encode = (fields: Record<string, string>): string =>
new URLSearchParams(fields).toString();
const sign = (body: string, timestamp: string, secret = SECRET): string =>
`v0=${createHmac('sha256', secret).update(`v0:${timestamp}:${body}`).digest('hex')}`;
const now = (): string => Math.floor(Date.now() / 1000).toString();
const postRaw = (body: string, signature: string, timestamp: string) =>
request(app)
.post('/v1/slack/commands')
.set('Content-Type', 'application/x-www-form-urlencoded')
.set('x-slack-request-timestamp', timestamp)
.set('x-slack-signature', signature)
.send(body);
/** Signs whatever it sends, so the happy path is the default. */
const postCommand = (overrides: Record<string, string | undefined> = {}) => {
const fields: Record<string, string> = { ...FIELDS };
for (const [key, value] of Object.entries(overrides)) {
if (value === undefined) {
delete fields[key];
} else {
fields[key] = value;
}
}
const body = encode(fields);
const timestamp = now();
return postRaw(body, sign(body, timestamp), timestamp);
};
describe('POST /v1/slack/commands', () => {
const originalSecret = process.env.SLACK_SIGNING_SECRET;
beforeEach(() => {
vi.clearAllMocks();
process.env.SLACK_SIGNING_SECRET = SECRET;
configureQueue({ baseDelayMs: 0, maxAttempts: 2 });
mockRecordDelivery.mockResolvedValue(42);
mockCreateNotes.mockResolvedValue([]);
});
afterEach(async () => {
await drain();
if (originalSecret === undefined) {
delete process.env.SLACK_SIGNING_SECRET;
} else {
process.env.SLACK_SIGNING_SECRET = originalSecret;
}
});
it('should confirm a valid command with ephemeral text', async () => {
const res = await postCommand();
expect(res.status).toBe(200);
expect(res.body).toEqual({
response_type: 'ephemeral',
text: 'Added to Kongruity: "Deploys are scary"',
});
});
it('should create the note behind the ack', async () => {
await postCommand();
await drain();
expect(mockCreateNotes).toHaveBeenCalledOnce();
const [notes] = mockCreateNotes.mock.calls[0];
expect(notes).toHaveLength(1);
expect(notes[0].id).toBe('slack_trg_1');
expect(notes[0].text).toBe('Deploys are scary');
expect(notes[0].author).toBe('jane');
});
it('should record provenance from the slash command fields', async () => {
await postCommand();
await drain();
const [notes] = mockCreateNotes.mock.calls[0];
expect(notes[0].sourceMeta).toMatchObject({
provider: 'slack',
externalId: 'trg_1',
channelId: 'C555',
channelName: 'retro',
authorHandle: 'jane',
authorUserId: 'U123',
teamId: 'T999',
via: 'slash-command',
});
expect(typeof notes[0].sourceMeta?.receivedAt).toBe('string');
});
it('should omit provenance keys whose source field is absent', async () => {
await postCommand({ channel_name: undefined, team_id: undefined });
await drain();
const [notes] = mockCreateNotes.mock.calls[0];
const meta = notes[0].sourceMeta ?? {};
expect('channelName' in meta).toBe(false);
expect('teamId' in meta).toBe(false);
});
it('should respond before the insert finishes', async () => {
let finishInsert: () => void = () => {};
mockCreateNotes.mockImplementation(
() => new Promise((resolve) => {
finishInsert = () => resolve([]);
})
);
const res = await postCommand();
expect(res.status).toBe(200);
expect(mockMarkDone).not.toHaveBeenCalled();
finishInsert();
await drain();
expect(mockMarkDone).toHaveBeenCalledWith(42);
});
it('should mark the delivery done once the insert succeeds', async () => {
await postCommand();
await drain();
expect(mockMarkProcessing).toHaveBeenCalledWith(42);
expect(mockMarkDone).toHaveBeenCalledWith(42);
expect(mockMarkFailed).not.toHaveBeenCalled();
});
it('should mark the delivery failed when the insert keeps failing', async () => {
mockCreateNotes.mockRejectedValue(new Error('connection refused'));
await postCommand();
await drain();
expect(mockMarkFailed).toHaveBeenCalledWith(42, 'connection refused');
expect(mockMarkDone).not.toHaveBeenCalled();
});
it('should apply cleanSlackText to the stored note', async () => {
await postCommand({
text: 'Ping <@U9|dan> in <#C1|ops> about <https://ex.com|the doc> &amp; ship',
});
await drain();
const [notes] = mockCreateNotes.mock.calls[0];
expect(notes[0].text).toBe('Ping @dan in #ops about the doc & ship');
});
it('should echo the cleaned text rather than the raw mrkdwn', async () => {
const res = await postCommand({ text: 'read <https://ex.com|the doc>' });
expect(res.body.text).toBe('Added to Kongruity: "read the doc"');
});
it('should truncate a long echo so it does not flood the channel', async () => {
const long = 'a'.repeat(400);
const res = await postCommand({ text: long });
expect(res.status).toBe(200);
expect(res.body.text).toContain('…');
expect(res.body.text.length).toBeLessThan(160);
});
it('should store the whole note even when the echo is truncated', async () => {
const long = 'a'.repeat(400);
await postCommand({ text: long });
await drain();
const [notes] = mockCreateNotes.mock.calls[0];
expect(notes[0].text).toBe(long);
});
it('should fall back to a synthetic external id when trigger_id is absent', async () => {
await postCommand({ trigger_id: undefined });
expect(mockRecordDelivery).toHaveBeenCalledOnce();
const [input] = mockRecordDelivery.mock.calls[0];
expect(input.provider).toBe('slack');
expect(input.externalId).toMatch(/^T999:U123:\d+$/);
});
it('should truncate the note id to the 64 character column width', async () => {
await postCommand({ trigger_id: 'trg_'.padEnd(120, 'x') });
await drain();
const [notes] = mockCreateNotes.mock.calls[0];
expect(notes[0].id).toHaveLength(64);
expect(notes[0].id.startsWith('slack_trg_x')).toBe(true);
});
it('should drop a duplicate trigger_id without enqueueing work', async () => {
mockRecordDelivery.mockResolvedValue(null);
const res = await postCommand();
await drain();
expect(res.status).toBe(200);
expect(res.body).toEqual({ response_type: 'ephemeral', text: 'Already captured.' });
expect(mockCreateNotes).not.toHaveBeenCalled();
expect(mockMarkProcessing).not.toHaveBeenCalled();
});
it('should return usage when the text is empty', async () => {
const res = await postCommand({ text: '' });
expect(res.status).toBe(200);
expect(res.body).toEqual({
response_type: 'ephemeral',
text: 'Usage: /sticky <your note>',
});
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
it('should return usage when the text is only whitespace', async () => {
const res = await postCommand({ text: ' ' });
expect(res.status).toBe(200);
expect(res.body.text).toBe('Usage: /sticky <your note>');
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
/**
* Markup can be non-empty before cleaning and empty after it. Recording the
* delivery first would consume the trigger_id that dedups a Slack retry.
*/
it('should return usage without recording a delivery when the text cleans away to nothing', async () => {
const res = await postCommand({ text: '<>' });
expect(res.status).toBe(200);
expect(res.body.text).toBe('Usage: /sticky <your note>');
expect(mockRecordDelivery).not.toHaveBeenCalled();
expect(mockCreateNotes).not.toHaveBeenCalled();
});
it('should reject an unrecognized command name', async () => {
const res = await postCommand({ command: '/todo' });
expect(res.status).toBe(200);
expect(res.body).toEqual({
response_type: 'ephemeral',
text: 'Unknown command /todo.',
});
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
it('should fall back to the user id when no user_name is sent', async () => {
await postCommand({ user_name: undefined });
await drain();
const [notes] = mockCreateNotes.mock.calls[0];
expect(notes[0].author).toBe('U123');
});
it('should fall back to "unknown" when the sender is unidentified', async () => {
await postCommand({ user_name: undefined, user_id: undefined });
await drain();
const [notes] = mockCreateNotes.mock.calls[0];
expect(notes[0].author).toBe('unknown');
});
it('should reject a signature computed with the wrong secret', async () => {
const body = encode(FIELDS);
const timestamp = now();
const res = await postRaw(body, sign(body, timestamp, 'wrong-secret'), timestamp);
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'Invalid signature' });
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
it('should reject a body altered after signing', async () => {
const body = encode(FIELDS);
const timestamp = now();
const signature = sign(body, timestamp);
const tampered = body.replace('scary', 'great');
const res = await postRaw(tampered, signature, timestamp);
expect(res.status).toBe(401);
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
it('should reject a request with no signature header', async () => {
const body = encode(FIELDS);
const res = await request(app)
.post('/v1/slack/commands')
.set('Content-Type', 'application/x-www-form-urlencoded')
.set('x-slack-request-timestamp', now())
.send(body);
expect(res.status).toBe(401);
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
it('should reject a replayed request whose timestamp is outside the tolerance', async () => {
const body = encode(FIELDS);
const stale = (Math.floor(Date.now() / 1000) - 3600).toString();
const res = await postRaw(body, sign(body, stale), stale);
expect(res.status).toBe(401);
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
it('should return 500 when no signing secret is configured', async () => {
delete process.env.SLACK_SIGNING_SECRET;
const res = await postCommand();
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'Slack is not configured' });
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
it('should return 500 when recording the delivery fails', async () => {
mockRecordDelivery.mockRejectedValue(new Error('connection refused'));
const res = await postCommand();
expect(res.status).toBe(500);
expect(mockCreateNotes).not.toHaveBeenCalled();
});
it('should answer every user-facing outcome with ephemeral JSON', async () => {
const responses = await Promise.all([
postCommand(),
postCommand({ text: '' }),
postCommand({ command: '/todo' }),
]);
for (const res of responses) {
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/application\/json/);
expect(res.body.response_type).toBe('ephemeral');
expect(typeof res.body.text).toBe('string');
}
});
});

View File

@@ -0,0 +1,650 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() }));
// The real HttpRequestError is kept so its propagation through callSlack can be
// asserted against the actual class rather than a stand-in.
vi.mock('../lib/httpClient.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/httpClient.js')>();
return { ...actual, requestJson: mockRequestJson };
});
import { HttpRequestError } from '../lib/httpClient.js';
import {
callSlack,
authTest,
getUserDisplayName,
getMessageText,
enrichSlackDelivery,
isSlackApiError,
SlackApiError,
resetSlackCaches,
} from '../services/slack.service.js';
import type { NormalizedDelivery } from '../types/integration.js';
const API_BASE = 'https://slack.com/api/';
/** Answers each Slack method from a fixture; an unrouted method is a test bug. */
const route = (routes: Record<string, unknown>): void => {
mockRequestJson.mockImplementation((url: unknown) => {
const method = String(url).slice(API_BASE.length);
if (!(method in routes)) {
return Promise.reject(new Error(`unexpected Slack method "${method}"`));
}
return Promise.resolve(routes[method]);
});
};
const formOf = (callIndex: number): URLSearchParams => {
const [, init] = mockRequestJson.mock.calls[callIndex] as [string, RequestInit];
return new URLSearchParams(String(init.body));
};
const caught = async (promise: Promise<unknown>): Promise<unknown> =>
promise.then(() => null).catch((err: unknown) => err);
const historyOf = (message: Record<string, unknown> | null): Record<string, unknown> => ({
ok: true,
messages: message === null ? [] : [message],
});
const delivery = (notes: NormalizedDelivery['notes']): NormalizedDelivery => ({
externalId: 'evt_1',
notes,
});
describe('slack.service', () => {
let originalToken: string | undefined;
beforeEach(() => {
vi.clearAllMocks();
resetSlackCaches();
originalToken = process.env.SLACK_BOT_TOKEN;
process.env.SLACK_BOT_TOKEN = 'xoxb-test-token';
});
afterEach(() => {
if (originalToken === undefined) delete process.env.SLACK_BOT_TOKEN;
else process.env.SLACK_BOT_TOKEN = originalToken;
});
describe('callSlack', () => {
it('should post form-encoded params to the named method with a bearer token', async () => {
route({ 'users.info': { ok: true, user: { name: 'ada' } } });
await callSlack('users.info', { user: 'U1', include_locale: 'false' });
const [url, init] = mockRequestJson.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://slack.com/api/users.info');
expect(init.method).toBe('POST');
expect(init.headers).toMatchObject({
authorization: 'Bearer xoxb-test-token',
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
});
expect(formOf(0).get('user')).toBe('U1');
expect(formOf(0).get('include_locale')).toBe('false');
});
it('should send an empty body when no params are given', async () => {
route({ 'auth.test': { ok: true } });
await callSlack('auth.test');
const [, init] = mockRequestJson.mock.calls[0] as [string, RequestInit];
expect(init.body).toBe('');
});
it('should resolve the parsed envelope when ok is true', async () => {
route({ 'auth.test': { ok: true, team: 'Acme' } });
await expect(callSlack('auth.test')).resolves.toEqual({ ok: true, team: 'Acme' });
});
it('should throw a SlackApiError for an HTTP 200 carrying ok false', async () => {
route({ 'users.info': { ok: false, error: 'invalid_auth' } });
const error = await caught(callSlack('users.info', { user: 'U1' }));
expect(isSlackApiError(error)).toBe(true);
expect(error).toBeInstanceOf(SlackApiError);
expect(isSlackApiError(error) && error.slackError).toBe('invalid_auth');
expect(isSlackApiError(error) && error.method).toBe('users.info');
expect(error instanceof Error ? error.message : '').toContain('invalid_auth');
});
it('should classify every known non-retryable error string as permanent', async () => {
const permanent = [
'invalid_auth',
'not_authed',
'account_inactive',
'token_revoked',
'token_expired',
'no_permission',
'missing_scope',
'channel_not_found',
'not_in_channel',
'message_not_found',
'user_not_found',
'invalid_arguments',
'invalid_form_data',
'is_archived',
] as const;
for (const slackError of permanent) {
route({ 'conversations.history': { ok: false, error: slackError } });
const error = await caught(callSlack('conversations.history'));
expect(isSlackApiError(error) && error.kind, slackError).toBe('permanent');
}
});
it('should classify a known transient error string as retryable', async () => {
route({ 'conversations.history': { ok: false, error: 'ratelimited' } });
const error = await caught(callSlack('conversations.history'));
expect(isSlackApiError(error) && error.kind).toBe('retryable');
});
it('should classify an unrecognized error string as retryable', async () => {
route({ 'conversations.history': { ok: false, error: 'some_future_slack_error' } });
const error = await caught(callSlack('conversations.history'));
expect(isSlackApiError(error) && error.slackError).toBe('some_future_slack_error');
expect(isSlackApiError(error) && error.kind).toBe('retryable');
});
it('should report unknown_error when the failure body names no error', async () => {
route({ 'auth.test': { ok: false } });
const error = await caught(callSlack('auth.test'));
expect(isSlackApiError(error) && error.slackError).toBe('unknown_error');
expect(isSlackApiError(error) && error.kind).toBe('retryable');
});
it('should report unknown_error when the body is not an object', async () => {
route({ 'auth.test': 'maintenance' });
const error = await caught(callSlack('auth.test'));
expect(isSlackApiError(error) && error.slackError).toBe('unknown_error');
});
it('should throw a permanent no_token error without making a request', async () => {
delete process.env.SLACK_BOT_TOKEN;
const error = await caught(callSlack('auth.test'));
expect(isSlackApiError(error) && error.slackError).toBe('no_token');
expect(isSlackApiError(error) && error.kind).toBe('permanent');
expect(mockRequestJson).not.toHaveBeenCalled();
});
it('should treat an empty or whitespace token as absent', async () => {
process.env.SLACK_BOT_TOKEN = '';
expect(isSlackApiError(await caught(callSlack('auth.test')))).toBe(true);
process.env.SLACK_BOT_TOKEN = ' ';
expect(isSlackApiError(await caught(callSlack('auth.test')))).toBe(true);
expect(mockRequestJson).not.toHaveBeenCalled();
});
it('should read the token at call time rather than at module load', async () => {
process.env.SLACK_BOT_TOKEN = 'xoxb-rotated';
route({ 'auth.test': { ok: true } });
await callSlack('auth.test');
const [, init] = mockRequestJson.mock.calls[0] as [string, RequestInit];
expect(init.headers).toMatchObject({ authorization: 'Bearer xoxb-rotated' });
});
it('should let an HttpRequestError propagate unwrapped', async () => {
const transport = new HttpRequestError(`${API_BASE}auth.test`, 'timeout', 3);
mockRequestJson.mockRejectedValue(transport);
const error = await caught(callSlack('auth.test'));
expect(error).toBe(transport);
expect(isSlackApiError(error)).toBe(false);
});
it('should distinguish a Slack failure from an unrelated error', () => {
expect(isSlackApiError(new TypeError('bad call'))).toBe(false);
expect(isSlackApiError('invalid_auth')).toBe(false);
expect(isSlackApiError(null)).toBe(false);
});
});
describe('authTest', () => {
it('should map the identity fields of a successful auth.test', async () => {
route({
'auth.test': { ok: true, team_id: 'T1', team: 'Acme', user_id: 'U_BOT', url: 'x' },
});
await expect(authTest()).resolves.toEqual({
teamId: 'T1',
teamName: 'Acme',
botUserId: 'U_BOT',
});
});
it('should reject with a SlackApiError when the credential is rejected', async () => {
route({ 'auth.test': { ok: false, error: 'token_revoked' } });
const error = await caught(authTest());
expect(isSlackApiError(error) && error.kind).toBe('permanent');
});
});
describe('getUserDisplayName', () => {
it('should request users.info for the given user', async () => {
route({ 'users.info': { ok: true, user: { profile: { display_name: 'ada' } } } });
await getUserDisplayName('U1');
const [url] = mockRequestJson.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://slack.com/api/users.info');
expect(formOf(0).get('user')).toBe('U1');
});
it('should prefer profile.display_name above every other name', async () => {
route({
'users.info': {
ok: true,
user: {
name: 'ada.l',
real_name: 'Ada L',
profile: { display_name: 'ada', real_name: 'Ada Lovelace' },
},
},
});
await expect(getUserDisplayName('U1')).resolves.toBe('ada');
});
it('should fall back to profile.real_name when display_name is empty', async () => {
route({
'users.info': {
ok: true,
user: {
name: 'ada.l',
real_name: 'Ada L',
profile: { display_name: '', real_name: 'Ada Lovelace' },
},
},
});
await expect(getUserDisplayName('U1')).resolves.toBe('Ada Lovelace');
});
it('should fall back to the top-level real_name when the profile has neither', async () => {
route({
'users.info': { ok: true, user: { name: 'ada.l', real_name: 'Ada L', profile: {} } },
});
await expect(getUserDisplayName('U1')).resolves.toBe('Ada L');
});
it('should fall back to the account name when no real name is set', async () => {
route({ 'users.info': { ok: true, user: { name: 'ada.l' } } });
await expect(getUserDisplayName('U1')).resolves.toBe('ada.l');
});
it('should fall back to the raw user id when the profile carries no name', async () => {
route({ 'users.info': { ok: true, user: { profile: {} } } });
await expect(getUserDisplayName('U1')).resolves.toBe('U1');
});
it('should memoize a resolved name and not request it twice', async () => {
route({ 'users.info': { ok: true, user: { profile: { display_name: 'ada' } } } });
expect(await getUserDisplayName('U1')).toBe('ada');
expect(await getUserDisplayName('U1')).toBe('ada');
expect(mockRequestJson).toHaveBeenCalledTimes(1);
});
it('should memoize each user separately', async () => {
mockRequestJson.mockImplementation((_url: unknown, init?: RequestInit) => {
const user = new URLSearchParams(String(init?.body)).get('user');
return Promise.resolve({ ok: true, user: { profile: { display_name: `name-${user}` } } });
});
expect(await getUserDisplayName('U1')).toBe('name-U1');
expect(await getUserDisplayName('U2')).toBe('name-U2');
expect(mockRequestJson).toHaveBeenCalledTimes(2);
});
it('should clear memoized names on resetSlackCaches', async () => {
route({ 'users.info': { ok: true, user: { profile: { display_name: 'ada' } } } });
await getUserDisplayName('U1');
resetSlackCaches();
await getUserDisplayName('U1');
expect(mockRequestJson).toHaveBeenCalledTimes(2);
});
it('should degrade to the raw user id on a permanent error', async () => {
route({ 'users.info': { ok: false, error: 'user_not_found' } });
await expect(getUserDisplayName('U_GONE')).resolves.toBe('U_GONE');
});
it('should degrade to the raw user id when the token is missing', async () => {
delete process.env.SLACK_BOT_TOKEN;
await expect(getUserDisplayName('U1')).resolves.toBe('U1');
expect(mockRequestJson).not.toHaveBeenCalled();
});
it('should rethrow a retryable error so the queue can retry', async () => {
route({ 'users.info': { ok: false, error: 'ratelimited' } });
const error = await caught(getUserDisplayName('U1'));
expect(isSlackApiError(error) && error.kind).toBe('retryable');
});
it('should rethrow a transport error unwrapped', async () => {
const transport = new HttpRequestError(`${API_BASE}users.info`, 'network', 3);
mockRequestJson.mockRejectedValue(transport);
expect(await caught(getUserDisplayName('U1'))).toBe(transport);
});
});
describe('getMessageText', () => {
it('should read a single message from conversations.history by timestamp', async () => {
route({ 'conversations.history': historyOf({ text: 'ship it', user: 'U1' }) });
const result = await getMessageText('C1', '1700000000.000100');
expect(result).toEqual({ text: 'ship it', userId: 'U1' });
const [url] = mockRequestJson.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://slack.com/api/conversations.history');
const form = formOf(0);
expect(form.get('channel')).toBe('C1');
expect(form.get('latest')).toBe('1700000000.000100');
expect(form.get('oldest')).toBe('1700000000.000100');
expect(form.get('inclusive')).toBe('true');
expect(form.get('limit')).toBe('1');
});
it('should clean Slack mrkdwn out of the returned text', async () => {
route({
'conversations.history': historyOf({
text: 'ask <@U9|ada> about <https://kb.test/x|the doc> &amp; ship ',
}),
});
await expect(getMessageText('C1', '1.1')).resolves.toEqual({
text: 'ask @ada about the doc & ship',
});
});
it('should omit userId when the message carries no user', async () => {
route({ 'conversations.history': historyOf({ text: 'from a bot' }) });
const result = await getMessageText('C1', '1.1');
expect(result).toEqual({ text: 'from a bot' });
expect(result === null ? true : 'userId' in result).toBe(false);
});
it('should fall back to conversations.replies for a threaded reply', async () => {
route({
'conversations.history': historyOf(null),
'conversations.replies': {
ok: true,
messages: [{ ts: '1.1', text: 'thread parent' }, { ts: '2.2', text: 'the reply' }],
},
});
const result = await getMessageText('C1', '2.2');
expect(result).toEqual({ text: 'the reply' });
const [url] = mockRequestJson.mock.calls[1] as [string, RequestInit];
expect(url).toBe('https://slack.com/api/conversations.replies');
const form = formOf(1);
expect(form.get('channel')).toBe('C1');
expect(form.get('ts')).toBe('2.2');
expect(form.get('limit')).toBe('1');
expect(form.get('inclusive')).toBe('true');
});
it('should not call conversations.replies when history already answered', async () => {
route({ 'conversations.history': historyOf({ text: 'ship it' }) });
await getMessageText('C1', '1.1');
expect(mockRequestJson).toHaveBeenCalledTimes(1);
});
it('should return null when neither history nor replies yields a message', async () => {
route({
'conversations.history': historyOf(null),
'conversations.replies': { ok: true, messages: [] },
});
await expect(getMessageText('C1', '1.1')).resolves.toBeNull();
expect(mockRequestJson).toHaveBeenCalledTimes(2);
});
it('should return null when the messages field is missing entirely', async () => {
route({
'conversations.history': { ok: true },
'conversations.replies': { ok: true },
});
await expect(getMessageText('C1', '1.1')).resolves.toBeNull();
});
it('should return null for a file-only message whose text is empty', async () => {
route({ 'conversations.history': historyOf({ text: '', user: 'U1' }) });
await expect(getMessageText('C1', '1.1')).resolves.toBeNull();
});
it('should return null when the text is only markup that cleans away', async () => {
route({ 'conversations.history': historyOf({ text: ' ', user: 'U1' }) });
await expect(getMessageText('C1', '1.1')).resolves.toBeNull();
});
it('should reject with a SlackApiError when the channel is unreadable', async () => {
route({ 'conversations.history': { ok: false, error: 'not_in_channel' } });
const error = await caught(getMessageText('C1', '1.1'));
expect(isSlackApiError(error) && error.kind).toBe('permanent');
});
});
describe('enrichSlackDelivery', () => {
const taggedNote = (overrides: Record<string, unknown> = {}) => ({
id: 'slack_C1_1.1',
text: '(pending message text)',
author: 'unknown',
sourceMeta: {
provider: 'slack',
externalId: 'evt_1',
receivedAt: '2026-01-01T00:00:00.000Z',
needsMessageText: true,
channelId: 'C1',
messageTs: '1.1',
...overrides,
},
});
it('should replace placeholder text and resolve the author', async () => {
route({
'conversations.history': historyOf({ text: 'ship the thing', user: 'U_AUTHOR' }),
'users.info': { ok: true, user: { profile: { display_name: 'ada' } } },
});
const result = await enrichSlackDelivery(delivery([taggedNote()]), {});
expect(result.notes).toHaveLength(1);
expect(result.notes[0]?.text).toBe('ship the thing');
expect(result.notes[0]?.author).toBe('ada');
expect(result.notes[0]?.sourceMeta?.authorHandle).toBe('ada');
});
it('should strip the needsMessageText marker from the resulting sourceMeta', async () => {
route({
'conversations.history': historyOf({ text: 'ship it', user: 'U_AUTHOR' }),
'users.info': { ok: true, user: { profile: { display_name: 'ada' } } },
});
const result = await enrichSlackDelivery(delivery([taggedNote()]), {});
expect(result.notes[0]?.sourceMeta).not.toHaveProperty('needsMessageText');
expect(result.notes[0]?.sourceMeta).toMatchObject({
provider: 'slack',
externalId: 'evt_1',
channelId: 'C1',
messageTs: '1.1',
});
});
it('should prefer an explicit authorUserId over the message author', async () => {
mockRequestJson.mockImplementation((url: unknown, init?: RequestInit) => {
if (String(url).endsWith('conversations.history')) {
return Promise.resolve(historyOf({ text: 'ship it', user: 'U_MESSAGE' }));
}
const user = new URLSearchParams(String(init?.body)).get('user');
return Promise.resolve({ ok: true, user: { profile: { display_name: `name-${user}` } } });
});
const result = await enrichSlackDelivery(
delivery([taggedNote({ authorUserId: 'U_REACTOR' })]),
{}
);
expect(result.notes[0]?.author).toBe('name-U_REACTOR');
});
it('should keep the existing author when no user id can be resolved', async () => {
route({ 'conversations.history': historyOf({ text: 'from a bot' }) });
const result = await enrichSlackDelivery(delivery([taggedNote()]), {});
expect(result.notes[0]?.text).toBe('from a bot');
expect(result.notes[0]?.author).toBe('unknown');
expect(mockRequestJson).toHaveBeenCalledTimes(1);
});
it('should drop a note whose message cannot be fetched', async () => {
route({
'conversations.history': historyOf(null),
'conversations.replies': { ok: true, messages: [] },
});
const result = await enrichSlackDelivery(delivery([taggedNote()]), {});
expect(result.notes).toEqual([]);
expect(result.externalId).toBe('evt_1');
});
it('should drop a tagged note that carries no channel or timestamp', async () => {
route({});
const result = await enrichSlackDelivery(
delivery([taggedNote({ channelId: undefined, messageTs: undefined })]),
{}
);
expect(result.notes).toEqual([]);
expect(mockRequestJson).not.toHaveBeenCalled();
});
it('should pass an untagged note through untouched', async () => {
route({});
const plain = {
id: 'slack_evt_2',
text: 'already complete',
author: 'Ada Lovelace',
sourceMeta: { provider: 'slack', externalId: 'evt_2', authorHandle: 'ada' },
};
const result = await enrichSlackDelivery(delivery([plain]), {});
expect(result.notes[0]).toBe(plain);
expect(mockRequestJson).not.toHaveBeenCalled();
});
it('should pass a note through when it has no sourceMeta at all', async () => {
route({});
const plain = { id: 'n1', text: 'manual note', author: 'ada' };
const result = await enrichSlackDelivery(delivery([plain]), {});
expect(result.notes[0]).toBe(plain);
});
it('should enrich tagged notes while leaving untagged ones in place', async () => {
route({
'conversations.history': historyOf({ text: 'fetched', user: 'U_AUTHOR' }),
'users.info': { ok: true, user: { profile: { display_name: 'ada' } } },
});
const plain = { id: 'n_plain', text: 'untouched', author: 'someone' };
const result = await enrichSlackDelivery(delivery([plain, taggedNote()]), {});
expect(result.notes).toHaveLength(2);
expect(result.notes[0]).toBe(plain);
expect(result.notes[1]?.text).toBe('fetched');
});
it('should not mutate the delivery or the notes it was given', async () => {
route({
'conversations.history': historyOf({ text: 'ship it', user: 'U_AUTHOR' }),
'users.info': { ok: true, user: { profile: { display_name: 'ada' } } },
});
const input = delivery([taggedNote()]);
const snapshot = structuredClone(input);
const result = await enrichSlackDelivery(input, {});
expect(input).toEqual(snapshot);
expect(result).not.toBe(input);
expect(result.notes).not.toBe(input.notes);
});
it('should return an empty delivery unchanged in shape', async () => {
route({});
await expect(enrichSlackDelivery(delivery([]), {})).resolves.toEqual({
externalId: 'evt_1',
notes: [],
});
});
it('should propagate a retryable failure so the queue retries the delivery', async () => {
route({ 'conversations.history': { ok: false, error: 'ratelimited' } });
const error = await caught(enrichSlackDelivery(delivery([taggedNote()]), {}));
expect(isSlackApiError(error) && error.kind).toBe('retryable');
});
it('should accept a payload of any shape without needing it', async () => {
route({ 'conversations.history': historyOf({ text: 'ship it' }) });
await expect(
enrichSlackDelivery(delivery([taggedNote()]), undefined)
).resolves.toMatchObject({ notes: [{ text: 'ship it' }] });
});
});
});

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { cleanSlackText } from '../lib/slackText.js';
describe('cleanSlackText', () => {
it('should leave plain text untouched', () => {
expect(cleanSlackText('ship the thing')).toBe('ship the thing');
});
it('should keep a bare user mention readable when no label is supplied', () => {
expect(cleanSlackText('ping <@U0123> about it')).toBe('ping @U0123 about it');
});
it('should prefer the label on a user mention', () => {
expect(cleanSlackText('ping <@U0123|kevin> about it')).toBe('ping @kevin about it');
});
it('should render a channel reference by name', () => {
expect(cleanSlackText('see <#C0123|general>')).toBe('see #general');
});
it('should keep a channel reference without a label', () => {
expect(cleanSlackText('see <#C0123>')).toBe('see #C0123');
});
it('should convert broadcast mentions', () => {
expect(cleanSlackText('<!here> heads up')).toBe('@here heads up');
expect(cleanSlackText('<!channel> heads up')).toBe('@channel heads up');
});
it('should replace a labelled link with its label', () => {
expect(cleanSlackText('read <https://example.com|the docs>')).toBe('read the docs');
});
it('should keep the url when a link has no label', () => {
expect(cleanSlackText('read <https://example.com>')).toBe('read https://example.com');
});
it('should unwrap a mailto link', () => {
expect(cleanSlackText('mail <mailto:a@b.com|a@b.com>')).toBe('mail a@b.com');
expect(cleanSlackText('mail <mailto:a@b.com>')).toBe('mail a@b.com');
});
it('should unescape html entities', () => {
expect(cleanSlackText('tabs &amp; spaces')).toBe('tabs & spaces');
});
/**
* Slack escapes a literal angle bracket so it is not read as markup. If
* entities were unescaped first, this would be parsed as a link and the
* user's text would silently disappear.
*/
it('should not reparse an escaped angle bracket as markup', () => {
expect(cleanSlackText('if a &lt;b&gt; then stop')).toBe('if a <b> then stop');
});
it('should handle several references in one message', () => {
expect(
cleanSlackText('<@U1|amy> moved <#C1|ops> to <https://x.co|the wiki>')
).toBe('@amy moved #ops to the wiki');
});
it('should trim surrounding whitespace and trailing spaces on each line', () => {
expect(cleanSlackText(' first line \n second ')).toBe('first line\n second');
});
it('should return an empty string for whitespace-only input', () => {
expect(cleanSlackText(' \n ')).toBe('');
});
it('should leave an empty angle-bracket pair alone rather than throwing', () => {
expect(cleanSlackText('a <> b')).toBe('a b');
});
});