mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
a0f2ba680d
improve container startup for rootless Podman, plus related refactors and tests. Key changes: - Add/modify Audiobookshelf-related code and wiring (src/lib/services/audiobookshelf/api.ts, library service refs) and update documentation TABLEOFCONTENTS to reference ABS implementation. - Detect user namespace in docker/unified app-start.sh and redis-start.sh and skip gosu when running in rootless Podman to preserve UID mapping; improve startup logging and verification. - Add utility/service files (auth-token-cache.service.ts, credential-migration.service.ts, cleanup-helpers.ts) and corresponding tests; update chapter-merger and metadata-tagger utilities/tests. - Update many admin/auth API routes and tests to reflect changes in settings and integrations. - Remove large AI agent and Audiobookshelf implementation guide docs (AGENTS.md and the implementation guide) and add README note about AI-assisted workflow. These changes enable Audiobookshelf backend mode, improve compatibility with rootless container runtimes, and include cleanup/refactor work and unit tests.
415 lines
14 KiB
TypeScript
415 lines
14 KiB
TypeScript
/**
|
|
* 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']);
|
|
});
|
|
});
|
|
});
|