fix(history): require confirmation before clearing all history
This commit is contained in:
parent
819bc9d789
commit
a8983c583a
5 changed files with 441 additions and 13 deletions
160
apps/desktop/tests/unit/history-clear-all-redteam-r1-28.test.ts
Normal file
160
apps/desktop/tests/unit/history-clear-all-redteam-r1-28.test.ts
Normal 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\}/)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue