mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
test(backup): restore every release's own backup file
Every backup test exports and restores with the same code, so a change that moves both ends at once passes all of them and still breaks a file a self-hoster downloaded last month. Commit the disaster-recovery file the 1.46.1 exporter writes for the shared backup seed, with a manifest of its counts and SHA-256, and restore each committed file through the admin upload and restore routes into an empty account, comparing the re-exported counts section by section. The folder takes one file per release (GOLDEN_BACKUP_WRITE=1 writes the current one; the README says when). The seed's own illness-symptom catalogue key is swapped for a shipped one so every link resolves on a fresh instance. Ciphertext is sealed under the suite's fixed key. A measurement tombstone that ages past retention may only lower its count.
This commit is contained in:
1 parent
1340200cac
commit
9d27b6f103
5 files changed
+393
No files matched your search
@@ -30,6 +30,11 @@ scripts/bench/results
|
||||
# parity test itself (BACKUP_PARITY_WRITE=1), so a rewrite diffs cleanly.
|
||||
tests/integration/__golden__
|
||||
|
||||
# A release's backup file exactly as its exporter wrote it; the golden-backup
|
||||
# test checks the bytes against the manifest's SHA-256, so it is never
|
||||
# reformatted. The manifests are written by the same test.
|
||||
tests/fixtures/golden-backups/*.json
|
||||
|
||||
# inlang writes its working files (cache, metadata, README) next to the
|
||||
# project settings when its CLI or SDK runs; only settings.json is tracked.
|
||||
project.inlang/*
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# Golden backups
|
||||
|
||||
One disaster-recovery backup file per release, written by that release's own
|
||||
exporter, and a manifest of what it held. `tests/integration/golden-backups.test.ts`
|
||||
uploads every file here through the admin upload route, restores it into an
|
||||
empty account through the restore route and job, exports the account again and
|
||||
checks the counts section by section against the manifest. A backup a person
|
||||
downloaded from an older release has to keep restoring after every later
|
||||
change; this is where that is held still.
|
||||
|
||||
| File | What it is |
|
||||
| -------------------------- | --------------------------------------------------------------------------- |
|
||||
| `v<release>.json` | the file exactly as the exporter wrote it (compact JSON, never reformatted) |
|
||||
| `v<release>.manifest.json` | release, schema version, owner id, `exportedAt`, SHA-256, byte size, counts |
|
||||
|
||||
The account is the shared backup seed (`tests/integration/backup-fixture.ts`:
|
||||
one row of every two-ended model plus the column shapes), so every section the
|
||||
backup writes holds at least one row. Ciphertext inside the file is sealed
|
||||
under the integration suite's fixed key (`ENCRYPTION_KEY` in
|
||||
`vitest.integration.config.mts`), so the file opens on every machine that runs
|
||||
the suite and no real key is involved.
|
||||
|
||||
## Adding the next release's file
|
||||
|
||||
Once per release, on the release branch after the version bump, with Docker
|
||||
running:
|
||||
|
||||
```bash
|
||||
GOLDEN_BACKUP_WRITE=1 pnpm vitest run --config vitest.integration.config.mts tests/integration/golden-backups.test.ts
|
||||
pnpm vitest run --config vitest.integration.config.mts tests/integration/golden-backups.test.ts
|
||||
```
|
||||
|
||||
The first command writes `v<version from package.json>.json` and its manifest;
|
||||
the second restores every file here, the new one included. Commit both files.
|
||||
|
||||
Never rewrite an existing release's file: the test checks each file against the
|
||||
SHA-256 in its manifest, because a file regenerated by today's exporter would
|
||||
prove only that today's exporter and today's restore agree. When a change means
|
||||
an old file can no longer restore as it was written, that is a breaking change
|
||||
to the backup format, and it is decided in the restore code, not by replacing
|
||||
the fixture.
|
||||
|
||||
A count the restore is documented to drop by age (a measurement tombstone past
|
||||
its retention) is listed in `AGES_OUT` in the test and may only go down.
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"schemaVersion":"2","exportedAt":"2026-10-29T08:54:55.373Z","userId":"round-trip-owner","appSettings":null,"accountSettings":{"heightCm":null,"dateOfBirth":null,"gender":null,"hasDiabetes":false,"displayName":null,"fullName":null,"insurerName":null,"insuranceNumber":null,"insuranceNumberEncrypted":null,"insurerIkNumber":null,"lastReportPracticeName":null,"avatarBytes":null,"avatarContentType":null,"avatarUpdatedAt":null,"homeTimezone":null,"homeSince":null,"homeLocationEncrypted":null,"environmentAirQualityEnabled":true,"timezone":"Europe/Berlin","locale":null,"unitPreference":null,"glucoseUnit":null,"timeFormat":"AUTO","dateFormat":"AUTO","modulePreferencesJson":null,"thresholdsJson":null,"healthScoreConfigJson":null,"sourcePriorityJson":null,"dashboardWidgetsJson":null,"insightsLayoutJson":null,"medicationListLayoutJson":null,"moodTagLayoutJson":null,"documentsLayoutJson":null,"moduleListLayoutJson":null,"activityGoalsJson":null,"reportSelectionJson":null,"globalExcludedInjectionSites":[],"healthKitConfigJson":null,"notificationPrefs":null,"moodReminderEnabled":false,"aiProvider":null,"aiModel":null,"aiBaseUrl":null,"aiCompatBaseUrl":null,"aiCompatModel":null,"aiProviderChain":null,"aiResponseTimeoutSeconds":null,"useCentralCodex":false,"insightsPrivacyMode":"aggregated","insightsExcludeMetrics":[],"disableCoach":false,"aiUsageLevel":null,"coachPrefsJson":null,"documentsAutoAiRead":false,"labsLocalOcrEnabled":false,"storeWorkoutRoutes":false,"onboardingCompletedAt":null,"onboardingTourCompleted":false,"onboardingTourProgressJson":null,"disclaimerAcknowledgedAt":null,"disclaimerAcknowledgedVersion":null,"passkeyUpgradeNudgeDismissed":false},"measurements":[{"id":"cmvtb53ls002rvdovt5ne4m0v","type":"BLOOD_GLUCOSE","value":5.4,"valueMin":null,"valueMax":null,"unit":"mmol/L","measuredAt":"2026-07-03T06:45:00.000Z","source":"MANUAL","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":"FASTING","sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-29T08:54:55.360Z","updatedAt":"2026-10-29T08:54:55.360Z"},{"id":"cmvtb53lt002svdov5wtjes6d","type":"SLEEP_DURATION","value":95,"valueMin":null,"valueMax":null,"unit":"min","measuredAt":"2026-07-03T02:00:00.000Z","source":"APPLE_HEALTH","notes":null,"notesEncrypted":null,"externalId":"round-trip-deep-sleep","externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":"DEEP","rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-29T08:54:55.361Z","updatedAt":"2026-10-29T08:54:55.361Z"},{"id":"cmvtb53gu0001vdovkhes3wde","type":"PULSE","value":61,"valueMin":null,"valueMax":null,"unit":"bpm","measuredAt":"2026-07-02T06:30:00.000Z","source":"APPLE_HEALTH","notes":null,"notesEncrypted":null,"externalId":"round-trip-deleted-sample","externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":4,"deletedAt":"2026-10-19T08:54:54.000Z","createdAt":"2026-10-29T08:54:55.183Z","updatedAt":"2026-10-29T08:54:55.183Z"},{"id":"cmvtb53gq0000vdovpfeu4gu1","type":"WEIGHT","value":74.2,"valueMin":null,"valueMax":null,"unit":"kg","measuredAt":"2026-07-01T07:00:00.000Z","source":"MANUAL","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-29T08:54:55.178Z","updatedAt":"2026-10-29T08:54:55.178Z"},{"id":"cmvtb53gx0002vdovwm23zudk","type":"IRREGULAR_RHYTHM_NOTIFICATION","value":1,"valueMin":null,"valueMax":null,"unit":"event","measuredAt":"2026-06-15T14:22:00.000Z","source":"WITHINGS","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":"IRREGULAR","deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-29T08:54:55.185Z","updatedAt":"2026-10-29T08:54:55.185Z"}],"customMedicationCategories":[{"key":"custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10","label":null,"labelEncrypted":"djEueldXclgxVTZmcGhBaG9RUUFseFphVDloaWNLendKSWNtUGJZSmVTZkJ3RW5rbllZMm4zZklIZkYxc3ltamZ5VUx3PT0=","sortOrder":2,"isActive":true,"createdAt":"2026-10-29T08:54:55.214Z","updatedAt":"2026-10-29T08:54:55.214Z"}],"medications":[{"id":"cmvtb53hg0005vdovsxoh5965","treatmentClass":"GENERIC","dosesPerUnit":null,"unitsPerDose":"1","notificationsEnabled":true,"pausedAt":null,"snoozedUntil":null,"startsOn":"2026-06-10T00:00:00.000Z","endsOn":"2026-06-16T00:00:00.000Z","oneShot":false,"asNeeded":false,"deliveryForm":"ORAL","trackInjectionSites":false,"allowedInjectionSites":[],"liveActivityEnabled"Line truncated
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"release": "1.46.1",
|
||||
"schemaVersion": "2",
|
||||
"userId": "round-trip-owner",
|
||||
"exportedAt": "2026-10-29T08:54:55.373Z",
|
||||
"sha256": "56d798b77d92a19a5048be1c364fdbb1b527c713a9f9dedc574b5d2c44bdc1dc",
|
||||
"bytes": 41985,
|
||||
"counts": {
|
||||
"measurements": 5,
|
||||
"medications": 1,
|
||||
"intakeEvents": 1,
|
||||
"medicationSideEffects": 1,
|
||||
"medicationPauseEras": 2,
|
||||
"medicationDoseChanges": 2,
|
||||
"medicationInventoryItems": 2,
|
||||
"medicationInventoryEvents": 2,
|
||||
"reminderPhaseConfigs": 1,
|
||||
"medicationEfficacyTargets": 1,
|
||||
"medicationScheduleRevisions": 3,
|
||||
"moodEntries": 1,
|
||||
"customMoodTagCategories": 1,
|
||||
"hiddenMoodTags": 1,
|
||||
"cycles": 2,
|
||||
"cycleDayLogs": 1,
|
||||
"nutrientDays": 1,
|
||||
"labResults": 1,
|
||||
"biomarkers": 1,
|
||||
"illnessEpisodes": 2,
|
||||
"illnessDayLogs": 1,
|
||||
"allergies": 1,
|
||||
"familyHistory": 1,
|
||||
"workouts": 1,
|
||||
"documents": 1,
|
||||
"healthProfile": 1,
|
||||
"customMetrics": 1,
|
||||
"healthProfileFactRevisions": 1,
|
||||
"customMetricEntries": 2,
|
||||
"correlationPatterns": 1,
|
||||
"intradayProfiles": 1,
|
||||
"healthScoreRecords": 1,
|
||||
"onboardingRecords": 1,
|
||||
"practitioners": 1,
|
||||
"encounters": 1,
|
||||
"encounterLinks": 3,
|
||||
"customVaccines": 1,
|
||||
"vaccinations": 1,
|
||||
"vaccinationLinks": 1,
|
||||
"measurementReminders": 1,
|
||||
"measurementReminderEvents": 2,
|
||||
"coachConversations": 2,
|
||||
"coachMessages": 3,
|
||||
"coachConversationDocuments": 1,
|
||||
"coachFacts": 1,
|
||||
"coachPlans": 1,
|
||||
"coachReminders": 1,
|
||||
"mentalHealthAssessments": 1,
|
||||
"consentReceipts": 2,
|
||||
"documentConditionLinks": 1,
|
||||
"extractedFacts": 1,
|
||||
"documentImportKeys": 1,
|
||||
"personalRecords": 3,
|
||||
"userAchievements": 2,
|
||||
"environmentContexts": 2,
|
||||
"environmentTravelLocations": 1,
|
||||
"dawarichDays": 1,
|
||||
"ecgRecordings": 1,
|
||||
"symptomDefinitions": 2,
|
||||
"symptomEvents": 3,
|
||||
"lifeEvents": 2,
|
||||
"relationLinks": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* A backup an older release wrote keeps restoring.
|
||||
*
|
||||
* Every other backup test exports with today's code and restores with
|
||||
* today's code, so a change that moves both ends at once (a renamed field, a
|
||||
* section that changes shape, a new required value) passes every one of them
|
||||
* and still breaks the file a self-hoster downloaded last month. This file
|
||||
* holds the other end still: each release commits the disaster-recovery file
|
||||
* its own exporter wrote (`tests/fixtures/golden-backups/v<release>.json`),
|
||||
* with a manifest of what that file held, and every later release has to
|
||||
* restore each committed file into an empty account and get the same counts
|
||||
* back, section by section.
|
||||
*
|
||||
* The files go in through the admin routes an operator uses, the upload and
|
||||
* then the restore (`restore-job-driver.ts` runs the queued job in-process),
|
||||
* so the upload's own checks of an old file are part of the claim. The counts
|
||||
* are read back through the current exporter, which is the reading a person
|
||||
* gets when they export again.
|
||||
*
|
||||
* Ciphertext inside the files is sealed under the suite's fixed key
|
||||
* (`ENCRYPTION_KEY` in `vitest.integration.config.mts`), so they open on any
|
||||
* machine that runs the suite.
|
||||
*
|
||||
* `GOLDEN_BACKUP_WRITE=1` writes the current release's file instead of
|
||||
* restoring; `tests/fixtures/golden-backups/README.md` says when and how.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
|
||||
import {
|
||||
OWNER_ID,
|
||||
createOwner,
|
||||
seedAdminSession,
|
||||
seedColumnShapes,
|
||||
seedEveryTwoEndedModel,
|
||||
} from "./backup-fixture";
|
||||
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),
|
||||
}));
|
||||
|
||||
const FIXTURE_DIR = resolve(__dirname, "../fixtures/golden-backups");
|
||||
const WRITE = process.env.GOLDEN_BACKUP_WRITE === "1";
|
||||
const SLOW = 240_000;
|
||||
|
||||
/** What a committed file held when its release wrote it. */
|
||||
interface GoldenManifest {
|
||||
release: string;
|
||||
schemaVersion: string;
|
||||
userId: string;
|
||||
exportedAt: string;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
counts: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows the restore is documented to drop by age rather than carry, so a file
|
||||
* restored long after it was written comes back without them. Each entry is
|
||||
* a count key and the reason; the count may only go down, never up.
|
||||
*/
|
||||
const AGES_OUT: Readonly<Record<string, string>> = {
|
||||
measurements:
|
||||
"a measurement tombstone older than TOMBSTONE_RETENTION_DAYS is not written back (`measurements-restore.ts`)",
|
||||
};
|
||||
|
||||
function sha256(bytes: Buffer): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function goldenFiles(): string[] {
|
||||
return readdirSync(FIXTURE_DIR)
|
||||
.filter((name) => /^v\d+\.\d+\.\d+\.json$/.test(name))
|
||||
.sort();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe.runIf(WRITE)("golden backups: write this release's file", () => {
|
||||
it(
|
||||
"writes the disaster-recovery file of the seeded account",
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const release = JSON.parse(
|
||||
readFileSync(resolve(__dirname, "../../package.json"), "utf8"),
|
||||
).version as string;
|
||||
await createOwner(prisma);
|
||||
await seedEveryTwoEndedModel(prisma);
|
||||
await seedColumnShapes(prisma);
|
||||
// The shared seed files its illness symptom under a catalogue key of
|
||||
// its own. A file restored on another instance resolves catalogue keys
|
||||
// against what that instance ships, so the committed file names a
|
||||
// shipped symptom instead and every link has somewhere to land.
|
||||
const shipped = await prisma.illnessSymptom.findFirstOrThrow({
|
||||
where: { key: { not: "round_trip_cough" } },
|
||||
orderBy: { key: "asc" },
|
||||
});
|
||||
const own = await prisma.illnessSymptom.findUniqueOrThrow({
|
||||
where: { key: "round_trip_cough" },
|
||||
});
|
||||
await prisma.illnessSymptomLink.updateMany({
|
||||
where: { symptomId: own.id },
|
||||
data: { symptomId: shipped.id },
|
||||
});
|
||||
await prisma.illnessSymptom.delete({ where: { id: own.id } });
|
||||
|
||||
const exportedAt = new Date();
|
||||
let json = "";
|
||||
const counts = await streamFullBackupJson(
|
||||
prisma,
|
||||
OWNER_ID,
|
||||
(chunk) => {
|
||||
json += chunk;
|
||||
},
|
||||
{ purpose: "disaster-recovery", exportedAt },
|
||||
);
|
||||
const bytes = Buffer.from(json, "utf8");
|
||||
const payload = JSON.parse(json) as { schemaVersion: string };
|
||||
const manifest: GoldenManifest = {
|
||||
release,
|
||||
schemaVersion: payload.schemaVersion,
|
||||
userId: OWNER_ID,
|
||||
exportedAt: exportedAt.toISOString(),
|
||||
sha256: sha256(bytes),
|
||||
bytes: bytes.length,
|
||||
counts: { ...counts },
|
||||
};
|
||||
writeFileSync(resolve(FIXTURE_DIR, `v${release}.json`), bytes);
|
||||
writeFileSync(
|
||||
resolve(FIXTURE_DIR, `v${release}.manifest.json`),
|
||||
JSON.stringify(manifest, null, 2) + "\n",
|
||||
);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(WRITE)("golden backups: every committed file restores", () => {
|
||||
it("has at least one committed file, each with its manifest", () => {
|
||||
const files = goldenFiles();
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
for (const file of files) {
|
||||
expect(readdirSync(FIXTURE_DIR)).toContain(
|
||||
file.replace(/\.json$/, ".manifest.json"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
for (const file of goldenFiles()) {
|
||||
it(
|
||||
`restores ${file} into an empty account with the counts it was written with`,
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const bytes = readFileSync(resolve(FIXTURE_DIR, file));
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(FIXTURE_DIR, file.replace(/\.json$/, ".manifest.json")),
|
||||
"utf8",
|
||||
),
|
||||
) as GoldenManifest;
|
||||
// The file is the release's own bytes. Regenerating it from today's
|
||||
// exporter would turn this into one more same-code round trip.
|
||||
expect(sha256(bytes), `${file} was rewritten`).toBe(manifest.sha256);
|
||||
// A file that held nearly nothing would agree with nearly nothing.
|
||||
const nonEmpty = Object.values(manifest.counts).filter((n) => n > 0);
|
||||
expect(nonEmpty.length).toBeGreaterThan(40);
|
||||
|
||||
await seedAdminSession(prisma);
|
||||
// The empty account the file is restored into: the restore writes
|
||||
// into the account the file names, which has to exist.
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: manifest.userId,
|
||||
username: `golden-${manifest.release}`,
|
||||
email: `golden-${manifest.release}@example.test`,
|
||||
},
|
||||
});
|
||||
|
||||
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/json" },
|
||||
body: bytes,
|
||||
}) as unknown as Parameters<typeof upload>[0],
|
||||
);
|
||||
const uploadBody = (await uploaded.json()) as {
|
||||
data: { id: string } | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(uploaded.status, JSON.stringify(uploadBody)).toBe(201);
|
||||
const backupId = uploadBody.data!.id;
|
||||
|
||||
const { POST: restore } = await import("./restore-job-driver");
|
||||
const restored = await restore(
|
||||
new Request(
|
||||
`http://localhost/api/admin/backups/${backupId}/restore`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
},
|
||||
) as unknown as Parameters<typeof restore>[0],
|
||||
{ params: Promise.resolve({ id: backupId }) },
|
||||
);
|
||||
const restoreBody = (await restored.json()) as {
|
||||
data: { skipped: { links: number } } | null;
|
||||
error: string | null;
|
||||
meta?: unknown;
|
||||
};
|
||||
expect(restored.status, JSON.stringify(restoreBody)).toBe(200);
|
||||
expect(
|
||||
restoreBody.data!.skipped.links,
|
||||
JSON.stringify(restoreBody.data!.skipped),
|
||||
).toBe(0);
|
||||
|
||||
const { counts } = await buildFullBackupPayload(
|
||||
prisma,
|
||||
manifest.userId,
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
const now: Record<string, number> = { ...counts };
|
||||
for (const [section, expected] of Object.entries(manifest.counts)) {
|
||||
if (section in AGES_OUT) {
|
||||
expect(now[section], `${section} grew`).toBeLessThanOrEqual(
|
||||
expected,
|
||||
);
|
||||
expect(now[section], `${section} came back empty`).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
expect(now[section], `${section} after restoring ${file}`).toBe(
|
||||
expected,
|
||||
);
|
||||
}
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user