- 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)
424 lines
18 KiB
JavaScript
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)
|
|
}
|
|
})
|
|
})
|