import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import type { VaultEntry } from '../types' import { useNoteActions, type NoteActionsConfig } from './useNoteActions' vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })) vi.mock('../mock-tauri', () => ({ isTauri: vi.fn(() => false), addMockEntry: vi.fn(), updateMockContent: vi.fn(), trackMockChange: vi.fn(), mockInvoke: vi.fn(), })) const makeEntry = (overrides: Partial = {}): VaultEntry => ({ path: '/test/vault/missing.md', filename: 'missing.md', title: 'Missing Note', isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: null, archived: false, modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], template: null, sort: null, sidebarLabel: null, view: null, visible: null, properties: {}, organized: false, favorite: false, favoriteIndex: null, listPropertiesDisplay: [], hasH1: false, ...overrides, }) function makeConfig(overrides: Partial = {}): NoteActionsConfig { return { addEntry: vi.fn(), removeEntry: vi.fn(), entries: [makeEntry()], reloadVault: vi.fn().mockResolvedValue([]), setToastMessage: vi.fn(), updateEntry: vi.fn(), vaultPath: '/test/vault', ...overrides, } } async function openNoteForFrontmatterRecovery( result: { current: ReturnType }, entry: VaultEntry, ) { vi.mocked(mockInvoke).mockResolvedValue('# Missing Note\n') await act(async () => { await result.current.handleSelectNote(entry) }) vi.mocked(isTauri).mockReturnValue(true) vi.mocked(invoke).mockRejectedValueOnce(new Error('File does not exist: /test/vault/missing.md')) } function expectMissingFrontmatterRecovery( result: { current: ReturnType }, config: NoteActionsConfig, ) { expect(result.current.tabs).toEqual([]) expect(result.current.activeTabPath).toBeNull() expect(config.reloadVault).toHaveBeenCalledTimes(1) expect(config.setToastMessage).toHaveBeenCalledWith( '"Missing Note" could not be opened because its file is missing or moved.', ) } describe('useNoteActions missing-path recovery', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(invoke).mockReset() vi.mocked(isTauri).mockReturnValue(false) }) it('reloads vault state and shows a toast when the selected note path is gone', async () => { vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File does not exist: /test/vault/missing.md')) const config = makeConfig() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { result } = renderHook(() => useNoteActions(config)) await act(async () => { await result.current.handleSelectNote(makeEntry()) }) expect(result.current.tabs).toEqual([]) expect(result.current.activeTabPath).toBeNull() expect(config.reloadVault).toHaveBeenCalledTimes(1) expect(config.setToastMessage).toHaveBeenCalledWith( '"Missing Note" could not be opened because its file is missing or moved.', ) warnSpy.mockRestore() }) it('recovers from missing stale sidebar entries with incomplete string metadata', async () => { vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File does not exist: /test/vault/reloaded.md')) const staleEntry = { ...makeEntry({ path: '/test/vault/reloaded.md' }), filename: undefined, title: undefined, } as unknown as VaultEntry const config = makeConfig() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { result } = renderHook(() => useNoteActions(config)) await act(async () => { await result.current.handleSelectNote(staleEntry) }) expect(result.current.tabs).toEqual([]) expect(result.current.activeTabPath).toBeNull() expect(config.reloadVault).toHaveBeenCalledTimes(1) expect(config.setToastMessage).toHaveBeenCalledWith( '"Note" could not be opened because its file is missing or moved.', ) warnSpy.mockRestore() }) it('shows a toast without reloading the vault when note content is not valid UTF-8 text', async () => { vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File is not valid UTF-8 text: /test/vault/bad.csv')) const config = makeConfig({ entries: [makeEntry({ path: '/test/vault/bad.csv', filename: 'bad.csv', title: 'bad.csv', fileKind: 'text' })] }) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { result } = renderHook(() => useNoteActions(config)) await act(async () => { await result.current.handleSelectNote(makeEntry({ path: '/test/vault/bad.csv', filename: 'bad.csv', title: 'bad.csv', fileKind: 'text' })) }) expect(result.current.tabs).toEqual([]) expect(result.current.activeTabPath).toBeNull() expect(config.reloadVault).not.toHaveBeenCalled() expect(config.setToastMessage).toHaveBeenCalledWith( '"bad.csv" could not be opened because it is not valid UTF-8 text.', ) warnSpy.mockRestore() }) it('reloads and clears the active tab when a frontmatter write targets a deleted note file', async () => { const entry = makeEntry() const config = makeConfig({ entries: [entry] }) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { result } = renderHook(() => useNoteActions(config)) await openNoteForFrontmatterRecovery(result, entry) await act(async () => { await result.current.handleUpdateFrontmatter(entry.path, 'status', 'Done') }) expectMissingFrontmatterRecovery(result, config) expect(config.updateEntry).not.toHaveBeenCalled() warnSpy.mockRestore() }) it('still rejects silent frontmatter writes after running missing-note recovery', async () => { const entry = makeEntry() const config = makeConfig({ entries: [entry] }) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { result } = renderHook(() => useNoteActions(config)) await openNoteForFrontmatterRecovery(result, entry) let thrown: unknown await act(async () => { try { await result.current.handleUpdateFrontmatter(entry.path, '_archived', true, { silent: true }) } catch (err) { thrown = err } }) expect(thrown).toBeInstanceOf(Error) expectMissingFrontmatterRecovery(result, config) warnSpy.mockRestore() }) })