test: backups service

This commit is contained in:
Nicolas Meienberger 2025-12-19 18:26:06 +01:00
parent 94f49e2022
commit 760327a0b3
16 changed files with 276 additions and 14 deletions

1
.env.test Normal file
View file

@ -0,0 +1 @@
DATABASE_URL=:memory:

View file

@ -1,7 +1,7 @@
export const OPERATION_TIMEOUT = 5000;
export const VOLUME_MOUNT_BASE = "/var/lib/zerobyte/volumes";
export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories";
export const DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db";
export const DATABASE_URL = process.env.DATABASE_URL || "/var/lib/zerobyte/data/ironmount.db";
export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
export const DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE];

View file

@ -21,6 +21,7 @@ export const volumesTable = sqliteTable("volumes_table", {
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
});
export type Volume = typeof volumesTable.$inferSelect;
export type VolumeInsert = typeof volumesTable.$inferInsert;
/**
* Users Table
@ -61,6 +62,7 @@ export const repositoriesTable = sqliteTable("repositories_table", {
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
});
export type Repository = typeof repositoriesTable.$inferSelect;
export type RepositoryInsert = typeof repositoriesTable.$inferInsert;
/**
* Backup Schedules Table
@ -96,6 +98,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
});
export type BackupScheduleInsert = typeof backupSchedulesTable.$inferInsert;
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
volume: one(volumesTable, {

View file

@ -1,6 +1,5 @@
import { Job } from "../core/scheduler";
import { backupsService } from "../modules/backups/backups.service";
import { toMessage } from "../utils/errors";
import { logger } from "../utils/logger";
export class BackupExecutionJob extends Job {
@ -17,9 +16,7 @@ export class BackupExecutionJob extends Job {
logger.info(`Found ${scheduleIds.length} backup schedule(s) to execute`);
for (const scheduleId of scheduleIds) {
backupsService.executeBackup(scheduleId).catch((error) => {
logger.error(`Failed to execute backup for schedule ${scheduleId}: ${toMessage(error)}`);
});
backupsService.executeBackup(scheduleId);
}
return { done: true, timestamp: new Date(), executed: scheduleIds.length };

View file

@ -0,0 +1,151 @@
import { test, describe, mock, expect } from "bun:test";
import { backupsService } from "../backups.service";
import { createTestVolume } from "~/test/helpers/volume";
import { createTestBackupSchedule } from "~/test/helpers/backup";
import { createTestRepository } from "~/test/helpers/repository";
import { generateBackupOutput } from "~/test/helpers/restic";
import { beforeEach } from "bun:test";
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0 }));
mock.module("~/server/utils/spawn", () => ({
safeSpawn: resticBackupMock,
}));
beforeEach(() => {
resticBackupMock.mockClear();
});
describe("execute backup", () => {
test("should correctly set next backup time", async () => {
// arrange
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
cronExpression: "*/5 * * * *",
});
expect(schedule.nextBackupAt).toBeNull();
resticBackupMock.mockImplementationOnce(() =>
Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" }),
);
// act
await backupsService.executeBackup(schedule.id);
// assert
const updatedSchedule = await backupsService.getSchedule(schedule.id);
expect(updatedSchedule.nextBackupAt).not.toBeNull();
const nextBackupAt = new Date(updatedSchedule.nextBackupAt ?? 0);
const now = new Date();
expect(nextBackupAt.getTime()).toBeGreaterThanOrEqual(now.getTime());
expect(nextBackupAt.getTime() - now.getTime()).toBeLessThanOrEqual(5 * 60 * 1000);
});
test("should skip backup if schedule is disabled", async () => {
// arrange
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
enabled: false,
});
// act
await backupsService.executeBackup(schedule.id);
// assert
expect(resticBackupMock).not.toHaveBeenCalled();
});
test("should execute backup if schedule is disabled but the run is manual", async () => {
// arrange
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
enabled: false,
});
resticBackupMock.mockImplementationOnce(() =>
Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" }),
);
// act
await backupsService.executeBackup(schedule.id, true);
// assert
expect(resticBackupMock).toHaveBeenCalled();
});
test("should skip the backup if the previous one is still running", async () => {
// arrange
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
resticBackupMock.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
return Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" });
});
// act
backupsService.executeBackup(schedule.id);
await new Promise((resolve) => setTimeout(resolve, 10));
await backupsService.executeBackup(schedule.id);
// assert
expect(resticBackupMock).toHaveBeenCalledTimes(1);
});
test("should set the backup status to failed if restic returns a 3 exit code", async () => {
// arrange
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
resticBackupMock.mockImplementationOnce(() =>
Promise.resolve({ exitCode: 3, stdout: generateBackupOutput(), stderr: "Some error occurred" }),
);
// act
await backupsService.executeBackup(schedule.id);
// assert
const updatedSchedule = await backupsService.getSchedule(schedule.id);
expect(updatedSchedule.lastBackupStatus).toBe("warning");
});
test("should set the backup status to failed if restic returns a non zero exit code", async () => {
// arrange
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
resticBackupMock.mockImplementationOnce(() =>
Promise.resolve({ exitCode: 1, stdout: generateBackupOutput(), stderr: "Some error occurred" }),
);
// act
await backupsService.executeBackup(schedule.id);
// assert
const updatedSchedule = await backupsService.getSchedule(schedule.id);
expect(updatedSchedule.lastBackupStatus).toBe("error");
});
});

View file

@ -86,9 +86,7 @@ export const backupScheduleController = new Hono()
.post("/:scheduleId/run", runBackupNowDto, async (c) => {
const scheduleId = c.req.param("scheduleId");
backupsService.executeBackup(Number(scheduleId), true).catch((error) => {
console.error("Backup execution failed:", error);
});
backupsService.executeBackup(Number(scheduleId), true);
return c.json<RunBackupNowDto>({ success: true }, 200);
})

View file

@ -363,8 +363,6 @@ const executeBackup = async (scheduleId: number, manual = false) => {
.catch((notifError) => {
logger.error(`Failed to send backup failure notification: ${toMessage(notifError)}`);
});
throw error;
} finally {
runningBackups.delete(scheduleId);
}

View file

@ -29,6 +29,7 @@ const backupOutputSchema = type({
total_duration: "number",
snapshot_id: "string",
});
export type BackupOutput = typeof backupOutputSchema.infer;
const snapshotInfoSchema = type({
gid: "number?",
@ -344,7 +345,7 @@ const backup = async (
throw new ResticError(res.exitCode, res.stderr.toString());
}
const lastLine = stdout.trim();
const lastLine = (stdout || res.stdout).trim();
let summaryLine = "";
try {
const resSummary = JSON.parse(lastLine ?? "{}");

View file

@ -1,6 +1,6 @@
import { spawn } from "node:child_process";
interface Params {
export interface SafeSpawnParams {
command: string;
args: string[];
env?: NodeJS.ProcessEnv;
@ -18,7 +18,7 @@ type SpawnResult = {
stderr: string;
};
export const safeSpawn = (params: Params) => {
export const safeSpawn = (params: SafeSpawnParams) => {
const { command, args, env = {}, signal, ...callbacks } = params;
return new Promise<SpawnResult>((resolve) => {

View file

@ -0,0 +1,16 @@
import { db } from "~/server/db/db";
import { faker } from "@faker-js/faker";
import { backupSchedulesTable, type BackupScheduleInsert } from "~/server/db/schema";
export const createTestBackupSchedule = async (overrides: Partial<BackupScheduleInsert> = {}) => {
const backup: BackupScheduleInsert = {
name: faker.system.fileName(),
cronExpression: "0 0 * * *",
repositoryId: "repo_123",
volumeId: 1,
...overrides,
};
const data = await db.insert(backupSchedulesTable).values(backup).returning();
return data[0];
};

View file

@ -0,0 +1,20 @@
import { db } from "~/server/db/db";
import { faker } from "@faker-js/faker";
import { repositoriesTable, type RepositoryInsert } from "~/server/db/schema";
export const createTestRepository = async (overrides: Partial<RepositoryInsert> = {}) => {
const repository: RepositoryInsert = {
id: faker.string.alphanumeric(6),
name: faker.string.alphanumeric(10),
shortId: faker.string.alphanumeric(6),
config: {
name: "test-repo",
backend: "local",
},
type: "local",
...overrides,
};
const data = await db.insert(repositoriesTable).values(repository).returning();
return data[0];
};

View file

@ -0,0 +1,18 @@
export const generateBackupOutput = () => {
return JSON.stringify({
message_type: "summary",
files_new: 10,
files_changed: 5,
files_unmodified: 85,
dirs_new: 2,
dirs_changed: 1,
dirs_unmodified: 17,
data_blobs: 20,
tree_blobs: 5,
data_added: 1048576,
total_files_processed: 100,
total_bytes_processed: 2097152,
total_duration: 12.34,
snapshot_id: "abcd1234",
});
};

View file

@ -0,0 +1,21 @@
import { db } from "~/server/db/db";
import { faker } from "@faker-js/faker";
import { volumesTable, type VolumeInsert } from "~/server/db/schema";
export const createTestVolume = async (overrides: Partial<VolumeInsert> = {}) => {
const volume: VolumeInsert = {
name: faker.system.fileName(),
config: {
backend: "directory",
path: `/mnt/volumes/${faker.system.fileName()}`,
},
status: "mounted",
autoRemount: true,
shortId: faker.string.alphanumeric(6),
type: "directory",
...overrides,
};
const data = await db.insert(volumesTable).values(volume).returning();
return data[0];
};

19
app/test/setup.ts Normal file
View file

@ -0,0 +1,19 @@
import { beforeAll, mock } from "bun:test";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import path from "node:path";
import { cwd } from "node:process";
import { db } from "~/server/db/db";
mock.module("~/server/utils/logger", () => ({
logger: {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
},
}));
beforeAll(async () => {
const migrationsFolder = path.join(cwd(), "app", "drizzle");
migrate(db, { migrationsFolder });
});

View file

@ -58,6 +58,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@faker-js/faker": "^10.1.0",
"@hey-api/openapi-ts": "^0.88.0",
"@react-router/dev": "^7.10.0",
"@tailwindcss/vite": "^4.1.17",
@ -66,6 +67,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.7",
"lightningcss": "^1.30.2",
"tailwindcss": "^4.1.17",
@ -230,6 +232,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
"@faker-js/faker": ["@faker-js/faker@10.1.0", "", {}, "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
@ -578,6 +582,8 @@
"cron-parser": ["cron-parser@5.4.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
@ -748,6 +754,8 @@
"isbot": ["isbot@5.1.32", "", {}, "sha512-VNfjM73zz2IBZmdShMfAUg10prm6t7HFUQmNAEOAVS4YH92ZrZcvkMcGX6cIgBJAzWDzPent/EeAtYEHNPNPBQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@ -812,6 +820,8 @@
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"morgan": ["morgan@1.10.1", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.1.0" } }, "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@ -848,6 +858,8 @@
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
@ -936,6 +948,10 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],

View file

@ -14,7 +14,8 @@
"start:prod": "docker compose down && docker compose up --build zerobyte-prod",
"gen:api-client": "openapi-ts",
"gen:migrations": "drizzle-kit generate",
"studio": "drizzle-kit studio"
"studio": "drizzle-kit studio",
"test": "dotenv -e .env.test -- bun test --preload ./app/test/setup.ts"
},
"overrides": {
"esbuild": "^0.27.2"
@ -73,6 +74,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@faker-js/faker": "^10.1.0",
"@hey-api/openapi-ts": "^0.88.0",
"@react-router/dev": "^7.10.0",
"@tailwindcss/vite": "^4.1.17",
@ -81,6 +83,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.7",
"lightningcss": "^1.30.2",
"tailwindcss": "^4.1.17",