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:
Sam Chau
2025-10-21 09:02:58 +10:30
parent b8949b5148
commit 5cd1bf82ff
9 changed files with 427 additions and 34 deletions

View File

@@ -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

View File

@@ -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();

View File

@@ -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';
/**

View File

@@ -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', {

View 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),
};
}
}

View File

@@ -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;

View File

@@ -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