Files
inkling/tests/unit/telemetryEvents.test.ts
altair823 d672ec3afa fix(expiry): review round 1 — minor/nit 6건 일괄 (#5 v0.2.3)
m1 — spec §5.3 dialog 버튼 순서를 impl 패턴 (`['옮기기','취소'], defaultId=1, cancelId=1`) 으로 보정. project 의 permanentDelete/emptyTrash 와 일관 (위험 액션은 default focus = 취소).

m2 — telemetryEvents.test.ts 에 `expired_batch_trash` 의 extra-field 회귀 가드 추가. `expired_banner_shown` 과 대칭 (privacy invariant).

m3 — ExpiryBanner.InnerProps.candidates 타입을 narrow subset → `Note` 로 통일. v0.2.4 에서 Note 타입 진화 시 silent drift 방지.

m4 — onTrash 의 `void trashExpiredBatch(ids)` → `.catch((e) => console.warn(...))` 로 Promise rejection 가시화. (project-wide error toast 도입은 v0.2.4 backlog 유지)

n1 — 24h+ 앱 켜둔 상태에서 snooze 자동 만료. `setTimeout(snoozeUntilMs - now)` 으로 자정 KST 시점에 force re-render. (refreshMeta trigger 의존 제거)

n2 — CaptureService.listExpired 의 dedup signature reset on empty 의도 주석 1줄. future maintainer 위해.

n3 (`as any[]`) 은 repo 전체 hydrate 패턴 — 단독 fix 시 inconsistency. v0.2.4 backlog #22 로 합산.

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

198 lines
5.6 KiB
TypeScript

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();
});
});