340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
NormalizationError,
|
|
isNoteInputArray,
|
|
normalizeLinear,
|
|
normalizeRest,
|
|
} from '../config/normalizers.js';
|
|
import { normalize, hasNormalizer } from '../services/normalize.service.js';
|
|
import type { NoteInput } from '../types/domain.js';
|
|
|
|
const note = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
|
|
id: 'n1',
|
|
text: 'Deploys are scary',
|
|
author: 'kim',
|
|
...overrides,
|
|
});
|
|
|
|
const sourceMeta = (input: NoteInput): Record<string, unknown> => {
|
|
expect(input.sourceMeta).toBeDefined();
|
|
return input.sourceMeta as Record<string, unknown>;
|
|
};
|
|
|
|
const linearPayload = (
|
|
data: Record<string, unknown> = {}
|
|
): Record<string, unknown> => ({
|
|
action: 'create',
|
|
type: 'Comment',
|
|
data: {
|
|
id: 'cmt_9f2',
|
|
body: 'Retros keep surfacing the same deploy pain',
|
|
url: 'https://linear.app/acme/issue/ENG-42#comment-cmt_9f2',
|
|
user: { name: 'dana' },
|
|
...data,
|
|
},
|
|
});
|
|
|
|
describe('isNoteInputArray', () => {
|
|
it('should accept an empty array', () => {
|
|
expect(isNoteInputArray([])).toBe(true);
|
|
});
|
|
|
|
it('should accept an array of well-formed notes', () => {
|
|
expect(isNoteInputArray([note(), note({ id: 'n2', x: 1, y: 2, color: '#fff' })])).toBe(true);
|
|
});
|
|
|
|
it('should reject a non-array', () => {
|
|
expect(isNoteInputArray(undefined)).toBe(false);
|
|
expect(isNoteInputArray(null)).toBe(false);
|
|
expect(isNoteInputArray('notes')).toBe(false);
|
|
expect(isNoteInputArray({ 0: note(), length: 1 })).toBe(false);
|
|
});
|
|
|
|
it('should reject an element that is not an object', () => {
|
|
expect(isNoteInputArray([note(), 'nope'])).toBe(false);
|
|
expect(isNoteInputArray([null])).toBe(false);
|
|
});
|
|
|
|
it('should reject an element missing a required field', () => {
|
|
expect(isNoteInputArray([note({ id: undefined })])).toBe(false);
|
|
expect(isNoteInputArray([note({ text: undefined })])).toBe(false);
|
|
expect(isNoteInputArray([note({ author: undefined })])).toBe(false);
|
|
});
|
|
|
|
it('should reject an element whose required field has the wrong type', () => {
|
|
expect(isNoteInputArray([note({ id: 7 })])).toBe(false);
|
|
expect(isNoteInputArray([note({ text: { body: 'hi' } })])).toBe(false);
|
|
expect(isNoteInputArray([note({ author: ['kim'] })])).toBe(false);
|
|
});
|
|
|
|
it('should reject an element with empty strings', () => {
|
|
expect(isNoteInputArray([note({ id: '' })])).toBe(false);
|
|
expect(isNoteInputArray([note({ text: '' })])).toBe(false);
|
|
expect(isNoteInputArray([note({ author: '' })])).toBe(false);
|
|
});
|
|
|
|
it('should reject an element whose optional field has the wrong type', () => {
|
|
expect(isNoteInputArray([note({ x: '10' })])).toBe(false);
|
|
expect(isNoteInputArray([note({ y: null })])).toBe(false);
|
|
expect(isNoteInputArray([note({ color: 0xffffff })])).toBe(false);
|
|
});
|
|
|
|
it('should accept an element whose optional fields are undefined', () => {
|
|
expect(isNoteInputArray([note({ x: undefined, y: undefined, color: undefined })])).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('normalizeRest', () => {
|
|
it('should return the supplied notes with provenance attached', () => {
|
|
const result = normalizeRest({
|
|
batchId: 'batch_7',
|
|
notes: [note(), note({ id: 'n2', text: 'Standups run long' })],
|
|
});
|
|
|
|
expect(result.externalId).toBe('batch_7');
|
|
expect(result.notes).toHaveLength(2);
|
|
expect(result.notes[0].id).toBe('n1');
|
|
expect(result.notes[0].text).toBe('Deploys are scary');
|
|
expect(result.notes[0].author).toBe('kim');
|
|
expect(sourceMeta(result.notes[0])).toMatchObject({
|
|
provider: 'rest',
|
|
externalId: 'batch_7',
|
|
});
|
|
expect(sourceMeta(result.notes[1]).externalId).toBe('batch_7');
|
|
});
|
|
|
|
it('should record an ISO receivedAt timestamp in provenance', () => {
|
|
const result = normalizeRest({ notes: [note()] });
|
|
const receivedAt = String(sourceMeta(result.notes[0]).receivedAt);
|
|
|
|
expect(new Date(receivedAt).toISOString()).toBe(receivedAt);
|
|
});
|
|
|
|
it('should preserve optional positional and color fields', () => {
|
|
const result = normalizeRest({
|
|
notes: [note({ x: 12, y: -3, color: '#ffcc00' })],
|
|
});
|
|
|
|
expect(result.notes[0]).toMatchObject({ x: 12, y: -3, color: '#ffcc00' });
|
|
});
|
|
|
|
it('should generate a non-empty externalId when batchId is absent', () => {
|
|
const result = normalizeRest({ notes: [note()] });
|
|
|
|
expect(result.externalId.length).toBeGreaterThan(0);
|
|
expect(sourceMeta(result.notes[0]).externalId).toBe(result.externalId);
|
|
});
|
|
|
|
it('should generate a distinct externalId per call', () => {
|
|
const first = normalizeRest({ notes: [note()] });
|
|
const second = normalizeRest({ notes: [note()] });
|
|
|
|
expect(first.externalId).not.toBe(second.externalId);
|
|
});
|
|
|
|
it('should ignore an empty batchId in favour of a generated one', () => {
|
|
const result = normalizeRest({ batchId: '', notes: [note()] });
|
|
|
|
expect(result.externalId.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should ignore a non-string batchId in favour of a generated one', () => {
|
|
const result = normalizeRest({ batchId: 42, notes: [note()] });
|
|
|
|
expect(result.externalId).not.toBe('42');
|
|
expect(result.externalId.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should accept an empty notes array', () => {
|
|
const result = normalizeRest({ batchId: 'batch_empty', notes: [] });
|
|
|
|
expect(result).toEqual({ externalId: 'batch_empty', notes: [] });
|
|
});
|
|
|
|
it('should throw when notes is missing', () => {
|
|
expect(() => normalizeRest({ batchId: 'batch_7' })).toThrow(NormalizationError);
|
|
expect(() => normalizeRest({})).toThrow(/"notes" array/);
|
|
});
|
|
|
|
it('should throw when notes is not an array', () => {
|
|
expect(() => normalizeRest({ notes: 'one note' })).toThrow(NormalizationError);
|
|
expect(() => normalizeRest({ notes: { id: 'n1' } })).toThrow(NormalizationError);
|
|
});
|
|
|
|
it('should throw when a note is missing text', () => {
|
|
expect(() => normalizeRest({ notes: [note({ text: undefined })] })).toThrow(
|
|
NormalizationError
|
|
);
|
|
});
|
|
|
|
it('should throw when a note id is not a string', () => {
|
|
expect(() => normalizeRest({ notes: [note({ id: 99 })] })).toThrow(NormalizationError);
|
|
});
|
|
|
|
it('should throw when a note carries empty strings', () => {
|
|
expect(() => normalizeRest({ notes: [note({ text: '' })] })).toThrow(NormalizationError);
|
|
expect(() => normalizeRest({ notes: [note({ id: '' })] })).toThrow(NormalizationError);
|
|
expect(() => normalizeRest({ notes: [note({ author: '' })] })).toThrow(NormalizationError);
|
|
});
|
|
|
|
it('should throw when one note in an otherwise valid batch is invalid', () => {
|
|
expect(() => normalizeRest({ notes: [note(), note({ id: 'n2', author: '' })] })).toThrow(
|
|
NormalizationError
|
|
);
|
|
});
|
|
|
|
it('should throw for a non-object payload', () => {
|
|
expect(() => normalizeRest(null)).toThrow(/not an object/);
|
|
expect(() => normalizeRest(undefined)).toThrow(NormalizationError);
|
|
expect(() => normalizeRest('notes')).toThrow(NormalizationError);
|
|
expect(() => normalizeRest([note()])).toThrow(/not an object/);
|
|
});
|
|
|
|
it('should overwrite any caller-supplied sourceMeta with real provenance', () => {
|
|
const result = normalizeRest({
|
|
batchId: 'batch_7',
|
|
notes: [note({ sourceMeta: { provider: 'slack', externalId: 'spoofed' } })],
|
|
});
|
|
|
|
expect(sourceMeta(result.notes[0])).toMatchObject({
|
|
provider: 'rest',
|
|
externalId: 'batch_7',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('normalizeLinear', () => {
|
|
it('should map a comment webhook to a single note', () => {
|
|
const result = normalizeLinear(linearPayload());
|
|
|
|
expect(result.externalId).toBe('cmt_9f2');
|
|
expect(result.notes).toHaveLength(1);
|
|
expect(result.notes[0].id).toBe('linear_cmt_9f2');
|
|
expect(result.notes[0].text).toBe('Retros keep surfacing the same deploy pain');
|
|
expect(result.notes[0].author).toBe('dana');
|
|
});
|
|
|
|
it('should attach provenance including provider, externalId and permalink', () => {
|
|
const meta = sourceMeta(normalizeLinear(linearPayload()).notes[0]);
|
|
|
|
expect(meta).toMatchObject({
|
|
provider: 'linear',
|
|
externalId: 'cmt_9f2',
|
|
permalink: 'https://linear.app/acme/issue/ENG-42#comment-cmt_9f2',
|
|
authorHandle: 'dana',
|
|
});
|
|
expect(typeof meta.receivedAt).toBe('string');
|
|
});
|
|
|
|
it('should fall back to an unknown author when the user is missing or unnamed', () => {
|
|
expect(normalizeLinear(linearPayload({ user: undefined })).notes[0].author).toBe('unknown');
|
|
expect(normalizeLinear(linearPayload({ user: null })).notes[0].author).toBe('unknown');
|
|
expect(normalizeLinear(linearPayload({ user: {} })).notes[0].author).toBe('unknown');
|
|
expect(normalizeLinear(linearPayload({ user: { name: '' } })).notes[0].author).toBe('unknown');
|
|
expect(normalizeLinear(linearPayload({ user: 'dana' })).notes[0].author).toBe('unknown');
|
|
});
|
|
|
|
it('should omit the permalink when url is absent', () => {
|
|
const meta = sourceMeta(normalizeLinear(linearPayload({ url: undefined })).notes[0]);
|
|
|
|
expect(meta.permalink).toBeUndefined();
|
|
});
|
|
|
|
it('should throw when data.id is missing', () => {
|
|
expect(() => normalizeLinear(linearPayload({ id: undefined }))).toThrow(NormalizationError);
|
|
expect(() => normalizeLinear(linearPayload({ id: '' }))).toThrow(/missing data.id/);
|
|
expect(() => normalizeLinear(linearPayload({ id: 42 }))).toThrow(NormalizationError);
|
|
});
|
|
|
|
it('should throw when data itself is missing or not an object', () => {
|
|
expect(() => normalizeLinear({ action: 'create', type: 'Comment' })).toThrow(
|
|
/not an object/
|
|
);
|
|
expect(() => normalizeLinear({ data: 'cmt_9f2' })).toThrow(NormalizationError);
|
|
expect(() => normalizeLinear({ data: [] })).toThrow(NormalizationError);
|
|
});
|
|
|
|
it('should return zero notes but keep the externalId when the body is empty', () => {
|
|
expect(normalizeLinear(linearPayload({ body: '' }))).toEqual({
|
|
externalId: 'cmt_9f2',
|
|
notes: [],
|
|
});
|
|
});
|
|
|
|
it('should return zero notes but keep the externalId when the body is absent', () => {
|
|
expect(normalizeLinear(linearPayload({ body: undefined }))).toEqual({
|
|
externalId: 'cmt_9f2',
|
|
notes: [],
|
|
});
|
|
expect(normalizeLinear(linearPayload({ body: null }))).toEqual({
|
|
externalId: 'cmt_9f2',
|
|
notes: [],
|
|
});
|
|
});
|
|
|
|
it('should throw for a non-object payload', () => {
|
|
expect(() => normalizeLinear(null)).toThrow(/not an object/);
|
|
expect(() => normalizeLinear('cmt_9f2')).toThrow(NormalizationError);
|
|
expect(() => normalizeLinear(7)).toThrow(NormalizationError);
|
|
expect(() => normalizeLinear([linearPayload()])).toThrow(/not an object/);
|
|
});
|
|
|
|
it('should truncate a generated note id to 64 characters', () => {
|
|
const longId = 'x'.repeat(400);
|
|
const result = normalizeLinear(linearPayload({ id: longId }));
|
|
|
|
expect(result.externalId).toBe(longId);
|
|
expect(result.notes[0].id).toHaveLength(64);
|
|
expect(result.notes[0].id).toBe(`linear_${longId}`.slice(0, 64));
|
|
expect(sourceMeta(result.notes[0]).externalId).toBe(longId);
|
|
});
|
|
|
|
it('should produce notes accepted by the note input guard', () => {
|
|
expect(isNoteInputArray(normalizeLinear(linearPayload()).notes)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('normalize', () => {
|
|
it('should delegate rest deliveries to the rest normalizer', () => {
|
|
const result = normalize('rest', { batchId: 'batch_7', notes: [note()] });
|
|
|
|
expect(result.externalId).toBe('batch_7');
|
|
expect(result.notes[0]).toMatchObject({ id: 'n1', author: 'kim' });
|
|
expect(sourceMeta(result.notes[0])).toMatchObject({
|
|
provider: 'rest',
|
|
externalId: 'batch_7',
|
|
});
|
|
});
|
|
|
|
it('should delegate linear deliveries to the linear normalizer', () => {
|
|
const result = normalize('linear', linearPayload());
|
|
|
|
expect(result.externalId).toBe('cmt_9f2');
|
|
expect(result.notes[0].id).toBe('linear_cmt_9f2');
|
|
});
|
|
|
|
it('should propagate normalizer failures unchanged', () => {
|
|
expect(() => normalize('rest', {})).toThrow(NormalizationError);
|
|
expect(() => normalize('linear', {})).toThrow(NormalizationError);
|
|
});
|
|
|
|
it('should throw for a provider with no registered normalizer', () => {
|
|
expect(() => normalize('github', {})).toThrow(NormalizationError);
|
|
expect(() => normalize('github', {})).toThrow(/No normalizer registered/);
|
|
expect(() => normalize('jira', {})).toThrow(/No normalizer registered/);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|