fix: simplify api service test

This commit is contained in:
Eli Bosley
2025-05-23 20:44:30 -04:00
parent cebca3d6bf
commit 7f9f4c68ac
3 changed files with 426 additions and 100 deletions

View File

@@ -0,0 +1,374 @@
import { HTTPError } from 'got';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import {
CreateRCloneRemoteDto,
DeleteRCloneRemoteDto,
GetRCloneJobStatusDto,
GetRCloneRemoteConfigDto,
GetRCloneRemoteDetailsDto,
RCloneStartBackupInput,
UpdateRCloneRemoteDto,
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
vi.mock('got');
vi.mock('execa');
vi.mock('p-retry');
vi.mock('node:fs', () => ({
existsSync: vi.fn(),
}));
vi.mock('node:fs/promises', () => ({
mkdir: vi.fn(),
rm: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock('@app/core/log.js', () => ({
sanitizeParams: vi.fn((params) => params),
}));
vi.mock('@app/store/index.js', () => ({
getters: {
paths: () => ({
'rclone-socket': '/tmp/rclone.sock',
'log-base': '/var/log',
}),
},
}));
// Mock NestJS Logger to suppress logs during tests
vi.mock('@nestjs/common', async (importOriginal) => {
const original = await importOriginal<typeof import('@nestjs/common')>();
return {
...original,
Logger: vi.fn(() => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})),
};
});
describe('RCloneApiService', () => {
let service: RCloneApiService;
let mockGot: any;
let mockExeca: any;
let mockPRetry: any;
let mockExistsSync: any;
beforeEach(async () => {
vi.clearAllMocks();
const { default: got } = await import('got');
const { execa } = await import('execa');
const pRetry = await import('p-retry');
const { existsSync } = await import('node:fs');
mockGot = vi.mocked(got);
mockExeca = vi.mocked(execa);
mockPRetry = vi.mocked(pRetry.default);
mockExistsSync = vi.mocked(existsSync);
mockGot.post = vi.fn().mockResolvedValue({ body: {} });
mockExeca.mockReturnValue({
on: vi.fn(),
kill: vi.fn(),
killed: false,
pid: 12345,
} as any);
mockPRetry.mockResolvedValue(undefined);
mockExistsSync.mockReturnValue(false);
service = new RCloneApiService();
await service.onModuleInit();
});
describe('getProviders', () => {
it('should return list of providers', async () => {
const mockProviders = [
{ name: 'aws', prefix: 's3', description: 'Amazon S3' },
{ name: 'google', prefix: 'drive', description: 'Google Drive' },
];
mockGot.post.mockResolvedValue({
body: { providers: mockProviders },
});
const result = await service.getProviders();
expect(result).toEqual(mockProviders);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/providers',
expect.objectContaining({
json: {},
responseType: 'json',
enableUnixSockets: true,
})
);
});
it('should return empty array when no providers', async () => {
mockGot.post.mockResolvedValue({ body: {} });
const result = await service.getProviders();
expect(result).toEqual([]);
});
});
describe('listRemotes', () => {
it('should return list of remotes', async () => {
const mockRemotes = ['backup-s3', 'drive-storage'];
mockGot.post.mockResolvedValue({
body: { remotes: mockRemotes },
});
const result = await service.listRemotes();
expect(result).toEqual(mockRemotes);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/listremotes',
expect.objectContaining({
json: {},
})
);
});
it('should return empty array when no remotes', async () => {
mockGot.post.mockResolvedValue({ body: {} });
const result = await service.listRemotes();
expect(result).toEqual([]);
});
});
describe('getRemoteDetails', () => {
it('should return remote details', async () => {
const input: GetRCloneRemoteDetailsDto = { name: 'test-remote' };
const mockConfig = { type: 's3', provider: 'AWS' };
mockGot.post.mockResolvedValue({ body: mockConfig });
const result = await service.getRemoteDetails(input);
expect(result).toEqual(mockConfig);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/get',
expect.objectContaining({
json: { name: 'test-remote' },
})
);
});
});
describe('getRemoteConfig', () => {
it('should return remote configuration', async () => {
const input: GetRCloneRemoteConfigDto = { name: 'test-remote' };
const mockConfig = { type: 's3', access_key_id: 'AKIA...' };
mockGot.post.mockResolvedValue({ body: mockConfig });
const result = await service.getRemoteConfig(input);
expect(result).toEqual(mockConfig);
});
});
describe('createRemote', () => {
it('should create a new remote', async () => {
const input: CreateRCloneRemoteDto = {
name: 'new-remote',
type: 's3',
parameters: { access_key_id: 'AKIA...', secret_access_key: 'secret' },
};
const mockResponse = { success: true };
mockGot.post.mockResolvedValue({ body: mockResponse });
const result = await service.createRemote(input);
expect(result).toEqual(mockResponse);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/create',
expect.objectContaining({
json: {
name: 'new-remote',
type: 's3',
parameters: { access_key_id: 'AKIA...', secret_access_key: 'secret' },
},
})
);
});
});
describe('updateRemote', () => {
it('should update an existing remote', async () => {
const input: UpdateRCloneRemoteDto = {
name: 'existing-remote',
parameters: { access_key_id: 'NEW_AKIA...' },
};
const mockResponse = { success: true };
mockGot.post.mockResolvedValue({ body: mockResponse });
const result = await service.updateRemote(input);
expect(result).toEqual(mockResponse);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/update',
expect.objectContaining({
json: {
name: 'existing-remote',
access_key_id: 'NEW_AKIA...',
},
})
);
});
});
describe('deleteRemote', () => {
it('should delete a remote', async () => {
const input: DeleteRCloneRemoteDto = { name: 'remote-to-delete' };
const mockResponse = { success: true };
mockGot.post.mockResolvedValue({ body: mockResponse });
const result = await service.deleteRemote(input);
expect(result).toEqual(mockResponse);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/delete',
expect.objectContaining({
json: { name: 'remote-to-delete' },
})
);
});
});
describe('startBackup', () => {
it('should start a backup operation', async () => {
const input: RCloneStartBackupInput = {
srcPath: '/source/path',
dstPath: 'remote:backup/path',
options: { delete_on: 'dst' },
};
const mockResponse = { jobid: 'job-123' };
mockGot.post.mockResolvedValue({ body: mockResponse });
const result = await service.startBackup(input);
expect(result).toEqual(mockResponse);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/sync/copy',
expect.objectContaining({
json: {
srcFs: '/source/path',
dstFs: 'remote:backup/path',
delete_on: 'dst',
},
})
);
});
});
describe('getJobStatus', () => {
it('should return job status', async () => {
const input: GetRCloneJobStatusDto = { jobId: 'job-123' };
const mockStatus = { status: 'running', progress: 0.5 };
mockGot.post.mockResolvedValue({ body: mockStatus });
const result = await service.getJobStatus(input);
expect(result).toEqual(mockStatus);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/job/status',
expect.objectContaining({
json: { jobid: 'job-123' },
})
);
});
});
describe('listRunningJobs', () => {
it('should return list of running jobs', async () => {
const mockJobs = [
{ id: 'job-1', status: 'running' },
{ id: 'job-2', status: 'finished' },
];
mockGot.post.mockResolvedValue({ body: mockJobs });
const result = await service.listRunningJobs();
expect(result).toEqual(mockJobs);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/job/list',
expect.objectContaining({
json: {},
})
);
});
});
describe('error handling', () => {
it('should handle HTTP errors with detailed messages', async () => {
const httpError = {
name: 'HTTPError',
message: 'Request failed',
response: {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' }),
},
};
Object.setPrototypeOf(httpError, HTTPError.prototype);
mockGot.post.mockRejectedValue(httpError);
await expect(service.getProviders()).rejects.toThrow(
'Rclone API Error (config/providers, HTTP 500): Rclone Error: Internal server error'
);
});
it('should handle HTTP errors with empty response body', async () => {
const httpError = {
name: 'HTTPError',
message: 'Request failed',
response: {
statusCode: 404,
body: '',
},
};
Object.setPrototypeOf(httpError, HTTPError.prototype);
mockGot.post.mockRejectedValue(httpError);
await expect(service.getProviders()).rejects.toThrow(
'Rclone API Error (config/providers, HTTP 404): Failed to process error response body. Raw body:'
);
});
it('should handle HTTP errors with malformed JSON', async () => {
const httpError = {
name: 'HTTPError',
message: 'Request failed',
response: {
statusCode: 400,
body: 'invalid json',
},
};
Object.setPrototypeOf(httpError, HTTPError.prototype);
mockGot.post.mockRejectedValue(httpError);
await expect(service.getProviders()).rejects.toThrow(
'Rclone API Error (config/providers, HTTP 400): Failed to process error response body. Raw body: invalid json'
);
});
it('should handle non-HTTP errors', async () => {
const networkError = new Error('Network connection failed');
mockGot.post.mockRejectedValue(networkError);
await expect(service.getProviders()).rejects.toThrow('Network connection failed');
});
it('should handle unknown errors', async () => {
mockGot.post.mockRejectedValue('unknown error');
await expect(service.getProviders()).rejects.toThrow(
'Unknown error calling RClone API (config/providers) with params {}: unknown error'
);
});
});
});

View File

@@ -24,25 +24,6 @@ import {
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
// Define the structure returned by mapProviderOptions inline
interface MappedRCloneProviderOption {
name: string;
help: string;
provider?: string;
default: any;
value: any;
shortOpt?: string;
hide?: number;
required?: boolean;
isPassword?: boolean;
noPrefix?: boolean;
advanced?: boolean;
defaultStr?: string;
valueStr?: string;
type?: string;
examples?: { value: string; help: string; provider?: string }[];
}
@Injectable()
export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
private isInitialized: boolean = false;
@@ -241,33 +222,6 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
return response?.providers || [];
}
/**
* Maps RClone provider options from API format to our model format
*/
private mapProviderOptions(options: RCloneProviderOptionResponse[]): MappedRCloneProviderOption[] {
return options.map((option) => ({
name: option.Name,
help: option.Help,
provider: option.Provider,
default: option.Default,
value: option.Value,
shortOpt: option.ShortOpt,
hide: option.Hide,
required: option.Required,
isPassword: option.IsPassword,
noPrefix: option.NoPrefix,
advanced: option.Advanced,
defaultStr: option.DefaultStr,
valueStr: option.ValueStr,
type: option.Type,
examples: option.Examples?.map((example) => ({
value: example.Value,
help: example.Help,
provider: example.Provider,
})),
}));
}
/**
* List all remotes configured in rclone
*/
@@ -367,7 +321,9 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
private async callRcloneApi(endpoint: string, params: Record<string, any> = {}): Promise<any> {
const url = `${this.rcloneBaseUrl}/${endpoint}`;
try {
this.logger.debug(`Calling RClone API: ${url} with params: ${JSON.stringify(params)}`);
this.logger.debug(
`Calling RClone API: ${url} with params: ${JSON.stringify(sanitizeParams(params))}`
);
const response = await got.post(url, {
json: params,
@@ -376,68 +332,63 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
headers: {
Authorization: `Basic ${Buffer.from(`${this.rcloneUsername}:${this.rclonePassword}`).toString('base64')}`,
},
// Add timeout? retry logic? Consider these based on need.
});
return response.body;
} catch (error: unknown) {
let detailedErrorMessage = 'An unknown error occurred';
if (error instanceof HTTPError) {
const statusCode = error.response.statusCode;
let rcloneError = 'Could not extract Rclone error details.';
const responseBody = error.response.body; // Get the body
this.handleApiError(error, endpoint, params);
}
}
try {
let errorBody: any;
// Check if the body is a string that needs parsing or already an object
if (typeof responseBody === 'string') {
errorBody = JSON.parse(responseBody);
} else if (typeof responseBody === 'object' && responseBody !== null) {
errorBody = responseBody; // It's already an object
}
private handleApiError(error: unknown, endpoint: string, params: Record<string, unknown>): never {
if (error instanceof HTTPError) {
const statusCode = error.response.statusCode;
const rcloneError = this.extractRcloneError(error.response.body, params);
const detailedErrorMessage = `Rclone API Error (${endpoint}, HTTP ${statusCode}): ${rcloneError}`;
if (errorBody && errorBody.error) {
rcloneError = `Rclone Error: ${errorBody.error}`;
// Add input details if available, check for different structures
if (errorBody.input) {
rcloneError += ` | Input: ${JSON.stringify(errorBody.input)}`;
} else if (params) {
// Fallback to original params if errorBody.input is missing
rcloneError += ` | Original Params: ${JSON.stringify(params)}`;
}
} else if (responseBody) {
// Body exists but doesn't match expected error structure
rcloneError = `Non-standard error response body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`;
} else {
rcloneError = 'Empty error response body received.';
}
} catch (parseOrAccessError) {
// Handle errors during parsing or accessing properties
rcloneError = `Failed to process error response body. Raw body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`;
}
// Construct the detailed message for the new error
detailedErrorMessage = `Rclone API Error (${endpoint}, HTTP ${statusCode}): ${rcloneError}`;
const sanitizedParams = sanitizeParams(params);
this.logger.error(
`Original ${detailedErrorMessage} | Params: ${JSON.stringify(sanitizedParams)}`,
error.stack
);
// Log the detailed error including the original stack if available
const sanitizedParams = sanitizeParams(params);
this.logger.error(
`Original ${detailedErrorMessage} | Params: ${JSON.stringify(sanitizedParams)}`,
error.stack
);
throw new Error(detailedErrorMessage);
} else if (error instanceof Error) {
const detailedErrorMessage = `Error calling RClone API (${endpoint}) with params ${JSON.stringify(sanitizeParams(params))}: ${error.message}`;
this.logger.error(detailedErrorMessage, error.stack);
throw error;
} else {
const detailedErrorMessage = `Unknown error calling RClone API (${endpoint}) with params ${JSON.stringify(sanitizeParams(params))}: ${String(error)}`;
this.logger.error(detailedErrorMessage);
throw new Error(detailedErrorMessage);
}
}
// Throw a NEW error with the detailed Rclone message
throw new Error(detailedErrorMessage);
} else if (error instanceof Error) {
// For non-HTTP errors, log and re-throw as before
detailedErrorMessage = `Error calling RClone API (${endpoint}) with params ${JSON.stringify(sanitizeParams(params))}: ${error.message}`;
this.logger.error(detailedErrorMessage, error.stack);
throw error; // Re-throw original non-HTTP error
} else {
// Handle unknown error types
detailedErrorMessage = `Unknown error calling RClone API (${endpoint}) with params ${JSON.stringify(sanitizeParams(params))}: ${String(error)}`;
this.logger.error(detailedErrorMessage);
throw new Error(detailedErrorMessage); // Throw a new error for unknown types
private extractRcloneError(responseBody: unknown, fallbackParams: Record<string, unknown>): string {
try {
let errorBody: unknown;
if (typeof responseBody === 'string') {
errorBody = JSON.parse(responseBody);
} else if (typeof responseBody === 'object' && responseBody !== null) {
errorBody = responseBody;
}
if (errorBody && typeof errorBody === 'object' && 'error' in errorBody) {
const typedErrorBody = errorBody as { error: unknown; input?: unknown };
let rcloneError = `Rclone Error: ${String(typedErrorBody.error)}`;
if (typedErrorBody.input) {
rcloneError += ` | Input: ${JSON.stringify(typedErrorBody.input)}`;
} else if (fallbackParams) {
rcloneError += ` | Original Params: ${JSON.stringify(fallbackParams)}`;
}
return rcloneError;
} else if (responseBody) {
return `Non-standard error response body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`;
} else {
return 'Empty error response body received.';
}
} catch (parseOrAccessError) {
return `Failed to process error response body. Raw body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`;
}
}
}

View File

@@ -18,3 +18,4 @@ export * from '@/components/common/tooltip';
export * from '@/components/common/toast';
export * from '@/components/common/popover';
export * from '@/components/modals';
export * from '@/components/common/accordion';