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(); 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): 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): Promise => promise.then(() => null).catch((err: unknown) => err); const historyOf = (message: Record | null): Record => ({ 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 & 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 = {}) => ({ 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' }] }); }); }); });