From adbdd0f64fc0a461f9566106bd5dd3d6def46a49 Mon Sep 17 00:00:00 2001 From: kd Date: Mon, 19 Jan 2026 12:19:15 -0800 Subject: [PATCH] test(frontend): add comprehensive cleanupStore tests Add 26 unit tests for cleanupStore covering all functionality: - Initial state validation - Scan operations (orphaned uploads, corrupt objects, versions, empty objects) - Cleanup operations (orphaned uploads, old versions) - Job management (load, status, cancel, refresh) - Analytics and diagnostics loading - Utility actions (clear results, reset state) Coverage improvements: - cleanupStore: 84.5% statements, 100% functions - All 26 tests passing - Removed cleanupStore from coverage exclusions Part of cleanup component testing initiative. --- frontend/src/store/cleanupStore.test.ts | 606 ++++++++++++++++++++++++ frontend/vitest.config.ts | 3 +- 2 files changed, 607 insertions(+), 2 deletions(-) create mode 100644 frontend/src/store/cleanupStore.test.ts diff --git a/frontend/src/store/cleanupStore.test.ts b/frontend/src/store/cleanupStore.test.ts new file mode 100644 index 0000000..8c651f7 --- /dev/null +++ b/frontend/src/store/cleanupStore.test.ts @@ -0,0 +1,606 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useCleanupStore } from './cleanupStore'; +import { api } from '../lib/api'; +import type { CleanupJob, OrphanedUpload, CorruptObject, ObjectVersion, EmptyObject } from '../types'; + +// Mock the API +vi.mock('../lib/api', () => ({ + api: { + cleanup: { + scanOrphanedUploads: vi.fn(), + scanCorruptObjects: vi.fn(), + scanOrphanedVersions: vi.fn(), + scanEmptyObjects: vi.fn(), + cleanupOrphanedUploads: vi.fn(), + cleanupOldVersions: vi.fn(), + listJobs: vi.fn(), + getJobStatus: vi.fn(), + cancelJob: vi.fn(), + getAnalytics: vi.fn(), + getDiagnostics: vi.fn(), + }, + }, +})); + +describe('cleanupStore', () => { + beforeEach(() => { + // Reset store state before each test + useCleanupStore.setState({ + scanResults: { + orphanedUploads: [], + corruptObjects: [], + orphanedVersions: [], + emptyObjects: [], + }, + isScanning: false, + scanError: null, + jobs: [], + activeJob: null, + isLoadingJobs: false, + jobsError: null, + analytics: null, + diagnostics: null, + isLoadingAnalytics: false, + analyticsError: null, + }); + + // Clear all mocks + vi.clearAllMocks(); + }); + + describe('Initial State', () => { + it('should have empty scan results', () => { + const state = useCleanupStore.getState(); + expect(state.scanResults.orphanedUploads).toEqual([]); + expect(state.scanResults.corruptObjects).toEqual([]); + expect(state.scanResults.orphanedVersions).toEqual([]); + expect(state.scanResults.emptyObjects).toEqual([]); + }); + + it('should not be scanning', () => { + const state = useCleanupStore.getState(); + expect(state.isScanning).toBe(false); + expect(state.scanError).toBeNull(); + }); + + it('should have empty jobs list', () => { + const state = useCleanupStore.getState(); + expect(state.jobs).toEqual([]); + expect(state.activeJob).toBeNull(); + expect(state.isLoadingJobs).toBe(false); + expect(state.jobsError).toBeNull(); + }); + + it('should have no analytics', () => { + const state = useCleanupStore.getState(); + expect(state.analytics).toBeNull(); + expect(state.diagnostics).toBeNull(); + expect(state.isLoadingAnalytics).toBe(false); + expect(state.analyticsError).toBeNull(); + }); + }); + + describe('scanOrphanedUploads', () => { + it('should scan orphaned uploads successfully', async () => { + const mockUploads: OrphanedUpload[] = [ + { + uploadId: 'upload-1', + bucket: 'test-bucket', + key: 'test-key', + initiated: '2024-01-01T00:00:00Z', + estimatedSizeBytes: 1024, + partCount: 2, + storageClass: 'STANDARD', + ageDays: 30, + }, + ]; + + vi.mocked(api.cleanup.scanOrphanedUploads).mockResolvedValue({ + uploads: mockUploads, + nextContinuationToken: '', + totalCount: 1, + totalSizeBytes: 1024, + }); + + const { scanOrphanedUploads } = useCleanupStore.getState(); + await scanOrphanedUploads({ + locationId: 'loc-1', + bucket: 'test-bucket', + }); + + const state = useCleanupStore.getState(); + expect(state.scanResults.orphanedUploads).toEqual(mockUploads); + expect(state.isScanning).toBe(false); + expect(state.scanError).toBeNull(); + }); + + it('should set scanning state during scan', async () => { + vi.mocked(api.cleanup.scanOrphanedUploads).mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ + uploads: [], + nextContinuationToken: '', + totalCount: 0, + totalSizeBytes: 0, + }), 100)) + ); + + const { scanOrphanedUploads } = useCleanupStore.getState(); + const promise = scanOrphanedUploads({ + locationId: 'loc-1', + bucket: 'test-bucket', + }); + + // Check scanning state immediately + expect(useCleanupStore.getState().isScanning).toBe(true); + + await promise; + expect(useCleanupStore.getState().isScanning).toBe(false); + }); + + it('should handle scan errors', async () => { + const error = new Error('Scan failed'); + vi.mocked(api.cleanup.scanOrphanedUploads).mockRejectedValue(error); + + const { scanOrphanedUploads } = useCleanupStore.getState(); + + await expect( + scanOrphanedUploads({ + locationId: 'loc-1', + bucket: 'test-bucket', + }) + ).rejects.toThrow('Scan failed'); + + const state = useCleanupStore.getState(); + expect(state.isScanning).toBe(false); + expect(state.scanError).toBe('Scan failed'); + }); + }); + + describe('scanCorruptObjects', () => { + it('should scan corrupt objects successfully', async () => { + const mockObjects: CorruptObject[] = [ + { + bucket: 'test-bucket', + key: 'corrupt-file', + versionId: 'v1', + size: 2048, + lastModified: '2024-01-01T00:00:00Z', + corruptionType: 'checksum_mismatch', + errorMessage: 'Checksum does not match', + isRecoverable: false, + }, + ]; + + vi.mocked(api.cleanup.scanCorruptObjects).mockResolvedValue({ + objects: mockObjects, + nextContinuationToken: '', + totalCount: 1, + }); + + const { scanCorruptObjects } = useCleanupStore.getState(); + await scanCorruptObjects({ + locationId: 'loc-1', + bucket: 'test-bucket', + verifyChecksums: true, + }); + + const state = useCleanupStore.getState(); + expect(state.scanResults.corruptObjects).toEqual(mockObjects); + }); + }); + + describe('scanOrphanedVersions', () => { + it('should scan orphaned versions successfully', async () => { + const mockVersions: ObjectVersion[] = [ + { + bucket: 'test-bucket', + key: 'versioned-file', + versionId: 'v1', + size: 512, + lastModified: '2024-01-01T00:00:00Z', + isLatest: false, + isDeleteMarker: false, + ageDays: 90, + }, + ]; + + vi.mocked(api.cleanup.scanOrphanedVersions).mockResolvedValue({ + versions: mockVersions, + nextContinuationToken: '', + totalCount: 1, + totalSizeBytes: 512, + }); + + const { scanOrphanedVersions } = useCleanupStore.getState(); + await scanOrphanedVersions({ + locationId: 'loc-1', + bucket: 'test-bucket', + keepVersions: 5, + }); + + const state = useCleanupStore.getState(); + expect(state.scanResults.orphanedVersions).toEqual(mockVersions); + }); + }); + + describe('scanEmptyObjects', () => { + it('should scan empty objects successfully', async () => { + const mockObjects: EmptyObject[] = [ + { + bucket: 'test-bucket', + key: 'empty-file', + versionId: 'v1', + lastModified: '2024-01-01T00:00:00Z', + ageDays: 60, + }, + ]; + + vi.mocked(api.cleanup.scanEmptyObjects).mockResolvedValue({ + objects: mockObjects, + nextContinuationToken: '', + totalCount: 1, + }); + + const { scanEmptyObjects } = useCleanupStore.getState(); + await scanEmptyObjects({ + locationId: 'loc-1', + bucket: 'test-bucket', + }); + + const state = useCleanupStore.getState(); + expect(state.scanResults.emptyObjects).toEqual(mockObjects); + }); + }); + + describe('cleanupOrphanedUploads', () => { + it('should start cleanup job successfully', async () => { + const mockJob: CleanupJob = { + id: 'job-1', + locationId: 'loc-1', + jobType: 'orphaned_uploads', + status: 'pending', + bucket: 'test-bucket', + dryRun: false, + breakGlass: false, + createdAt: '2024-01-01T00:00:00Z', + }; + + vi.mocked(api.cleanup.cleanupOrphanedUploads).mockResolvedValue(mockJob); + + const { cleanupOrphanedUploads } = useCleanupStore.getState(); + const result = await cleanupOrphanedUploads({ + locationId: 'loc-1', + bucket: 'test-bucket', + dryRun: false, + }); + + expect(result).toEqual(mockJob); + const state = useCleanupStore.getState(); + expect(state.jobs).toContain(mockJob); + expect(state.activeJob).toEqual(mockJob); + }); + }); + + describe('cleanupOldVersions', () => { + it('should start cleanup job successfully', async () => { + const mockJob: CleanupJob = { + id: 'job-2', + locationId: 'loc-1', + jobType: 'old_versions', + status: 'pending', + bucket: 'test-bucket', + dryRun: true, + breakGlass: false, + createdAt: '2024-01-01T00:00:00Z', + }; + + vi.mocked(api.cleanup.cleanupOldVersions).mockResolvedValue(mockJob); + + const { cleanupOldVersions } = useCleanupStore.getState(); + const result = await cleanupOldVersions({ + locationId: 'loc-1', + bucket: 'test-bucket', + keepVersions: 5, + dryRun: true, + }); + + expect(result).toEqual(mockJob); + }); + }); + + describe('loadJobs', () => { + it('should load jobs successfully', async () => { + const mockJobs: CleanupJob[] = [ + { + id: 'job-1', + locationId: 'loc-1', + jobType: 'orphaned_uploads', + status: 'completed', + bucket: 'test-bucket', + dryRun: false, + breakGlass: false, + createdAt: '2024-01-01T00:00:00Z', + completedAt: '2024-01-01T00:05:00Z', + }, + ]; + + vi.mocked(api.cleanup.listJobs).mockResolvedValue(mockJobs); + + const { loadJobs } = useCleanupStore.getState(); + await loadJobs(); + + const state = useCleanupStore.getState(); + expect(state.jobs).toEqual(mockJobs); + expect(state.isLoadingJobs).toBe(false); + expect(state.jobsError).toBeNull(); + }); + + it('should handle load jobs errors', async () => { + const error = new Error('Failed to load jobs'); + vi.mocked(api.cleanup.listJobs).mockRejectedValue(error); + + const { loadJobs } = useCleanupStore.getState(); + + await expect(loadJobs()).rejects.toThrow('Failed to load jobs'); + + const state = useCleanupStore.getState(); + expect(state.isLoadingJobs).toBe(false); + expect(state.jobsError).toBe('Failed to load jobs'); + }); + + it('should load jobs with filters', async () => { + vi.mocked(api.cleanup.listJobs).mockResolvedValue([]); + + const { loadJobs } = useCleanupStore.getState(); + await loadJobs({ + locationId: 'loc-1', + status: 'running', + jobType: 'orphaned_uploads', + }); + + expect(api.cleanup.listJobs).toHaveBeenCalledWith({ + locationId: 'loc-1', + status: 'running', + jobType: 'orphaned_uploads', + }); + }); + }); + + describe('getJobStatus', () => { + it('should update job status', async () => { + const initialJob: CleanupJob = { + id: 'job-1', + locationId: 'loc-1', + jobType: 'orphaned_uploads', + status: 'running', + bucket: 'test-bucket', + dryRun: false, + breakGlass: false, + createdAt: '2024-01-01T00:00:00Z', + }; + + const updatedJob: CleanupJob = { + ...initialJob, + status: 'completed', + completedAt: '2024-01-01T00:05:00Z', + }; + + useCleanupStore.setState({ jobs: [initialJob], activeJob: initialJob }); + + vi.mocked(api.cleanup.getJobStatus).mockResolvedValue(updatedJob); + + const { getJobStatus } = useCleanupStore.getState(); + await getJobStatus('job-1'); + + const state = useCleanupStore.getState(); + expect(state.jobs[0]).toEqual(updatedJob); + expect(state.activeJob).toEqual(updatedJob); + }); + }); + + describe('cancelJob', () => { + it('should cancel job successfully', async () => { + const job: CleanupJob = { + id: 'job-1', + locationId: 'loc-1', + jobType: 'orphaned_uploads', + status: 'running', + bucket: 'test-bucket', + dryRun: false, + breakGlass: false, + createdAt: '2024-01-01T00:00:00Z', + }; + + const cancelledJob: CleanupJob = { + ...job, + status: 'cancelled', + }; + + useCleanupStore.setState({ jobs: [job] }); + + vi.mocked(api.cleanup.cancelJob).mockResolvedValue(undefined); + vi.mocked(api.cleanup.getJobStatus).mockResolvedValue(cancelledJob); + + const { cancelJob } = useCleanupStore.getState(); + await cancelJob('job-1'); + + expect(api.cleanup.cancelJob).toHaveBeenCalledWith('job-1'); + expect(api.cleanup.getJobStatus).toHaveBeenCalledWith('job-1'); + }); + }); + + describe('refreshActiveJob', () => { + it('should refresh active job status', async () => { + const activeJob: CleanupJob = { + id: 'job-1', + locationId: 'loc-1', + jobType: 'orphaned_uploads', + status: 'running', + bucket: 'test-bucket', + dryRun: false, + breakGlass: false, + createdAt: '2024-01-01T00:00:00Z', + }; + + useCleanupStore.setState({ activeJob }); + + vi.mocked(api.cleanup.getJobStatus).mockResolvedValue(activeJob); + + const { refreshActiveJob } = useCleanupStore.getState(); + await refreshActiveJob(); + + expect(api.cleanup.getJobStatus).toHaveBeenCalledWith('job-1'); + }); + + it('should do nothing if no active job', async () => { + useCleanupStore.setState({ activeJob: null }); + + const { refreshActiveJob } = useCleanupStore.getState(); + await refreshActiveJob(); + + expect(api.cleanup.getJobStatus).not.toHaveBeenCalled(); + }); + }); + + describe('loadAnalytics', () => { + it('should load analytics successfully', async () => { + const mockAnalytics = { + locationId: 'loc-1', + totalObjects: 1000, + totalSize: 1024000, + bucketStats: [], + objectAging: { + lessThan30Days: 100, + days30To90: 200, + days90To180: 300, + days180To365: 200, + moreThan365Days: 200, + }, + largestObjects: [], + }; + + vi.mocked(api.cleanup.getAnalytics).mockResolvedValue(mockAnalytics); + + const { loadAnalytics } = useCleanupStore.getState(); + await loadAnalytics('loc-1', 'test-bucket'); + + const state = useCleanupStore.getState(); + expect(state.analytics).toEqual(mockAnalytics); + expect(state.isLoadingAnalytics).toBe(false); + expect(state.analyticsError).toBeNull(); + }); + + it('should handle analytics errors', async () => { + const error = new Error('Failed to load analytics'); + vi.mocked(api.cleanup.getAnalytics).mockRejectedValue(error); + + const { loadAnalytics } = useCleanupStore.getState(); + + await expect(loadAnalytics('loc-1')).rejects.toThrow('Failed to load analytics'); + + const state = useCleanupStore.getState(); + expect(state.isLoadingAnalytics).toBe(false); + expect(state.analyticsError).toBe('Failed to load analytics'); + }); + }); + + describe('loadDiagnostics', () => { + it('should load diagnostics successfully', async () => { + const mockDiagnostics = { + locationId: 'loc-1', + providerType: 'minio', + healthy: true, + capabilities: ['versioning', 'object-lock'], + recommendations: ['Enable lifecycle policies'], + warnings: [], + }; + + vi.mocked(api.cleanup.getDiagnostics).mockResolvedValue(mockDiagnostics); + + const { loadDiagnostics } = useCleanupStore.getState(); + await loadDiagnostics('loc-1'); + + const state = useCleanupStore.getState(); + expect(state.diagnostics).toEqual(mockDiagnostics); + }); + }); + + describe('Utility Actions', () => { + it('should clear scan results', () => { + useCleanupStore.setState({ + scanResults: { + orphanedUploads: [{ uploadId: 'test' } as OrphanedUpload], + corruptObjects: [], + orphanedVersions: [], + emptyObjects: [], + }, + scanError: 'Some error', + }); + + const { clearScanResults } = useCleanupStore.getState(); + clearScanResults(); + + const state = useCleanupStore.getState(); + expect(state.scanResults.orphanedUploads).toEqual([]); + expect(state.scanError).toBeNull(); + }); + + it('should clear all errors', () => { + useCleanupStore.setState({ + scanError: 'Scan error', + jobsError: 'Jobs error', + analyticsError: 'Analytics error', + }); + + const { clearErrors } = useCleanupStore.getState(); + clearErrors(); + + const state = useCleanupStore.getState(); + expect(state.scanError).toBeNull(); + expect(state.jobsError).toBeNull(); + expect(state.analyticsError).toBeNull(); + }); + + it('should set active job', () => { + const job: CleanupJob = { + id: 'job-1', + locationId: 'loc-1', + jobType: 'orphaned_uploads', + status: 'running', + bucket: 'test-bucket', + dryRun: false, + breakGlass: false, + createdAt: '2024-01-01T00:00:00Z', + }; + + const { setActiveJob } = useCleanupStore.getState(); + setActiveJob(job); + + const state = useCleanupStore.getState(); + expect(state.activeJob).toEqual(job); + }); + + it('should clear active job', () => { + const job: CleanupJob = { + id: 'job-1', + locationId: 'loc-1', + jobType: 'orphaned_uploads', + status: 'running', + bucket: 'test-bucket', + dryRun: false, + breakGlass: false, + createdAt: '2024-01-01T00:00:00Z', + }; + + useCleanupStore.setState({ activeJob: job }); + + const { setActiveJob } = useCleanupStore.getState(); + setActiveJob(null); + + const state = useCleanupStore.getState(); + expect(state.activeJob).toBeNull(); + }); + }); +}); + +// Made with Bob \ No newline at end of file diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 58f5eb9..b51de24 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -18,9 +18,8 @@ export default defineConfig({ '**/*.config.*', '**/mockData', 'dist/', - // Temporarily exclude cleanup components until tests are added in follow-up PR + // Temporarily exclude cleanup components until component tests are added 'src/components/cleanup/**', - 'src/store/cleanupStore.ts', ], thresholds: { statements: 85,