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