mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
A restore never runs beside a backup pass or another large job: the heavy lane puts its delivery back in the queue for a minute at a time. The job's own row says queued for the whole wait, and the console said the restore starts "as soon as the background worker picks it up", which read as a stuck worker while a backup ran. The status routes now ask the queue. A delivery the lane put back sits in created with a start_after ahead of now, and the active row of a heavy queue is what holds the lane. The job view carries waiting: "backup" | "busy" | null, and the console says it waits for the running backup, or for another large job, and since when it has been queued, in all nine locales. A queue that cannot be asked answers no wait and is reported at warn. The job view and its reads move to backup-restore-view.ts, which keeps the worker module under its size baseline.
658 lines
24 KiB
TypeScript
658 lines
24 KiB
TypeScript
/**
|
|
* A backup restore runs as a background job, end to end.
|
|
*
|
|
* The restore of a large account outlived a reverse proxy's 60-second request
|
|
* limit, so the operator saw an error for a restore that went on to finish.
|
|
* The request now answers 202 with a job id, the `backup-restore` queue runs
|
|
* the restore, and `GET /api/admin/backups/restores/{id}` reports it. Pinned
|
|
* here against a real Postgres:
|
|
*
|
|
* 1. upload → 202 → the job queued → run → the status route says succeeded,
|
|
* with the counts, and the rows are back;
|
|
* 2. a copy that cannot be decrypted is refused in the request itself, and
|
|
* no job is created;
|
|
* 3. a restore that fails part-way ends `failed` with a reason, and the
|
|
* account is exactly as it was (the transaction rolled back);
|
|
* 4. a second restore of the same account while one is queued answers 409,
|
|
* and two requests racing each other admit exactly one;
|
|
* 5. a job whose worker stopped is queued again by the sweep and then
|
|
* finishes; after the second interruption it fails instead of looping;
|
|
* the listing a reloaded page reads shows the job while it runs;
|
|
* 6. a copy replaced while the job waited is not restored in its place;
|
|
* 7. a file whose transaction would outlast the job's budget is refused
|
|
* before anything is deleted.
|
|
*
|
|
* Mutation checks (each run once, each turned the named case red):
|
|
* - drop the partial unique index from migration 0347: case 4's race admits
|
|
* two jobs;
|
|
* - skip the digest comparison in `runBackupRestoreJob`: case 6 restores the
|
|
* replaced copy;
|
|
* - remove the deadline check in `restoreBackup`: case 7 deletes the rows;
|
|
* - drop the attempts cap in `sweepInterruptedRestores`: case 5's third
|
|
* interruption is queued again.
|
|
*/
|
|
import type { PgBoss } from "pg-boss";
|
|
import { gzipSync } from "node:zlib";
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { encrypt } from "@/lib/crypto";
|
|
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
|
|
import { runJob } from "@/lib/jobs/run-job";
|
|
import {
|
|
BACKUP_RESTORE_MAX_ATTEMPTS,
|
|
handleBackupRestore,
|
|
runBackupRestoreJob,
|
|
sweepInterruptedRestores,
|
|
} from "@/lib/jobs/backup-restore";
|
|
import { readBackupRestoreJob } from "@/lib/jobs/backup-restore-view";
|
|
import { BACKUP_RESTORE_QUEUE } from "@/lib/jobs/backup-restore-queue";
|
|
import { backupPayloadSchema } from "@/lib/validations/backup";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
/** A queue that records what it is sent and what it is told is finished. */
|
|
function recordingBoss() {
|
|
const sent: Array<{ queue: string; data: unknown }> = [];
|
|
const completed: string[] = [];
|
|
let next = 0;
|
|
const boss = {
|
|
send: vi.fn(async (queue: string, data: unknown) => {
|
|
sent.push({ queue, data });
|
|
next += 1;
|
|
return `boss-${next}`;
|
|
}),
|
|
complete: vi.fn(async (_queue: string, id: string) => {
|
|
completed.push(id);
|
|
return {};
|
|
}),
|
|
} as unknown as PgBoss;
|
|
return { boss, sent, completed };
|
|
}
|
|
|
|
let queue: ReturnType<typeof recordingBoss>;
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
queue = recordingBoss();
|
|
setGlobalBoss(queue.boss);
|
|
});
|
|
|
|
async function seedAdmin(username = "restore-job-admin") {
|
|
const prisma = getPrismaClient();
|
|
const admin = await prisma.user.create({
|
|
data: { username, email: `${username}@example.test`, role: "ADMIN" },
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: admin.id, expiresAt: new Date(Date.now() + 600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return admin;
|
|
}
|
|
|
|
function payloadFor(userId: string, measurementIds: string[]) {
|
|
return backupPayloadSchema.parse({
|
|
schemaVersion: "2",
|
|
exportedAt: "2026-09-20T00:00:00.000Z",
|
|
userId,
|
|
measurements: measurementIds.map((id, i) => ({
|
|
id,
|
|
type: "PULSE",
|
|
value: 60 + i,
|
|
unit: "bpm",
|
|
measuredAt: new Date(Date.UTC(2026, 8, 1, 7, i)).toISOString(),
|
|
source: "MANUAL",
|
|
})),
|
|
});
|
|
}
|
|
|
|
async function storeBackup(userId: string, measurementIds: string[]) {
|
|
return getPrismaClient().dataBackup.create({
|
|
data: {
|
|
userId,
|
|
type: `MANUAL_UPLOAD_${Date.now()}_${Math.random()}`,
|
|
data: encrypt(JSON.stringify(payloadFor(userId, measurementIds))),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function seedMeasurements(userId: string, ids: string[]) {
|
|
await getPrismaClient().measurement.createMany({
|
|
data: ids.map((id, i) => ({
|
|
id,
|
|
userId,
|
|
type: "WEIGHT" as const,
|
|
value: 80 + i,
|
|
unit: "kg",
|
|
measuredAt: new Date(Date.UTC(2026, 7, 1, 7, i)),
|
|
})),
|
|
});
|
|
}
|
|
|
|
async function requestRestore(backupId: string, key?: string) {
|
|
const { POST } = await import("@/app/api/admin/backups/[id]/restore/route");
|
|
return POST(
|
|
new Request(`http://localhost/api/admin/backups/${backupId}/restore`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(key ? { "idempotency-key": key } : {}),
|
|
},
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
}) as never,
|
|
{ params: Promise.resolve({ id: backupId }) },
|
|
);
|
|
}
|
|
|
|
async function readStatus(jobId: string) {
|
|
const { GET } =
|
|
await import("@/app/api/admin/backups/restores/[jobId]/route");
|
|
const res = await GET(
|
|
new Request(
|
|
`http://localhost/api/admin/backups/restores/${jobId}`,
|
|
) as never,
|
|
{ params: Promise.resolve({ jobId }) },
|
|
);
|
|
return res;
|
|
}
|
|
|
|
async function listStatus() {
|
|
const { GET } = await import("@/app/api/admin/backups/restores/route");
|
|
// The listing handler declares no parameters; `apiHandler` reads the
|
|
// request defensively, so calling it bare is what a GET without a body is.
|
|
const res = await GET();
|
|
return (await res.json()) as {
|
|
data: { jobs: Array<{ id: string; status: string }> };
|
|
};
|
|
}
|
|
|
|
async function measurementIdsOf(userId: string) {
|
|
const rows = await getPrismaClient().measurement.findMany({
|
|
where: { userId },
|
|
orderBy: { id: "asc" },
|
|
select: { id: true },
|
|
});
|
|
return rows.map((row) => row.id);
|
|
}
|
|
|
|
describe("backup restore as a background job", () => {
|
|
it("upload → 202 → queued → run → succeeded, and the rows are back", async () => {
|
|
const admin = await seedAdmin();
|
|
await seedMeasurements(admin.id, ["live-1", "live-2"]);
|
|
|
|
const file = Buffer.from(
|
|
JSON.stringify(payloadFor(admin.id, ["m-a", "m-b", "m-c"])),
|
|
);
|
|
const { POST: upload } =
|
|
await import("@/app/api/admin/backups/upload/route");
|
|
const uploaded = await upload(
|
|
new Request("http://localhost/api/admin/backups/upload", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/gzip" },
|
|
body: gzipSync(file),
|
|
}) as never,
|
|
);
|
|
expect(uploaded.status).toBe(201);
|
|
const { data: stored } = (await uploaded.json()) as {
|
|
data: { id: string };
|
|
};
|
|
|
|
const queued = await requestRestore(stored.id);
|
|
expect(queued.status).toBe(202);
|
|
const { data } = (await queued.json()) as {
|
|
data: { jobId: string; status: string; statusUrl: string };
|
|
};
|
|
expect(data.status).toBe("queued");
|
|
expect(data.statusUrl).toBe(`/api/admin/backups/restores/${data.jobId}`);
|
|
// The request queued the job and changed nothing yet.
|
|
expect(queue.sent).toEqual([
|
|
{ queue: BACKUP_RESTORE_QUEUE, data: { restoreJobId: data.jobId } },
|
|
]);
|
|
expect(await measurementIdsOf(admin.id)).toEqual(["live-1", "live-2"]);
|
|
const waiting = (await (await readStatus(data.jobId)).json()) as {
|
|
data: { status: string };
|
|
};
|
|
expect(waiting.data.status).toBe("queued");
|
|
|
|
// Through the same wrapper the worker binds, so the outcome pg-boss
|
|
// persists is validated too, not only the handler's return value.
|
|
const persisted = await runJob(
|
|
BACKUP_RESTORE_QUEUE,
|
|
handleBackupRestore,
|
|
)([
|
|
{
|
|
id: "boss-1",
|
|
name: BACKUP_RESTORE_QUEUE,
|
|
data: { restoreJobId: data.jobId },
|
|
expireInSeconds: 7200,
|
|
signal: new AbortController().signal,
|
|
} as never,
|
|
]);
|
|
expect(persisted).toEqual({
|
|
ok: true,
|
|
did: { restore_measurements: 3, restore_skipped_links: 0 },
|
|
});
|
|
|
|
const finished = await readStatus(data.jobId);
|
|
expect(finished.status).toBe(200);
|
|
const body = (await finished.json()) as {
|
|
data: {
|
|
status: string;
|
|
phase: string | null;
|
|
attempts: number;
|
|
progress: { measurementsWritten: number; measurementsTotal: number };
|
|
result: {
|
|
summary: { measurements: number };
|
|
cleared: { measurements: number };
|
|
};
|
|
failure: unknown;
|
|
};
|
|
};
|
|
expect(body.data.status).toBe("succeeded");
|
|
expect(body.data.phase).toBeNull();
|
|
expect(body.data.attempts).toBe(1);
|
|
expect(body.data.failure).toBeNull();
|
|
expect(body.data.result.summary.measurements).toBe(3);
|
|
expect(body.data.result.cleared.measurements).toBe(2);
|
|
expect(body.data.progress.measurementsTotal).toBe(3);
|
|
expect(body.data.progress.measurementsWritten).toBe(3);
|
|
expect(await measurementIdsOf(admin.id)).toEqual(["m-a", "m-b", "m-c"]);
|
|
|
|
// The trail says who queued it and that it ran.
|
|
const actions = (
|
|
await getPrismaClient().auditLog.findMany({
|
|
where: { userId: admin.id },
|
|
select: { action: true },
|
|
})
|
|
).map((row) => row.action);
|
|
expect(actions).toEqual(
|
|
expect.arrayContaining([
|
|
"admin.backups.restore.queued",
|
|
"admin.backups.restore.start",
|
|
"admin.backups.restore",
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("refuses a copy that cannot be decrypted in the request, and queues nothing", async () => {
|
|
const admin = await seedAdmin();
|
|
const backup = await getPrismaClient().dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "MANUAL_UPLOAD_1",
|
|
data: "v1:not-a-real-ciphertext",
|
|
},
|
|
});
|
|
|
|
const res = await requestRestore(backup.id);
|
|
expect(res.status).toBe(422);
|
|
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
|
expect(body.meta?.errorCode).toBe("backup.payload.undecryptable");
|
|
expect(queue.sent).toEqual([]);
|
|
expect(await getPrismaClient().backupRestoreJob.count()).toBe(0);
|
|
});
|
|
|
|
it("a restore that fails part-way ends failed with a reason, and nothing changed", async () => {
|
|
const admin = await seedAdmin();
|
|
await seedMeasurements(admin.id, ["live-1"]);
|
|
// Another account already owns this id, so the insert fails AFTER the
|
|
// restore deleted the owner's rows, inside the transaction.
|
|
const other = await getPrismaClient().user.create({
|
|
data: { username: "someone-else", email: "else@example.test" },
|
|
});
|
|
await seedMeasurements(other.id, ["taken"]);
|
|
const backup = await storeBackup(admin.id, ["m-a", "taken"]);
|
|
|
|
const queued = await requestRestore(backup.id);
|
|
expect(queued.status).toBe(202);
|
|
const { data } = (await queued.json()) as { data: { jobId: string } };
|
|
const outcome = await runBackupRestoreJob(data.jobId);
|
|
// A transaction that could not be written is a fault the operator's job
|
|
// card should show, not a quiet success.
|
|
expect(outcome.ok).toBe(false);
|
|
|
|
const job = await readBackupRestoreJob(data.jobId);
|
|
expect(job?.status).toBe("failed");
|
|
expect(job?.failure?.code).toBe("transaction_failed");
|
|
expect(job?.failure?.message).toContain("Nothing was changed");
|
|
expect(await measurementIdsOf(admin.id)).toEqual(["live-1"]);
|
|
expect(await measurementIdsOf(other.id)).toEqual(["taken"]);
|
|
// The staged copy stays, so a retry needs no new upload.
|
|
expect(
|
|
await getPrismaClient().dataBackup.findUnique({
|
|
where: { id: backup.id },
|
|
}),
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it("refuses a second restore of the same account while one is queued", async () => {
|
|
const admin = await seedAdmin();
|
|
const first = await storeBackup(admin.id, ["m-a"]);
|
|
const second = await storeBackup(admin.id, ["m-b"]);
|
|
|
|
const accepted = await requestRestore(first.id);
|
|
expect(accepted.status).toBe(202);
|
|
const { data } = (await accepted.json()) as { data: { jobId: string } };
|
|
|
|
const refused = await requestRestore(second.id);
|
|
expect(refused.status).toBe(409);
|
|
const body = (await refused.json()) as {
|
|
meta?: { errorCode?: string; jobId?: string };
|
|
};
|
|
expect(body.meta?.errorCode).toBe("backup.restore.active");
|
|
expect(body.meta?.jobId).toBe(data.jobId);
|
|
|
|
// Once it has finished, the account can be restored again.
|
|
await runBackupRestoreJob(data.jobId);
|
|
expect((await requestRestore(second.id)).status).toBe(202);
|
|
});
|
|
|
|
it("two requests racing for one account admit exactly one", async () => {
|
|
const admin = await seedAdmin();
|
|
const first = await storeBackup(admin.id, ["m-a"]);
|
|
const second = await storeBackup(admin.id, ["m-b"]);
|
|
|
|
const statuses = (
|
|
await Promise.all([
|
|
requestRestore(first.id, "race-a"),
|
|
requestRestore(second.id, "race-b"),
|
|
])
|
|
)
|
|
.map((res) => res.status)
|
|
.sort();
|
|
expect(statuses).toEqual([202, 409]);
|
|
expect(
|
|
await getPrismaClient().backupRestoreJob.count({
|
|
where: { status: { in: ["queued", "running"] } },
|
|
}),
|
|
).toBe(1);
|
|
});
|
|
|
|
it("picks up a restore a stopped worker left running, and stops after the second time", async () => {
|
|
const admin = await seedAdmin();
|
|
await seedMeasurements(admin.id, ["live-1"]);
|
|
const backup = await storeBackup(admin.id, ["m-a", "m-b"]);
|
|
const prisma = getPrismaClient();
|
|
|
|
const queued = await requestRestore(backup.id);
|
|
const { data } = (await queued.json()) as { data: { jobId: string } };
|
|
|
|
// What a worker that died mid-restore leaves behind: the row says running,
|
|
// its heartbeat stopped minutes ago, the transaction rolled back with the
|
|
// process, so the account is untouched.
|
|
const stale = new Date(Date.now() - 10 * 60_000);
|
|
await prisma.backupRestoreJob.update({
|
|
where: { id: data.jobId },
|
|
data: {
|
|
status: "running",
|
|
phase: "measurements",
|
|
attempts: 1,
|
|
heartbeatAt: stale,
|
|
startedAt: stale,
|
|
},
|
|
});
|
|
|
|
// A page reloaded now finds the job and keeps polling it.
|
|
expect((await listStatus()).data.jobs).toEqual([
|
|
expect.objectContaining({ id: data.jobId, status: "running" }),
|
|
]);
|
|
|
|
const swept = await sweepInterruptedRestores(queue.boss);
|
|
expect(swept).toEqual({ requeued: 1, failed: 0 });
|
|
// The delivery the dead worker held is finished, not left to time out.
|
|
expect(queue.completed).toEqual(["boss-1"]);
|
|
expect(queue.sent.at(-1)).toEqual({
|
|
queue: BACKUP_RESTORE_QUEUE,
|
|
data: { restoreJobId: data.jobId },
|
|
});
|
|
expect((await readBackupRestoreJob(data.jobId))?.status).toBe("queued");
|
|
|
|
await runBackupRestoreJob(data.jobId);
|
|
const resumed = await readBackupRestoreJob(data.jobId);
|
|
expect(resumed?.status).toBe("succeeded");
|
|
expect(resumed?.attempts).toBe(2);
|
|
expect(await measurementIdsOf(admin.id)).toEqual(["m-a", "m-b"]);
|
|
|
|
// A job that has already been started the maximum number of times fails
|
|
// instead of being queued a third time.
|
|
const again = await requestRestore(backup.id);
|
|
const { data: next } = (await again.json()) as { data: { jobId: string } };
|
|
await prisma.backupRestoreJob.update({
|
|
where: { id: next.jobId },
|
|
data: {
|
|
status: "running",
|
|
attempts: BACKUP_RESTORE_MAX_ATTEMPTS,
|
|
heartbeatAt: stale,
|
|
},
|
|
});
|
|
expect(await sweepInterruptedRestores(queue.boss)).toEqual({
|
|
requeued: 0,
|
|
failed: 1,
|
|
});
|
|
const gaveUp = await readBackupRestoreJob(next.jobId);
|
|
expect(gaveUp?.status).toBe("failed");
|
|
expect(gaveUp?.failure?.code).toBe("interrupted");
|
|
});
|
|
|
|
it("never runs a committed restore again when its worker stopped in the rebuild", async () => {
|
|
const admin = await seedAdmin();
|
|
const backup = await storeBackup(admin.id, ["m-a", "m-b"]);
|
|
const prisma = getPrismaClient();
|
|
|
|
// Run the restore for real, so the commit marker is written by the code
|
|
// that writes it, then put the row back the way a worker that died in the
|
|
// rollup rebuild leaves it: running, phase rebuilding, heartbeat stale.
|
|
const queued = await requestRestore(backup.id);
|
|
const { data } = (await queued.json()) as { data: { jobId: string } };
|
|
await runBackupRestoreJob(data.jobId);
|
|
const committed = await prisma.backupRestoreJob.findUniqueOrThrow({
|
|
where: { id: data.jobId },
|
|
});
|
|
expect(committed.committedAt).not.toBeNull();
|
|
const stale = new Date(Date.now() - 10 * 60_000);
|
|
await prisma.backupRestoreJob.update({
|
|
where: { id: data.jobId },
|
|
data: {
|
|
status: "running",
|
|
phase: "rebuilding",
|
|
heartbeatAt: stale,
|
|
completedAt: null,
|
|
result: undefined,
|
|
},
|
|
});
|
|
// A reading the person logged after the restore committed.
|
|
await seedMeasurements(admin.id, ["after-commit"]);
|
|
|
|
expect(await sweepInterruptedRestores(queue.boss)).toEqual({
|
|
requeued: 0,
|
|
failed: 1,
|
|
});
|
|
const job = await readBackupRestoreJob(data.jobId);
|
|
expect(job?.status).toBe("failed");
|
|
expect(job?.failure?.code).toBe("failed_after_commit");
|
|
expect(job?.failure?.message).not.toContain("Nothing was changed");
|
|
// Not queued again, so the reading from after the commit survives.
|
|
expect(
|
|
queue.sent.filter(
|
|
(entry) =>
|
|
(entry.data as { restoreJobId?: string }).restoreJobId === data.jobId,
|
|
),
|
|
).toHaveLength(1);
|
|
expect(await measurementIdsOf(admin.id)).toEqual([
|
|
"after-commit",
|
|
"m-a",
|
|
"m-b",
|
|
]);
|
|
});
|
|
|
|
it("the admission check closes a committed stale job as restored, not rolled back", async () => {
|
|
const admin = await seedAdmin();
|
|
const backup = await storeBackup(admin.id, ["m-a"]);
|
|
const prisma = getPrismaClient();
|
|
const queued = await requestRestore(backup.id);
|
|
const { data } = (await queued.json()) as { data: { jobId: string } };
|
|
const stale = new Date(Date.now() - 10 * 60_000);
|
|
await prisma.backupRestoreJob.update({
|
|
where: { id: data.jobId },
|
|
data: {
|
|
status: "running",
|
|
phase: "rebuilding",
|
|
attempts: 1,
|
|
heartbeatAt: stale,
|
|
committedAt: stale,
|
|
},
|
|
});
|
|
|
|
expect((await requestRestore(backup.id)).status).toBe(202);
|
|
const job = await readBackupRestoreJob(data.jobId);
|
|
expect(job?.status).toBe("failed");
|
|
expect(job?.failure?.code).toBe("failed_after_commit");
|
|
});
|
|
|
|
it("dates a medication from a file without a creation date by its earliest dose", async () => {
|
|
const admin = await seedAdmin();
|
|
const prisma = getPrismaClient();
|
|
// A portable file from before v1.39.1: no medication createdAt.
|
|
const payload = backupPayloadSchema.parse({
|
|
schemaVersion: "1",
|
|
exportedAt: "2026-09-20T00:00:00.000Z",
|
|
userId: admin.id,
|
|
medications: [{ name: "Ramipril", dose: "5mg", schedules: [] }],
|
|
intakeEvents: [
|
|
{
|
|
medication: "Ramipril",
|
|
scheduledFor: "2025-03-10T08:00:00.000Z",
|
|
takenAt: "2025-03-10T08:04:00.000Z",
|
|
},
|
|
{
|
|
medication: "Ramipril",
|
|
scheduledFor: "2025-03-09T08:00:00.000Z",
|
|
autoMissed: true,
|
|
},
|
|
],
|
|
});
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "MANUAL_UPLOAD_OLD_PORTABLE",
|
|
data: encrypt(JSON.stringify(payload)),
|
|
},
|
|
});
|
|
const queued = await requestRestore(backup.id);
|
|
const { data } = (await queued.json()) as { data: { jobId: string } };
|
|
await runBackupRestoreJob(data.jobId);
|
|
expect((await readBackupRestoreJob(data.jobId))?.status).toBe("succeeded");
|
|
|
|
const medication = await prisma.medication.findFirstOrThrow({
|
|
where: { userId: admin.id },
|
|
});
|
|
// Not the restore time: the dose history reads this as the day the
|
|
// medication began, and every miss before it would vanish.
|
|
expect(medication.createdAt.toISOString()).toBe("2025-03-09T08:00:00.000Z");
|
|
});
|
|
|
|
it("a live job's heartbeat keeps it out of the sweep", async () => {
|
|
const admin = await seedAdmin();
|
|
const backup = await storeBackup(admin.id, ["m-a"]);
|
|
const queued = await requestRestore(backup.id);
|
|
const { data } = (await queued.json()) as { data: { jobId: string } };
|
|
await getPrismaClient().backupRestoreJob.update({
|
|
where: { id: data.jobId },
|
|
data: { status: "running", attempts: 1, heartbeatAt: new Date() },
|
|
});
|
|
|
|
expect(await sweepInterruptedRestores(queue.boss)).toEqual({
|
|
requeued: 0,
|
|
failed: 0,
|
|
});
|
|
expect((await readBackupRestoreJob(data.jobId))?.status).toBe("running");
|
|
// And a second request for the account is still refused while it runs.
|
|
expect((await requestRestore(backup.id)).status).toBe(409);
|
|
});
|
|
|
|
it("does not restore a copy that was replaced while the job waited", async () => {
|
|
const admin = await seedAdmin();
|
|
await seedMeasurements(admin.id, ["live-1"]);
|
|
const backup = await storeBackup(admin.id, ["m-a"]);
|
|
const queued = await requestRestore(backup.id);
|
|
const { data } = (await queued.json()) as { data: { jobId: string } };
|
|
|
|
// The weekly job overwrites its row in place.
|
|
await getPrismaClient().dataBackup.update({
|
|
where: { id: backup.id },
|
|
data: {
|
|
data: encrypt(JSON.stringify(payloadFor(admin.id, ["m-newer"]))),
|
|
},
|
|
});
|
|
|
|
const persisted = await runJob(
|
|
BACKUP_RESTORE_QUEUE,
|
|
handleBackupRestore,
|
|
)([
|
|
{
|
|
id: "boss-1",
|
|
name: BACKUP_RESTORE_QUEUE,
|
|
data: { restoreJobId: data.jobId },
|
|
expireInSeconds: 7200,
|
|
signal: new AbortController().signal,
|
|
} as never,
|
|
]);
|
|
// A refusal is the job doing what it was asked, not a crash.
|
|
expect(persisted).toEqual({ ok: true, did: { refused: "backup_changed" } });
|
|
const job = await readBackupRestoreJob(data.jobId);
|
|
expect(job?.status).toBe("failed");
|
|
expect(job?.failure?.code).toBe("backup_changed");
|
|
expect(await measurementIdsOf(admin.id)).toEqual(["live-1"]);
|
|
});
|
|
|
|
it("refuses, before deleting anything, a file that would outlast the job's budget", async () => {
|
|
const admin = await seedAdmin();
|
|
await seedMeasurements(admin.id, ["live-1"]);
|
|
const backup = await storeBackup(admin.id, ["m-a"]);
|
|
const queued = await requestRestore(backup.id);
|
|
const { data } = (await queued.json()) as { data: { jobId: string } };
|
|
|
|
// A deadline one minute out: shorter than the transaction's own
|
|
// two-minute floor, so the restore cannot promise to finish in time.
|
|
await runBackupRestoreJob(data.jobId, { deadline: Date.now() + 60_000 });
|
|
const job = await readBackupRestoreJob(data.jobId);
|
|
expect(job?.status).toBe("failed");
|
|
expect(job?.failure?.code).toBe("time_budget");
|
|
// Both halves of the work are named: what the file carries and what the
|
|
// account holds now, since the clearing of the latter costs time too.
|
|
expect(job?.failure?.message).toContain("1 reading in the backup");
|
|
expect(job?.failure?.message).toContain("1 reading this account holds now");
|
|
expect(await measurementIdsOf(admin.id)).toEqual(["live-1"]);
|
|
});
|
|
});
|