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)
This commit is contained in:
@@ -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: '<div />', 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: '<div />', 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: '<div />', 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: '<div />', 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: '<div />', 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: '<div />', 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: '<div />', 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: '<div />', 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: '<div />', 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: '<div />', 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: '<div />', 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: '<div />', 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: '<div />', 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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -412,3 +412,154 @@ describe('filenames_render_as_text_not_html', () => {
|
||||
expect(html).not.toContain('<img src=x onerror=alert(1)>')
|
||||
})
|
||||
})
|
||||
|
||||
// ── 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
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user