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

@ -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 (
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
@ -143,11 +138,13 @@ export function HistoryPage(): React.ReactElement {
</PhysicalButton>
</Tooltip>
<Tooltip title={t('common.clearAll') || '모두 지우기'} arrow>
<Tooltip title={t('history.clearAll')} arrow>
<IconButton
size="small"
data-testid="clear-all-history-button"
onClick={handleDeleteAll}
aria-label={t('history.clearAll')}
aria-haspopup="dialog"
onClick={clearAll.request}
sx={{
color: d3roPalette.text.inactive,
p: 0.75,
@ -163,6 +160,15 @@ export function HistoryPage(): React.ReactElement {
}
/>
<ClearAllHistoryDialog
open={isClearAllDialogOpen(clearAllState)}
count={'count' in clearAllState ? clearAllState.count : 0}
filtered={isHistoryViewFiltered(activeTag, search)}
deleting={clearAllState.phase === 'deleting'}
onCancel={clearAll.cancel}
onConfirm={clearAll.confirm}
/>
{/* Search Input Bar */}
{!activeTag && (
<SearchInput

View file

@ -0,0 +1,94 @@
// src/renderer/pages/history/ClearAllHistoryDialog.tsx
// 히스토리 전체 삭제 확인 다이얼로그 — 삭제 건수, 다른 기기 전파, 필터 무시 여부를 알린다.
import React from 'react'
import { Dialog, DialogTitle, DialogContent, DialogActions, Box } from '@mui/material'
import { PhosphorText, PhysicalButton } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
export interface ClearAllHistoryDialogProps {
open: boolean
/** 필터와 무관한 전체 삭제 대상 건수 */
count: number
/** 화면이 태그·검색으로 좁혀져 있으면 '보이는 것만 지워지지 않는다'를 알린다 */
filtered: boolean
deleting: boolean
onCancel: () => void
onConfirm: () => void
}
export function ClearAllHistoryDialog({
open,
count,
filtered,
deleting,
onCancel,
onConfirm,
}: ClearAllHistoryDialogProps): React.ReactElement {
const { t } = useI18n()
return (
<Dialog
open={open}
onClose={deleting ? undefined : onCancel}
maxWidth="xs"
fullWidth
data-testid="clear-all-history-dialog"
slotProps={{
paper: {
sx: {
bgcolor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.inner,
boxShadow: d3roShadow.tooltip,
},
},
}}
>
<DialogTitle>
<PhosphorText variant="label">{t('history.clearAll')}</PhosphorText>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<PhosphorText variant="body" component="p">
{t('history.clearAllConfirm', { count })}
</PhosphorText>
{filtered && (
<PhosphorText
variant="body"
component="p"
data-testid="clear-all-history-filter-warning"
sx={{ color: d3roPalette.status.warning }}
>
{t('history.clearAllIgnoresFilter')}
</PhosphorText>
)}
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
<PhysicalButton
size="small"
data-testid="clear-all-history-cancel"
onClick={onCancel}
disabled={deleting}
autoFocus
sx={{ height: 36, fontSize: d3roTypo.engrave.size }}
>
{t('common.cancel')}
</PhysicalButton>
<PhysicalButton
size="small"
data-testid="clear-all-history-confirm"
onClick={onConfirm}
disabled={deleting}
sx={{ height: 36, fontSize: d3roTypo.engrave.size, color: d3roPalette.status.danger }}
>
{t('common.delete')}
</PhysicalButton>
</DialogActions>
</Dialog>
)
}

View file

@ -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<number | null>
/** 전체 기록을 삭제한다. 성공 여부를 돌려준다 */
deleteAll(): Promise<boolean>
}
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<void> {
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<boolean> {
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)
}
}

View file

@ -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<ClearAllHistoryState>(CLEAR_ALL_IDLE)
const onSettledRef = useRef(onSettled)
onSettledRef.current = onSettled
const flowRef = useRef<ClearAllHistoryFlow | null>(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 }
}

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\}/)
})
})