Files
kite/frontend/src/views/__tests__/CloudFolderView.test.js
T
curo1305 dc3e1725da feat(12.1-03): align cloud browser contract — kind, provider_item_id, server freshness
- 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
2026-06-22 08:49:19 +02:00

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)
})
})