/** * Component: Cleanup Helpers Tests * Documentation: documentation/phase3/sabnzbd.md */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { removeEmptyParentDirectories } from '@/lib/utils/cleanup-helpers'; // Mock fs/promises const fsMock = vi.hoisted(() => ({ readdir: vi.fn(), rmdir: vi.fn(), })); // Mock logger const loggerMock = vi.hoisted(() => ({ RMABLogger: { create: vi.fn(() => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), })), forJob: vi.fn(() => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), })), }, })); vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, })); vi.mock('@/lib/utils/logger', () => loggerMock); describe('removeEmptyParentDirectories', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('basic functionality', () => { it('removes a single empty parent directory', async () => { // Setup: /downloads/category/audiobook was deleted // /downloads/category is empty fsMock.readdir.mockResolvedValueOnce([]); // /downloads/category is empty fsMock.rmdir.mockResolvedValueOnce(undefined); const result = await removeEmptyParentDirectories( '/downloads/category/audiobook', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); expect(result.removedDirectories).toEqual(['/downloads/category']); expect(result.stoppedReason).toBe('boundary_reached'); expect(fsMock.rmdir).toHaveBeenCalledWith('/downloads/category'); }); it('removes multiple nested empty directories', async () => { // Setup: /downloads/cat/subcat/audiobook was deleted // Both /downloads/cat/subcat and /downloads/cat are empty fsMock.readdir .mockResolvedValueOnce([]) // /downloads/cat/subcat is empty .mockResolvedValueOnce([]); // /downloads/cat is empty fsMock.rmdir.mockResolvedValue(undefined); const result = await removeEmptyParentDirectories( '/downloads/cat/subcat/audiobook', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); expect(result.removedDirectories).toHaveLength(2); expect(result.removedDirectories).toContain('/downloads/cat/subcat'); expect(result.removedDirectories).toContain('/downloads/cat'); expect(result.stoppedReason).toBe('boundary_reached'); }); it('stops when encountering a non-empty directory', async () => { // Setup: /downloads/category/audiobook was deleted // /downloads/category has other files fsMock.readdir.mockResolvedValueOnce(['other-file.txt']); const result = await removeEmptyParentDirectories( '/downloads/category/audiobook', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); expect(result.removedDirectories).toHaveLength(0); expect(result.stoppedReason).toBe('non_empty'); expect(result.stoppedAt).toBe('/downloads/category'); expect(fsMock.rmdir).not.toHaveBeenCalled(); }); it('removes first empty dir but stops at non-empty parent', async () => { // Setup: /downloads/cat/subcat/audiobook was deleted // /downloads/cat/subcat is empty, /downloads/cat has other stuff fsMock.readdir .mockResolvedValueOnce([]) // /downloads/cat/subcat is empty .mockResolvedValueOnce(['other-subcat']); // /downloads/cat has other subcat fsMock.rmdir.mockResolvedValueOnce(undefined); const result = await removeEmptyParentDirectories( '/downloads/cat/subcat/audiobook', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); expect(result.removedDirectories).toEqual(['/downloads/cat/subcat']); expect(result.stoppedReason).toBe('non_empty'); expect(result.stoppedAt).toBe('/downloads/cat'); }); }); describe('boundary protection', () => { it('never deletes the boundary directory itself', async () => { // Setup: /downloads/audiobook was deleted (directly under boundary) // /downloads is empty fsMock.readdir.mockResolvedValueOnce([]); const result = await removeEmptyParentDirectories( '/downloads/audiobook', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); expect(result.removedDirectories).toHaveLength(0); expect(result.stoppedReason).toBe('boundary_reached'); // Should NOT try to remove /downloads expect(fsMock.rmdir).not.toHaveBeenCalled(); }); it('never deletes above the boundary directory', async () => { // Setup: Deep nested structure with empty parents all the way up fsMock.readdir.mockResolvedValue([]); // All directories empty fsMock.rmdir.mockResolvedValue(undefined); const result = await removeEmptyParentDirectories( '/downloads/a/b/c/d/audiobook', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); // Should remove a/b/c/d, a/b/c, a/b, a - but NOT /downloads expect(result.removedDirectories).toHaveLength(4); expect(result.removedDirectories).not.toContain('/downloads'); expect(result.stoppedReason).toBe('boundary_reached'); }); it('handles boundary with trailing slash', async () => { fsMock.readdir.mockResolvedValueOnce([]); fsMock.rmdir.mockResolvedValueOnce(undefined); const result = await removeEmptyParentDirectories( '/downloads/category/audiobook', { boundaryPath: '/downloads/' } // Trailing slash ); expect(result.success).toBe(true); expect(result.removedDirectories).toEqual(['/downloads/category']); }); it('handles path directly at boundary level', async () => { const result = await removeEmptyParentDirectories( '/downloads/audiobook', { boundaryPath: '/downloads' } ); // Parent of /downloads/audiobook is /downloads which is the boundary expect(result.success).toBe(true); expect(result.removedDirectories).toHaveLength(0); expect(result.stoppedReason).toBe('boundary_reached'); }); }); describe('error handling', () => { it('handles ENOENT gracefully (directory already deleted)', async () => { // First directory check succeeds (empty), rmdir fails with ENOENT fsMock.readdir.mockResolvedValueOnce([]); fsMock.rmdir.mockRejectedValueOnce( Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) ); const result = await removeEmptyParentDirectories( '/downloads/category/audiobook', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); // Should continue without error }); it('handles ENOENT when checking if directory exists', async () => { // Directory doesn't exist when we try to read it fsMock.readdir.mockRejectedValueOnce( Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) ); const result = await removeEmptyParentDirectories( '/downloads/category/audiobook', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); // Should handle gracefully, move to parent }); it('handles ENOTEMPTY race condition gracefully', async () => { // Directory was empty when checked, but became non-empty before removal fsMock.readdir.mockResolvedValueOnce([]); fsMock.rmdir.mockRejectedValueOnce( Object.assign(new Error('ENOTEMPTY'), { code: 'ENOTEMPTY' }) ); const result = await removeEmptyParentDirectories( '/downloads/category/audiobook', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); expect(result.stoppedReason).toBe('non_empty'); }); it('handles EACCES permission error gracefully', async () => { fsMock.readdir.mockResolvedValueOnce([]); fsMock.rmdir.mockRejectedValueOnce( Object.assign(new Error('Permission denied'), { code: 'EACCES' }) ); const result = await removeEmptyParentDirectories( '/downloads/category/audiobook', { boundaryPath: '/downloads' } ); // Should still be considered partial success expect(result.success).toBe(true); expect(result.stoppedReason).toBe('error'); expect(result.error).toContain('Permission denied'); }); it('handles EPERM permission error gracefully', async () => { fsMock.readdir.mockResolvedValueOnce([]); fsMock.rmdir.mockRejectedValueOnce( Object.assign(new Error('Operation not permitted'), { code: 'EPERM' }) ); const result = await removeEmptyParentDirectories( '/downloads/category/audiobook', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); expect(result.stoppedReason).toBe('error'); }); it('handles unexpected errors', async () => { fsMock.readdir.mockResolvedValueOnce([]); fsMock.rmdir.mockRejectedValueOnce( Object.assign(new Error('Unknown error'), { code: 'EUNKNOWN' }) ); const result = await removeEmptyParentDirectories( '/downloads/category/audiobook', { boundaryPath: '/downloads' } ); expect(result.success).toBe(false); expect(result.stoppedReason).toBe('error'); expect(result.error).toBe('Unknown error'); }); }); describe('path edge cases', () => { it('handles Windows-style backslash paths', async () => { fsMock.readdir.mockResolvedValueOnce([]); fsMock.rmdir.mockResolvedValueOnce(undefined); const result = await removeEmptyParentDirectories( 'C:\\downloads\\category\\audiobook', { boundaryPath: 'C:\\downloads' } ); expect(result.success).toBe(true); // Should normalize paths and work correctly }); it('handles mixed slash paths', async () => { fsMock.readdir.mockResolvedValueOnce([]); fsMock.rmdir.mockResolvedValueOnce(undefined); const result = await removeEmptyParentDirectories( '/downloads/category\\audiobook', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); }); it('handles paths with redundant slashes', async () => { fsMock.readdir.mockResolvedValueOnce([]); fsMock.rmdir.mockResolvedValueOnce(undefined); const result = await removeEmptyParentDirectories( '/downloads//category///audiobook', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); }); it('prevents /downloads2 from matching /downloads boundary', async () => { // If boundary is /downloads, path /downloads2/cat/audio should NOT match fsMock.readdir.mockResolvedValue([]); // All empty fsMock.rmdir.mockResolvedValue(undefined); const result = await removeEmptyParentDirectories( '/downloads2/category/audiobook', { boundaryPath: '/downloads' } ); // Should reach root (no boundary match) or handle gracefully // The boundary check should NOT match /downloads2 when boundary is /downloads expect(result.success).toBe(true); // Should have removed /downloads2/category and /downloads2 (or hit root) }); }); describe('with job context logging', () => { it('uses job-aware logger when context provided', async () => { fsMock.readdir.mockResolvedValueOnce([]); fsMock.rmdir.mockResolvedValueOnce(undefined); const result = await removeEmptyParentDirectories( '/downloads/category/audiobook', { boundaryPath: '/downloads', logContext: { jobId: 'job-123', context: 'TestCleanup' }, } ); expect(result.success).toBe(true); expect(loggerMock.RMABLogger.forJob).toHaveBeenCalledWith('job-123', 'TestCleanup'); }); it('uses default logger when no context provided', async () => { fsMock.readdir.mockResolvedValueOnce([]); fsMock.rmdir.mockResolvedValueOnce(undefined); const result = await removeEmptyParentDirectories( '/downloads/category/audiobook', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); // Default logger is created at module load time, not per-call // Just verify the function works without job context expect(loggerMock.RMABLogger.forJob).not.toHaveBeenCalled(); }); }); describe('realistic SABnzbd scenarios', () => { it('cleans up empty readmeabook category folder', async () => { // Real scenario: SABnzbd downloads to /downloads/readmeabook/My.Audiobook.Name/ // After organizing, My.Audiobook.Name is deleted // readmeabook folder should be cleaned up too fsMock.readdir.mockResolvedValueOnce([]); // /downloads/readmeabook is empty fsMock.rmdir.mockResolvedValueOnce(undefined); const result = await removeEmptyParentDirectories( '/downloads/readmeabook/My.Audiobook.Name', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); expect(result.removedDirectories).toEqual(['/downloads/readmeabook']); expect(result.stoppedReason).toBe('boundary_reached'); }); it('preserves category folder with other downloads', async () => { // Real scenario: Multiple downloads in readmeabook category // Only one is being cleaned up fsMock.readdir.mockResolvedValueOnce(['Other.Audiobook.Name']); // Other download exists const result = await removeEmptyParentDirectories( '/downloads/readmeabook/My.Audiobook.Name', { boundaryPath: '/downloads' } ); expect(result.success).toBe(true); expect(result.removedDirectories).toHaveLength(0); expect(result.stoppedReason).toBe('non_empty'); }); it('handles path mapping scenario (mapped download_dir)', async () => { // Real scenario: download_dir is /media/usenet/complete // after path mapping from SABnzbd's perspective fsMock.readdir.mockResolvedValueOnce([]); fsMock.rmdir.mockResolvedValueOnce(undefined); const result = await removeEmptyParentDirectories( '/media/usenet/complete/readmeabook/My.Audiobook.Name', { boundaryPath: '/media/usenet/complete' } ); expect(result.success).toBe(true); expect(result.removedDirectories).toEqual(['/media/usenet/complete/readmeabook']); }); }); });