From a8983c583ac9d2516e4f70c45977c4a8e306e832 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:54:02 +0900 Subject: [PATCH] fix(history): require confirmation before clearing all history --- .../src/renderer/pages/HistoryPage.tsx | 32 ++-- .../pages/history/ClearAllHistoryDialog.tsx | 94 ++++++++++ .../pages/history/clear-all-history-flow.ts | 103 +++++++++++ .../pages/history/useClearAllHistory.ts | 65 +++++++ .../history-clear-all-redteam-r1-28.test.ts | 160 ++++++++++++++++++ 5 files changed, 441 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/renderer/pages/history/ClearAllHistoryDialog.tsx create mode 100644 apps/desktop/src/renderer/pages/history/clear-all-history-flow.ts create mode 100644 apps/desktop/src/renderer/pages/history/useClearAllHistory.ts create mode 100644 apps/desktop/tests/unit/history-clear-all-redteam-r1-28.test.ts diff --git a/apps/desktop/src/renderer/pages/HistoryPage.tsx b/apps/desktop/src/renderer/pages/HistoryPage.tsx index 64c8dad..e2f3c56 100644 --- a/apps/desktop/src/renderer/pages/HistoryPage.tsx +++ b/apps/desktop/src/renderer/pages/HistoryPage.tsx @@ -10,6 +10,9 @@ import { useI18n } from '@d3ro/i18n' import { getDateKey } from '../utils/formatters' import { EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard } from '../components/shared' import type { HistoryEntry, HistoryPage as HistoryPageData, TagCount } from '@d3ro/core/types' +import { isClearAllDialogOpen, isHistoryViewFiltered } from './history/clear-all-history-flow' +import { useClearAllHistory } from './history/useClearAllHistory' +import { ClearAllHistoryDialog } from './history/ClearAllHistoryDialog' const PAGE_SIZE = 50 @@ -69,7 +72,6 @@ export function HistoryPage(): React.ReactElement { }, []) const isExportingRef = useRef(false) - const isDeletingAllRef = useRef(false) const isDeletingSingleRef = useRef(false) const handleDelete = useCallback( @@ -117,16 +119,9 @@ export function HistoryPage(): React.ReactElement { } }, [activeTag]) - const handleDeleteAll = useCallback(async () => { - if (isDeletingAllRef.current) return - isDeletingAllRef.current = true - try { - await window.electronAPI.history.deleteAll() - loadData() - } finally { - isDeletingAllRef.current = false - } - }, [loadData]) + // 전체 삭제는 필터와 무관하고 동기화로 다른 기기에도 전파되므로 반드시 확인 단계를 거친다 + const clearAll = useClearAllHistory(loadData) + const clearAllState = clearAll.state return ( @@ -143,11 +138,13 @@ export function HistoryPage(): React.ReactElement { - + + + {/* Search Input Bar */} {!activeTag && ( void + onConfirm: () => void +} + +export function ClearAllHistoryDialog({ + open, + count, + filtered, + deleting, + onCancel, + onConfirm, +}: ClearAllHistoryDialogProps): React.ReactElement { + const { t } = useI18n() + + return ( + + + {t('history.clearAll')} + + + + + + {t('history.clearAllConfirm', { count })} + + {filtered && ( + + {t('history.clearAllIgnoresFilter')} + + )} + + + + + + {t('common.cancel')} + + + {t('common.delete')} + + + + ) +} diff --git a/apps/desktop/src/renderer/pages/history/clear-all-history-flow.ts b/apps/desktop/src/renderer/pages/history/clear-all-history-flow.ts new file mode 100644 index 0000000..def1fb7 --- /dev/null +++ b/apps/desktop/src/renderer/pages/history/clear-all-history-flow.ts @@ -0,0 +1,103 @@ +// src/renderer/pages/history/clear-all-history-flow.ts +// 히스토리 '전체 삭제' 흐름 — 확인 단계를 거쳐야만 삭제가 실행되도록 강제하는 순수 상태 머신. +// +// 전체 삭제는 필터·검색과 무관하게 모든 기록을 지우고, 클라우드 동기화 중이면 다른 기기로도 +// 전파된다(HistoryService.deleteAll → sync.pushDelete). 되돌릴 수 없으므로 한 번의 클릭으로 +// 실행되면 안 된다. 이 모듈은 React·Electron에 의존하지 않으며 IO는 포트로 주입받는다. + +/** 전체 삭제 흐름이 필요로 하는 IO 포트 (DIP: UI는 IPC 세부를 모른다) */ +export interface ClearAllHistoryPort { + /** 필터와 무관한 전체 기록 수. 조회에 실패하면 null */ + countAll(): Promise + /** 전체 기록을 삭제한다. 성공 여부를 돌려준다 */ + deleteAll(): Promise +} + +export type ClearAllHistoryState = + | { readonly phase: 'idle' } + | { readonly phase: 'counting' } + | { readonly phase: 'confirming'; readonly count: number } + | { readonly phase: 'deleting'; readonly count: number } + +export type ClearAllHistoryListener = (state: ClearAllHistoryState) => void + +export const CLEAR_ALL_IDLE: ClearAllHistoryState = { phase: 'idle' } + +/** 현재 화면이 태그 필터나 검색으로 좁혀져 있는지 — 전체 삭제가 '보이는 것만'이 아님을 알려야 하는 경우 */ +export function isHistoryViewFiltered(activeTag: string | null, search: string): boolean { + return activeTag !== null || search.trim().length > 0 +} + +/** 확인 다이얼로그를 띄워야 하는 상태인지 */ +export function isClearAllDialogOpen(state: ClearAllHistoryState): boolean { + return state.phase === 'confirming' || state.phase === 'deleting' +} + +/** + * idle → (request) → counting → confirming(count) → (confirm) → deleting → idle + * └──── (cancel) ────────────→ idle + * + * - deleteAll은 confirming 상태에서 confirm()이 호출될 때만, 정확히 한 번 실행된다. + * - 삭제할 기록이 없거나 개수 조회에 실패하면 확인 단계로 가지 않고 idle로 돌아간다. + * - 개수 조회 중에 취소되면 늦게 도착한 결과는 버린다. + */ +export class ClearAllHistoryFlow { + private state: ClearAllHistoryState = CLEAR_ALL_IDLE + private generation = 0 + + constructor( + private readonly port: ClearAllHistoryPort, + private readonly onChange: ClearAllHistoryListener = () => undefined, + ) {} + + getState(): ClearAllHistoryState { + return this.state + } + + /** 삭제 요청 — 실제 삭제는 하지 않고 확인 단계로만 이동한다 */ + async request(): Promise { + if (this.state.phase !== 'idle') return + const generation = ++this.generation + this.set({ phase: 'counting' }) + + let count: number | null + try { + count = await this.port.countAll() + } catch { + count = null + } + + if (generation !== this.generation) return + if (count === null || count <= 0) { + this.set(CLEAR_ALL_IDLE) + return + } + this.set({ phase: 'confirming', count }) + } + + /** 확인 단계를 닫는다. 삭제 진행 중에는 취소할 수 없다 */ + cancel(): void { + if (this.state.phase !== 'counting' && this.state.phase !== 'confirming') return + this.generation++ + this.set(CLEAR_ALL_IDLE) + } + + /** 사용자가 확인했을 때만 삭제를 실행한다. 삭제가 실행되어 성공하면 true */ + async confirm(): Promise { + if (this.state.phase !== 'confirming') return false + this.set({ phase: 'deleting', count: this.state.count }) + try { + return await this.port.deleteAll() + } catch { + return false + } finally { + this.generation++ + this.set(CLEAR_ALL_IDLE) + } + } + + private set(next: ClearAllHistoryState): void { + this.state = next + this.onChange(next) + } +} diff --git a/apps/desktop/src/renderer/pages/history/useClearAllHistory.ts b/apps/desktop/src/renderer/pages/history/useClearAllHistory.ts new file mode 100644 index 0000000..7d18d96 --- /dev/null +++ b/apps/desktop/src/renderer/pages/history/useClearAllHistory.ts @@ -0,0 +1,65 @@ +// src/renderer/pages/history/useClearAllHistory.ts +// ClearAllHistoryFlow를 React 상태에 연결하고, Electron IPC를 포트로 어댑팅한다. + +import { useCallback, useRef, useState } from 'react' +import { + CLEAR_ALL_IDLE, + ClearAllHistoryFlow, + type ClearAllHistoryPort, + type ClearAllHistoryState, +} from './clear-all-history-flow' + +/** preload history API → ClearAllHistoryPort 어댑터 */ +export function createElectronClearAllHistoryPort(): ClearAllHistoryPort { + return { + async countAll() { + const result = await window.electronAPI.history.getAll({ page: 0, pageSize: 1, sortOrder: 'desc' }) + return result.success ? result.data.total : null + }, + async deleteAll() { + const result = await window.electronAPI.history.deleteAll() + return result.success + }, + } +} + +export interface ClearAllHistoryControls { + state: ClearAllHistoryState + request: () => void + confirm: () => void + cancel: () => void +} + +/** + * @param onSettled 삭제가 실행된 뒤(성공·실패 무관) 목록을 다시 불러오기 위한 콜백 + * @param port 테스트·다른 호스트용 포트 주입 지점. 기본값은 Electron IPC + */ +export function useClearAllHistory( + onSettled: () => void, + port?: ClearAllHistoryPort, +): ClearAllHistoryControls { + const [state, setState] = useState(CLEAR_ALL_IDLE) + const onSettledRef = useRef(onSettled) + onSettledRef.current = onSettled + + const flowRef = useRef(null) + if (flowRef.current === null) { + flowRef.current = new ClearAllHistoryFlow(port ?? createElectronClearAllHistoryPort(), setState) + } + const flow = flowRef.current + + const request = useCallback(() => { + void flow.request() + }, [flow]) + + const confirm = useCallback(() => { + if (flow.getState().phase !== 'confirming') return + void flow.confirm().finally(() => onSettledRef.current()) + }, [flow]) + + const cancel = useCallback(() => { + flow.cancel() + }, [flow]) + + return { state, request, confirm, cancel } +} diff --git a/apps/desktop/tests/unit/history-clear-all-redteam-r1-28.test.ts b/apps/desktop/tests/unit/history-clear-all-redteam-r1-28.test.ts new file mode 100644 index 0000000..a13cef0 --- /dev/null +++ b/apps/desktop/tests/unit/history-clear-all-redteam-r1-28.test.ts @@ -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(): { promise: Promise; resolve: (v: T) => void } { + let resolve!: (v: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +function makePort(count: number | null = 12): ClearAllHistoryPort & { + countAll: ReturnType + deleteAll: ReturnType +} { + 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() + 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() + 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\}/) + }) +})