Files
inkling/tests/unit/telemetryEvents.test.ts
altair823 05c45c1e10 refactor(v026): #21 hasNoteId type predicate helper
기존 4-line narrowing 체인 (e.kind !== 'empty_trash' && ... && ...) 이
union 확장 시 길어짐 → hasNoteId(ev) type predicate 로 통합.

- telemetryEvents.ts: NO_NOTE_ID_KINDS Set + hasNoteId(ev): ev is ... export
- TelemetryService.test.ts: 2 narrowing callsite 단축
- 단위 +2 cases (noteId-bearing / noteId-less)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:31:16 +09:00

352 lines
11 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { validateEvent, hasNoteId } 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();
});
});
describe('validateEvent — recall', () => {
it('accepts recall_shown event', () => {
const e = validateEvent({
ts: '2026-05-02T00:00:00.000Z',
kind: 'recall_shown',
payload: { noteId: 'n1', ageDays: 14 }
});
expect(e.kind).toBe('recall_shown');
});
it('rejects recall_shown with extra field (privacy)', () => {
expect(() => validateEvent({
ts: '2026-05-02T00:00:00.000Z',
kind: 'recall_shown',
payload: { noteId: 'n1', ageDays: 14, content: 'leak' }
})).toThrow();
});
it('accepts recall_opened/dismissed/snoozed (NoteIdPayload reused)', () => {
for (const kind of ['recall_opened', 'recall_dismissed', 'recall_snoozed'] as const) {
const e = validateEvent({ ts: '2026-05-02T00:00:00.000Z', kind, payload: { noteId: 'n1' } });
expect(e.kind).toBe(kind);
}
});
});
describe('hasNoteId', () => {
it('returns true for noteId-bearing events', () => {
const e1 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } });
const e2 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'recall_shown', payload: { noteId: 'n1', ageDays: 14 } });
expect(hasNoteId(e1)).toBe(true);
expect(hasNoteId(e2)).toBe(true);
});
it('returns false for noteId-less events', () => {
const e1 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'empty_trash', payload: { count: 5 } });
const e2 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'tag_vocab_hit', payload: { tagId: 1, vocabSize: 10 } });
expect(hasNoteId(e1)).toBe(false);
expect(hasNoteId(e2)).toBe(false);
});
});