Files
kite/frontend/src/stores/__tests__/cloudConnections.test.js
T
curo1305 8923ed5b3c test(13-02): add red store, health-flow, reconnect, and no-probe tests
- SettingsCloudTab.health.test.js: Test/Reconnect/Disconnect per-connection actions,
  explicit confirmation before credential removal, Google Drive broader scope consent
  copy (D-12/D-13/D-15/D-16/D-17/T-13-09)
- cloudConnections.test.js: connectionHealth state map, setConnectionHealth translation,
  degraded vs requires_reauth distinction, pendingHealthRetest after failure, reconnect
  preserves cached items, auto-test after connect, no-probe-on-navigation (D-12..D-15)
- CloudFolderRenderedFlow.test.js: warning+reconnect banner alongside cached items,
  requires_reauth renders actionable prompt, folder-navigate never triggers health probe
  (D-12/D-13/D-14/CONN-02/T-13-06)
2026-06-22 18:13:39 +02:00

424 lines
18 KiB
JavaScript

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