import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' // Use connection UUID, never provider slug const mockPush = vi.fn() const mockReplace = vi.fn() vi.mock('vue-router', () => ({ useRouter: () => ({ push: mockPush, replace: mockReplace }), useRoute: () => ({ params: { connectionId: 'uuid-conn-1', folderId: 'root' }, query: {}, }), })) const mockFetchConnections = vi.fn().mockResolvedValue(undefined) const mockSelectConnection = vi.fn() const mockSetBrowseState = vi.fn() vi.mock('../../stores/cloudConnections.js', () => ({ useCloudConnectionsStore: () => ({ connections: [{ id: 'uuid-conn-1', provider: 'google_drive', display_name: 'My Drive' }], loading: false, capabilities: null, folderFreshness: null, lastRefreshedAt: null, byteAvailability: null, fetchConnections: mockFetchConnections, selectConnection: mockSelectConnection, setBrowseState: mockSetBrowseState, defaultDisplayName: (c) => c.provider, }), saveLastFolder: vi.fn(), loadLastFolder: vi.fn(() => null), })) vi.mock('../../api/client.js', () => ({ getCloudFoldersByConnectionId: vi.fn().mockResolvedValue({ items: [], capabilities: null }), uploadToCloud: vi.fn(), listCloudConnections: vi.fn().mockResolvedValue({ items: [] }), })) vi.mock('../../stores/toast.js', () => ({ useToastStore: () => ({ show: vi.fn() }), })) import CloudFolderView from '../CloudFolderView.vue' import * as api from '../../api/client.js' const globalStubs = { StorageBrowser: { template: '
', props: [ 'mode', 'folders', 'files', 'breadcrumb', 'uploadQueue', 'loading', 'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot', 'folderFreshness', 'lastRefreshedAt', 'byteAvailability', ], emits: ['breadcrumb-navigate', 'upload', 'folder-navigate', 'file-open'], }, } beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() sessionStorage.clear() }) afterEach(() => { sessionStorage.clear() }) describe('CloudFolderView', () => { it('calls getCloudFoldersByConnectionId with connection UUID, never provider slug', async () => { const w = mount(CloudFolderView, { global: { stubs: globalStubs } }) await flushPromises() expect(api.getCloudFoldersByConnectionId).toHaveBeenCalledWith('uuid-conn-1', 'root') // Never called with provider slug expect(api.getCloudFoldersByConnectionId).not.toHaveBeenCalledWith('google_drive', expect.anything()) }) it('renders StorageBrowser (no parallel file grid)', () => { const w = mount(CloudFolderView, { global: { stubs: globalStubs } }) expect(w.find('[data-test="storage-browser"]').exists()).toBe(true) // No grid markup in view itself expect(w.findAll('table').length).toBe(0) }) it('passes connectionId to store selectConnection', async () => { const w = mount(CloudFolderView, { global: { stubs: globalStubs } }) await flushPromises() expect(mockSelectConnection).toHaveBeenCalledWith('uuid-conn-1') }) it('fresh session at root does not redirect when no stored folder', async () => { const w = mount(CloudFolderView, { global: { stubs: globalStubs } }) await flushPromises() expect(mockReplace).not.toHaveBeenCalled() }) }) // ── Normalized API shape: kind/provider_item_id regressions ────────────────── /** * Realistic CloudItemOut fixtures matching the canonical API (no is_dir field). * Distinct id vs provider_item_id ensures no accidental pass-by-coincidence. * Duplicate display names ("Documents") with distinct IDs/refs confirm * classification cannot rely on name. */ const FOLDER_A = { id: 'docuvault-uuid-folder-a', // DocuVault stable row key provider_item_id: 'provider/ref/folderA', // opaque provider reference name: 'Documents', kind: 'folder', parent_ref: null, content_type: null, size: null, modified_at: null, etag: null, capabilities: {}, } const FOLDER_B = { id: 'docuvault-uuid-folder-b', // distinct stable id provider_item_id: 'provider/ref/folderB', // distinct provider ref name: 'Documents', // intentional duplicate display name kind: 'folder', parent_ref: null, content_type: null, size: null, modified_at: null, etag: null, capabilities: {}, } const FILE_A = { id: 'docuvault-uuid-file-a', provider_item_id: 'provider/ref/fileA', name: 'report.pdf', kind: 'file', parent_ref: null, content_type: 'application/pdf', size: 12345, modified_at: '2026-01-01T00:00:00Z', etag: '"etag-abc"', capabilities: {}, } const FILE_B = { id: 'docuvault-uuid-file-b', provider_item_id: 'provider/ref/fileB', name: 'report.pdf', // intentional duplicate display name kind: 'file', parent_ref: null, content_type: 'application/pdf', size: 99999, modified_at: null, etag: null, capabilities: {}, } // Opaque-reference fixtures with reserved characters const FOLDER_OPAQUE = { id: 'docuvault-uuid-opaque-folder', provider_item_id: 'providers/path?query=1&other=2#frag with spaces/Unicode/日本語', name: 'Opaque Folder', kind: 'folder', parent_ref: null, content_type: null, size: null, modified_at: null, etag: null, capabilities: {}, } describe('renders_kind_folder_and_file_in_shared_browser', () => { it('folders prop uses kind=folder items only — not is_dir', async () => { api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [FOLDER_A, FOLDER_B, FILE_A, FILE_B], capabilities: null, freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-22T00:00:00Z' }, }) // Capture props passed to StorageBrowser let capturedFolders = null let capturedFiles = null const CapturingStub = { template: '
', props: ['folders', 'files', 'mode', 'breadcrumb', 'uploadQueue', 'loading', 'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot', 'folderFreshness', 'lastRefreshedAt', 'byteAvailability'], mounted() { capturedFolders = this.folders capturedFiles = this.files }, updated() { capturedFolders = this.folders capturedFiles = this.files }, } const w = mount(CloudFolderView, { global: { stubs: { StorageBrowser: CapturingStub } }, }) await flushPromises() // Must classify by kind, not is_dir expect(capturedFolders).not.toBeNull() expect(capturedFolders.length).toBe(2) expect(capturedFolders.every(f => f.kind === 'folder')).toBe(true) expect(capturedFiles.length).toBe(2) expect(capturedFiles.every(f => f.kind === 'file')).toBe(true) // Duplicate display names are distinguished by id, not name expect(capturedFolders[0].id).not.toBe(capturedFolders[1].id) expect(capturedFiles[0].id).not.toBe(capturedFiles[1].id) }) }) describe('folder_click_uses_provider_item_id_not_id', () => { it('folder-navigate event triggers navigation with provider_item_id, not DocuVault id', async () => { api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [FOLDER_A], capabilities: null, freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-22T00:00:00Z' }, }) let didEmit = false const CapturingStub = { template: '
', props: ['folders', 'files', 'mode', 'breadcrumb', 'uploadQueue', 'loading', 'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot', 'folderFreshness', 'lastRefreshedAt', 'byteAvailability'], emits: ['folder-navigate'], mounted() {}, updated() { // Emit folder-navigate once folders are populated (after load completes) if (this.folders.length > 0 && !didEmit) { didEmit = true this.$emit('folder-navigate', this.folders[0]) } }, } const w = mount(CloudFolderView, { global: { stubs: { StorageBrowser: CapturingStub } }, }) await flushPromises() // navigateTo must use provider_item_id for the route param, not DocuVault id expect(mockPush).toHaveBeenCalled() // Find the push call that carries provider_item_id (not the session-restore push) const hasPidRef = mockPush.mock.calls.some(call => { const arg = call[0] if (typeof arg === 'string') return arg.includes(FOLDER_A.provider_item_id) if (typeof arg === 'object') { const paramValues = Object.values(arg.params ?? {}) return paramValues.some(v => String(v) === FOLDER_A.provider_item_id) } return false }) expect(hasPidRef).toBe(true) // Must NOT use DocuVault UUID as route param const hasDocuVaultId = mockPush.mock.calls.some(call => { const arg = call[0] if (typeof arg === 'string') return arg.includes(FOLDER_A.id) if (typeof arg === 'object') { const paramValues = Object.values(arg.params ?? {}) return paramValues.some(v => String(v) === FOLDER_A.id) } return false }) expect(hasDocuVaultId).toBe(false) }) }) describe('stable_docuvault_id_remains_row_key', () => { it('folders passed to StorageBrowser retain their DocuVault id for Vue key identity', async () => { api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [FOLDER_A, FOLDER_B], capabilities: null, freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-22T00:00:00Z' }, }) let capturedFolders = null const CapturingStub = { template: '
', props: ['folders', 'files', 'mode', 'breadcrumb', 'uploadQueue', 'loading', 'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot', 'folderFreshness', 'lastRefreshedAt', 'byteAvailability'], mounted() { capturedFolders = this.folders }, updated() { capturedFolders = this.folders }, } const w = mount(CloudFolderView, { global: { stubs: { StorageBrowser: CapturingStub } }, }) await flushPromises() // DocuVault stable ids must be preserved as row identity expect(capturedFolders.map(f => f.id)).toContain('docuvault-uuid-folder-a') expect(capturedFolders.map(f => f.id)).toContain('docuvault-uuid-folder-b') // provider_item_id must also be present on each item expect(capturedFolders.every(f => f.provider_item_id !== undefined)).toBe(true) }) }) describe('opaque_reference_round_trip', () => { it('provider_item_id containing reserved characters is used intact for navigation', async () => { api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [FOLDER_OPAQUE], capabilities: null, freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-22T00:00:00Z' }, }) let capturedFolders = [] let didEmit = false const CapturingStub = { template: '
', props: ['folders', 'files', 'mode', 'breadcrumb', 'uploadQueue', 'loading', 'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot', 'folderFreshness', 'lastRefreshedAt', 'byteAvailability'], emits: ['folder-navigate'], mounted() { capturedFolders = this.folders }, updated() { capturedFolders = this.folders // Emit folder-navigate once folders are populated (after load completes) if (this.folders.length > 0 && !didEmit) { didEmit = true this.$emit('folder-navigate', this.folders[0]) } }, } const w = mount(CloudFolderView, { global: { stubs: { StorageBrowser: CapturingStub } }, }) await flushPromises() // Navigation must use provider_item_id verbatim — no splitting or decoding expect(mockPush).toHaveBeenCalled() // Find the push call that was triggered by folder-navigate (not the initial replace) const pushCalls = mockPush.mock.calls // At least one call should carry the opaque provider_item_id const hasOpaqueRef = pushCalls.some(call => { const arg = call[0] if (typeof arg === 'string') return arg.includes(FOLDER_OPAQUE.provider_item_id) if (typeof arg === 'object') { const paramValues = Object.values(arg.params ?? {}) return paramValues.some(v => String(v) === FOLDER_OPAQUE.provider_item_id) } return false }) expect(hasOpaqueRef).toBe(true) }) }) // ── Freshness mapping: server state is authoritative ───────────────────────── describe('maps_server_refreshing_freshness', () => { it('setBrowseState is called with refreshing before the API resolves', async () => { // Make the API resolve async so we can check the in-flight state let resolveApi api.getCloudFoldersByConnectionId.mockReturnValue(new Promise(r => { resolveApi = r })) const w = mount(CloudFolderView, { global: { stubs: globalStubs } }) // Before resolution: refreshing must have been set expect(mockSetBrowseState).toHaveBeenCalledWith(expect.objectContaining({ freshness: 'refreshing' })) // Complete the request resolveApi({ items: [], capabilities: null, freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-22T00:00:00Z' }, }) await flushPromises() }) }) describe('maps_server_warning_and_last_success', () => { it('HTTP 200 with warning freshness calls setBrowseState with warning, not fresh', async () => { const SERVER_TS = '2026-06-10T12:00:00.000Z' api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [FOLDER_A], capabilities: null, freshness: { refresh_state: 'warning', last_refreshed_at: SERVER_TS, error_code: 'incomplete_listing', error_message: 'Provider returned incomplete results.', }, }) const w = mount(CloudFolderView, { global: { stubs: globalStubs } }) await flushPromises() // setBrowseState must be called with freshness='warning', NOT 'fresh' const calls = mockSetBrowseState.mock.calls const finalCall = calls.find(c => c[0].freshness === 'warning') expect(finalCall).toBeDefined() // fresh must NOT be set after a warning response const freshCall = calls.find(c => c[0].freshness === 'fresh') expect(freshCall).toBeUndefined() }) }) describe('http_200_does_not_imply_fresh', () => { it('HTTP 200 with warning state does not result in fresh freshness being set', async () => { api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [], capabilities: null, freshness: { refresh_state: 'warning', last_refreshed_at: null, error_code: 'incomplete_listing', error_message: null, }, }) const w = mount(CloudFolderView, { global: { stubs: globalStubs } }) await flushPromises() // None of the setBrowseState calls (excluding the initial 'refreshing') should set 'fresh' const freshAfterLoad = mockSetBrowseState.mock.calls .filter(c => c[0].freshness === 'fresh') expect(freshAfterLoad.length).toBe(0) }) }) describe('does_not_use_browser_clock_as_refresh_evidence', () => { it('refreshedAt passed to setBrowseState comes from server last_refreshed_at, not new Date()', async () => { const SERVER_TS = '2026-06-01T08:00:00.000Z' api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [], capabilities: null, freshness: { refresh_state: 'fresh', last_refreshed_at: SERVER_TS, }, }) const w = mount(CloudFolderView, { global: { stubs: globalStubs } }) await flushPromises() // Find the setBrowseState call that carries a refreshedAt const calls = mockSetBrowseState.mock.calls const callWithTs = calls.find(c => c[0].refreshedAt !== undefined && c[0].refreshedAt !== null) expect(callWithTs).toBeDefined() // The timestamp must be exactly what the server sent expect(callWithTs[0].refreshedAt).toBe(SERVER_TS) // Must NOT contain 'Z' as a result of new Date().toISOString() producing a different value // (if the test is run quickly, these might match by coincidence — but the semantic check is: // does the code use the server value or call new Date()?) // We verify this by checking the value matches the exact server string, not a generated one expect(callWithTs[0].refreshedAt).not.toMatch(/^2026-06-22/) // not today's date }) }) describe('cached_items_remain_visible_during_warning', () => { it('on HTTP 200 warning, items from response are still passed to store', async () => { api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [FOLDER_A, FILE_A], capabilities: null, freshness: { refresh_state: 'warning', last_refreshed_at: '2026-06-01T00:00:00Z', error_code: 'incomplete_listing', error_message: null, }, }) const w = mount(CloudFolderView, { global: { stubs: globalStubs } }) await flushPromises() // Items must be passed to the store even during a warning state const callWithItems = mockSetBrowseState.mock.calls.find(c => c[0].items !== undefined) expect(callWithItems).toBeDefined() expect(callWithItems[0].items.length).toBe(2) }) })