test: extract useBulkActions hook and add unit tests (#192)

useBulkActions was embedded in App.tsx with zero test coverage. It handles
bulk archive, trash, and restore operations — all with partial-failure
semantics and toast messaging.

Extract to src/hooks/useBulkActions.ts and add 15 unit tests covering:
- Plural/singular toast messages ("2 notes archived" vs "1 note archived")
- Partial failures: only successful operations counted in toast
- All-failure case: no toast shown
- Empty array: no operations called, no toast

Risk mitigated: silent bugs in batch operations (wrong count in toast,
toast shown when nothing succeeded, partial failure not handled).

Co-authored-by: Test <test@test.com>
This commit is contained in:
Luca Rossi
2026-03-12 03:19:32 +01:00
committed by GitHub
parent 6c9b39c0f0
commit 9891a29f7f
3 changed files with 222 additions and 34 deletions

View File

@@ -38,6 +38,7 @@ import { useThemeManager } from './hooks/useThemeManager'
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
import { useNavigationGestures } from './hooks/useNavigationGestures'
import { useAiActivity } from './hooks/useAiActivity'
import { useBulkActions } from './hooks/useBulkActions'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
import { UpdateBanner } from './components/UpdateBanner'
@@ -61,40 +62,6 @@ declare global {
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
function useBulkActions(
entryActions: { handleArchiveNote: (path: string) => Promise<void>; handleTrashNote: (path: string) => Promise<void>; handleRestoreNote: (path: string) => Promise<void> },
setToastMessage: (msg: string | null) => void,
) {
const handleBulkArchive = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleArchiveNote(path); ok++ }
catch { /* error toast already shown by flushBeforeAction */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`)
}, [entryActions, setToastMessage])
const handleBulkTrash = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleTrashNote(path); ok++ }
catch { /* error toast already shown by flushBeforeAction */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} moved to trash`)
}, [entryActions, setToastMessage])
const handleBulkRestore = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleRestoreNote(path); ok++ }
catch { /* skip — error toast already shown */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} restored`)
}, [entryActions, setToastMessage])
return { handleBulkArchive, handleBulkTrash, handleBulkRestore }
}
function useLayoutPanels() {
const [sidebarWidth, setSidebarWidth] = useState(250)
const [noteListWidth, setNoteListWidth] = useState(300)

View File

@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useBulkActions } from './useBulkActions'
describe('useBulkActions', () => {
let handleArchiveNote: ReturnType<typeof vi.fn>
let handleTrashNote: ReturnType<typeof vi.fn>
let handleRestoreNote: ReturnType<typeof vi.fn>
let setToastMessage: ReturnType<typeof vi.fn>
beforeEach(() => {
handleArchiveNote = vi.fn().mockResolvedValue(undefined)
handleTrashNote = vi.fn().mockResolvedValue(undefined)
handleRestoreNote = vi.fn().mockResolvedValue(undefined)
setToastMessage = vi.fn()
})
function renderBulkActions() {
return renderHook(() =>
useBulkActions(
{ handleArchiveNote, handleTrashNote, handleRestoreNote },
setToastMessage,
),
)
}
// --- handleBulkArchive ---
describe('handleBulkArchive', () => {
it('archives each path and shows plural toast for multiple notes', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md'])
})
expect(handleArchiveNote).toHaveBeenCalledTimes(2)
expect(handleArchiveNote).toHaveBeenCalledWith('/vault/a.md')
expect(handleArchiveNote).toHaveBeenCalledWith('/vault/b.md')
expect(setToastMessage).toHaveBeenCalledWith('2 notes archived')
})
it('shows singular toast when one note archived', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkArchive(['/vault/a.md'])
})
expect(setToastMessage).toHaveBeenCalledWith('1 note archived')
})
it('does not show toast when empty array given', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkArchive([])
})
expect(handleArchiveNote).not.toHaveBeenCalled()
expect(setToastMessage).not.toHaveBeenCalled()
})
it('skips failed paths and only counts successes in toast', async () => {
handleArchiveNote
.mockResolvedValueOnce(undefined) // /vault/a.md succeeds
.mockRejectedValueOnce(new Error('fail')) // /vault/b.md fails
.mockResolvedValueOnce(undefined) // /vault/c.md succeeds
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md', '/vault/c.md'])
})
expect(handleArchiveNote).toHaveBeenCalledTimes(3)
expect(setToastMessage).toHaveBeenCalledWith('2 notes archived')
})
it('shows no toast when all paths fail', async () => {
handleArchiveNote.mockRejectedValue(new Error('fail'))
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md'])
})
expect(setToastMessage).not.toHaveBeenCalled()
})
})
// --- handleBulkTrash ---
describe('handleBulkTrash', () => {
it('trashes each path and shows plural toast', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash(['/vault/a.md', '/vault/b.md'])
})
expect(handleTrashNote).toHaveBeenCalledTimes(2)
expect(setToastMessage).toHaveBeenCalledWith('2 notes moved to trash')
})
it('shows singular toast when one note trashed', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash(['/vault/a.md'])
})
expect(setToastMessage).toHaveBeenCalledWith('1 note moved to trash')
})
it('does not show toast when empty array given', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash([])
})
expect(setToastMessage).not.toHaveBeenCalled()
})
it('skips failed paths and counts only successes', async () => {
handleTrashNote
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValueOnce(undefined)
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash(['/vault/a.md', '/vault/b.md'])
})
expect(setToastMessage).toHaveBeenCalledWith('1 note moved to trash')
})
it('shows no toast when all fail', async () => {
handleTrashNote.mockRejectedValue(new Error('fail'))
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash(['/vault/a.md'])
})
expect(setToastMessage).not.toHaveBeenCalled()
})
})
// --- handleBulkRestore ---
describe('handleBulkRestore', () => {
it('restores each path and shows plural toast', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md', '/vault/c.md'])
})
expect(handleRestoreNote).toHaveBeenCalledTimes(3)
expect(setToastMessage).toHaveBeenCalledWith('3 notes restored')
})
it('shows singular toast when one note restored', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore(['/vault/only.md'])
})
expect(setToastMessage).toHaveBeenCalledWith('1 note restored')
})
it('does not show toast when empty array given', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore([])
})
expect(setToastMessage).not.toHaveBeenCalled()
})
it('partial failure: counts only successful restores in toast', async () => {
handleRestoreNote
.mockResolvedValueOnce(undefined) // /vault/a.md ok
.mockRejectedValueOnce(new Error('not found')) // /vault/b.md fails
.mockResolvedValueOnce(undefined) // /vault/c.md ok
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md', '/vault/c.md'])
})
expect(handleRestoreNote).toHaveBeenCalledTimes(3)
expect(setToastMessage).toHaveBeenCalledWith('2 notes restored')
})
it('shows no toast when all restores fail', async () => {
handleRestoreNote.mockRejectedValue(new Error('fail'))
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md'])
})
expect(setToastMessage).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,41 @@
import { useCallback } from 'react'
interface BulkEntryActions {
handleArchiveNote: (path: string) => Promise<void>
handleTrashNote: (path: string) => Promise<void>
handleRestoreNote: (path: string) => Promise<void>
}
export function useBulkActions(
entryActions: BulkEntryActions,
setToastMessage: (msg: string | null) => void,
) {
const handleBulkArchive = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleArchiveNote(path); ok++ }
catch { /* error toast already shown by flushBeforeAction */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`)
}, [entryActions, setToastMessage])
const handleBulkTrash = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleTrashNote(path); ok++ }
catch { /* error toast already shown by flushBeforeAction */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} moved to trash`)
}, [entryActions, setToastMessage])
const handleBulkRestore = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleRestoreNote(path); ok++ }
catch { /* skip — error toast already shown */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} restored`)
}, [entryActions, setToastMessage])
return { handleBulkArchive, handleBulkTrash, handleBulkRestore }
}