import { describe, it, expect } from 'vitest'; import { validateEvent } from '@main/services/telemetryEvents.js'; describe('validateEvent — happy path', () => { it('accepts capture event', () => { const e = validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'n1', rawTextLength: 12, hasMedia: false } }); expect(e.kind).toBe('capture'); }); it('accepts ai_succeeded event', () => { const e = validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'ai_succeeded', payload: { noteId: 'n1', durationMs: 1234, attempts: 0 } }); expect(e.kind).toBe('ai_succeeded'); }); it('accepts ai_failed event with reason enum', () => { const e = validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'ai_failed', payload: { noteId: 'n1', reason: 'unreachable', attempts: 3 } }); expect(e.kind).toBe('ai_failed'); }); }); describe('validateEvent — privacy invariant', () => { it('rejects payload with rawText leak', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false, rawText: 'leak' } })).toThrow(); }); it('rejects payload with title leak', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'ai_succeeded', payload: { noteId: 'n1', durationMs: 1, attempts: 0, title: 'leak' } })).toThrow(); }); it('rejects payload with summary leak', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'ai_succeeded', payload: { noteId: 'n1', durationMs: 1, attempts: 0, summary: 'leak' } })).toThrow(); }); it('rejects payload with userIntent leak', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false, userIntent: 'leak' } })).toThrow(); }); it('rejects payload with tag name leak', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false, tagNames: ['일정'] } })).toThrow(); }); it('rejects unknown reason in ai_failed', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'ai_failed', payload: { noteId: 'n1', reason: 'unicorn', attempts: 1 } })).toThrow(); }); it('rejects unknown event kind', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'mystery', payload: {} })).toThrow(); }); }); describe('validateEvent — trash family (v0.2.3 #4)', () => { it('accepts trash event', () => { const e = validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'trash', payload: { noteId: 'n1' } }); expect(e.kind).toBe('trash'); }); it('accepts restore event', () => { const e = validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'restore', payload: { noteId: 'n1' } }); expect(e.kind).toBe('restore'); }); it('accepts permanent_delete event', () => { const e = validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'permanent_delete', payload: { noteId: 'n1' } }); expect(e.kind).toBe('permanent_delete'); }); it('accepts empty_trash event with count', () => { const e = validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'empty_trash', payload: { count: 7 } }); expect(e.kind).toBe('empty_trash'); }); it('rejects trash payload with rawText leak', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'trash', payload: { noteId: 'n1', rawText: 'leak' } })).toThrow(); }); it('rejects empty_trash with negative count', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'empty_trash', payload: { count: -1 } })).toThrow(); }); it('rejects empty_trash with non-integer count', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'empty_trash', payload: { count: 1.5 } })).toThrow(); }); }); describe('expired_banner_shown / expired_batch_trash events', () => { it('parses valid expired_banner_shown', () => { const ev = validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'expired_banner_shown', payload: { candidateCount: 7 } }); if (ev.kind !== 'expired_banner_shown') throw new Error('discriminant'); expect(ev.payload.candidateCount).toBe(7); }); it('parses valid expired_batch_trash', () => { const ev = validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'expired_batch_trash', payload: { count: 3 } }); if (ev.kind !== 'expired_batch_trash') throw new Error('discriminant'); expect(ev.payload.count).toBe(3); }); it('rejects expired_banner_shown with extra payload field (privacy invariant)', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'expired_banner_shown', payload: { candidateCount: 7, rawText: 'leak' } })).toThrow(); }); it('rejects expired_batch_trash with negative count', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'expired_batch_trash', payload: { count: -1 } })).toThrow(); }); it('rejects expired_batch_trash with extra payload field (privacy invariant)', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'expired_batch_trash', payload: { count: 3, rawText: 'leak' } })).toThrow(); }); }); describe('ollama_unreachable / ollama_recovered / ollama_recheck_manual events', () => { it('parses valid ollama_unreachable', () => { const ev = validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'ollama_unreachable', payload: { reason: 'connection refused' } }); if (ev.kind !== 'ollama_unreachable') throw new Error('discriminant'); expect(ev.payload.reason).toBe('connection refused'); }); it('parses valid ollama_recovered', () => { const ev = validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'ollama_recovered', payload: { downtimeMs: 60000 } }); if (ev.kind !== 'ollama_recovered') throw new Error('discriminant'); expect(ev.payload.downtimeMs).toBe(60000); }); it('parses valid ollama_recheck_manual (empty payload)', () => { const ev = validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'ollama_recheck_manual', payload: {} }); expect(ev.kind).toBe('ollama_recheck_manual'); }); it('rejects ollama_unreachable with extra payload field (privacy invariant)', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'ollama_unreachable', payload: { reason: 'refused', rawText: 'leak' } })).toThrow(); }); it('rejects ollama_recovered with negative downtimeMs', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'ollama_recovered', payload: { downtimeMs: -1 } })).toThrow(); }); it('rejects ollama_recheck_manual with non-empty payload (privacy invariant)', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'ollama_recheck_manual', payload: { foo: 'bar' } })).toThrow(); }); }); describe('ai_retry_manual event', () => { it('parses valid ai_retry_manual', () => { const ev = validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'ai_retry_manual', payload: { failedCount: 5 } }); if (ev.kind !== 'ai_retry_manual') throw new Error('discriminant'); expect(ev.payload.failedCount).toBe(5); }); it('rejects ai_retry_manual with failedCount=0 (≥1 invariant)', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'ai_retry_manual', payload: { failedCount: 0 } })).toThrow(); }); it('rejects ai_retry_manual with extra payload field (privacy invariant)', () => { expect(() => validateEvent({ ts: '2026-05-01T00:00:00.000Z', kind: 'ai_retry_manual', payload: { failedCount: 5, rawText: 'leak' } })).toThrow(); }); }); describe('validateEvent — tag vocab', () => { it('accepts tag_vocab_hit event', () => { const e = validateEvent({ ts: '2026-05-02T00:00:00.000Z', kind: 'tag_vocab_hit', payload: { tagId: 42, vocabSize: 17 } }); expect(e.kind).toBe('tag_vocab_hit'); }); it('accepts tag_vocab_miss event without tagId', () => { const e = validateEvent({ ts: '2026-05-02T00:00:00.000Z', kind: 'tag_vocab_miss', payload: { vocabSize: 17 } }); expect(e.kind).toBe('tag_vocab_miss'); }); it('rejects tag_vocab_hit with extra field (privacy invariant)', () => { expect(() => validateEvent({ ts: '2026-05-02T00:00:00.000Z', kind: 'tag_vocab_hit', payload: { tagId: 42, vocabSize: 17, tagName: 'leak' } })).toThrow(); }); });