mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add rootless Podman fixes, and others
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.
This commit is contained in:
@@ -77,6 +77,13 @@ vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
invalidateDownloadClientManager: invalidateDownloadClientManagerMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => ({
|
||||
encrypt: (value: string) => `enc-${value}`,
|
||||
decrypt: (value: string) => value.replace('enc-', ''),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Admin settings core routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -30,6 +30,12 @@ const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
constants: { R_OK: 4 },
|
||||
}));
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getAllClients: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
@@ -73,6 +79,14 @@ vi.mock('fs/promises', () => ({
|
||||
...fsMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
getDownloadClientManager: () => downloadClientManagerMock,
|
||||
}));
|
||||
|
||||
describe('Admin settings test routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -211,7 +225,10 @@ describe('Admin settings test routes', () => {
|
||||
});
|
||||
|
||||
it('uses stored password when masked password is provided', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce({ value: 'stored-pass' });
|
||||
// Mock download client manager to return the stored password
|
||||
downloadClientManagerMock.getAllClients.mockResolvedValueOnce([
|
||||
{ type: 'qbittorrent', password: 'stored-pass' },
|
||||
]);
|
||||
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.1.0');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
@@ -236,7 +253,8 @@ describe('Admin settings test routes', () => {
|
||||
});
|
||||
|
||||
it('returns error when masked password is missing in storage', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce(null);
|
||||
// Mock download client manager to return no matching client
|
||||
downloadClientManagerMock.getAllClients.mockResolvedValueOnce([]);
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'qbittorrent',
|
||||
|
||||
@@ -22,6 +22,11 @@ const encryptionServiceMock = vi.hoisted(() => ({
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getPlexConfig: vi.fn(),
|
||||
}));
|
||||
const authTokenCacheMock = vi.hoisted(() => ({
|
||||
set: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
const generateAccessTokenMock = vi.hoisted(() => vi.fn(() => 'access-token'));
|
||||
const generateRefreshTokenMock = vi.hoisted(() => vi.fn(() => 'refresh-token'));
|
||||
|
||||
@@ -41,6 +46,10 @@ vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/auth-token-cache.service', () => ({
|
||||
getAuthTokenCache: () => authTokenCacheMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/jwt', () => ({
|
||||
generateAccessToken: generateAccessTokenMock,
|
||||
generateRefreshToken: generateRefreshTokenMock,
|
||||
@@ -194,8 +203,10 @@ describe('Plex auth routes', () => {
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.headers.get('content-type')).toContain('text/html');
|
||||
expect(html).toContain('sessionStorage.setItem');
|
||||
// Token is now stored server-side, not in sessionStorage
|
||||
expect(html).toContain('token is stored server-side');
|
||||
expect(html).toContain('https://example.com/auth/select-profile?pinId=3');
|
||||
expect(authTokenCacheMock.set).toHaveBeenCalledWith('3', 'token');
|
||||
});
|
||||
|
||||
it('returns tokens for successful Plex auth', async () => {
|
||||
@@ -267,18 +278,20 @@ describe('Plex auth routes', () => {
|
||||
expect(html).toContain('#authData=');
|
||||
});
|
||||
|
||||
it('returns Plex home users when token is provided', async () => {
|
||||
it('returns Plex home users when pinId is provided', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue('cached-token');
|
||||
plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }]);
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/home-users/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-token': 'token' }) as any);
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-pin-id': '123' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.users).toHaveLength(1);
|
||||
expect(authTokenCacheMock.get).toHaveBeenCalledWith('123');
|
||||
});
|
||||
|
||||
it('rejects Plex home users when token is missing', async () => {
|
||||
it('rejects Plex home users when pinId is missing', async () => {
|
||||
const { GET } = await import('@/app/api/auth/plex/home-users/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users') as any);
|
||||
const payload = await response.json();
|
||||
@@ -287,18 +300,30 @@ describe('Plex auth routes', () => {
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 401 when token not found in cache', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue(null);
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/home-users/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-pin-id': '123' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('SessionExpired');
|
||||
});
|
||||
|
||||
it('returns 500 when Plex home users fetch fails', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue('cached-token');
|
||||
plexServiceMock.getHomeUsers.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/home-users/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-token': 'token' }) as any);
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-pin-id': '123' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toBe('ServerError');
|
||||
});
|
||||
|
||||
it('rejects profile switch without main account token', async () => {
|
||||
it('rejects profile switch without pinId', async () => {
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({ userId: 'home-1' });
|
||||
@@ -310,10 +335,26 @@ describe('Plex auth routes', () => {
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('rejects profile switch when userId is missing', async () => {
|
||||
it('rejects profile switch when token not found in cache', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue(null);
|
||||
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
|
||||
request.json.mockResolvedValue({});
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({ userId: 'home-1', pinId: '123' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('SessionExpired');
|
||||
});
|
||||
|
||||
it('rejects profile switch when userId is missing', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue('cached-token');
|
||||
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({ pinId: '123' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
@@ -323,11 +364,12 @@ describe('Plex auth routes', () => {
|
||||
});
|
||||
|
||||
it('returns 401 for invalid profile PIN', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue('cached-token');
|
||||
plexServiceMock.switchHomeUser.mockRejectedValue(new Error('Invalid PIN'));
|
||||
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
|
||||
request.json.mockResolvedValue({ userId: 'home-1', pin: '0000' });
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({ userId: 'home-1', pin: '0000', pinId: '123' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
@@ -337,6 +379,7 @@ describe('Plex auth routes', () => {
|
||||
});
|
||||
|
||||
it('switches Plex profile using provided profile info', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue('cached-token');
|
||||
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
|
||||
prismaMock.user.count.mockResolvedValue(1);
|
||||
prismaMock.user.upsert.mockResolvedValue({
|
||||
@@ -349,10 +392,11 @@ describe('Plex auth routes', () => {
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({
|
||||
userId: 'home-1',
|
||||
pin: '1234',
|
||||
pinId: '123',
|
||||
profileInfo: { uuid: 'uuid-1', friendlyName: 'Profile' },
|
||||
});
|
||||
|
||||
@@ -361,9 +405,11 @@ describe('Plex auth routes', () => {
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.accessToken).toBe('access-token');
|
||||
expect(authTokenCacheMock.delete).toHaveBeenCalledWith('123');
|
||||
});
|
||||
|
||||
it('switches Plex profile using getUserInfo fallback', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue('cached-token');
|
||||
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({
|
||||
id: 'plex-3',
|
||||
@@ -382,8 +428,8 @@ describe('Plex auth routes', () => {
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
|
||||
request.json.mockResolvedValue({ userId: 'home-2', pin: '1234' });
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({ userId: 'home-2', pin: '1234', pinId: '123' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
@@ -394,12 +440,13 @@ describe('Plex auth routes', () => {
|
||||
});
|
||||
|
||||
it('returns 500 when profile info lookup fails', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue('cached-token');
|
||||
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({ id: null });
|
||||
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
|
||||
request.json.mockResolvedValue({ userId: 'home-2', pin: '1234' });
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({ userId: 'home-2', pin: '1234', pinId: '123' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('SelectProfilePage', () => {
|
||||
});
|
||||
|
||||
it('selects an unprotected profile and stores auth data', async () => {
|
||||
sessionStorage.setItem('plex_main_token', 'main-token');
|
||||
// Token is now stored server-side, only pinId needed in URL
|
||||
setMockSearchParams('pinId=123');
|
||||
|
||||
const setAuthDataMock = vi.fn();
|
||||
@@ -71,6 +71,9 @@ describe('SelectProfilePage', () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/plex/home-users') {
|
||||
// Verify pinId header is sent instead of token
|
||||
const headers = (init as RequestInit)?.headers as Record<string, string>;
|
||||
expect(headers?.['X-Plex-Pin-Id']).toBe('123');
|
||||
return makeJsonResponse({ users: profiles });
|
||||
}
|
||||
if (url === '/api/auth/plex/switch-profile') {
|
||||
@@ -107,7 +110,7 @@ describe('SelectProfilePage', () => {
|
||||
});
|
||||
|
||||
it('prompts for a PIN and handles invalid submissions', async () => {
|
||||
sessionStorage.setItem('plex_main_token', 'main-token');
|
||||
// Token is now stored server-side, only pinId needed in URL
|
||||
setMockSearchParams('pinId=555');
|
||||
|
||||
const setAuthDataMock = vi.fn();
|
||||
@@ -136,7 +139,8 @@ describe('SelectProfilePage', () => {
|
||||
return makeJsonResponse({ users: profiles });
|
||||
}
|
||||
if (url === '/api/auth/plex/switch-profile') {
|
||||
return makeJsonResponse({ message: 'Invalid PIN' }, false, 401);
|
||||
// Return InvalidPIN error type to trigger PIN error message
|
||||
return makeJsonResponse({ error: 'InvalidPIN', message: 'Invalid PIN' }, false, 401);
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,19 @@ vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
// Mock credential migration service - passwords in tests are plaintext
|
||||
vi.mock('@/lib/services/credential-migration.service', () => ({
|
||||
isEncryptedFormat: () => false, // Test passwords are plaintext
|
||||
}));
|
||||
|
||||
// Mock encryption service
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => ({
|
||||
encrypt: (value: string) => `enc-${value}`,
|
||||
decrypt: (value: string) => value.replace('enc-', ''),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock qBittorrent and SABnzbd services - use vi.hoisted to ensure they're available at mock time
|
||||
const { qbtServiceMock, sabServiceMock } = vi.hoisted(() => ({
|
||||
qbtServiceMock: {
|
||||
|
||||
@@ -620,4 +620,120 @@ describe('chapter merger', () => {
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/Merged file not created/i);
|
||||
});
|
||||
|
||||
describe('metadata escaping', () => {
|
||||
it('does NOT escape single quotes in metadata (they are literal in double-quoted shell strings)', async () => {
|
||||
const outputPath = '/tmp/output.m4b';
|
||||
const chapters = [
|
||||
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
|
||||
];
|
||||
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.stat.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === outputPath) {
|
||||
return { size: 2 * 1024 * 1024 };
|
||||
}
|
||||
return { size: 500 * 1024 };
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
if (command.startsWith('ffprobe')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
format: { duration: '60', bit_rate: '128000', tags: {} },
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command.startsWith('ffmpeg -v error')) {
|
||||
return { stdout: '' };
|
||||
}
|
||||
return { stdout: '' };
|
||||
});
|
||||
|
||||
spawnMock.mockReturnValue(createSpawnProcess(0));
|
||||
|
||||
await mergeChapters(chapters, {
|
||||
title: "It's Not Her",
|
||||
author: "O'Brien",
|
||||
narrator: "Jane's Voice",
|
||||
outputPath,
|
||||
});
|
||||
|
||||
// Get the args passed to spawn
|
||||
const spawnCall = spawnMock.mock.calls[0];
|
||||
const args = spawnCall[1] as string[];
|
||||
|
||||
// Find the title metadata arg (format after parsing: title="It's Not Her)
|
||||
const titleArg = args.find((arg: string) => arg.startsWith('title='));
|
||||
const albumArtistArg = args.find((arg: string) => arg.startsWith('album_artist='));
|
||||
const composerArg = args.find((arg: string) => arg.startsWith('composer='));
|
||||
|
||||
// Single quotes should appear as-is ('s), NOT escaped with backslash (\'s)
|
||||
// The args contain the value with opening quote: title="It's Not Her
|
||||
expect(titleArg).toContain("It's Not Her");
|
||||
expect(titleArg).not.toContain("\\'"); // No escaped single quotes
|
||||
expect(albumArtistArg).toContain("O'Brien");
|
||||
expect(albumArtistArg).not.toContain("\\'");
|
||||
expect(composerArg).toContain("Jane's Voice");
|
||||
expect(composerArg).not.toContain("\\'");
|
||||
|
||||
// Verify no backslash-escaped single quotes anywhere in args
|
||||
const allArgsJoined = args.join(' ');
|
||||
expect(allArgsJoined).not.toContain("\\'");
|
||||
});
|
||||
|
||||
it('properly escapes double quotes and special shell characters', async () => {
|
||||
const outputPath = '/tmp/output.m4b';
|
||||
const chapters = [
|
||||
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
|
||||
];
|
||||
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.stat.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === outputPath) {
|
||||
return { size: 2 * 1024 * 1024 };
|
||||
}
|
||||
return { size: 500 * 1024 };
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
if (command.startsWith('ffprobe')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
format: { duration: '60', bit_rate: '128000', tags: {} },
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command.startsWith('ffmpeg -v error')) {
|
||||
return { stdout: '' };
|
||||
}
|
||||
return { stdout: '' };
|
||||
});
|
||||
|
||||
spawnMock.mockReturnValue(createSpawnProcess(0));
|
||||
|
||||
await mergeChapters(chapters, {
|
||||
title: 'Book "Quoted" $100',
|
||||
author: 'Author',
|
||||
outputPath,
|
||||
});
|
||||
|
||||
// Get the args passed to spawn
|
||||
const spawnCall = spawnMock.mock.calls[0];
|
||||
const args = spawnCall[1] as string[];
|
||||
|
||||
// Find the title arg - double quotes and $ should be escaped
|
||||
const titleArg = args.find((arg: string) => arg.startsWith('title='));
|
||||
|
||||
// Verify escaping is present for double quotes and dollar signs
|
||||
expect(titleArg).toContain('\\"Quoted\\"');
|
||||
expect(titleArg).toContain('\\$100');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -113,4 +113,89 @@ describe('metadata tagger', () => {
|
||||
mockExecFailure('not installed');
|
||||
await expect(checkFfmpegAvailable()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
describe('metadata escaping', () => {
|
||||
it('does NOT escape single quotes (they are literal in double-quoted shell strings)', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
mockExecSuccess('done');
|
||||
|
||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||
title: "It's Not Her",
|
||||
author: "Author's Name",
|
||||
});
|
||||
|
||||
const command = execMock.mock.calls[0][0] as string;
|
||||
// Single quotes should appear as-is, NOT escaped with backslash
|
||||
expect(command).toContain('-metadata title="It\'s Not Her"');
|
||||
expect(command).not.toContain("It\\'s"); // No backslash-escaped single quotes
|
||||
expect(command).toContain('-metadata album_artist="Author\'s Name"');
|
||||
});
|
||||
|
||||
it('escapes double quotes in metadata values', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
mockExecSuccess('done');
|
||||
|
||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||
title: 'Book "Title"',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
const command = execMock.mock.calls[0][0] as string;
|
||||
expect(command).toContain('-metadata title="Book \\"Title\\""');
|
||||
});
|
||||
|
||||
it('escapes backticks to prevent command substitution', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
mockExecSuccess('done');
|
||||
|
||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||
title: 'Book `test`',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
const command = execMock.mock.calls[0][0] as string;
|
||||
expect(command).toContain('-metadata title="Book \\`test\\`"');
|
||||
});
|
||||
|
||||
it('escapes dollar signs to prevent variable expansion', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
mockExecSuccess('done');
|
||||
|
||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||
title: 'Book $100',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
const command = execMock.mock.calls[0][0] as string;
|
||||
expect(command).toContain('-metadata title="Book \\$100"');
|
||||
});
|
||||
|
||||
it('escapes backslashes before other characters', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
mockExecSuccess('done');
|
||||
|
||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||
title: 'Path\\to\\book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
const command = execMock.mock.calls[0][0] as string;
|
||||
expect(command).toContain('-metadata title="Path\\\\to\\\\book"');
|
||||
});
|
||||
|
||||
it('handles complex titles with multiple special characters', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
mockExecSuccess('done');
|
||||
|
||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||
title: "Don't Say \"Hello\" for $5",
|
||||
author: "O'Brien",
|
||||
});
|
||||
|
||||
const command = execMock.mock.calls[0][0] as string;
|
||||
// Single quotes literal, double quotes escaped, dollar escaped
|
||||
expect(command).toContain('-metadata title="Don\'t Say \\"Hello\\" for \\$5"');
|
||||
expect(command).toContain('-metadata album_artist="O\'Brien"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user