133 lines
5.4 KiB
TypeScript
133 lines
5.4 KiB
TypeScript
// 이력 레드팀 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<typeof createTestDb>
|
|
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<void> {
|
|
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)
|
|
})
|
|
})
|