diff --git a/frontend/src/components/settings/__tests__/SettingsCloudTab.health.test.js b/frontend/src/components/settings/__tests__/SettingsCloudTab.health.test.js new file mode 100644 index 0000000..3dee446 --- /dev/null +++ b/frontend/src/components/settings/__tests__/SettingsCloudTab.health.test.js @@ -0,0 +1,477 @@ +/** + * Phase 13 Plan 02 Task 2 — SettingsCloudTab health/reconnect/disconnect tests. + * + * RED tests: these define the only acceptable connection health, reconnect, disconnect, + * and Google Drive consent copy behavior for Phase 13. They MUST FAIL against the + * current SettingsCloudTab implementation. + * + * Coverage: + * D-12: Full diagnostics plus explicit Test and Reconnect controls in Settings. + * D-13: Automatic health retest after connect/reconnect; explicit Test action allowed. + * D-15: Transient outage warns without deleting credentials or cached metadata. + * D-16: Explicit Disconnect requires confirmation and removes credentials/metadata. + * D-17: Google Drive consent copy must explicitly mention broader storage access. + * T-13-09: Reconnect/consent UX requires explicit broader Google scope explanation. + * + * Security constraints: + * - credentials_enc must never appear in test fixtures or expected HTML. + * - Disconnect confirmation must be required before credential removal. + * - Reconnect must not create a new connection row (must update the existing row). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +// ── Store mock ──────────────────────────────────────────────────────────────── +const mockTestConnection = vi.fn() +const mockReconnect = vi.fn() +const mockDisconnect = vi.fn() +const mockFetchConnections = vi.fn() + +vi.mock('../../../stores/cloudConnections.js', () => ({ + useCloudConnectionsStore: () => ({ + connections: [], + loading: false, + error: null, + fetchConnections: mockFetchConnections, + disconnect: mockDisconnect, + disconnectAll: vi.fn(), + rename: vi.fn(), + testConnection: mockTestConnection, + reconnect: mockReconnect, + defaultDisplayName: (c) => c.display_name ?? c.provider, + }), +})) + +vi.mock('../../../api/client.js', () => ({ + connectWebDav: vi.fn(), + updateWebDavCredentials: vi.fn(), + listCloudConnections: vi.fn(), + disconnectCloud: vi.fn(), + renameCloudConnection: vi.fn(), + initiateOAuth: vi.fn(), + testCloudConnection: vi.fn(), + reconnectCloudConnection: vi.fn(), +})) + +import SettingsCloudTab from '../SettingsCloudTab.vue' + +// ── Connection health fixtures ──────────────────────────────────────────────── +const ACTIVE_GDRIVE = { + id: 'conn-gdrive-1', + provider: 'google_drive', + display_name: 'Work Drive', + status: 'ACTIVE', + health: { state: 'healthy', checked_at: '2026-06-22T10:00:00Z', error_code: null }, + connected_at: '2026-06-01T00:00:00Z', +} + +const REQUIRES_REAUTH_GDRIVE = { + id: 'conn-gdrive-reauth', + provider: 'google_drive', + display_name: 'Stale Drive', + status: 'REQUIRES_REAUTH', + health: { + state: 'requires_reauth', + checked_at: '2026-06-22T09:00:00Z', + error_code: 'token_expired', + error_message: 'Access token has expired. Please reconnect.', + }, + connected_at: '2026-05-01T00:00:00Z', +} + +const TRANSIENT_OUTAGE_ONEDRIVE = { + id: 'conn-onedrive-1', + provider: 'onedrive', + display_name: 'Personal OneDrive', + status: 'DEGRADED', + health: { + state: 'degraded', + checked_at: '2026-06-22T11:00:00Z', + error_code: 'provider_timeout', + error_message: 'OneDrive temporarily unreachable.', + }, + connected_at: '2026-04-01T00:00:00Z', +} + +function makeGlobal(connections) { + return { + plugins: [createPinia()], + stubs: { + CloudCredentialModal: { template: '
', props: ['show', 'provider', 'existing'] }, + AppIcon: true, + ConfirmBlock: true, + }, + provide: { connections }, + } +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +// ── D-12: Full diagnostics in Settings ──────────────────────────────────────── + +describe('SettingsCloudTab_shows_full_health_diagnostics', () => { + it('renders a Test connection button for active connections', () => { + /** + * D-12: Full diagnostics plus explicit Test action in Settings. + * An active connection must expose a "Test connection" button. + * + * RED: current SettingsCloudTab has no Test button per-connection. + */ + vi.doMock('../../../stores/cloudConnections.js', () => ({ + useCloudConnectionsStore: () => ({ + connections: [ACTIVE_GDRIVE], + loading: false, + error: null, + fetchConnections: mockFetchConnections, + disconnect: mockDisconnect, + disconnectAll: vi.fn(), + rename: vi.fn(), + testConnection: mockTestConnection, + reconnect: mockReconnect, + defaultDisplayName: (c) => c.display_name ?? c.provider, + }), + })) + // Mount with ACTIVE_GDRIVE connection inline — the store mock returns empty + // (module mock applies globally), so we verify the structural requirement: + // the component must declare testConnection interaction. + const wrapper = mount(SettingsCloudTab, { + global: { + plugins: [createPinia()], + stubs: { + CloudCredentialModal: { template: '
', props: ['show', 'provider', 'existing'] }, + AppIcon: true, + ConfirmBlock: true, + }, + }, + }) + // At minimum, a "Test" button pattern must be declared — fails until implemented + const html = wrapper.html() + // Either a visible Test button or a data-test="test-connection" element must exist + const hasTestUi = html.includes('Test') || wrapper.find('[data-test="test-connection"]').exists() + expect(hasTestUi).toBe(true) + }) + + it('renders a Reconnect button for connections in REQUIRES_REAUTH status', () => { + /** + * D-12: Explicit Reconnect control must appear when a connection requires + * re-authentication. The button must not appear for healthy connections. + * + * RED: current SettingsCloudTab has no Reconnect button per-connection. + */ + const wrapper = mount(SettingsCloudTab, { + global: { + plugins: [createPinia()], + stubs: { + CloudCredentialModal: { template: '
', props: ['show', 'provider', 'existing'] }, + AppIcon: true, + ConfirmBlock: true, + }, + }, + }) + // Must have reconnect affordance somewhere (button or link) + const html = wrapper.html() + const hasReconnectUi = html.includes('Reconnect') + || wrapper.find('[data-test="reconnect-connection"]').exists() + expect(hasReconnectUi).toBe(true) + }) + + it('shows error message for REQUIRES_REAUTH connection — not a generic status', () => { + /** + * D-12: Actionable error details must be visible for expired/revoked/invalid + * credentials. A generic "Error" badge is insufficient. + * + * RED: current SettingsCloudTab shows no per-connection error detail. + */ + const wrapper = mount(SettingsCloudTab, { + global: { + plugins: [createPinia()], + stubs: { + CloudCredentialModal: { template: '
', props: ['show', 'provider', 'existing'] }, + AppIcon: true, + ConfirmBlock: true, + }, + }, + }) + // Health error state must be represented in the UI beyond just a badge + // The component must have a [data-test="connection-health-detail"] or similar + const hasHealthDetail = wrapper.find('[data-test="connection-health"]').exists() + || wrapper.find('[data-test="health-error"]').exists() + || wrapper.find('[data-test="connection-health-detail"]').exists() + expect(hasHealthDetail).toBe(true) + }) +}) + +// ── D-13: Explicit Test action in Settings ──────────────────────────────────── + +describe('SettingsCloudTab_test_connection_action', () => { + it('clicking Test connection calls testConnection with the connection id', async () => { + /** + * D-13: Allow an explicit Test action in Settings. + * Clicking the Test button must call the store's testConnection method + * with the specific connection's ID. + * + * RED: no testConnection method or call exists in the current component. + */ + mockTestConnection.mockResolvedValue({ state: 'healthy' }) + + const wrapper = mount(SettingsCloudTab, { + global: { + plugins: [createPinia()], + stubs: { + CloudCredentialModal: { template: '
', props: ['show', 'provider', 'existing'] }, + AppIcon: true, + ConfirmBlock: true, + }, + }, + }) + + const testBtn = wrapper.find('[data-test="test-connection"]') + if (testBtn.exists()) { + await testBtn.trigger('click') + await flushPromises() + expect(mockTestConnection).toHaveBeenCalled() + } else { + // RED: button does not exist yet + expect(wrapper.find('[data-test="test-connection"]').exists()).toBe(true) + } + }) +}) + +// ── D-15: Transient outage — preserve credentials, show warning ─────────────── + +describe('SettingsCloudTab_transient_outage_warning', () => { + it('DEGRADED connection shows an actionable warning without a Disconnect prompt', () => { + /** + * D-15: A timeout or temporarily unreachable provider is a warning state only. + * The Settings UI must show an actionable warning (retry/reconnect) and must + * NOT immediately prompt for disconnect or credential deletion. + * + * RED: current component does not distinguish degraded from reauth state. + */ + const wrapper = mount(SettingsCloudTab, { + global: { + plugins: [createPinia()], + stubs: { + CloudCredentialModal: { template: '
', props: ['show', 'provider', 'existing'] }, + AppIcon: true, + ConfirmBlock: true, + }, + }, + }) + // A connection health warning indicator must exist + const hasWarning = wrapper.find('[data-test="health-warning"]').exists() + || wrapper.find('[data-test="connection-warning"]').exists() + expect(hasWarning).toBe(true) + }) + + it('DEGRADED connection does not auto-trigger disconnect confirmation', () => { + /** + * D-15: A transient failure must never trigger automatic disconnect. + * The disconnect confirm dialog must NOT appear automatically. + * + * RED: no DEGRADED state distinction in current component. + */ + const wrapper = mount(SettingsCloudTab, { + global: { + plugins: [createPinia()], + stubs: { + CloudCredentialModal: { template: '
', props: ['show', 'provider', 'existing'] }, + AppIcon: true, + ConfirmBlock: true, + }, + }, + }) + // Disconnect confirm must NOT appear on mount (only on explicit user action) + expect(wrapper.find('[data-test="disconnect-confirm-dialog"]').exists()).toBe(false) + }) +}) + +// ── D-16: Explicit Disconnect requires confirmation ─────────────────────────── + +describe('SettingsCloudTab_disconnect_requires_confirmation', () => { + it('clicking Disconnect opens a confirmation dialog before removing credentials', async () => { + /** + * D-16: Explicit user-initiated Disconnect requires confirmation. + * Credentials and cloud metadata must not be removed without user confirmation. + * + * RED: no explicit disconnect confirmation flow exists in the current component. + */ + const wrapper = mount(SettingsCloudTab, { + global: { + plugins: [createPinia()], + stubs: { + CloudCredentialModal: { template: '
', props: ['show', 'provider', 'existing'] }, + AppIcon: true, + ConfirmBlock: true, + }, + }, + }) + + // Find any disconnect button + const disconnectBtn = wrapper.find('[data-test="disconnect-connection"]') + ?? wrapper.findAll('button').find(b => b.text().toLowerCase().includes('disconnect')) + + if (disconnectBtn && disconnectBtn.exists()) { + await disconnectBtn.trigger('click') + await flushPromises() + // Store disconnect must NOT be called immediately — confirmation required + expect(mockDisconnect).not.toHaveBeenCalled() + } else { + // RED: No Disconnect button exists yet + const hasDisconnect = wrapper.find('[data-test="disconnect-connection"]').exists() + expect(hasDisconnect).toBe(true) + } + }) + + it('Disconnect confirmation clearly states provider files are untouched', () => { + /** + * D-16: Disconnect removes credentials and DocuVault cloud metadata, + * but leaves all provider files untouched. The confirmation copy must say so. + * + * RED: no disconnect confirmation dialog with this copy exists. + */ + const wrapper = mount(SettingsCloudTab, { + global: { + plugins: [createPinia()], + stubs: { + CloudCredentialModal: { template: '
', props: ['show', 'provider', 'existing'] }, + AppIcon: true, + ConfirmBlock: true, + }, + }, + }) + // The confirmation text must describe what is removed vs preserved + // Since dialog is not open by default, check that when a confirm element appears + // it has the correct structural intent. For now, the data-test must exist. + const confirmEl = wrapper.find('[data-test="disconnect-confirm-dialog"]') + || wrapper.find('[data-test="confirm-disconnect-copy"]') + // RED: confirm element does not exist until implemented + expect( + wrapper.find('[data-test="disconnect-confirm-dialog"]').exists() + || wrapper.find('[data-test="confirm-disconnect-copy"]').exists() + ).toBe(true) + }) +}) + +// ── D-17 / T-13-09: Google Drive broader scope consent copy ────────────────── + +describe('SettingsCloudTab_google_drive_consent_copy', () => { + it('Google Drive connect/reconnect copy mentions broader storage access scope', () => { + /** + * D-17 and T-13-09: Phase 13 requests broader Google Drive access beyond + * drive.file scope. The consent copy shown to users must explicitly mention + * that the app will access files throughout their Google Drive, not just + * files created by the app. + * + * RED: current SettingsCloudTab shows generic "Google Drive" connect text + * without any scope explanation. + */ + const wrapper = mount(SettingsCloudTab, { + global: { + plugins: [createPinia()], + stubs: { + CloudCredentialModal: { template: '
', props: ['show', 'provider', 'existing'] }, + AppIcon: true, + ConfirmBlock: true, + }, + }, + }) + + const html = wrapper.html() + + // Must contain scope information in some form: + // - "all files", "full Drive access", "entire Drive", "all your Google Drive files" + // - or a data-test="gdrive-scope-notice" element + const hasScopeNote = html.includes('all files') + || html.includes('full Drive') + || html.includes('entire Drive') + || html.includes('all your Google Drive') + || html.includes('broader') + || wrapper.find('[data-test="gdrive-scope-notice"]').exists() + + // RED: no scope notice exists in the current component + expect(hasScopeNote).toBe(true) + }) + + it('Google Drive reconnect dialog also includes broader scope explanation', () => { + /** + * T-13-09: The expanded Phase 13 scope must not ship silently. + * Even the reconnect dialog must explain the broader access being requested. + * + * RED: no reconnect dialog with scope explanation exists. + */ + const wrapper = mount(SettingsCloudTab, { + global: { + plugins: [createPinia()], + stubs: { + CloudCredentialModal: { template: '
', props: ['show', 'provider', 'existing'] }, + AppIcon: true, + ConfirmBlock: true, + }, + }, + }) + + // A data-test attribute covering the gdrive scope disclosure must exist + const hasScopeEl = wrapper.find('[data-test="gdrive-scope-notice"]').exists() + || wrapper.find('[data-test="gdrive-consent-copy"]').exists() + // RED: no scope disclosure element exists yet + expect(hasScopeEl).toBe(true) + }) +}) + +// ── D-12: Compact health status beside each connection ──────────────────────── + +describe('SettingsCloudTab_health_state_per_connection', () => { + it('each connection row shows a health status indicator', () => { + /** + * D-12: Connection health must appear beside each connection listing. + * Each row must have a health status element. + * + * RED: current component shows only "Connected" text, no per-connection health. + */ + const wrapper = mount(SettingsCloudTab, { + global: { + plugins: [createPinia()], + stubs: { + CloudCredentialModal: { template: '
', props: ['show', 'provider', 'existing'] }, + AppIcon: true, + ConfirmBlock: true, + }, + }, + }) + + // Structural check: health status elements must be declared + // Even with empty connections, the component must have health state structure + const hasHealthStructure = wrapper.find('[data-test="connection-health"]').exists() + || wrapper.find('[data-test="health-status"]').exists() + // RED: no health status structure exists in the current component + expect(hasHealthStructure).toBe(true) + }) + + it('credentials_enc does not appear in any rendered output', () => { + /** + * Security invariant: the encrypted credentials must never appear in the + * component's rendered HTML output. + * + * This passes in the current component (it doesn't render credentials), + * but must remain passing throughout Phase 13 changes. + */ + const wrapper = mount(SettingsCloudTab, { + global: { + plugins: [createPinia()], + stubs: { + CloudCredentialModal: { template: '
', props: ['show', 'provider', 'existing'] }, + AppIcon: true, + ConfirmBlock: true, + }, + }, + }) + expect(wrapper.html()).not.toContain('credentials_enc') + expect(wrapper.html()).not.toContain('access_token') + expect(wrapper.html()).not.toContain('refresh_token') + }) +}) diff --git a/frontend/src/stores/__tests__/cloudConnections.test.js b/frontend/src/stores/__tests__/cloudConnections.test.js index a71567d..87764a8 100644 --- a/frontend/src/stores/__tests__/cloudConnections.test.js +++ b/frontend/src/stores/__tests__/cloudConnections.test.js @@ -236,3 +236,188 @@ describe('setBrowseState freshness mapping', () => { 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) + } + }) +}) diff --git a/frontend/src/views/__tests__/CloudFolderRenderedFlow.test.js b/frontend/src/views/__tests__/CloudFolderRenderedFlow.test.js index 4a58cf9..d81981e 100644 --- a/frontend/src/views/__tests__/CloudFolderRenderedFlow.test.js +++ b/frontend/src/views/__tests__/CloudFolderRenderedFlow.test.js @@ -412,3 +412,154 @@ describe('filenames_render_as_text_not_html', () => { expect(html).not.toContain('') }) }) + +// ── Phase 13 Plan 02: health warning and reconnect state rendered-flow tests ── +// These tests MUST FAIL until Phase 13 health/reconnect UI is implemented. + +describe('warning_state_shows_health_banner_while_keeping_items_D14', () => { + it('warning freshness shows a health/reconnect banner alongside cached items', async () => { + /** + * D-12 / D-14: On a warning freshness state, the browser must show BOTH: + * 1. The cached items (stale metadata visible while DocuVault revalidates) + * 2. A health/reconnect banner with compact status and Reconnect action + * + * RED: current StorageBrowser shows a freshness warning text but no actionable + * reconnect affordance within the cloud browser. + */ + api.getCloudFoldersByConnectionId.mockResolvedValue({ + items: [ROOT_FOLDER_A, ROOT_FILE_A], + capabilities: null, + freshness: WARNING_FRESHNESS, + }) + + const wrapper = mount(CloudFolderView, { + global: { stubs: leafStubs }, + }) + await flushPromises() + + const html = wrapper.html() + + // Items must still be rendered (stale metadata visible) + expect(html).toContain('Alpha') + expect(html).toContain('report.pdf') + + // A health/reconnect UI element must be present + const hasHealthBanner = wrapper.find('[data-test="cloud-health-banner"]').exists() + || wrapper.find('[data-test="connection-warning"]').exists() + || wrapper.find('[data-test="reconnect-action"]').exists() + + // RED: no health banner with reconnect affordance in the current browser + expect(hasHealthBanner).toBe(true) + }) + + it('warning state renders a Reconnect button inside the browser, not just a badge', async () => { + /** + * D-12: Compact status plus Reconnect must appear in the cloud browser — + * not just in Settings. The browser must surface actionable reconnect. + * + * RED: current browser shows stale warning text but no Reconnect button. + */ + api.getCloudFoldersByConnectionId.mockResolvedValue({ + items: [ROOT_FOLDER_A], + capabilities: null, + freshness: WARNING_FRESHNESS, + }) + + const wrapper = mount(CloudFolderView, { + global: { stubs: leafStubs }, + }) + await flushPromises() + + // A reconnect action must be accessible within the browser component + const reconnectEl = wrapper.find('[data-test="reconnect-action"]') + || wrapper.find('button[aria-label="Reconnect"]') + // RED: no reconnect button in browser view + expect( + wrapper.find('[data-test="reconnect-action"]').exists() + || !!wrapper.findAll('button').find(b => b.text().toLowerCase().includes('reconnect')) + ).toBe(true) + }) +}) + +describe('requires_reauth_state_shows_reauthentication_prompt_D14', () => { + it('requires_reauth freshness state shows a reauthentication prompt', async () => { + /** + * D-12 / CONN-02: When a connection requires re-authentication, the browser + * must show a clear reauthentication prompt (not just a generic error). + * + * RED: current rendered flow has no requires_reauth specific UI. + */ + const REAUTH_FRESHNESS = { + refresh_state: 'requires_reauth', + last_refreshed_at: '2026-06-20T10:00:00Z', + error_code: 'token_expired', + error_message: 'Your Google Drive access has expired.', + } + + api.getCloudFoldersByConnectionId.mockResolvedValue({ + items: [ROOT_FOLDER_A], + capabilities: null, + freshness: REAUTH_FRESHNESS, + }) + + const wrapper = mount(CloudFolderView, { + global: { stubs: leafStubs }, + }) + await flushPromises() + + // A reauthentication-specific UI element must be rendered + const hasReauthUi = wrapper.find('[data-test="requires-reauth"]').exists() + || wrapper.find('[data-test="reauth-prompt"]').exists() + || wrapper.html().toLowerCase().includes('reconnect') + + // RED: no reauth-specific UI in the current browser + expect(hasReauthUi).toBe(true) + + // Items must still be shown (D-14: cached metadata visible during reauth state) + expect(wrapper.html()).toContain('Alpha') + }) +}) + +describe('no_health_probe_on_folder_navigate_D13', () => { + it('navigating into a subfolder does NOT trigger a separate health API call', async () => { + /** + * D-13: Ordinary folder navigation must never trigger a provider health probe. + * getCloudFoldersByConnectionId is for browse — no side-effect health calls. + * + * RED: a naive implementation could call testCloudConnection on every navigate. + */ + api.getCloudFoldersByConnectionId.mockResolvedValue({ + items: [ROOT_FOLDER_A], + capabilities: null, + freshness: FRESH_FRESHNESS, + }) + + const wrapper = mount(CloudFolderView, { + global: { stubs: leafStubs }, + }) + await flushPromises() + + // Navigate into a subfolder + const storageBrowser = wrapper.findComponent({ name: 'StorageBrowser' }) + expect(storageBrowser.exists()).toBe(true) + + api.getCloudFoldersByConnectionId.mockResolvedValue({ + items: [ROOT_FILE_A], + capabilities: null, + freshness: FRESH_FRESHNESS, + }) + + await storageBrowser.vm.$emit('folder-navigate', ROOT_FOLDER_A) + await flushPromises() + + // Browse API was called for the subfolder — that's expected + expect(api.getCloudFoldersByConnectionId).toHaveBeenCalledTimes(2) + + // testCloudConnection (if it exists) must NOT have been called during navigation + const { testCloudConnection } = await import('../../api/client.js') + if (typeof testCloudConnection === 'function') { + expect(testCloudConnection).not.toHaveBeenCalled() + } + // The invariant is: browse calls = exactly N navigations; health calls = 0 + }) +})