import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' // Mock api/client.js — no real HTTP calls in unit tests (CLAUDE.md W4) vi.mock('../../api/client.js', () => ({ listCloudConnections: vi.fn(), disconnectCloud: vi.fn(), connectWebDav: vi.fn(), updateDefaultStorage: vi.fn(), renameCloudConnection: vi.fn(), getCloudFoldersByConnectionId: vi.fn(), })) import { useCloudConnectionsStore, saveLastFolder, loadLastFolder } from '../cloudConnections.js' import * as api from '../../api/client.js' beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() // Clear sessionStorage between tests sessionStorage.clear() }) afterEach(() => { sessionStorage.clear() }) describe('useCloudConnectionsStore', () => { it('fetchConnections sets connections from API response', async () => { api.listCloudConnections.mockResolvedValue({ items: [{ id: '1', provider: 'google_drive', status: 'ACTIVE' }], }) const store = useCloudConnectionsStore() await store.fetchConnections() expect(store.connections).toHaveLength(1) expect(store.connections[0].provider).toBe('google_drive') expect(store.loading).toBe(false) }) it('fetchConnections sets error on API failure', async () => { api.listCloudConnections.mockRejectedValue(new Error('Network error')) const store = useCloudConnectionsStore() await store.fetchConnections() expect(store.error).toBeTruthy() expect(store.connections).toHaveLength(0) }) it('disconnect removes connection from state after API call', async () => { api.disconnectCloud.mockResolvedValue(null) const store = useCloudConnectionsStore() store.connections = [{ id: 'conn-1', provider: 'google_drive', status: 'ACTIVE' }] await store.disconnect('conn-1') expect(store.connections).toHaveLength(0) expect(api.disconnectCloud).toHaveBeenCalledWith('conn-1') }) it('disconnectAll clears all connections', async () => { api.disconnectCloud.mockResolvedValue(null) const store = useCloudConnectionsStore() store.connections = [ { id: 'a', provider: 'google_drive', status: 'ACTIVE' }, { id: 'b', provider: 'onedrive', status: 'ACTIVE' }, ] await store.disconnectAll() expect(store.connections).toHaveLength(0) }) // Rename tests it('rename updates the connection in state on success', async () => { api.renameCloudConnection.mockResolvedValue({ id: 'conn-1', provider: 'google_drive', display_name: 'My Drive', }) const store = useCloudConnectionsStore() store.connections = [{ id: 'conn-1', provider: 'google_drive', display_name: 'Google Drive' }] await store.rename('conn-1', 'My Drive') expect(store.connections[0].display_name).toBe('My Drive') expect(api.renameCloudConnection).toHaveBeenCalledWith('conn-1', 'My Drive') }) it('rename rejects on empty display name', async () => { const store = useCloudConnectionsStore() await expect(store.rename('conn-1', '')).rejects.toThrow() }) it('rename rejects on whitespace-only display name', async () => { const store = useCloudConnectionsStore() await expect(store.rename('conn-1', ' ')).rejects.toThrow() }) it('rename propagates API error', async () => { api.renameCloudConnection.mockRejectedValue(new Error('API error')) const store = useCloudConnectionsStore() store.connections = [{ id: 'conn-1', provider: 'google_drive', display_name: 'Google Drive' }] await expect(store.rename('conn-1', 'New Name')).rejects.toThrow('API error') }) // Two same-provider connections render as separate roots it('two google_drive connections render as separate roots with UUID suffixes in defaultDisplayName', () => { const store = useCloudConnectionsStore() store.connections = [ { id: 'aabb-ccdd-1', provider: 'google_drive', display_name: null }, { id: 'eeff-gggg-2', provider: 'google_drive', display_name: null }, ] const nameA = store.defaultDisplayName(store.connections[0]) const nameB = store.defaultDisplayName(store.connections[1]) // Both should contain "Google Drive" and a 4-char suffix expect(nameA).toContain('Google Drive') expect(nameB).toContain('Google Drive') expect(nameA).toContain('aabb') expect(nameB).toContain('eeff') }) it('single connection has no suffix in defaultDisplayName', () => { const store = useCloudConnectionsStore() store.connections = [{ id: 'conn-1', provider: 'google_drive' }] expect(store.defaultDisplayName(store.connections[0])).toBe('Google Drive') }) // Connection UUID based URLs it('selectConnection sets selectedConnectionId', () => { const store = useCloudConnectionsStore() store.connections = [{ id: 'uuid-123', provider: 'onedrive' }] store.selectConnection('uuid-123') expect(store.selectedConnectionId).toBe('uuid-123') expect(store.selectedConnection?.id).toBe('uuid-123') }) // Session storage for folder navigation it('saveLastFolder stores folder in sessionStorage', () => { saveLastFolder('conn-abc', 'Documents/Work') expect(sessionStorage.getItem('docuvault:cloud:folder:conn-abc')).toBe('Documents/Work') }) it('loadLastFolder retrieves stored folder', () => { sessionStorage.setItem('docuvault:cloud:folder:conn-xyz', 'Photos') expect(loadLastFolder('conn-xyz')).toBe('Photos') }) it('saveLastFolder removes key for root folder', () => { sessionStorage.setItem('docuvault:cloud:folder:conn-abc', 'Docs') saveLastFolder('conn-abc', 'root') expect(sessionStorage.getItem('docuvault:cloud:folder:conn-abc')).toBeNull() }) it('loadLastFolder returns null when nothing stored', () => { expect(loadLastFolder('nonexistent')).toBeNull() }) it('sessionStorage stores only folder references, not tokens', () => { // Ensure save only stores the folderId string under the namespaced key saveLastFolder('conn-123', 'Projects/Secret') const stored = sessionStorage.getItem('docuvault:cloud:folder:conn-123') expect(stored).toBe('Projects/Secret') // Stored value is a plain string path, not a JSON object with credentials expect(typeof stored).toBe('string') expect(stored.startsWith('{')).toBe(false) }) }) // ── Freshness state mapping ────────────────────────────────────────────────── describe('setBrowseState freshness mapping', () => { it('setBrowseState with freshness=warning sets folderFreshness to "warning"', () => { const store = useCloudConnectionsStore() store.setBrowseState({ freshness: 'warning' }) expect(store.folderFreshness).toBe('warning') }) it('setBrowseState with freshness=refreshing sets folderFreshness to "refreshing"', () => { const store = useCloudConnectionsStore() store.setBrowseState({ freshness: 'refreshing' }) expect(store.folderFreshness).toBe('refreshing') }) it('setBrowseState with freshness=fresh sets folderFreshness to "fresh"', () => { const store = useCloudConnectionsStore() store.setBrowseState({ freshness: 'fresh' }) expect(store.folderFreshness).toBe('fresh') }) it('maps_server_warning_and_last_success: server last_refreshed_at is preserved exactly', () => { const store = useCloudConnectionsStore() const serverTimestamp = '2026-06-15T10:30:00Z' store.setBrowseState({ freshness: 'warning', refreshedAt: serverTimestamp, }) expect(store.folderFreshness).toBe('warning') expect(store.lastRefreshedAt).toBe(serverTimestamp) }) it('selectConnection clears cloud browse state without persisting secrets', () => { const store = useCloudConnectionsStore() store.connections = [{ id: 'conn-1', provider: 'google_drive' }] // Set some browse state store.setBrowseState({ items: [{ id: 'item-1', name: 'doc.pdf' }], caps: { share: true }, freshness: 'fresh', refreshedAt: '2026-06-15T10:30:00Z', byteAvail: 'cached', err: null, }) // Select a different connection — should clear state store.selectConnection('conn-1') expect(store.browseItems).toHaveLength(0) expect(store.folderFreshness).toBeNull() expect(store.lastRefreshedAt).toBeNull() expect(store.capabilities).toBeNull() expect(store.byteAvailability).toBeNull() // No tokens or credentials should be present expect(store.browseError).toBeNull() }) it('does_not_use_browser_clock_as_refresh_evidence: refreshedAt from server, not Date.now', () => { const store = useCloudConnectionsStore() // Store only accepts what is passed explicitly — no implicit Date() const SERVER_TS = '2026-06-10T08:00:00.000Z' store.setBrowseState({ freshness: 'fresh', refreshedAt: SERVER_TS }) // lastRefreshedAt must equal exactly what was passed — not a new Date() expect(store.lastRefreshedAt).toBe(SERVER_TS) }) it('cached_items_remain_visible_during_warning: items not cleared on warning freshness', () => { const store = useCloudConnectionsStore() const cachedItems = [ { id: 'item-a', provider_item_id: 'pref-a', name: 'Cached Folder', kind: 'folder' }, ] // First, set fresh state with items store.setBrowseState({ items: cachedItems, freshness: 'fresh', refreshedAt: '2026-06-01T00:00:00Z' }) expect(store.browseItems).toHaveLength(1) // Now simulate a warning without clearing items (only freshness changes) store.setBrowseState({ freshness: 'warning' }) // Items must still be there — warning does not delete cached items expect(store.browseItems).toHaveLength(1) expect(store.browseItems[0].id).toBe('item-a') }) }) // ── Phase 13 Plan 02: health-state, auto-test, and no-probe-on-navigation ────── // These tests MUST FAIL until Phase 13 store extensions are implemented. describe('health_state_translation_D12', () => { it('store exposes connectionHealth state for settings and browser display', () => { /** * D-12: Connection health appears in both Settings and the cloud browser. * The store must provide a centralized health state so both surfaces * consume the same server-translated values without duplicating translation logic. * * RED: current store has no connectionHealth or healthState field. */ const store = useCloudConnectionsStore() // Store must expose a health state map or structured health field // Either a computed connectionHealth getter or a healthState ref expect(store.connectionHealth !== undefined || store.healthState !== undefined).toBe(true) }) it('store.setConnectionHealth translates server requires_reauth to a UI actionable state', () => { /** * D-12 and CONN-02: The store must translate server health states once * so both the browser compact status and Settings diagnostics use the same * actionable vocabulary. `requires_reauth` must map to a UI-actionable state * (not a raw HTTP status or API error string). * * RED: no setConnectionHealth or health translation exists in the current store. */ const store = useCloudConnectionsStore() if (typeof store.setConnectionHealth === 'function') { store.setConnectionHealth('conn-1', { state: 'requires_reauth', error_code: 'token_expired' }) const health = store.connectionHealth?.['conn-1'] ?? store.getConnectionHealth?.('conn-1') expect(health).toBeDefined() expect(health.state).toBe('requires_reauth') } else { // RED: method does not exist yet expect(typeof store.setConnectionHealth).toBe('function') } }) it('store.setConnectionHealth translates server degraded to a transient warning state', () => { /** * D-15: Transient outage must be distinguished from requires_reauth. * The store must hold separate states for these two failure modes so * the UI can show different affordances (retry vs reconnect). * * RED: no health state distinction exists. */ const store = useCloudConnectionsStore() if (typeof store.setConnectionHealth === 'function') { store.setConnectionHealth('conn-1', { state: 'degraded', error_code: 'provider_timeout' }) const health = store.connectionHealth?.['conn-1'] ?? store.getConnectionHealth?.('conn-1') expect(health?.state).toBe('degraded') // degraded must not equal requires_reauth expect(health?.state).not.toBe('requires_reauth') } else { expect(typeof store.setConnectionHealth).toBe('function') } }) }) describe('no_probe_on_navigation_D13', () => { it('ordinary folder navigation does NOT call testCloudConnection', async () => { /** * D-13: "Do not probe the provider on every folder navigation." * When getCloudFoldersByConnectionId is called for browse, the store must * NOT trigger a health probe (testCloudConnection) as a side effect. * * RED: no probe-suppression guard exists; getCloudFoldersByConnectionId * could trigger a health probe as a side effect in a naive implementation. */ api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [], capabilities: null, freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-22T00:00:00Z' }, }) const store = useCloudConnectionsStore() store.connections = [{ id: 'conn-browse', provider: 'onedrive', display_name: 'My OneDrive' }] // Simulate browse (what CloudFolderView calls on navigation) if (typeof store.browse === 'function') { await store.browse('conn-browse', '') } else { // Direct API call (current approach) await api.getCloudFoldersByConnectionId('conn-browse', '') } // testCloudConnection must NOT have been called as a navigation side effect expect(api.testCloudConnection ?? vi.fn()).not.toHaveBeenCalled() }) it('health probe DOES trigger after credential-related failure (requires_reauth)', async () => { /** * D-13: Health retest is automatically triggered after credential-related * failures. This SHOULD happen — it is the complement of the no-probe rule * (probe only after failures, not on normal navigation). * * RED: no auto-probe-after-failure mechanism exists in the store. */ const store = useCloudConnectionsStore() if (typeof store.handleHealthFailure === 'function') { // After a reauth failure, the store should schedule/trigger a health retest store.handleHealthFailure('conn-1', { state: 'requires_reauth', error_code: 'token_expired' }) // Must set a pending retest flag or have called testConnection const needsRetest = store.pendingHealthRetest?.has?.('conn-1') ?? store.healthRetestPending?.['conn-1'] expect(needsRetest).toBe(true) } else { // RED: method does not exist yet expect(typeof store.handleHealthFailure).toBe('function') } }) }) describe('reconnect_refreshes_current_folder_D14', () => { it('store records that reconnect should refresh the current folder', () => { /** * D-14: A successful reconnect must immediately refresh the current folder. * The store must record a pending folder refresh flag so CloudFolderView * can detect and execute the refresh after reconnect. * * RED: no reconnect-triggered refresh flag exists in the current store. */ const store = useCloudConnectionsStore() if (typeof store.markReconnectRefreshPending === 'function') { store.markReconnectRefreshPending('conn-1') expect(store.reconnectRefreshPending).toBe(true) } else { // RED: no such method expect(typeof store.markReconnectRefreshPending).toBe('function') } }) it('reconnect preserves cached metadata as stale (does not clear browseItems)', () => { /** * D-14: A successful reconnect keeps cached metadata visible as stale. * The store must NOT clear browseItems during reconnect — it must set * freshness to 'stale' or 'reconnecting' without wiping the item list. * * RED: current selectConnection clears all browse state; reconnect must differ. */ const store = useCloudConnectionsStore() const cachedItems = [ { id: 'item-1', provider_item_id: 'pref-1', name: 'Old Folder', kind: 'folder' }, ] store.setBrowseState({ items: cachedItems, freshness: 'fresh' }) if (typeof store.markReconnecting === 'function') { store.markReconnecting('conn-1') // Items must remain visible during reconnect (stale but visible) expect(store.browseItems).toHaveLength(1) // Freshness changes to stale or reconnecting — but items are preserved expect(['stale', 'reconnecting', 'warning'].includes(store.folderFreshness)).toBe(true) } else { // RED: no markReconnecting method expect(typeof store.markReconnecting).toBe('function') } }) }) describe('auto_test_after_connect_D13', () => { it('store exposes a pendingHealthTest flag or similar after a connect action', async () => { /** * D-13: Automatically test health after connect/reconnect. * The store must have a mechanism to schedule or trigger a health test * immediately after a new connection is established. * * RED: no auto-test-after-connect mechanism exists in the store. */ const store = useCloudConnectionsStore() // After fetchConnections, newly added connections should trigger health check api.listCloudConnections.mockResolvedValue({ items: [{ id: 'conn-new', provider: 'nextcloud', status: 'ACTIVE', health: null }], }) await store.fetchConnections() // The store should mark connections with null health for testing const needsTest = store.connectionsNeedingHealthCheck ?? store.connections.filter(c => c.health === null || c.health?.state === undefined) expect(needsTest).toBeDefined() // At least one connection should be marked for health testing if (Array.isArray(needsTest)) { expect(needsTest.length).toBeGreaterThan(0) } }) })