160 lines
5.5 KiB
TypeScript
160 lines
5.5 KiB
TypeScript
// 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\}/)
|
|
})
|
|
})
|