fix(sync): bind sync engine to its user DB, scope instructions/templates per account, harden navigation

This commit is contained in:
Yun Chan 2026-09-28 00:53:42 +09:00
parent 1b8fe445f3
commit 9aa7302944
30 changed files with 2614 additions and 386 deletions

View file

@ -0,0 +1,133 @@
// 이력 레드팀 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)
})
})