// 이력 레드팀 r1-0: 삭제 시 녹음 WAV 정리, 대시보드 통계를 history 에서 계산 import fs from 'fs' import path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createTestDb } from '../../helpers/createTestDb' import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db' import { history } from '../../../src/main/db/schema' import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService' import { getHistoryService, resetHistoryServiceForTests } from '../../../src/main/services/HistoryService' import { isAppRecordingPath, recordingsDir } from '../../../src/main/services/history-deletion' import { computeStreakDays, localDayKey } from '../../../src/main/services/history-stats' import { resetAccountScopeForTests } from '../../../src/main/services/account-scope' let testDb: ReturnType const created: string[] = [] function writeRecording(name: string): string { const dir = recordingsDir() fs.mkdirSync(dir, { recursive: true }) const file = path.join(dir, name) fs.writeFileSync(file, Buffer.from('RIFF')) created.push(file) return file } function insertHistory(opts: { audio?: string | null; createdAt?: number; status?: 'completed' | 'error'; words?: number; duration?: number }): string { const id = crypto.randomUUID() const at = opts.createdAt ?? Date.now() testDb.db .insert(history) .values({ id, originalText: 'x', duration: opts.duration ?? 1, wordCount: opts.words ?? 1, status: opts.status ?? 'completed', audioLocalPath: opts.audio ?? null, createdAt: at, updatedAt: at, }) .run() return id } async function waitGone(file: string): Promise { await vi.waitFor(() => expect(fs.existsSync(file)).toBe(false), { timeout: 2000, interval: 10 }) } beforeEach(() => { resetAccountScopeForTests() testDb = createTestDb() bindTestDatabase(testDb.db, '_local') initInMemoryConfig() resetHistoryServiceForTests() }) afterEach(() => { unbindTestDatabase() resetInMemoryConfig() testDb.close() for (const f of created.splice(0)) fs.rmSync(f, { force: true }) resetAccountScopeForTests() }) describe('녹음 파일 정리', () => { it('단건 삭제는 녹음 WAV도 지운다', async () => { const wav = writeRecording(`${crypto.randomUUID()}.wav`) const id = insertHistory({ audio: wav }) expect(getHistoryService().delete(id)).toBe(true) await waitGone(wav) }) it('전체 삭제·원격 삭제 반영도 녹음을 지우지만, 녹음 폴더 밖 사용자 원본은 건드리지 않는다', async () => { const wav1 = writeRecording(`${crypto.randomUUID()}.wav`) const wav2 = writeRecording(`${crypto.randomUUID()}.wav`) const outside = path.join(path.dirname(recordingsDir()), `user-source-${crypto.randomUUID()}.wav`) fs.writeFileSync(outside, Buffer.from('RIFF')) created.push(outside) insertHistory({ audio: wav1 }) insertHistory({ audio: wav2 }) insertHistory({ audio: outside }) getHistoryService().deleteAll() await waitGone(wav1) await waitGone(wav2) expect(fs.existsSync(outside)).toBe(true) expect(testDb.db.select().from(history).all()).toEqual([]) }) it('동기화의 원격 삭제 반영(historyAdapter.deleteLocal)도 녹음을 지운다', async () => { const { adapterFor } = await import('../../../src/main/services/sync/sync-adapters') const wav = writeRecording(`${crypto.randomUUID()}.wav`) const id = insertHistory({ audio: wav }) expect(adapterFor('history')?.deleteLocal(id)).toBe(true) await waitGone(wav) }) it('녹음 폴더 경계 판정', () => { const dir = recordingsDir() expect(isAppRecordingPath(path.join(dir, 'a.wav'), dir)).toBe(true) expect(isAppRecordingPath(path.join(dir, '..', 'a.wav'), dir)).toBe(false) expect(isAppRecordingPath(`${dir}-evil${path.sep}a.wav`, dir)).toBe(false) expect(isAppRecordingPath(dir, dir)).toBe(false) expect(isAppRecordingPath(null, dir)).toBe(false) }) }) describe('대시보드 통계', () => { it('동기화·가져오기로 들어온 기록도 누적 통계에 들어가고 연속 사용일을 계산한다', () => { const day = 24 * 60 * 60 * 1000 const now = Date.now() // create()를 거치지 않은 행(동기화 반영) — 예전 카운터에는 반영되지 않았다 insertHistory({ createdAt: now, words: 5, duration: 2 }) insertHistory({ createdAt: now - day, words: 3, duration: 1 }) insertHistory({ createdAt: now - 2 * day, words: 2, duration: 1 }) insertHistory({ createdAt: now - 2 * day, status: 'error', words: 100, duration: 50 }) const stats = getHistoryService().getStats() expect(stats.totalSessionCount).toBe(3) expect(stats.totalWordCount).toBe(10) expect(stats.totalRecordingTimeMs).toBe(4000) expect(stats.streakDays).toBe(3) }) it('computeStreakDays: 오늘 기록이 없으면 어제부터, 끊기면 멈춘다', () => { const today = new Date(2026, 8, 28, 15) const key = (offset: number): string => localDayKey(new Date(2026, 8, 28 - offset)) expect(computeStreakDays([], today)).toBe(0) expect(computeStreakDays([key(0), key(1), key(2)], today)).toBe(3) expect(computeStreakDays([key(1), key(2)], today)).toBe(2) expect(computeStreakDays([key(0), key(2)], today)).toBe(1) expect(computeStreakDays([key(2), key(3)], today)).toBe(0) }) })