651 lines
23 KiB
TypeScript
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> & 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' }] });
|
|
});
|
|
});
|
|
});
|