From 284bfcbdd1b516cb4ee43784d34a937d8273e1d5 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 21:05:06 +0900 Subject: [PATCH] feat(trash): telemetry 4 new kinds (trash/restore/permanent_delete/empty_trash) (#4 v0.2.3) Co-Authored-By: Claude Sonnet 4.6 --- src/main/services/TelemetryService.ts | 6 ++- src/main/services/telemetryEvents.ts | 14 +++++- tests/unit/TelemetryService.test.ts | 4 +- tests/unit/telemetryEvents.test.ts | 62 +++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index 9b2778a..936a80e 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -18,7 +18,11 @@ export interface TelemetryServiceOptions { export type EmitInput = | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } } | { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } } - | { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }; + | { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } } + | { kind: 'trash'; payload: { noteId: string } } + | { kind: 'restore'; payload: { noteId: string } } + | { kind: 'permanent_delete'; payload: { noteId: string } } + | { kind: 'empty_trash'; payload: { count: number } }; export class TelemetryService { constructor( diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts index 4a90759..0d1bcf9 100644 --- a/src/main/services/telemetryEvents.ts +++ b/src/main/services/telemetryEvents.ts @@ -20,10 +20,22 @@ const AiFailedPayload = z.object({ attempts: z.number().int().nonnegative() }).strict(); +const NoteIdPayload = z.object({ + noteId: z.string().min(1) +}).strict(); + +const EmptyTrashPayload = z.object({ + count: z.number().int().nonnegative() +}).strict(); + export const TelemetryEventSchema = z.discriminatedUnion('kind', [ z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(), z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(), - z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict() + z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('trash'), payload: NoteIdPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('restore'), payload: NoteIdPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict() ]); export type TelemetryEvent = z.infer; diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts index 8da4ea6..fa47a83 100644 --- a/tests/unit/TelemetryService.test.ts +++ b/tests/unit/TelemetryService.test.ts @@ -146,7 +146,7 @@ describe('TelemetryService.readAllRecent', () => { const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14); const events = await svc.readAllRecent(); expect(events).toHaveLength(3); - expect(events.map((e) => e.payload.noteId)).toEqual(['a', 'b', 'b']); + expect(events.map((e) => (e.payload as { noteId: string }).noteId)).toEqual(['a', 'b', 'b']); }); it('skips malformed lines (silent — invariant)', async () => { @@ -157,7 +157,7 @@ describe('TelemetryService.readAllRecent', () => { const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14); const events = await svc.readAllRecent(); expect(events).toHaveLength(1); - expect(events[0]!.payload.noteId).toBe('a'); + expect((events[0]!.payload as { noteId: string }).noteId).toBe('a'); }); it('returns [] when dir missing', async () => { diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts index 2b8d25b..0b6ba41 100644 --- a/tests/unit/telemetryEvents.test.ts +++ b/tests/unit/telemetryEvents.test.ts @@ -87,3 +87,65 @@ describe('validateEvent — privacy invariant', () => { })).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(); + }); +});