import waitForExpect from "wait-for-expect"; import { test, describe, mock, expect, afterEach, spyOn } from "bun:test"; import { eq } from "drizzle-orm"; 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 { faker } from "@faker-js/faker"; import * as spawnModule from "@zerobyte/core/node"; import { db } from "~/server/db/db"; import { backupScheduleMirrorsTable, repositoriesTable, volumesTable } from "~/server/db/schema"; import { TEST_ORG_ID } from "~/test/helpers/organization"; import * as context from "~/server/core/request-context"; import { backupsExecutionService } from "../backups.execution"; import { repositoriesService } from "~/server/modules/repositories/repositories.service"; const setup = () => { const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: "", error: "" })); const refreshStatsMock = mock(() => Promise.resolve({ total_size: 0, total_uncompressed_size: 0, compression_ratio: 0, compression_progress: 0, compression_space_saving: 0, snapshots_count: 0, }), ); spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock); spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock); spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); return { resticBackupMock, refreshStatsMock, }; }; afterEach(() => { mock.restore(); }); describe("execute backup", () => { test("should correctly set next backup time", async () => { // arrange const { resticBackupMock } = setup(); 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, summary: generateBackupOutput(), error: "" }), ); // act await backupsExecutionService.executeBackup(schedule.id); // assert const updatedSchedule = await backupsService.getScheduleById(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 { resticBackupMock } = setup(); const volume = await createTestVolume(); const repository = await createTestRepository(); const schedule = await createTestBackupSchedule({ volumeId: volume.id, repositoryId: repository.id, enabled: false, }); // act await backupsExecutionService.executeBackup(schedule.id); // assert expect(resticBackupMock).not.toHaveBeenCalled(); }); test("should execute backup if schedule is disabled but the run is manual", async () => { // arrange const { resticBackupMock } = setup(); 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, summary: generateBackupOutput(), error: "" }), ); // act await backupsExecutionService.executeBackup(schedule.id, true); // assert expect(resticBackupMock).toHaveBeenCalled(); }); test("should skip the backup if the previous one is still running", async () => { // arrange const { resticBackupMock } = setup(); 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, summary: generateBackupOutput(), error: "" }); }); // act void backupsExecutionService.executeBackup(schedule.id); await waitForExpect(() => { expect(resticBackupMock).toHaveBeenCalledTimes(1); }); await backupsExecutionService.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 { resticBackupMock } = setup(); const volume = await createTestVolume(); const repository = await createTestRepository(); const schedule = await createTestBackupSchedule({ volumeId: volume.id, repositoryId: repository.id, }); resticBackupMock.mockImplementationOnce(() => Promise.resolve({ exitCode: 3, summary: generateBackupOutput(), error: "Some error occurred" }), ); // act await backupsExecutionService.executeBackup(schedule.id); // assert const updatedSchedule = await backupsService.getScheduleById(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 { resticBackupMock } = setup(); const volume = await createTestVolume(); const repository = await createTestRepository(); const schedule = await createTestBackupSchedule({ volumeId: volume.id, repositoryId: repository.id, }); resticBackupMock.mockImplementationOnce(() => Promise.resolve({ exitCode: 1, summary: generateBackupOutput(), error: "Some error occurred" }), ); // act await backupsExecutionService.executeBackup(schedule.id); // assert const updatedSchedule = await backupsService.getScheduleById(schedule.id); expect(updatedSchedule.lastBackupStatus).toBe("error"); }); }); describe("getSchedulesToExecute", () => { test("should return schedules with NULL lastBackupStatus", async () => { // arrange setup(); const volume = await createTestVolume(); const repository = await createTestRepository(); const schedule = await createTestBackupSchedule({ volumeId: volume.id, repositoryId: repository.id, enabled: true, cronExpression: "* * * * *", lastBackupStatus: null, nextBackupAt: faker.date.past().getTime(), }); // act const schedulesToExecute = await backupsExecutionService.getSchedulesToExecute(); // assert expect(schedulesToExecute).toContain(schedule.id); }); }); describe("getScheduleByIdOrShortId", () => { test("should resolve a schedule by numeric id string", async () => { setup(); const volume = await createTestVolume(); const repository = await createTestRepository(); const schedule = await createTestBackupSchedule({ volumeId: volume.id, repositoryId: repository.id, }); const found = await backupsService.getScheduleByIdOrShortId(String(schedule.id)); expect(found.id).toBe(schedule.id); expect(found.shortId).toBe(schedule.shortId); }); test("should resolve a schedule by short id", async () => { setup(); const volume = await createTestVolume(); const repository = await createTestRepository(); const schedule = await createTestBackupSchedule({ volumeId: volume.id, repositoryId: repository.id, }); const found = await backupsService.getScheduleByIdOrShortId(schedule.shortId); expect(found.id).toBe(schedule.id); expect(found.shortId).toBe(schedule.shortId); }); test("should not return schedules from another organization", async () => { setup(); const otherOrgId = faker.string.uuid(); const schedule = await createTestBackupSchedule({ organizationId: otherOrgId, }); await expect(backupsService.getScheduleByIdOrShortId(schedule.shortId)).rejects.toThrow( "Backup schedule not found", ); await expect(backupsService.getScheduleByIdOrShortId(schedule.id)).rejects.toThrow("Backup schedule not found"); }); }); describe("listSchedules", () => { test("should ignore schedules with missing relations", async () => { setup(); const healthyVolume = await createTestVolume(); const healthyRepository = await createTestRepository(); const healthySchedule = await createTestBackupSchedule({ volumeId: healthyVolume.id, repositoryId: healthyRepository.id, }); const orphanVolume = await createTestVolume(); const orphanRepository = await createTestRepository(); const orphanSchedule = await createTestBackupSchedule({ volumeId: orphanVolume.id, repositoryId: orphanRepository.id, }); await db.delete(volumesTable).where(eq(volumesTable.id, orphanVolume.id)); const schedules = await backupsService.listSchedules(); expect(schedules.map((schedule) => schedule.id)).toContain(healthySchedule.id); expect(schedules.map((schedule) => schedule.id)).not.toContain(orphanSchedule.id); }); test("should ignore schedules with missing repository relation", async () => { setup(); const healthyVolume = await createTestVolume(); const healthyRepository = await createTestRepository(); const healthySchedule = await createTestBackupSchedule({ volumeId: healthyVolume.id, repositoryId: healthyRepository.id, }); const orphanVolume = await createTestVolume(); const orphanRepository = await createTestRepository(); const orphanSchedule = await createTestBackupSchedule({ volumeId: orphanVolume.id, repositoryId: orphanRepository.id, }); await db.delete(repositoriesTable).where(eq(repositoriesTable.id, orphanRepository.id)); const schedules = await backupsService.listSchedules(); expect(schedules.map((schedule) => schedule.id)).toContain(healthySchedule.id); expect(schedules.map((schedule) => schedule.id)).not.toContain(orphanSchedule.id); }); }); describe("cleanupOrphanedSchedules", () => { test("should return zero when cascades already removed orphaned schedules", async () => { setup(); const healthyVolume = await createTestVolume(); const healthyRepository = await createTestRepository(); const healthySchedule = await createTestBackupSchedule({ volumeId: healthyVolume.id, repositoryId: healthyRepository.id, }); const orphanVolume = await createTestVolume(); const orphanRepository = await createTestRepository(); const orphanSchedule = await createTestBackupSchedule({ volumeId: orphanVolume.id, repositoryId: orphanRepository.id, }); const mirrorRepository = await createTestRepository(); await db.insert(backupScheduleMirrorsTable).values({ scheduleId: orphanSchedule.id, repositoryId: mirrorRepository.id, enabled: true, }); await db.delete(volumesTable).where(eq(volumesTable.id, orphanVolume.id)); const cleanupResult = await backupsService.cleanupOrphanedSchedules(); expect(cleanupResult.deletedSchedules).toBe(0); const deletedSchedule = await db.query.backupSchedulesTable.findFirst({ where: { id: orphanSchedule.id }, columns: { id: true }, }); expect(deletedSchedule).toBeUndefined(); const remainingHealthySchedule = await db.query.backupSchedulesTable.findFirst({ where: { id: healthySchedule.id }, columns: { id: true }, }); expect(remainingHealthySchedule?.id).toBe(healthySchedule.id); const orphanMirrors = await db.query.backupScheduleMirrorsTable.findMany({ where: { scheduleId: orphanSchedule.id }, columns: { id: true }, }); expect(orphanMirrors).toHaveLength(0); }); });