fix(history): require confirmation before clearing all history

This commit is contained in:
Yun Chan 2026-09-28 00:54:02 +09:00
parent 819bc9d789
commit a8983c583a
5 changed files with 441 additions and 13 deletions

View file

@ -0,0 +1,160 @@
// tests/unit/history-clear-all-redteam-r1-28.test.ts
// 회귀: 히스토리 '모두 지우기'가 확인 없이 한 번 클릭으로 전체 기록을 지우고 클라우드로 전파하던 결함.
// HistoryPage는 이제 ClearAllHistoryFlow를 거쳐서만 deleteAll을 호출한다.
import { describe, it, expect, vi } from 'vitest'
import fs from 'fs'
import path from 'path'
import {
ClearAllHistoryFlow,
isClearAllDialogOpen,
isHistoryViewFiltered,
type ClearAllHistoryPort,
type ClearAllHistoryState,
} from '../../src/renderer/pages/history/clear-all-history-flow'
function deferred<T>(): { promise: Promise<T>; resolve: (v: T) => void } {
let resolve!: (v: T) => void
const promise = new Promise<T>((r) => {
resolve = r
})
return { promise, resolve }
}
function makePort(count: number | null = 12): ClearAllHistoryPort & {
countAll: ReturnType<typeof vi.fn>
deleteAll: ReturnType<typeof vi.fn>
} {
return {
countAll: vi.fn(async () => count),
deleteAll: vi.fn(async () => true),
}
}
describe('ClearAllHistoryFlow — 확인 없이는 전체 삭제하지 않는다', () => {
it('request()만으로는 deleteAll을 호출하지 않고 전체 건수와 함께 확인 단계로 간다', async () => {
const port = makePort(12)
const flow = new ClearAllHistoryFlow(port)
await flow.request()
expect(port.deleteAll).not.toHaveBeenCalled()
expect(flow.getState()).toEqual({ phase: 'confirming', count: 12 })
expect(isClearAllDialogOpen(flow.getState())).toBe(true)
})
it('confirm() 후에만 deleteAll이 정확히 한 번 실행되고 idle로 돌아온다', async () => {
const port = makePort(3)
const states: ClearAllHistoryState[] = []
const flow = new ClearAllHistoryFlow(port, (s) => states.push(s))
await flow.request()
const ok = await flow.confirm()
expect(ok).toBe(true)
expect(port.deleteAll).toHaveBeenCalledTimes(1)
expect(flow.getState()).toEqual({ phase: 'idle' })
expect(states.map((s) => s.phase)).toEqual(['counting', 'confirming', 'deleting', 'idle'])
})
it('cancel()하면 아무것도 지우지 않는다', async () => {
const port = makePort(5)
const flow = new ClearAllHistoryFlow(port)
await flow.request()
flow.cancel()
expect(await flow.confirm()).toBe(false)
expect(port.deleteAll).not.toHaveBeenCalled()
expect(flow.getState()).toEqual({ phase: 'idle' })
})
it('확인 단계 없이 confirm()을 불러도 삭제하지 않는다', async () => {
const port = makePort(5)
const flow = new ClearAllHistoryFlow(port)
expect(await flow.confirm()).toBe(false)
expect(port.deleteAll).not.toHaveBeenCalled()
})
it('confirm을 연타해도 deleteAll은 한 번만 실행된다', async () => {
const port = makePort(5)
const gate = deferred<boolean>()
port.deleteAll.mockImplementation(() => gate.promise)
const flow = new ClearAllHistoryFlow(port)
await flow.request()
const first = flow.confirm()
const second = flow.confirm()
gate.resolve(true)
expect(await first).toBe(true)
expect(await second).toBe(false)
expect(port.deleteAll).toHaveBeenCalledTimes(1)
})
it('지울 기록이 없거나 개수 조회에 실패하면 확인 단계로 가지 않는다', async () => {
for (const count of [0, null]) {
const port = makePort(count)
const flow = new ClearAllHistoryFlow(port)
await flow.request()
expect(flow.getState()).toEqual({ phase: 'idle' })
expect(port.deleteAll).not.toHaveBeenCalled()
}
const throwing = makePort(1)
throwing.countAll.mockRejectedValue(new Error('ipc down'))
const flow = new ClearAllHistoryFlow(throwing)
await flow.request()
expect(flow.getState()).toEqual({ phase: 'idle' })
})
it('개수 조회 중 취소되면 늦게 온 결과로 다이얼로그를 다시 열지 않는다', async () => {
const port = makePort()
const gate = deferred<number | null>()
port.countAll.mockImplementation(() => gate.promise)
const flow = new ClearAllHistoryFlow(port)
const pending = flow.request()
flow.cancel()
gate.resolve(9)
await pending
expect(flow.getState()).toEqual({ phase: 'idle' })
})
it('deleteAll이 실패하거나 throw해도 idle로 복귀하고 false를 돌려준다', async () => {
const port = makePort(2)
port.deleteAll.mockRejectedValue(new Error('db locked'))
const flow = new ClearAllHistoryFlow(port)
await flow.request()
expect(await flow.confirm()).toBe(false)
expect(flow.getState()).toEqual({ phase: 'idle' })
})
})
describe('isHistoryViewFiltered', () => {
it('태그 필터나 공백이 아닌 검색어가 있으면 필터된 화면이다', () => {
expect(isHistoryViewFiltered(null, '')).toBe(false)
expect(isHistoryViewFiltered(null, ' ')).toBe(false)
expect(isHistoryViewFiltered('work', '')).toBe(true)
expect(isHistoryViewFiltered(null, 'meeting')).toBe(true)
})
})
describe('HistoryPage 배선', () => {
const pageSource = fs.readFileSync(
path.resolve(__dirname, '../../src/renderer/pages/HistoryPage.tsx'),
'utf-8',
)
it('페이지가 history.deleteAll을 직접 호출하지 않는다 (확인 흐름을 우회하지 않음)', () => {
expect(pageSource).not.toMatch(/electronAPI\.history\.deleteAll\s*\(/)
})
it('휴지통 버튼은 삭제가 아니라 확인 요청만 한다', () => {
const button = pageSource.slice(pageSource.indexOf('data-testid="clear-all-history-button"'))
expect(button).toMatch(/^[^>]*onClick=\{clearAll\.request\}/)
})
})