test(14-01): add RED frontend tests for analysis queue UX and store actions
- StorageBrowser.analysis-queue.test.js: row-level Analyze action (D-01), toolbar Analyze Selection/Folder/Connection (D-01..03), estimate modal with D-03 required fields (supported_count/bytes/unsupported, Start action), aggregate progress with simplified/detailed labels (D-05..08), expandable queue item list, per-item cancel/retry/skip controls (D-09..10), batch Cancel all (D-09), architecture no-parallel-grid guard (CLAUDE.md) - cloudConnections.analysis.test.js: analysis state existence, translateAnalysisStatus single-source (D-06/D-12), API call assertions without credentials (T-14-02), cancel/retry/skip action coverage (ANALYZE-05), failure_behavior default=pause_batch (D-11), detailedAnalysisProgress preference (D-06), cache limit exposure (CACHE-04/D-15), credential-free localStorage guard Requirements: ANALYZE-01..05, CACHE-04..05 All tests fail against missing Phase 14 implementation — no import errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f67559c7f0
commit
7f2f570582
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* Phase 14 Plan 01 — RED tests for analysis state management in cloudConnections store.
|
||||
*
|
||||
* Requirements: ANALYZE-01, ANALYZE-04, ANALYZE-05, CACHE-04, CACHE-05
|
||||
* Decisions: D-06, D-07, D-09, D-11, D-12
|
||||
*
|
||||
* All tests check the cloudConnections store for Phase 14 additions and must
|
||||
* FAIL until analysis state/actions are added.
|
||||
*
|
||||
* Implementation note on store location:
|
||||
* Phase 14 may add analysis state to the existing cloudConnections store OR
|
||||
* introduce a separate cloudAnalysis.js store. Tests assert that
|
||||
* useCloudConnectionsStore exposes the expected properties/actions. If Phase 14
|
||||
* uses a separate store, those exports must also be re-exported from
|
||||
* cloudConnections.js so existing consumers do not break.
|
||||
*
|
||||
* We do NOT dynamically import a non-existent cloudAnalysis.js here because Vite
|
||||
* resolves dynamic imports at transform time (even inside try/catch) and would
|
||||
* fail the whole test suite at import-resolution, before any test runs.
|
||||
*
|
||||
* Security constraints:
|
||||
* - API calls never pass credentials or object keys in request payloads.
|
||||
* - Status translation (internal → simplified/detailed) lives in the store only.
|
||||
* - No localStorage or sessionStorage for credentials.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// ── Mock api/client.js — no real HTTP calls ──────────────────────────────────
|
||||
vi.mock('../../api/client.js', () => ({
|
||||
// Existing cloud connection API
|
||||
listCloudConnections: vi.fn(),
|
||||
disconnectCloud: vi.fn(),
|
||||
connectWebDav: vi.fn(),
|
||||
updateDefaultStorage: vi.fn(),
|
||||
renameCloudConnection: vi.fn(),
|
||||
getCloudFoldersByConnectionId: vi.fn(),
|
||||
testCloudConnection: vi.fn(),
|
||||
// Phase 14 analysis API — will be added to api/cloud.js
|
||||
estimateAnalysis: vi.fn(),
|
||||
enqueueAnalysis: vi.fn(),
|
||||
getAnalysisJobStatus: vi.fn(),
|
||||
cancelAnalysisJob: vi.fn(),
|
||||
cancelAnalysisItem: vi.fn(),
|
||||
skipAnalysisItem: vi.fn(),
|
||||
retryAnalysisItem: vi.fn(),
|
||||
getCacheSettings: vi.fn(),
|
||||
updateCacheSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
import * as api from '../../api/client.js'
|
||||
import { useCloudConnectionsStore } from '../cloudConnections.js'
|
||||
|
||||
// ── Status vocabulary ─────────────────────────────────────────────────────────
|
||||
const SIMPLIFIED_LABELS = {
|
||||
queued: 'waiting',
|
||||
downloading: 'working',
|
||||
extracting: 'working',
|
||||
classifying: 'working',
|
||||
indexed: 'done',
|
||||
already_current: 'done',
|
||||
cancelled: 'skipped',
|
||||
failed: 'failed',
|
||||
unsupported: 'skipped',
|
||||
stale: 'working',
|
||||
}
|
||||
|
||||
const DETAILED_LABELS = {
|
||||
queued: 'queued',
|
||||
downloading: 'downloading',
|
||||
extracting: 'extracting',
|
||||
classifying: 'classifying',
|
||||
indexed: 'indexed',
|
||||
already_current: 'indexed',
|
||||
cancelled: 'cancelled',
|
||||
failed: 'failed',
|
||||
unsupported: 'unsupported',
|
||||
stale: 'stale',
|
||||
}
|
||||
|
||||
// ── Job fixture ───────────────────────────────────────────────────────────────
|
||||
const MOCK_JOB = {
|
||||
job_id: 'job-uuid-analysis-001',
|
||||
connection_id: 'conn-uuid-001',
|
||||
scope_kind: 'file',
|
||||
status: 'running',
|
||||
total_count: 2,
|
||||
queued_count: 1,
|
||||
downloading_count: 1,
|
||||
extracting_count: 0,
|
||||
classifying_count: 0,
|
||||
indexed_count: 0,
|
||||
already_current_count: 0,
|
||||
cancelled_count: 0,
|
||||
failed_count: 0,
|
||||
unsupported_count: 0,
|
||||
failure_behavior: 'pause_batch',
|
||||
items: [
|
||||
{
|
||||
item_id: 'item-uuid-001',
|
||||
cloud_item_id: 'cloud-item-uuid-001',
|
||||
name: 'report.pdf',
|
||||
status: 'downloading',
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
item_id: 'item-uuid-002',
|
||||
cloud_item_id: 'cloud-item-uuid-002',
|
||||
name: 'budget.xlsx',
|
||||
status: 'queued',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// ─── Store existence ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('analysis_store_existence', () => {
|
||||
it('cloudConnections store exposes analysis state (Phase 14 addition)', () => {
|
||||
/**
|
||||
* Phase 14 must add analysis state to the cloudConnections store
|
||||
* (or a companion store re-exported from it).
|
||||
*
|
||||
* RED: no analysis state exists in current cloudConnections store.
|
||||
*/
|
||||
const store = useCloudConnectionsStore()
|
||||
|
||||
const hasAnalysisState = (
|
||||
'analysisJobs' in store ||
|
||||
'activeAnalysisJob' in store ||
|
||||
'analysisQueue' in store
|
||||
)
|
||||
|
||||
// RED failure expected here until Phase 14 adds analysis state
|
||||
expect(hasAnalysisState).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── D-06: Status translation — single source ────────────────────────────────
|
||||
|
||||
describe('status_translation', () => {
|
||||
it('cloudConnections store exposes translateAnalysisStatus function', () => {
|
||||
/**
|
||||
* D-06 / ANALYZE-04: The store must expose a translateAnalysisStatus helper
|
||||
* that maps internal status vocabulary to simplified UI labels.
|
||||
*
|
||||
* Single translation source — components must not translate independently.
|
||||
*
|
||||
* RED: no translateAnalysisStatus in current cloudConnections store.
|
||||
*/
|
||||
const store = useCloudConnectionsStore()
|
||||
expect(typeof store.translateAnalysisStatus).toBe('function')
|
||||
})
|
||||
|
||||
it('simplified mode maps internal statuses to simplified labels', () => {
|
||||
/**
|
||||
* D-06: In simplified mode (default), internal statuses must map to the
|
||||
* simplified vocabulary: waiting, working, done, skipped, failed.
|
||||
*/
|
||||
const store = useCloudConnectionsStore()
|
||||
const translateFn = store.translateAnalysisStatus
|
||||
|
||||
if (typeof translateFn !== 'function') {
|
||||
// RED: function doesn't exist
|
||||
expect(typeof translateFn).toBe('function')
|
||||
return
|
||||
}
|
||||
|
||||
for (const [internal, simplified] of Object.entries(SIMPLIFIED_LABELS)) {
|
||||
const label = translateFn(internal, { detailed: false })
|
||||
expect(label).toBe(simplified)
|
||||
}
|
||||
})
|
||||
|
||||
it('detailed mode maps internal statuses to detailed labels', () => {
|
||||
/**
|
||||
* D-06: In detailed mode (user Settings opt-in), internal statuses map to
|
||||
* the detailed vocabulary.
|
||||
*/
|
||||
const store = useCloudConnectionsStore()
|
||||
const translateFn = store.translateAnalysisStatus
|
||||
|
||||
if (typeof translateFn !== 'function') {
|
||||
expect(typeof translateFn).toBe('function')
|
||||
return
|
||||
}
|
||||
|
||||
for (const [internal, detailed] of Object.entries(DETAILED_LABELS)) {
|
||||
const label = translateFn(internal, { detailed: true })
|
||||
expect(label).toBe(detailed)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─── API client calls ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('analysis_api_calls', () => {
|
||||
it('requestEstimate action calls estimateAnalysis API without credentials', async () => {
|
||||
/**
|
||||
* ANALYZE-01 / ANALYZE-03: requestEstimate store action calls estimateAnalysis API.
|
||||
* API call must not include credentials.
|
||||
*
|
||||
* RED: no requestEstimate action in current store.
|
||||
*/
|
||||
api.estimateAnalysis.mockResolvedValue({
|
||||
supported_count: 5,
|
||||
unsupported_count: 1,
|
||||
total_provider_bytes: 1024 * 1024,
|
||||
recursive: false,
|
||||
scope_kind: 'selection',
|
||||
})
|
||||
|
||||
const store = useCloudConnectionsStore()
|
||||
expect(typeof store.requestEstimate).toBe('function')
|
||||
|
||||
if (typeof store.requestEstimate !== 'function') return
|
||||
|
||||
await store.requestEstimate('conn-uuid-001', {
|
||||
scope: 'selection',
|
||||
provider_item_ids: ['item-1', 'item-2'],
|
||||
})
|
||||
|
||||
expect(api.estimateAnalysis).toHaveBeenCalledWith(
|
||||
'conn-uuid-001',
|
||||
expect.objectContaining({ scope: 'selection' })
|
||||
)
|
||||
|
||||
// No credentials in the call arguments
|
||||
const callArgs = JSON.stringify(api.estimateAnalysis.mock.calls[0])
|
||||
expect(callArgs).not.toContain('access_token')
|
||||
expect(callArgs).not.toContain('credentials_enc')
|
||||
expect(callArgs).not.toContain('refresh_token')
|
||||
})
|
||||
|
||||
it('enqueueAnalysis action calls enqueueAnalysis API and records job_id', async () => {
|
||||
/**
|
||||
* ANALYZE-01: enqueueAnalysis store action calls the API and stores returned job_id.
|
||||
*
|
||||
* RED: no enqueueAnalysis action in current store.
|
||||
*/
|
||||
api.enqueueAnalysis.mockResolvedValue({ job_id: 'job-uuid-new-001' })
|
||||
|
||||
const store = useCloudConnectionsStore()
|
||||
expect(typeof store.enqueueAnalysis).toBe('function')
|
||||
|
||||
if (typeof store.enqueueAnalysis !== 'function') return
|
||||
|
||||
await store.enqueueAnalysis('conn-uuid-001', {
|
||||
scope: 'file',
|
||||
provider_item_ids: ['item-1'],
|
||||
failure_behavior: 'pause_batch',
|
||||
})
|
||||
|
||||
expect(api.enqueueAnalysis).toHaveBeenCalledWith(
|
||||
'conn-uuid-001',
|
||||
expect.objectContaining({ scope: 'file' })
|
||||
)
|
||||
})
|
||||
|
||||
it('cancelJob action calls cancelAnalysisJob API with job_id', async () => {
|
||||
/**
|
||||
* ANALYZE-05: cancelJob store action calls the cancel API.
|
||||
*
|
||||
* RED: no cancelJob action in current store.
|
||||
*/
|
||||
api.cancelAnalysisJob.mockResolvedValue({ status: 'cancelling' })
|
||||
|
||||
const store = useCloudConnectionsStore()
|
||||
expect(typeof store.cancelJob).toBe('function')
|
||||
|
||||
if (typeof store.cancelJob !== 'function') return
|
||||
|
||||
await store.cancelJob('job-uuid-001')
|
||||
expect(api.cancelAnalysisJob).toHaveBeenCalledWith('job-uuid-001')
|
||||
})
|
||||
|
||||
it('retryItem action calls retryAnalysisItem API with job_id and item_id', async () => {
|
||||
/**
|
||||
* ANALYZE-05: retryItem store action calls the per-item retry API.
|
||||
*
|
||||
* RED: no retryItem action in current store.
|
||||
*/
|
||||
api.retryAnalysisItem.mockResolvedValue({ status: 'queued' })
|
||||
|
||||
const store = useCloudConnectionsStore()
|
||||
expect(typeof store.retryItem).toBe('function')
|
||||
|
||||
if (typeof store.retryItem !== 'function') return
|
||||
|
||||
await store.retryItem('job-uuid-001', 'item-uuid-001')
|
||||
expect(api.retryAnalysisItem).toHaveBeenCalledWith('job-uuid-001', 'item-uuid-001')
|
||||
})
|
||||
|
||||
it('skipItem action calls skipAnalysisItem API with job_id and item_id', async () => {
|
||||
/**
|
||||
* ANALYZE-05: skipItem store action calls the per-item skip API.
|
||||
*/
|
||||
api.skipAnalysisItem.mockResolvedValue({ status: 'cancelled' })
|
||||
|
||||
const store = useCloudConnectionsStore()
|
||||
expect(typeof store.skipItem).toBe('function')
|
||||
|
||||
if (typeof store.skipItem !== 'function') return
|
||||
|
||||
await store.skipItem('job-uuid-001', 'item-uuid-001')
|
||||
expect(api.skipAnalysisItem).toHaveBeenCalledWith('job-uuid-001', 'item-uuid-001')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── D-11: Failure behavior setting ─────────────────────────────────────────
|
||||
|
||||
describe('failure_behavior_setting', () => {
|
||||
it('default failure behavior is pause_batch', () => {
|
||||
/**
|
||||
* D-11: Default failure behavior must be 'pause_batch'.
|
||||
*
|
||||
* RED: no failureBehavior state in current store.
|
||||
*/
|
||||
const store = useCloudConnectionsStore()
|
||||
|
||||
const behavior = store.failureBehavior ?? store.analysisFailureBehavior
|
||||
expect(behavior).toBe('pause_batch')
|
||||
})
|
||||
|
||||
it('failure behavior can be changed to continue_item', () => {
|
||||
/**
|
||||
* D-11: Users can configure 'continue_item' failure behavior.
|
||||
*/
|
||||
const store = useCloudConnectionsStore()
|
||||
|
||||
const setFn = store.setFailureBehavior ?? store.setAnalysisFailureBehavior
|
||||
expect(typeof setFn).toBe('function')
|
||||
|
||||
if (typeof setFn !== 'function') return
|
||||
|
||||
setFn('continue_item')
|
||||
const behavior = store.failureBehavior ?? store.analysisFailureBehavior
|
||||
expect(behavior).toBe('continue_item')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── D-06: Analysis progress detail preference ───────────────────────────────
|
||||
|
||||
describe('analysis_progress_detail_setting', () => {
|
||||
it('analysis progress detail defaults to simplified (falsy)', () => {
|
||||
/**
|
||||
* D-06: Default analysis progress detail is simplified (off).
|
||||
*
|
||||
* RED: no detailedAnalysisProgress state in current store.
|
||||
*/
|
||||
const store = useCloudConnectionsStore()
|
||||
|
||||
const detail = store.detailedAnalysisProgress ?? store.analysisProgressDetail
|
||||
expect(detail).toBeFalsy()
|
||||
})
|
||||
|
||||
it('analysis progress detail can be toggled to detailed mode', () => {
|
||||
/**
|
||||
* D-06: Setting detailedAnalysisProgress to true switches to detailed labels.
|
||||
*/
|
||||
const store = useCloudConnectionsStore()
|
||||
|
||||
const setFn = store.setDetailedAnalysisProgress ?? store.setAnalysisProgressDetail
|
||||
expect(typeof setFn).toBe('function')
|
||||
|
||||
if (typeof setFn !== 'function') return
|
||||
|
||||
setFn(true)
|
||||
const detail = store.detailedAnalysisProgress ?? store.analysisProgressDetail
|
||||
expect(detail).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Security: credentials never in storage ──────────────────────────────────
|
||||
|
||||
describe('security_no_credential_storage', () => {
|
||||
it('analysis store actions do not write credentials to localStorage', async () => {
|
||||
/**
|
||||
* Security: No analysis store action may read or write credentials
|
||||
* to localStorage or sessionStorage. Token storage is Pinia-memory only.
|
||||
*/
|
||||
const localStorageSpy = vi.spyOn(Storage.prototype, 'setItem')
|
||||
api.estimateAnalysis.mockResolvedValue({
|
||||
supported_count: 1,
|
||||
unsupported_count: 0,
|
||||
total_provider_bytes: 1024,
|
||||
})
|
||||
|
||||
const store = useCloudConnectionsStore()
|
||||
|
||||
if (typeof store.requestEstimate === 'function') {
|
||||
await store.requestEstimate('conn-uuid-001', { scope: 'file', provider_item_ids: [] })
|
||||
}
|
||||
|
||||
const credentialPatterns = ['credentials_enc', 'access_token', 'refresh_token', 'client_secret']
|
||||
for (const call of localStorageSpy.mock.calls) {
|
||||
const key = String(call[0] ?? '')
|
||||
const value = String(call[1] ?? '')
|
||||
for (const pattern of credentialPatterns) {
|
||||
expect(key).not.toContain(pattern)
|
||||
expect(value).not.toContain(pattern)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('analysis queue items stored in state do not contain credentials or object keys', async () => {
|
||||
/**
|
||||
* T-14-02: Queue items stored in the store's analysisQueue array must not
|
||||
* contain credentials_enc, raw MinIO object_keys, or auth tokens.
|
||||
*/
|
||||
api.getAnalysisJobStatus.mockResolvedValue(MOCK_JOB)
|
||||
|
||||
const store = useCloudConnectionsStore()
|
||||
|
||||
if (typeof store.fetchJobStatus === 'function') {
|
||||
await store.fetchJobStatus('job-uuid-001')
|
||||
const queueItems = store.analysisQueue ?? store.activeJob?.items ?? []
|
||||
const serialized = JSON.stringify(queueItems)
|
||||
const forbidden = ['credentials_enc', 'access_token', 'refresh_token', 'object_key']
|
||||
for (const f of forbidden) {
|
||||
expect(serialized).not.toContain(f)
|
||||
}
|
||||
} else {
|
||||
// fetchJobStatus not yet implemented — RED
|
||||
expect(typeof store.fetchJobStatus).toBe('function')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─── CACHE-04/D-15: Cache limit settings ─────────────────────────────────────
|
||||
|
||||
describe('cache_settings', () => {
|
||||
it('cache limit setting is accessible and has a positive default', () => {
|
||||
/**
|
||||
* CACHE-04 / D-15: Users can configure a cache limit. The store must expose
|
||||
* the current cache limit and a way to update it.
|
||||
*
|
||||
* RED: no cache limit state in current store.
|
||||
*/
|
||||
const store = useCloudConnectionsStore()
|
||||
|
||||
const limitKey = Object.keys(store).find(k =>
|
||||
k.includes('cacheLimit') || k.includes('cache_limit') || k.includes('CacheLimit')
|
||||
)
|
||||
expect(limitKey).toBeTruthy()
|
||||
|
||||
if (limitKey) {
|
||||
expect(store[limitKey]).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('updateCacheSettings action calls updateCacheSettings API', async () => {
|
||||
/**
|
||||
* CACHE-04: Updating the cache limit persists via the API.
|
||||
*/
|
||||
api.updateCacheSettings.mockResolvedValue({ cloud_cache_limit_bytes: 100 * 1024 * 1024 })
|
||||
|
||||
const store = useCloudConnectionsStore()
|
||||
const updateFn = store.updateCacheSettings ?? store.setCacheLimit
|
||||
expect(typeof updateFn).toBe('function')
|
||||
|
||||
if (typeof updateFn !== 'function') return
|
||||
|
||||
await updateFn({ cloud_cache_limit_bytes: 100 * 1024 * 1024 })
|
||||
expect(api.updateCacheSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cloud_cache_limit_bytes: 100 * 1024 * 1024 })
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user