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

651 lines
23 KiB
TypeScript

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' }] });
});
});
});