- CloudFolderView: folders/files classified by kind=folder/file (remove is_dir) - CloudFolderView: navigateTo uses item.provider_item_id for route param (not item.id) - CloudFolderView: handleBreadcrumbNavigate uses named route; opaque refs intact - CloudFolderView: setBrowseState maps server freshness.refresh_state verbatim - CloudFolderView: refreshedAt = server last_refreshed_at (not new Date()) - CloudFolderView: breadcrumb lineage built explicitly from visited nodes - CloudProviderTreeItem: loadChildren filters by kind=folder (not is_dir) - CloudFolderTreeItem: expandable = kind===folder; icon = kind===folder - CloudFolderTreeItem: navigate() uses provider_item_id (not folder.id) - CloudFolderTreeItem: loadChildren uses provider_item_id as parent_ref - CloudFolderTreeItem: isActive checks provider_item_id first All 82 focused tests pass; full suite: 369/369
455 lines
17 KiB
JavaScript
455 lines
17 KiB
JavaScript
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: '<div data-test="storage-browser" />',
|
|
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: '<div data-test="storage-browser" />',
|
|
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: '<div data-test="storage-browser" />',
|
|
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: '<div data-test="storage-browser" />',
|
|
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: '<div data-test="storage-browser" />',
|
|
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)
|
|
})
|
|
})
|