diff --git a/app/server/modules/backups/__tests__/backups.execution.test.ts b/app/server/modules/backups/__tests__/backups.execution.test.ts index 22d2a9e0..a0e03207 100644 --- a/app/server/modules/backups/__tests__/backups.execution.test.ts +++ b/app/server/modules/backups/__tests__/backups.execution.test.ts @@ -1,3 +1,4 @@ +import waitForExpect from "wait-for-expect"; import { test, describe, mock, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { backupsService } from "../backups.service"; import { backupsExecutionService } from "../backups.execution"; @@ -122,7 +123,11 @@ describe("stop backup", () => { }); void backupsExecutionService.executeBackup(schedule.id); - await new Promise((resolve) => setTimeout(resolve, 50)); + + await waitForExpect(async () => { + const runningSchedule = await backupsService.getScheduleById(schedule.id); + expect(runningSchedule.lastBackupStatus).toBe("in_progress"); + }); // act await backupsExecutionService.stopBackup(schedule.id); @@ -335,7 +340,9 @@ describe("mirror operations", () => { // act await backupsExecutionService.copyToMirrors(schedule.id, sourceRepository, schedule.retentionPolicy); - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForExpect(() => { + expect(resticCopyMock).toHaveBeenCalled(); + }); // assert expect(resticForgetMock).toHaveBeenCalledWith( @@ -363,7 +370,9 @@ describe("mirror operations", () => { // act await backupsExecutionService.copyToMirrors(schedule.id, sourceRepository, schedule.retentionPolicy); - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForExpect(() => { + expect(resticCopyMock).toHaveBeenCalled(); + }); // assert expect(resticForgetMock).not.toHaveBeenCalled(); diff --git a/app/server/modules/backups/__tests__/backups.queries.test.ts b/app/server/modules/backups/__tests__/backups.queries.test.ts new file mode 100644 index 00000000..2427cc3a --- /dev/null +++ b/app/server/modules/backups/__tests__/backups.queries.test.ts @@ -0,0 +1,178 @@ +import { test, describe, expect, beforeEach } from "bun:test"; +import { scheduleQueries } from "../backups.queries"; +import { createTestBackupSchedule } from "~/test/helpers/backup"; +import { createTestVolume } from "~/test/helpers/volume"; +import { createTestRepository } from "~/test/helpers/repository"; +import { TEST_ORG_ID } from "~/test/helpers/organization"; +import { faker } from "@faker-js/faker"; + +describe("scheduleQueries.findExecutable", () => { + let volume: { id: number }; + let repository: { id: string }; + + beforeEach(async () => { + volume = await createTestVolume(); + repository = await createTestRepository(); + }); + + test("should return enabled schedules with null nextBackupAt", async () => { + // arrange + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: true, + nextBackupAt: null, + lastBackupStatus: null, + }); + + // act + const result = await scheduleQueries.findExecutable(TEST_ORG_ID); + + // assert + expect(result).toContain(schedule.id); + }); + + test("should return enabled schedules with past nextBackupAt", async () => { + // arrange + const pastTime = faker.date.past().getTime(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: true, + nextBackupAt: pastTime, + lastBackupStatus: null, + }); + + // act + const result = await scheduleQueries.findExecutable(TEST_ORG_ID); + + // assert + expect(result).toContain(schedule.id); + }); + + test("should not return schedules with future nextBackupAt", async () => { + // arrange + const futureTime = faker.date.future().getTime(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: true, + nextBackupAt: futureTime, + lastBackupStatus: null, + }); + + // act + const result = await scheduleQueries.findExecutable(TEST_ORG_ID); + + // assert + expect(result).not.toContain(schedule.id); + }); + + test("should not return disabled schedules", async () => { + // arrange + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: false, + nextBackupAt: null, + lastBackupStatus: null, + }); + + // act + const result = await scheduleQueries.findExecutable(TEST_ORG_ID); + + // assert + expect(result).not.toContain(schedule.id); + }); + + test("should not return schedules with in_progress status", async () => { + // arrange + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: true, + nextBackupAt: null, + lastBackupStatus: "in_progress", + }); + + // act + const result = await scheduleQueries.findExecutable(TEST_ORG_ID); + + // assert + expect(result).not.toContain(schedule.id); + }); + + test("should return schedules with success status and past nextBackupAt", async () => { + // arrange + const pastTime = faker.date.past().getTime(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: true, + nextBackupAt: pastTime, + lastBackupStatus: "success", + }); + + // act + const result = await scheduleQueries.findExecutable(TEST_ORG_ID); + + // assert + expect(result).toContain(schedule.id); + }); + + test("should return schedules with error status and null nextBackupAt", async () => { + // arrange + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: true, + nextBackupAt: null, + lastBackupStatus: "error", + }); + + // act + const result = await scheduleQueries.findExecutable(TEST_ORG_ID); + + // assert + expect(result).toContain(schedule.id); + }); + + test("should not return schedules from other organizations", async () => { + // arrange + const otherOrgId = faker.string.uuid(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: true, + nextBackupAt: null, + lastBackupStatus: null, + organizationId: otherOrgId, + }); + + // act + const result = await scheduleQueries.findExecutable(TEST_ORG_ID); + + // assert + expect(result).not.toContain(schedule.id); + }); + + test("should only return schedule IDs", async () => { + // arrange + await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: true, + nextBackupAt: null, + lastBackupStatus: null, + }); + + // act + const result = await scheduleQueries.findExecutable(TEST_ORG_ID); + + // assert + expect(result.length).toBeGreaterThan(0); + for (const id of result) { + expect(typeof id).toBe("number"); + } + }); +}); diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts index 05dbb493..5570a6ef 100644 --- a/app/server/modules/backups/__tests__/backups.service.test.ts +++ b/app/server/modules/backups/__tests__/backups.service.test.ts @@ -1,3 +1,4 @@ +import waitForExpect from "wait-for-expect"; import { test, describe, mock, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { backupsService } from "../backups.service"; import { createTestVolume } from "~/test/helpers/volume"; @@ -106,7 +107,11 @@ describe("execute backup", () => { // act void backupsExecutionService.executeBackup(schedule.id); - await new Promise((resolve) => setTimeout(resolve, 10)); + + await waitForExpect(() => { + expect(resticBackupMock).toHaveBeenCalledTimes(1); + }); + await backupsExecutionService.executeBackup(schedule.id); // assert diff --git a/app/server/modules/backups/backup.helpers.ts b/app/server/modules/backups/backup.helpers.ts index e13f9e17..ed3d5ca5 100644 --- a/app/server/modules/backups/backup.helpers.ts +++ b/app/server/modules/backups/backup.helpers.ts @@ -23,7 +23,10 @@ export const processPattern = (pattern: string, volumePath: string) => { const isNegated = pattern.startsWith("!"); const p = isNegated ? pattern.slice(1) : pattern; - if (p.startsWith(volumePath) || !p.startsWith("/")) { + const relative = path.relative(volumePath, p); + const isInsideVolume = !relative.startsWith("..") && !path.isAbsolute(relative); + + if (isInsideVolume || !p.startsWith("/")) { return pattern; } diff --git a/app/server/modules/backups/backups.controller.ts b/app/server/modules/backups/backups.controller.ts index 8e97d301..84473978 100644 --- a/app/server/modules/backups/backups.controller.ts +++ b/app/server/modules/backups/backups.controller.ts @@ -43,6 +43,7 @@ import { import { notificationsService } from "../notifications/notifications.service"; import { requireAuth } from "../auth/auth.middleware"; import { backupsExecutionService } from "./backups.execution"; +import { logger } from "~/server/utils/logger"; export const backupScheduleController = new Hono() .use(requireAuth) @@ -95,7 +96,7 @@ export const backupScheduleController = new Hono() } backupsExecutionService.executeBackup(Number(scheduleId), true).catch((err) => { - console.error(`Error executing manual backup for schedule ${scheduleId}:`, err); + logger.error(`Error executing manual backup for schedule ${scheduleId}:`, err); }); return c.json({ success: true }, 200); diff --git a/app/server/modules/backups/backups.queries.ts b/app/server/modules/backups/backups.queries.ts index d7b58ea6..fcd7fcec 100644 --- a/app/server/modules/backups/backups.queries.ts +++ b/app/server/modules/backups/backups.queries.ts @@ -19,7 +19,7 @@ export const scheduleQueries = { where: { AND: [ { enabled: true }, - { OR: [{ lastBackupStatus: { NOT: "in_progress" } }, { lastBackupStatus: { isNull: true } }] }, + { OR: [{ lastBackupStatus: { ne: "in_progress" } }, { lastBackupStatus: { isNull: true } }] }, { organizationId }, ], }, @@ -47,11 +47,10 @@ export const scheduleQueries = { export const mirrorQueries = { findEnabledBySchedule: async (scheduleId: number) => { - const mirrors = await db.query.backupScheduleMirrorsTable.findMany({ - where: { scheduleId }, + return db.query.backupScheduleMirrorsTable.findMany({ + where: { scheduleId, enabled: true }, with: { repository: true }, }); - return mirrors.filter((m) => m.enabled); }, updateStatus: async ( diff --git a/bun.lock b/bun.lock index 7e866dbd..b48f585e 100644 --- a/bun.lock +++ b/bun.lock @@ -98,6 +98,7 @@ "vite-bundle-analyzer": "^1.2.3", "vite-plugin-babel": "^1.4.1", "vite-tsconfig-paths": "^6.0.5", + "wait-for-expect": "^4.0.0", }, }, }, @@ -1655,6 +1656,8 @@ "vite-tsconfig-paths": ["vite-tsconfig-paths@6.0.5", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-f/WvY6ekHykUF1rWJUAbCU7iS/5QYDIugwpqJA+ttwKbxSbzNlqlE8vZSrsnxNQciUW+z6lvhlXMaEyZn9MSig=="], + "wait-for-expect": ["wait-for-expect@4.0.0", "", {}, "sha512-mcH2HYUUHhdFGHVJkgwkBxRihZO4VSuPyh6xhYHz7LEnYkcaLbTAEEsTpYiFw4UY45XdTZYYIaquuMucw9wWMw=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], diff --git a/package.json b/package.json index cc554cb0..fdf8ae34 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,8 @@ "vite": "^7.3.1", "vite-bundle-analyzer": "^1.2.3", "vite-plugin-babel": "^1.4.1", - "vite-tsconfig-paths": "^6.0.5" + "vite-tsconfig-paths": "^6.0.5", + "wait-for-expect": "^4.0.0" }, "overrides": { "esbuild": "^0.27.2"