mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-24 19:51:03 +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
|
||||
|
||||
302
tests/jobs/createBackup.test.ts
Normal file
302
tests/jobs/createBackup.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Tests for createBackup job
|
||||
* Tests the creation of compressed backups
|
||||
*/
|
||||
|
||||
import { BaseTest } from "../base/BaseTest.ts";
|
||||
import { createBackup } from "../../src/jobs/logic/createBackup.ts";
|
||||
import { assertEquals } from "@std/assert";
|
||||
|
||||
class CreateBackupTest extends BaseTest {
|
||||
/**
|
||||
* Test 1: Basic successful backup
|
||||
*/
|
||||
testBasicSuccessfulBackup(): void {
|
||||
this.test("basic successful backup", async (context) => {
|
||||
// Setup: Create source directory with test files
|
||||
const sourceDir = `${context.tempDir}/source`;
|
||||
await Deno.mkdir(sourceDir, { recursive: true });
|
||||
|
||||
// Create some test files
|
||||
await Deno.writeTextFile(`${sourceDir}/file1.txt`, "test content 1\n");
|
||||
await Deno.writeTextFile(`${sourceDir}/file2.txt`, "test content 2\n");
|
||||
await Deno.writeTextFile(
|
||||
`${sourceDir}/file3.json`,
|
||||
JSON.stringify({ test: "data" }) + "\n",
|
||||
);
|
||||
|
||||
// Create backup directory
|
||||
const backupDir = `${context.tempDir}/backups`;
|
||||
|
||||
// Run backup
|
||||
const result = await createBackup(sourceDir, backupDir);
|
||||
|
||||
// Assert success
|
||||
assertEquals(result.success, true);
|
||||
assertEquals(typeof result.filename, "string");
|
||||
assertEquals(typeof result.sizeBytes, "number");
|
||||
|
||||
// Assert size is greater than 0
|
||||
if (result.sizeBytes) {
|
||||
assertEquals(result.sizeBytes > 0, true);
|
||||
}
|
||||
|
||||
// Assert no error
|
||||
assertEquals(result.error, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test 2: Backup filename format
|
||||
*/
|
||||
testBackupFilenameFormat(): void {
|
||||
this.test("backup filename format", async (context) => {
|
||||
// Setup: Create source directory with test files
|
||||
const sourceDir = `${context.tempDir}/source`;
|
||||
await Deno.mkdir(sourceDir, { recursive: true });
|
||||
await Deno.writeTextFile(`${sourceDir}/test.txt`, "content\n");
|
||||
|
||||
const backupDir = `${context.tempDir}/backups`;
|
||||
|
||||
// Use a custom timestamp for predictable filename
|
||||
const customTimestamp = new Date("2025-01-15T14:30:45.000Z");
|
||||
|
||||
// Run backup with custom timestamp
|
||||
const result = await createBackup(sourceDir, backupDir, customTimestamp);
|
||||
|
||||
// Assert success
|
||||
assertEquals(result.success, true);
|
||||
|
||||
// Assert filename matches expected format: backup-2025-01-15-143045.tar.gz
|
||||
assertEquals(result.filename, "backup-2025-01-15-143045.tar.gz");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test 3: Backup file exists and is valid
|
||||
*/
|
||||
testBackupFileExistsAndValid(): void {
|
||||
this.test("backup file exists and is valid", async (context) => {
|
||||
// Setup: Create source directory with test files
|
||||
const sourceDir = `${context.tempDir}/source`;
|
||||
await Deno.mkdir(sourceDir, { recursive: true });
|
||||
await Deno.writeTextFile(`${sourceDir}/file1.txt`, "test content\n");
|
||||
await Deno.writeTextFile(`${sourceDir}/file2.txt`, "more content\n");
|
||||
|
||||
const backupDir = `${context.tempDir}/backups`;
|
||||
|
||||
// Run backup
|
||||
const result = await createBackup(sourceDir, backupDir);
|
||||
|
||||
// Assert success
|
||||
assertEquals(result.success, true);
|
||||
|
||||
// Construct backup file path
|
||||
const backupFilePath = `${backupDir}/${result.filename}`;
|
||||
|
||||
// Assert backup file exists
|
||||
await this.assertFileExists(backupFilePath);
|
||||
|
||||
// Get file stats and verify size
|
||||
const stat = await Deno.stat(backupFilePath);
|
||||
assertEquals(stat.size > 0, true);
|
||||
|
||||
// Verify returned sizeBytes matches actual file size
|
||||
assertEquals(result.sizeBytes, stat.size);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test 4: Backup contains expected files
|
||||
*/
|
||||
testBackupContainsExpectedFiles(): void {
|
||||
this.test("backup contains expected files", async (context) => {
|
||||
// Setup: Create source directory with specific test files
|
||||
const sourceDir = `${context.tempDir}/source`;
|
||||
await Deno.mkdir(sourceDir, { recursive: true });
|
||||
|
||||
const testContent1 = "This is test file 1\n";
|
||||
const testContent2 = "This is test file 2\n";
|
||||
const testContent3 = JSON.stringify({ key: "value", data: [1, 2, 3] });
|
||||
|
||||
await Deno.writeTextFile(`${sourceDir}/file1.txt`, testContent1);
|
||||
await Deno.writeTextFile(`${sourceDir}/file2.txt`, testContent2);
|
||||
await Deno.writeTextFile(`${sourceDir}/data.json`, testContent3);
|
||||
|
||||
const backupDir = `${context.tempDir}/backups`;
|
||||
|
||||
// Run backup
|
||||
const result = await createBackup(sourceDir, backupDir);
|
||||
|
||||
// Assert success
|
||||
assertEquals(result.success, true);
|
||||
|
||||
// Extract backup to verify contents
|
||||
const extractDir = `${context.tempDir}/extract`;
|
||||
await Deno.mkdir(extractDir, { recursive: true });
|
||||
|
||||
const backupFilePath = `${backupDir}/${result.filename}`;
|
||||
|
||||
// Extract tar.gz
|
||||
const extractCommand = new Deno.Command("tar", {
|
||||
args: ["-xzf", backupFilePath, "-C", extractDir],
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
|
||||
const { code } = await extractCommand.output();
|
||||
assertEquals(code, 0);
|
||||
|
||||
// Verify extracted files exist and have correct content
|
||||
const extractedSourceDir = `${extractDir}/source`;
|
||||
await this.assertFileExists(`${extractedSourceDir}/file1.txt`);
|
||||
await this.assertFileExists(`${extractedSourceDir}/file2.txt`);
|
||||
await this.assertFileExists(`${extractedSourceDir}/data.json`);
|
||||
|
||||
// Verify file contents
|
||||
const extractedContent1 = await Deno.readTextFile(
|
||||
`${extractedSourceDir}/file1.txt`,
|
||||
);
|
||||
const extractedContent2 = await Deno.readTextFile(
|
||||
`${extractedSourceDir}/file2.txt`,
|
||||
);
|
||||
const extractedContent3 = await Deno.readTextFile(
|
||||
`${extractedSourceDir}/data.json`,
|
||||
);
|
||||
|
||||
assertEquals(extractedContent1, testContent1);
|
||||
assertEquals(extractedContent2, testContent2);
|
||||
assertEquals(extractedContent3, testContent3);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test 5: Non-existent source directory
|
||||
*/
|
||||
testNonExistentSourceDirectory(): void {
|
||||
this.test("non-existent source directory", async (context) => {
|
||||
// Setup: Create path to non-existent directory
|
||||
const sourceDir = `${context.tempDir}/does-not-exist`;
|
||||
const backupDir = `${context.tempDir}/backups`;
|
||||
|
||||
// Run backup on non-existent directory
|
||||
const result = await createBackup(sourceDir, backupDir);
|
||||
|
||||
// Assert failure
|
||||
assertEquals(result.success, false);
|
||||
|
||||
// Assert error message mentions source directory
|
||||
assertEquals(typeof result.error, "string");
|
||||
if (result.error) {
|
||||
assertEquals(
|
||||
result.error.includes("Source directory does not exist"),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// Assert no filename or size returned
|
||||
assertEquals(result.filename, undefined);
|
||||
assertEquals(result.sizeBytes, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test 6: Source path is a file, not directory
|
||||
*/
|
||||
testSourcePathIsFile(): void {
|
||||
this.test("source path is a file, not directory", async (context) => {
|
||||
// Setup: Create a file instead of a directory
|
||||
const sourcePath = `${context.tempDir}/somefile.txt`;
|
||||
await Deno.writeTextFile(sourcePath, "this is a file\n");
|
||||
|
||||
const backupDir = `${context.tempDir}/backups`;
|
||||
|
||||
// Run backup on file path
|
||||
const result = await createBackup(sourcePath, backupDir);
|
||||
|
||||
// Assert failure
|
||||
assertEquals(result.success, false);
|
||||
|
||||
// Assert error message mentions not a directory
|
||||
assertEquals(typeof result.error, "string");
|
||||
if (result.error) {
|
||||
assertEquals(
|
||||
result.error.includes("Source path is not a directory"),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// Assert no filename or size returned
|
||||
assertEquals(result.filename, undefined);
|
||||
assertEquals(result.sizeBytes, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test 7: Empty source directory
|
||||
*/
|
||||
testEmptySourceDirectory(): void {
|
||||
this.test("empty source directory", async (context) => {
|
||||
// Setup: Create an empty source directory
|
||||
const sourceDir = `${context.tempDir}/empty-source`;
|
||||
await Deno.mkdir(sourceDir, { recursive: true });
|
||||
|
||||
const backupDir = `${context.tempDir}/backups`;
|
||||
|
||||
// Run backup on empty directory
|
||||
const result = await createBackup(sourceDir, backupDir);
|
||||
|
||||
// Assert success
|
||||
assertEquals(result.success, true);
|
||||
assertEquals(typeof result.filename, "string");
|
||||
assertEquals(typeof result.sizeBytes, "number");
|
||||
|
||||
// Assert backup file exists
|
||||
const backupFilePath = `${backupDir}/${result.filename}`;
|
||||
await this.assertFileExists(backupFilePath);
|
||||
|
||||
// Assert file has size > 0 (even empty tar.gz has some size for headers)
|
||||
if (result.sizeBytes) {
|
||||
assertEquals(result.sizeBytes > 0, true);
|
||||
}
|
||||
|
||||
// Extract and verify empty directory structure is preserved
|
||||
const extractDir = `${context.tempDir}/extract`;
|
||||
await Deno.mkdir(extractDir, { recursive: true });
|
||||
|
||||
const extractCommand = new Deno.Command("tar", {
|
||||
args: ["-xzf", backupFilePath, "-C", extractDir],
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
|
||||
const { code } = await extractCommand.output();
|
||||
assertEquals(code, 0);
|
||||
|
||||
// Verify extracted directory exists and is empty
|
||||
const extractedSourceDir = `${extractDir}/empty-source`;
|
||||
await this.assertFileExists(extractedSourceDir);
|
||||
|
||||
// Read directory to verify it's empty
|
||||
const entries = [];
|
||||
for await (const entry of Deno.readDir(extractedSourceDir)) {
|
||||
entries.push(entry);
|
||||
}
|
||||
assertEquals(entries.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
runTests(): void {
|
||||
this.testBasicSuccessfulBackup();
|
||||
this.testBackupFilenameFormat();
|
||||
this.testBackupFileExistsAndValid();
|
||||
this.testBackupContainsExpectedFiles();
|
||||
this.testNonExistentSourceDirectory();
|
||||
this.testSourcePathIsFile();
|
||||
this.testEmptySourceDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests
|
||||
const test = new CreateBackupTest();
|
||||
await test.runTests();
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { BaseTest } from "../base/BaseTest.ts";
|
||||
import { cleanupLogs } from "../../src/utils/jobs/lib/cleanupLogs.ts";
|
||||
import { cleanupLogs } from "../../src/jobs/logic/cleanupLogs.ts";
|
||||
import { assertEquals } from "@std/assert";
|
||||
|
||||
class CleanupLogsTest extends BaseTest {
|
||||
|
||||
Reference in New Issue
Block a user