- FileManagerView: add mappedBreadcrumb computed ({id,label} from foldersStore.breadcrumb)
- FileManagerView: import useToastStore; add toast.show() to doMove, doDeleteDoc, onFilesSelected
- FileManagerView: remove console.error from doMove/doDeleteDoc (toast communicates errors)
- CloudFolderView: add mappedBreadcrumb computed; pass to StorageBrowser :breadcrumb
- Delete FolderBreadcrumb.vue + FolderBreadcrumb.test.js (no dead code per CLAUDE.md)
- FileManagerView.test.js: replace dead FolderBreadcrumb mock with BreadcrumbBar mock
- StorageBrowser.skeleton.test.js: remove unused FolderBreadcrumb stub
- Full suite: 164/164 pass, 0 failures
445 lines
16 KiB
JavaScript
445 lines
16 KiB
JavaScript
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/ui/BreadcrumbBar.vue', () => ({
|
|
default: { template: '<nav><slot/></nav>', props: ['segments', 'rootLabel', 'showRoot'], emits: ['navigate'] },
|
|
}))
|
|
vi.mock('../../components/upload/DropZone.vue', () => ({
|
|
default: { template: '<div class="dropzone"/>', emits: ['files-selected'] },
|
|
}))
|
|
vi.mock('../../components/upload/UploadProgress.vue', () => ({
|
|
default: { template: '<div/>', props: ['items'] },
|
|
}))
|
|
vi.mock('../../components/folders/FolderDeleteModal.vue', () => ({
|
|
default: { template: '<div/>', props: ['folder'], emits: ['confirm', 'cancel'] },
|
|
}))
|
|
vi.mock('../../components/sharing/ShareModal.vue', () => ({
|
|
default: { template: '<div/>', props: ['doc'], emits: ['close'] },
|
|
}))
|
|
vi.mock('../../components/documents/SearchBar.vue', () => ({
|
|
default: { template: '<input/>', props: ['modelValue'] },
|
|
}))
|
|
vi.mock('../../components/documents/SortControls.vue', () => ({
|
|
default: { template: '<div/>', props: ['sort', 'order'], emits: ['change'] },
|
|
}))
|
|
vi.mock('../../components/topics/TopicBadge.vue', () => ({
|
|
default: { template: '<span/>', 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: '<div/>' } },
|
|
],
|
|
})
|
|
}
|
|
|
|
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)
|
|
})
|
|
})
|