import { describe, it, expect, beforeEach, vi } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { setActivePinia, createPinia } from 'pinia' import { createRouter, createMemoryHistory } from 'vue-router' import FileManagerView from '../FileManagerView.vue' // ── API mock ────────────────────────────────────────────────────────────────── const mockListFolders = vi.fn() const mockListDocuments = vi.fn() const mockGetFolder = vi.fn() const mockCreateFolder = vi.fn() const mockRenameFolder = vi.fn() const mockDeleteFolder = vi.fn() const mockMoveDocument = vi.fn() vi.mock('../../api/client.js', () => ({ listFolders: (...a) => mockListFolders(...a), listDocuments: (...a) => mockListDocuments(...a), getFolder: (...a) => mockGetFolder(...a), createFolder: (...a) => mockCreateFolder(...a), renameFolder: (...a) => mockRenameFolder(...a), deleteFolder: (...a) => mockDeleteFolder(...a), moveDocument: (...a) => mockMoveDocument(...a), deleteDocument: vi.fn().mockResolvedValue(null), getSharedWithMe: vi.fn().mockResolvedValue([]), listTopics: vi.fn().mockResolvedValue([]), getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }), })) // Stub heavy child components so we only test FileManagerView logic vi.mock('../../components/folders/FolderBreadcrumb.vue', () => ({ default: { template: '', props: ['segments'], emits: ['navigate'] }, })) vi.mock('../../components/upload/DropZone.vue', () => ({ default: { template: '
', emits: ['files-selected'] }, })) vi.mock('../../components/upload/UploadProgress.vue', () => ({ default: { template: '
', props: ['items'] }, })) vi.mock('../../components/folders/FolderDeleteModal.vue', () => ({ default: { template: '
', props: ['folder'], emits: ['confirm', 'cancel'] }, })) vi.mock('../../components/sharing/ShareModal.vue', () => ({ default: { template: '
', props: ['doc'], emits: ['close'] }, })) vi.mock('../../components/documents/SearchBar.vue', () => ({ default: { template: '', props: ['modelValue'] }, })) vi.mock('../../components/documents/SortControls.vue', () => ({ default: { template: '
', props: ['sort', 'order'], emits: ['change'] }, })) vi.mock('../../components/topics/TopicBadge.vue', () => ({ default: { template: '', props: ['name', 'color'] }, })) vi.mock('../../stores/auth.js', () => ({ useAuthStore: () => ({ user: { email: 'test@example.com', role: 'user' }, accessToken: 'fake-token', fetchQuota: vi.fn().mockResolvedValue(null), }), })) vi.mock('../../stores/topics.js', () => ({ useTopicsStore: () => ({ topics: [], loading: false, fetchTopics: vi.fn().mockResolvedValue(null), }), })) // ── Helpers ─────────────────────────────────────────────────────────────────── function makeFolder(overrides = {}) { return { id: overrides.id ?? 'f1', name: overrides.name ?? 'TestFolder', parent_id: overrides.parent_id ?? null, has_children: overrides.has_children ?? false, created_at: '2026-01-01T00:00:00Z', ...overrides, } } function makeDoc(overrides = {}) { return { id: overrides.id ?? 'd1', original_name: overrides.original_name ?? 'test.pdf', filename: 'test.pdf', mime_type: 'application/pdf', size_bytes: 1024, topics: [], folder_id: overrides.folder_id ?? 'f1', created_at: '2026-01-01T00:00:00Z', ...overrides, } } function makeRouter() { return createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: FileManagerView }, { path: '/folders/:folderId', component: FileManagerView }, { path: '/document/:id', component: { template: '
' } }, ], }) } async function mountView(path = '/') { setActivePinia(createPinia()) const router = makeRouter() await router.push(path) await router.isReady() const w = mount(FileManagerView, { global: { plugins: [router], stubs: { QuotaBar: true, AppSpinner: true }, }, }) await flushPromises() return { w, router } } // ── Tests ───────────────────────────────────────────────────────────────────── describe('FileManagerView — root navigation', () => { beforeEach(() => { vi.clearAllMocks() mockListFolders.mockResolvedValue({ items: [] }) mockListDocuments.mockResolvedValue({ items: [], total: 0 }) mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [] }) }) it('at root (/), fetches root folders via listFolders(null)', async () => { const roots = [makeFolder({ id: 'r1', name: 'Root1' })] mockListFolders.mockResolvedValue({ items: roots }) await mountView('/') expect(mockListFolders).toHaveBeenCalledWith(null) }) it('at root, renders root folders in the item list', async () => { mockListFolders.mockResolvedValue({ items: [makeFolder({ id: 'r1', name: 'MyFolder' })] }) const { w } = await mountView('/') expect(w.text()).toContain('MyFolder') }) it('root view does NOT call listDocuments', async () => { await mountView('/') expect(mockListDocuments).not.toHaveBeenCalled() }) }) describe('FileManagerView — folder navigation', () => { beforeEach(() => { vi.clearAllMocks() mockListFolders.mockResolvedValue({ items: [] }) mockListDocuments.mockResolvedValue({ items: [], total: 0 }) mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [{ id: 'f1', name: 'Test' }] }) }) it('at /folders/:id, fetches that folder\'s children', async () => { const children = [makeFolder({ id: 'c1', name: 'Child', parent_id: 'f1' })] mockListFolders.mockResolvedValue({ items: children }) await mountView('/folders/f1') expect(mockListFolders).toHaveBeenCalledWith('f1') }) it('at /folders/:id, fetches documents for that folder', async () => { await mountView('/folders/f1') expect(mockListDocuments).toHaveBeenCalledWith( expect.objectContaining({ folderId: 'f1' }) ) }) it('renders subfolder rows', async () => { mockListFolders.mockResolvedValue({ items: [makeFolder({ id: 'sub1', name: 'SubFolder', parent_id: 'f1' })], }) const { w } = await mountView('/folders/f1') expect(w.text()).toContain('SubFolder') }) it('renders document rows', async () => { mockListDocuments.mockResolvedValue({ items: [makeDoc({ id: 'd1', original_name: 'report.pdf' })], total: 1, }) const { w } = await mountView('/folders/f1') expect(w.text()).toContain('report.pdf') }) }) describe('FileManagerView — folder row click navigation', () => { beforeEach(() => { vi.clearAllMocks() mockListFolders.mockResolvedValue({ items: [] }) mockListDocuments.mockResolvedValue({ items: [], total: 0 }) mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [] }) }) async function setupWithFolders(folders) { mockListFolders.mockResolvedValue({ items: folders }) const { w, router } = await mountView('/') return { w, router } } it('clicking folder ICON cell navigates to /folders/:id', async () => { const { w, router } = await setupWithFolders([makeFolder({ id: 'target-1', name: 'Clickme' })]) // Column 1 (icon cell) — the amber div const iconCell = w.find('.bg-amber-50.rounded-lg') await iconCell.trigger('click') await flushPromises() expect(router.currentRoute.value.path).toBe('/folders/target-1') }) it('clicking folder NAME text navigates to /folders/:id', async () => { // This tests the bug: @click.stop on the name wrapper div prevents navigation const { w, router } = await setupWithFolders([makeFolder({ id: 'target-1', name: 'Clickme' })]) // Column 2 (name cell) — the span with the folder name const nameSpan = w.findAll('span').find(s => s.text() === 'Clickme') expect(nameSpan).toBeTruthy() await nameSpan.trigger('click') await flushPromises() expect(router.currentRoute.value.path).toBe('/folders/target-1') }) it('clicking folder DATE cell navigates to /folders/:id', async () => { const { w, router } = await setupWithFolders([makeFolder({ id: 'target-1', name: 'Clickme' })]) // find the date cells (text-gray-400 text-xs spans) const dateCells = w.findAll('.text-right.text-xs.text-gray-400') // Click the first date cell (the "—" size or actual date) if (dateCells.length > 0) { await dateCells[0].trigger('click') await flushPromises() expect(router.currentRoute.value.path).toBe('/folders/target-1') } }) it('clicking rename button does NOT navigate', async () => { const { w, router } = await setupWithFolders([makeFolder({ id: 'target-1', name: 'Clickme' })]) // Find rename button (pencil icon button in hover actions) const actionBtns = w.findAll('.opacity-0.group-hover\\:opacity-100 button') if (actionBtns.length > 0) { await actionBtns[0].trigger('click') await flushPromises() // Should still be at root expect(router.currentRoute.value.path).toBe('/') } }) }) describe('FileManagerView — subfolder creation does not break navigation', () => { beforeEach(() => { vi.clearAllMocks() mockListDocuments.mockResolvedValue({ items: [], total: 0 }) mockGetFolder.mockResolvedValue({ id: 'parent-1', name: 'Test', breadcrumb: [{ id: 'parent-1', name: 'Test' }], }) }) it('after creating subfolder, clicking its name navigates correctly', async () => { const initialChildren = [makeFolder({ id: 'existing-1', name: 'Existing', parent_id: 'parent-1' })] mockListFolders.mockResolvedValue({ items: initialChildren }) const newSubfolder = makeFolder({ id: 'new-sub', name: 'NewSub', parent_id: 'parent-1' }) mockCreateFolder.mockResolvedValue(newSubfolder) const { w, router } = await mountView('/folders/parent-1') // Simulate creating a new subfolder (as if user typed in the new-folder input) const { useFoldersStore } = await import('../../stores/folders.js') const foldersStore = useFoldersStore() await foldersStore.createFolder('NewSub', 'parent-1') await flushPromises() // Now "NewSub" should appear in the list expect(w.text()).toContain('NewSub') // Clicking the name of "NewSub" should navigate — this is the regression test const nameSpan = w.findAll('span').find(s => s.text() === 'NewSub') expect(nameSpan).toBeTruthy() await nameSpan.trigger('click') await flushPromises() expect(router.currentRoute.value.path).toBe('/folders/new-sub') }) it('after creating subfolder, clicking existing folder name still navigates', async () => { const initial = [makeFolder({ id: 'existing-1', name: 'Existing', parent_id: 'parent-1' })] mockListFolders.mockResolvedValue({ items: initial }) mockCreateFolder.mockResolvedValue( makeFolder({ id: 'new-sub', name: 'NewSub', parent_id: 'parent-1' }) ) const { w, router } = await mountView('/folders/parent-1') const { useFoldersStore } = await import('../../stores/folders.js') await useFoldersStore().createFolder('NewSub', 'parent-1') await flushPromises() const nameSpan = w.findAll('span').find(s => s.text() === 'Existing') expect(nameSpan).toBeTruthy() await nameSpan.trigger('click') await flushPromises() expect(router.currentRoute.value.path).toBe('/folders/existing-1') }) }) describe('FileManagerView — drag-and-drop document onto folder', () => { beforeEach(() => { vi.clearAllMocks() mockMoveDocument.mockResolvedValue({}) mockListDocuments.mockResolvedValue({ items: [makeDoc({ id: 'd1', original_name: 'doc.pdf' })], total: 1, }) mockListFolders.mockResolvedValue({ items: [makeFolder({ id: 'f-target', name: 'Target' })], }) mockGetFolder.mockResolvedValue({ id: 'parent-1', name: 'Parent', breadcrumb: [{ id: 'parent-1', name: 'Parent' }], }) }) it('dragstart on document row sets dragging state', async () => { const { w } = await mountView('/folders/parent-1') const docRow = w.findAll('[draggable="true"]')[0] expect(docRow).toBeTruthy() await docRow.trigger('dragstart', { dataTransfer: { effectAllowed: '', setData: vi.fn(), types: [] } }) // The row gets opacity-50 when dragging await w.vm.$nextTick() }) it('drop on folder row calls moveToFolder', async () => { const { w } = await mountView('/folders/parent-1') await flushPromises() // Start dragging doc const docRow = w.findAll('[draggable="true"]')[0] const dataTransfer = { effectAllowed: '', setData: vi.fn(), types: [] } await docRow.trigger('dragstart', { dataTransfer }) await w.vm.$nextTick() // Find folder row and drop on it const folderRows = w.findAll('.grid.grid-cols-\\[2rem_1fr_6rem_8rem_6rem\\]').filter( el => el.text().includes('Target') ) if (folderRows.length > 0) { await folderRows[0].trigger('drop', { dataTransfer: { types: [] } }) await flushPromises() expect(mockMoveDocument).toHaveBeenCalled() } }) }) describe('FileManagerView — empty states', () => { beforeEach(() => { vi.clearAllMocks() mockListFolders.mockResolvedValue({ items: [] }) mockListDocuments.mockResolvedValue({ items: [], total: 0 }) mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Empty', breadcrumb: [] }) }) it('shows "No folders yet" when root has no folders', async () => { const { w } = await mountView('/') expect(w.text()).toContain('No folders yet') }) it('shows "This folder is empty" inside an empty subfolder', async () => { const { w } = await mountView('/folders/f1') expect(w.text()).toContain('This folder is empty') }) }) describe('FileManagerView — new folder creation', () => { beforeEach(() => { vi.clearAllMocks() mockListFolders.mockResolvedValue({ items: [] }) mockListDocuments.mockResolvedValue({ items: [], total: 0 }) mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [] }) mockCreateFolder.mockResolvedValue(makeFolder({ id: 'new-1', name: 'MyNewFolder' })) }) it('clicking "New folder" button shows inline input', async () => { const { w } = await mountView('/') const newBtn = w.find('button.flex.items-center.gap-1\\.5') await newBtn.trigger('click') await w.vm.$nextTick() expect(w.find('input[placeholder="Folder name"]').exists()).toBe(true) }) it('pressing Enter in folder name input creates folder at current level', async () => { const { w } = await mountView('/folders/f1') // Open new folder input await w.find('button.flex.items-center.gap-1\\.5').trigger('click') await w.vm.$nextTick() // Type folder name and submit const input = w.find('input[placeholder="Folder name"]') await input.setValue('MyNewFolder') await input.trigger('keydown.enter') await flushPromises() expect(mockCreateFolder).toHaveBeenCalledWith('MyNewFolder', 'f1') }) it('pressing Escape cancels folder creation', async () => { const { w } = await mountView('/') await w.find('button.flex.items-center.gap-1\\.5').trigger('click') await w.vm.$nextTick() await w.find('input[placeholder="Folder name"]').trigger('keydown.escape') await w.vm.$nextTick() expect(w.find('input[placeholder="Folder name"]').exists()).toBe(false) }) })