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 = {}): Record => ({ id: 'n1', text: 'Deploys are scary', author: 'kim', ...overrides, }); const sourceMeta = (input: NoteInput): Record => { expect(input.sourceMeta).toBeDefined(); return input.sourceMeta as Record; }; const linearPayload = ( data: Record = {} ): Record => ({ 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/); expect(() => normalize('slack', {})).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); }); it('should be false for providers awaiting an integration', () => { expect(hasNormalizer('github')).toBe(false); expect(hasNormalizer('jira')).toBe(false); expect(hasNormalizer('slack')).toBe(false); }); });