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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
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