mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-02-01 07:10:47 +01:00
refactor(createBackup): extract logic and add comprehensive tests
- Extract pure backup logic to /src/jobs/logic/createBackup.ts
- Update job definition to use extracted logic
- Fix absolute path parsing for tar command
- Add 7 tests: success cases, filename format, file extraction,
error handling (non-existent dir, file instead of dir), empty dir
- Uses BaseTest framework with temp directories and tar extraction
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { db } from '../db.ts';
|
||||
import type { Job, CreateJobInput, UpdateJobInput, JobRun } from '$utils/jobs/types.ts';
|
||||
import type { Job, CreateJobInput, UpdateJobInput, JobRun } from '../../jobs/types.ts';
|
||||
|
||||
/**
|
||||
* All queries for jobs table
|
||||
|
||||
@@ -3,8 +3,8 @@ import { logStartup } from './utils/logger/startup.ts';
|
||||
import { logSettings } from './utils/logger/settings.ts';
|
||||
import { db } from '$db/db.ts';
|
||||
import { runMigrations } from '$db/migrations.ts';
|
||||
import { initializeJobs } from './utils/jobs/init.ts';
|
||||
import { jobScheduler } from './utils/jobs/scheduler.ts';
|
||||
import { initializeJobs } from './jobs/init.ts';
|
||||
import { jobScheduler } from './jobs/scheduler.ts';
|
||||
|
||||
// Initialize configuration on server startup
|
||||
await config.init();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { config } from '$config';
|
||||
import { logSettingsQueries } from '$db/queries/logSettings.ts';
|
||||
import { logger } from '$logger';
|
||||
import { cleanupLogs } from '../lib/cleanupLogs.ts';
|
||||
import { cleanupLogs } from '../logic/cleanupLogs.ts';
|
||||
import type { JobDefinition, JobResult } from '../types.ts';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { config } from '$config';
|
||||
import { backupSettingsQueries } from '$db/queries/backupSettings.ts';
|
||||
import { logger } from '$logger';
|
||||
import { createBackup } from '../logic/createBackup.ts';
|
||||
import type { JobDefinition, JobResult } from '../types.ts';
|
||||
|
||||
/**
|
||||
@@ -23,53 +24,39 @@ export const createBackupJob: JobDefinition = {
|
||||
};
|
||||
}
|
||||
|
||||
const sourceDir = config.paths.data;
|
||||
const backupsDir = config.paths.backups;
|
||||
|
||||
// Generate backup filename with timestamp
|
||||
const now = new Date();
|
||||
const datePart = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const timePart = now.toISOString().split('T')[1].split('.')[0].replace(/:/g, ''); // HHMMSS
|
||||
const backupFilename = `backup-${datePart}-${timePart}.tar.gz`;
|
||||
const backupPath = `${backupsDir}/${backupFilename}`;
|
||||
|
||||
await logger.info(`Creating backup: ${backupFilename}`, {
|
||||
await logger.info('Creating backup', {
|
||||
source: 'CreateBackupJob',
|
||||
meta: { backupPath }
|
||||
meta: { sourceDir, backupsDir }
|
||||
});
|
||||
|
||||
// Create tar.gz archive of data directory
|
||||
const command = new Deno.Command('tar', {
|
||||
args: ['-czf', backupPath, '-C', config.paths.base, 'data'],
|
||||
stdout: 'piped',
|
||||
stderr: 'piped'
|
||||
});
|
||||
// Run backup creation
|
||||
const result = await createBackup(sourceDir, backupsDir);
|
||||
|
||||
const { code, stderr } = await command.output();
|
||||
|
||||
if (code !== 0) {
|
||||
const errorMessage = new TextDecoder().decode(stderr);
|
||||
if (!result.success) {
|
||||
await logger.error('Backup creation failed', {
|
||||
source: 'CreateBackupJob',
|
||||
meta: { error: errorMessage, exitCode: code }
|
||||
meta: { error: result.error }
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: `tar command failed with code ${code}: ${errorMessage}`
|
||||
error: result.error ?? 'Unknown error'
|
||||
};
|
||||
}
|
||||
|
||||
// Get backup file size
|
||||
const stat = await Deno.stat(backupPath);
|
||||
const sizeInMB = (stat.size / (1024 * 1024)).toFixed(2);
|
||||
// Calculate size in MB for display
|
||||
const sizeInMB = ((result.sizeBytes ?? 0) / (1024 * 1024)).toFixed(2);
|
||||
|
||||
await logger.info(`Backup created successfully: ${backupFilename} (${sizeInMB} MB)`, {
|
||||
await logger.info(`Backup created successfully: ${result.filename} (${sizeInMB} MB)`, {
|
||||
source: 'CreateBackupJob',
|
||||
meta: { filename: backupFilename, sizeBytes: stat.size }
|
||||
meta: { filename: result.filename, sizeBytes: result.sizeBytes }
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: `Backup created: ${backupFilename} (${sizeInMB} MB)`
|
||||
output: `Backup created: ${result.filename} (${sizeInMB} MB)`
|
||||
};
|
||||
} catch (error) {
|
||||
await logger.error('Backup creation failed', {
|
||||
|
||||
104
src/jobs/logic/createBackup.ts
Normal file
104
src/jobs/logic/createBackup.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Core backup creation logic
|
||||
* Separated from job definition to avoid database/config dependencies for testing
|
||||
*/
|
||||
|
||||
export interface CreateBackupResult {
|
||||
success: boolean;
|
||||
filename?: string;
|
||||
sizeBytes?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core backup logic - creates a tar.gz archive of a directory
|
||||
* Pure function that only depends on Deno APIs
|
||||
*
|
||||
* @param sourceDir Directory to backup (will backup this entire directory)
|
||||
* @param backupDir Directory where backup file will be saved
|
||||
* @param timestamp Optional timestamp for backup filename (defaults to current time)
|
||||
* @returns Backup result with filename and size or error
|
||||
*/
|
||||
export async function createBackup(
|
||||
sourceDir: string,
|
||||
backupDir: string,
|
||||
timestamp?: Date,
|
||||
): Promise<CreateBackupResult> {
|
||||
try {
|
||||
// Generate backup filename with timestamp
|
||||
const now = timestamp ?? new Date();
|
||||
const datePart = now.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
const timePart = now.toISOString().split("T")[1].split(".")[0].replace(
|
||||
/:/g,
|
||||
"",
|
||||
); // HHMMSS
|
||||
const backupFilename = `backup-${datePart}-${timePart}.tar.gz`;
|
||||
const backupPath = `${backupDir}/${backupFilename}`;
|
||||
|
||||
// Ensure backup directory exists
|
||||
try {
|
||||
await Deno.mkdir(backupDir, { recursive: true });
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to create backup directory: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Verify source directory exists
|
||||
try {
|
||||
const stat = await Deno.stat(sourceDir);
|
||||
if (!stat.isDirectory) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Source path is not a directory: ${sourceDir}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Source directory does not exist: ${sourceDir}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Get parent directory and directory name for tar command
|
||||
const isAbsolute = sourceDir.startsWith("/");
|
||||
const sourcePathParts = sourceDir.split("/").filter((p) => p);
|
||||
const dirName = sourcePathParts[sourcePathParts.length - 1];
|
||||
const parentDirParts = sourcePathParts.slice(0, -1);
|
||||
const parentDir = parentDirParts.length > 0
|
||||
? (isAbsolute ? "/" : "") + parentDirParts.join("/")
|
||||
: "/";
|
||||
|
||||
// Create tar.gz archive
|
||||
const command = new Deno.Command("tar", {
|
||||
args: ["-czf", backupPath, "-C", parentDir, dirName],
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
|
||||
const { code, stderr } = await command.output();
|
||||
|
||||
if (code !== 0) {
|
||||
const errorMessage = new TextDecoder().decode(stderr);
|
||||
return {
|
||||
success: false,
|
||||
error: `tar command failed with code ${code}: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Get backup file size
|
||||
const stat = await Deno.stat(backupPath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filename: backupFilename,
|
||||
sizeBytes: stat.size,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { Actions, RequestEvent } from '@sveltejs/kit';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { config } from '$config';
|
||||
import { logger } from '$logger';
|
||||
import { jobScheduler } from '$utils/jobs/scheduler.ts';
|
||||
import { jobScheduler } from '../../../jobs/scheduler.ts';
|
||||
|
||||
interface BackupFile {
|
||||
filename: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Actions, RequestEvent } from '@sveltejs/kit';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { jobsQueries, jobRunsQueries } from '$db/queries/jobs.ts';
|
||||
import { jobScheduler } from '$utils/jobs/scheduler.ts';
|
||||
import { jobScheduler } from '../../../jobs/scheduler.ts';
|
||||
import { logger } from '$logger';
|
||||
|
||||
// Helper to format schedule for display
|
||||
|
||||
Reference in New Issue
Block a user