mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Restoring a large account was one request: 94 s for 1.25 million readings.
A reverse proxy that cuts a request at 60 s (nginx's default) showed the
operator an error for a restore that went on to finish on the server.
The restore request now checks the typed confirmation, that the backup
exists and that its stored copy opens, admits one job for the account and
answers 202 with its id. The `backup-restore` queue runs the restore and
records its phase, counts and outcome on a `backup_restore_jobs` row
(migration 0347), which `GET /api/admin/backups/restores` and
`GET /api/admin/backups/restores/{id}` read back.
What a restore checks and writes is unchanged. The body moved from the
route into `restoreBackup` (src/lib/export/restore-backup.ts): every
refusal still happens before the first delete and the replacement is still
one transaction, so a failure part-way leaves the account as it was. While
it runs the account keeps showing its current data, and other writes are
neither refused nor held, as before.
- One restore per account: a partial unique index admits one queued or
running job, and a second request gets 409 `backup.restore.active` naming
the running job. A per-account advisory lock inside the transaction holds
even if a worker thought gone is still writing.
- Time budget: the job's expiry is two hours and a file whose transaction
limit would run past three quarters of it is refused before anything is
deleted (`jobDeadline`, beside `jobBudget`).
- Resuming: the job writes a heartbeat every two seconds. A boot sweep
queues a job whose worker stopped again from the start, at most twice,
then fails it as interrupted. The admission check releases an abandoned
job so it never holds the account's slot.
- The staged file is the stored `DataBackup` row, already encrypted at
rest. It stays whatever the outcome, so a retry needs no new upload. The
job records a digest of the copy and refuses one that was replaced while
it waited.
- Failures carry a stable code and a sentence that says what to do.
- The console follows the job: phase and counts while it runs, the outcome
after, and after a reload it finds the job again from the server. A poll
that fails on the network is never shown as a failed restore. A web
process separate from the worker evicts the account's caches when it
first reports the restore finished. The success line goes through the
outcome module, so an empty file is not drawn as a tick.
The model is registered in the backup plan (not carried) and the wipe plan
(wiped with the account); the queue in the maintenance registrar, the
subsystem surface and the job-fact allowlist; the new code in the error
catalogue; the routes in the OpenAPI contract. The tests that pin restore
semantics now drive the real route and the real job through one helper, and
`backup-restore-job.test.ts` pins the asynchronous contract itself.
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
/**
|
|
* How hard a symptom hit, through the whole backup pipe.
|
|
*
|
|
* `CycleSymptomLink.severity` is the 1-4 intensity the log-day sheet writes and
|
|
* the day-log DTO reads back. The backup carried only `symptomKeys`, so a day
|
|
* recorded as "cramps, and they were a 4" exported as "cramps" and restored as
|
|
* "cramps". The symptom was there, the number was gone, and nothing failed —
|
|
* which is why it went unnoticed.
|
|
*
|
|
* These tests assert on ROWS, never on the payload object. A writer test and a
|
|
* reader test were both green through the last defect on this exact path while
|
|
* the assembly between them dropped the field, so the only thing worth proving
|
|
* is what ends up in `cycle_symptom_links` after a real export and a real
|
|
* restore: the REAL `buildFullBackupPayload` writes the file, the REAL
|
|
* `parseBackupPayload` reads it back, and the REAL admin restore route puts the
|
|
* rows down.
|
|
*
|
|
* The restore wipes day-logs and recreates them, so the links it writes are new
|
|
* rows. To make that impossible to mistake, the live severities are overwritten
|
|
* with a different number before the restore runs: whatever comes back came out
|
|
* of the file.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY =
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { encrypt } from "@/lib/crypto";
|
|
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
|
import { parseBackupPayload } from "@/lib/validations/backup";
|
|
import { POST } from "./restore-job-driver";
|
|
import { invalidateUserData } from "@/lib/cache/invalidate";
|
|
|
|
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),
|
|
}));
|
|
|
|
vi.mock("@/lib/cache/invalidate", () => ({
|
|
invalidateUserData: vi.fn(),
|
|
}));
|
|
|
|
const RATED_KEY = "custom:severity-rated";
|
|
const UNRATED_KEY = "custom:severity-unrated";
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
vi.mocked(invalidateUserData).mockClear();
|
|
});
|
|
|
|
function makeRequest(id: string) {
|
|
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* One account with a day-log carrying two symptoms: one rated 4, one never
|
|
* rated. Returns the ids the assertions read back through.
|
|
*/
|
|
async function seedAccount(username: string) {
|
|
const prisma = getPrismaClient();
|
|
const owner = await prisma.user.create({
|
|
data: {
|
|
username,
|
|
email: `${username}@example.test`,
|
|
role: "ADMIN",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
const category = await prisma.cycleSymptomCategory.findFirstOrThrow();
|
|
const rated = await prisma.cycleSymptom.create({
|
|
data: {
|
|
userId: owner.id,
|
|
categoryId: category.id,
|
|
key: RATED_KEY,
|
|
labelKey: "cycle.symptom.custom",
|
|
labelEncrypted: encrypt("Cramps"),
|
|
sortOrder: 1,
|
|
},
|
|
});
|
|
const unrated = await prisma.cycleSymptom.create({
|
|
data: {
|
|
userId: owner.id,
|
|
categoryId: category.id,
|
|
key: UNRATED_KEY,
|
|
labelKey: "cycle.symptom.custom",
|
|
labelEncrypted: encrypt("Tender skin"),
|
|
sortOrder: 2,
|
|
},
|
|
});
|
|
await prisma.cycleProfile.create({
|
|
data: {
|
|
userId: owner.id,
|
|
goal: "GENERAL_HEALTH",
|
|
cycleTrackingEnabled: true,
|
|
typicalCycleLength: 29,
|
|
},
|
|
});
|
|
const cycle = await prisma.menstrualCycle.create({
|
|
data: {
|
|
userId: owner.id,
|
|
startDate: "2026-07-01",
|
|
periodEndDate: "2026-07-05",
|
|
tz: "Europe/Berlin",
|
|
},
|
|
});
|
|
const dayLog = await prisma.cycleDayLog.create({
|
|
data: {
|
|
userId: owner.id,
|
|
cycleId: cycle.id,
|
|
date: "2026-07-03",
|
|
flow: "MEDIUM",
|
|
source: "MANUAL",
|
|
tz: "Europe/Berlin",
|
|
symptomLinks: {
|
|
create: [
|
|
{ symptomId: rated.id, severity: 4 },
|
|
// Never rated. NULL here is the whole point of the second case: it
|
|
// has to come back NULL, not 0 and not 1.
|
|
{ symptomId: unrated.id },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
return { prisma, owner, rated, unrated, dayLog };
|
|
}
|
|
|
|
/**
|
|
* Overwrite the live intensities so a severity that survives the restore cannot
|
|
* have come from a row that was simply left alone.
|
|
*/
|
|
async function poisonLiveSeverities(userId: string) {
|
|
const prisma = getPrismaClient();
|
|
const links = await prisma.cycleSymptomLink.findMany({
|
|
where: { dayLog: { userId } },
|
|
select: { dayLogId: true, symptomId: true },
|
|
});
|
|
for (const link of links) {
|
|
await prisma.cycleSymptomLink.update({
|
|
where: {
|
|
dayLogId_symptomId: {
|
|
dayLogId: link.dayLogId,
|
|
symptomId: link.symptomId,
|
|
},
|
|
},
|
|
data: { severity: 1 },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function storeBackup(userId: string, parsed: unknown, type: string) {
|
|
const prisma = getPrismaClient();
|
|
return prisma.dataBackup.create({
|
|
data: { userId, type, data: encrypt(JSON.stringify(parsed)) },
|
|
});
|
|
}
|
|
|
|
async function severityByKey(userId: string) {
|
|
const prisma = getPrismaClient();
|
|
const links = await prisma.cycleSymptomLink.findMany({
|
|
where: { dayLog: { userId } },
|
|
select: { severity: true, symptom: { select: { key: true } } },
|
|
});
|
|
return Object.fromEntries(
|
|
links.map((l) => [l.symptom.key, l.severity]),
|
|
) as Record<string, number | null>;
|
|
}
|
|
|
|
describe("a symptom's recorded intensity across export and restore", () => {
|
|
it("comes back on the row it was recorded against", async () => {
|
|
const { prisma, owner, dayLog } = await seedAccount("cycle-severity-owner");
|
|
|
|
const { payload: built } = await buildFullBackupPayload(prisma, owner.id, {
|
|
purpose: "disaster-recovery",
|
|
});
|
|
const parsed = parseBackupPayload(JSON.stringify(built));
|
|
const backup = await storeBackup(
|
|
owner.id,
|
|
parsed,
|
|
"CYCLE_SYMPTOM_SEVERITY_ROUND_TRIP",
|
|
);
|
|
|
|
await poisonLiveSeverities(owner.id);
|
|
|
|
const response = await POST(
|
|
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
expect(response.status).toBe(200);
|
|
|
|
// Rows, not payload keys.
|
|
const restoredDayLog = await prisma.cycleDayLog.findUniqueOrThrow({
|
|
where: { id: dayLog.id },
|
|
});
|
|
expect(restoredDayLog.date).toBe("2026-07-03");
|
|
expect(await severityByKey(owner.id)).toEqual({
|
|
[RATED_KEY]: 4,
|
|
[UNRATED_KEY]: null,
|
|
});
|
|
});
|
|
|
|
it("stays absent when the file was written before intensities were carried", async () => {
|
|
const { prisma, owner } = await seedAccount("cycle-severity-legacy");
|
|
|
|
const { payload: built } = await buildFullBackupPayload(prisma, owner.id, {
|
|
purpose: "disaster-recovery",
|
|
});
|
|
|
|
// A file from the release before this one: the key is not there at all.
|
|
// Not an empty list — absent, the way an older writer left it.
|
|
const legacy = JSON.parse(JSON.stringify(built)) as {
|
|
cycleDayLogs: Array<Record<string, unknown>>;
|
|
};
|
|
for (const day of legacy.cycleDayLogs) delete day.symptomSeverities;
|
|
expect(legacy.cycleDayLogs.some((d) => "symptomSeverities" in d)).toBe(
|
|
false,
|
|
);
|
|
expect(legacy.cycleDayLogs[0].symptomKeys).toEqual(
|
|
expect.arrayContaining([RATED_KEY, UNRATED_KEY]),
|
|
);
|
|
|
|
// The old file has to parse, not be refused for the field it never had.
|
|
const parsed = parseBackupPayload(JSON.stringify(legacy));
|
|
const backup = await storeBackup(
|
|
owner.id,
|
|
parsed,
|
|
"CYCLE_SYMPTOM_SEVERITY_LEGACY_FILE",
|
|
);
|
|
|
|
await poisonLiveSeverities(owner.id);
|
|
|
|
const response = await POST(
|
|
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
expect(response.status).toBe(200);
|
|
|
|
// Both symptoms survive; neither is given a number the file never held.
|
|
expect(await severityByKey(owner.id)).toEqual({
|
|
[RATED_KEY]: null,
|
|
[UNRATED_KEY]: null,
|
|
});
|
|
});
|
|
|
|
it("keeps the symptom and drops an intensity outside the scale", async () => {
|
|
const { prisma, owner } = await seedAccount("cycle-severity-offscale");
|
|
|
|
const { payload: built } = await buildFullBackupPayload(prisma, owner.id, {
|
|
purpose: "disaster-recovery",
|
|
});
|
|
|
|
// A hand-edited file, or one from some later release with a wider scale.
|
|
// Refusing the whole restore over it would cost the account everything to
|
|
// save one number.
|
|
const offScale = JSON.parse(JSON.stringify(built)) as {
|
|
cycleDayLogs: Array<{
|
|
symptomSeverities: Array<{ key: string; severity: number }>;
|
|
}>;
|
|
};
|
|
for (const day of offScale.cycleDayLogs) {
|
|
day.symptomSeverities = [
|
|
{ key: RATED_KEY, severity: 9 },
|
|
// Names a symptom this day does not carry — nothing to attach it to.
|
|
{ key: "custom:not-on-this-day", severity: 2 },
|
|
];
|
|
}
|
|
|
|
const parsed = parseBackupPayload(JSON.stringify(offScale));
|
|
const backup = await storeBackup(
|
|
owner.id,
|
|
parsed,
|
|
"CYCLE_SYMPTOM_SEVERITY_OFF_SCALE",
|
|
);
|
|
|
|
await poisonLiveSeverities(owner.id);
|
|
|
|
const response = await POST(
|
|
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
expect(response.status).toBe(200);
|
|
|
|
expect(await severityByKey(owner.id)).toEqual({
|
|
[RATED_KEY]: null,
|
|
[UNRATED_KEY]: null,
|
|
});
|
|
});
|
|
});
|