mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'feat/v1462-tests' into release/v1.46.2
This commit is contained in:
commit
1525be5d53
9 files changed
+934
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/*
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* The app header keeps its height in every language.
|
||||
*
|
||||
* A translated label that is a little longer than the English one wraps, and
|
||||
* a header that wraps grows: the dashboard's title row once took a second
|
||||
* line in one locale while every English check stayed green. The top bar and
|
||||
* the dashboard's page-header row are measured here in every shipped locale,
|
||||
* on a 390 px phone and on a desktop window, against the height English gives
|
||||
* them on the same viewport.
|
||||
*
|
||||
* The language is switched the way the app switches it, through
|
||||
* `PUT /api/auth/me/locale`, which writes the account's language and the
|
||||
* locale cookie; `<html lang>` is then asserted before anything is measured,
|
||||
* so a switch that silently did not happen cannot pass as "same height".
|
||||
*
|
||||
* The locale list is the app's own (`src/lib/i18n/config.ts`), so a language
|
||||
* added there is swept without touching this file.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
import { HEADER_LOCALES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ label: "phone 390", width: 390, height: 844 },
|
||||
{ label: "desktop 1280", width: 1280, height: 800 },
|
||||
] as const;
|
||||
|
||||
type ViewportLabel = (typeof VIEWPORTS)[number]["label"];
|
||||
|
||||
interface HeaderGeometry {
|
||||
topBar: number;
|
||||
pageHeader: number;
|
||||
/** For the failure message: what wrapped, not only that something did. */
|
||||
title: string;
|
||||
titleHeight: number;
|
||||
}
|
||||
|
||||
/** English first: every other locale is compared with what it measured. */
|
||||
const ORDER: Locale[] = ["en", ...locales.filter((l) => l !== "en")];
|
||||
|
||||
const reference = new Map<ViewportLabel, HeaderGeometry>();
|
||||
|
||||
async function readHeader(page: Page): Promise<HeaderGeometry | null> {
|
||||
return page.evaluate(() => {
|
||||
const topBar = document.querySelector('[data-slot="top-bar"]');
|
||||
const rows = document.querySelectorAll(
|
||||
'main [data-slot="page-header-row"]',
|
||||
);
|
||||
if (!topBar || rows.length !== 1) return null;
|
||||
const row = rows[0];
|
||||
const title = row.querySelector("h1");
|
||||
return {
|
||||
topBar: topBar.getBoundingClientRect().height,
|
||||
pageHeader: row.getBoundingClientRect().height,
|
||||
title: title?.textContent ?? "",
|
||||
titleHeight: title?.getBoundingClientRect().height ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the header until two reads a frame apart agree. The greeting swaps to
|
||||
* the personalised line after hydration; measuring mid-swap would compare a
|
||||
* frame nobody keeps.
|
||||
*/
|
||||
async function settledHeader(page: Page): Promise<HeaderGeometry> {
|
||||
await page.evaluate(() => document.fonts.ready.then(() => undefined));
|
||||
let previous: HeaderGeometry | null = null;
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
const current = await readHeader(page);
|
||||
if (
|
||||
current &&
|
||||
previous &&
|
||||
current.topBar === previous.topBar &&
|
||||
current.pageHeader === previous.pageHeader
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
throw new Error(
|
||||
`header did not settle: ${JSON.stringify(previous ?? "not found")}`,
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("header height in every locale", () => {
|
||||
test.use({ storageState: HEADER_LOCALES_STORAGE_STATE_PATH });
|
||||
// The account's language is one value; the locales take turns on it.
|
||||
// It runs in the desktop project only (see `playwright.config.ts`) and sets
|
||||
// both viewports itself.
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
for (const locale of ORDER) {
|
||||
test(`top bar and dashboard header keep the English height in ${locale}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const switched = await page.request.put("/api/auth/me/locale", {
|
||||
data: { locale },
|
||||
});
|
||||
expect(switched.status(), await switched.text()).toBe(200);
|
||||
|
||||
for (const viewport of VIEWPORTS) {
|
||||
await page.setViewportSize({
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", locale);
|
||||
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('main [data-slot="page-header-row"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="dashboard-header-greeting"]'),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.innerWidth))
|
||||
.toBe(viewport.width);
|
||||
|
||||
const measured = await settledHeader(page);
|
||||
const at = `${locale}, ${viewport.label}`;
|
||||
// A band that is not there measures 0 in every locale and "agrees".
|
||||
expect(
|
||||
measured.topBar,
|
||||
`top bar has no height (${at})`,
|
||||
).toBeGreaterThan(32);
|
||||
expect(
|
||||
measured.pageHeader,
|
||||
`page header has no height (${at})`,
|
||||
).toBeGreaterThan(20);
|
||||
|
||||
if (locale === "en") {
|
||||
reference.set(viewport.label, measured);
|
||||
continue;
|
||||
}
|
||||
const english = reference.get(viewport.label);
|
||||
expect(english, "the English reference ran first").toBeDefined();
|
||||
const detail = `title "${measured.title}" ${measured.titleHeight}px tall vs "${english!.title}" ${english!.titleHeight}px`;
|
||||
expect(
|
||||
Math.abs(measured.topBar - english!.topBar),
|
||||
`top bar is ${measured.topBar}px in ${at}, ${english!.topBar}px in English`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(
|
||||
Math.abs(measured.pageHeader - english!.pageHeader),
|
||||
`dashboard header is ${measured.pageHeader}px in ${at}, ${english!.pageHeader}px in English; ${detail}`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -325,6 +325,25 @@ export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve(
|
||||
"e2e/setup/storageStateScoreHistory.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The header-height sweep's own account (`header-height-locales.spec.ts`).
|
||||
*
|
||||
* It moves the account's language through every shipped locale; the server
|
||||
* honours `User.locale` wherever a request carries no locale cookie, so on a
|
||||
* shared account a parallel spec could render in Korean halfway through.
|
||||
*/
|
||||
export const E2E_HEADER_LOCALES = {
|
||||
email: "e2e-header-locales@healthlog.test",
|
||||
username: "e2e-header-locales",
|
||||
password: "Hl8!Tq2vNx6mWr4Z",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
export const HEADER_LOCALES_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateHeaderLocales.json",
|
||||
);
|
||||
|
||||
export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateAiOptional.json",
|
||||
@@ -1102,6 +1121,33 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// The header-height sweep's account. Default modules and layout, so the
|
||||
// dashboard header carries what a fresh account's does; the language is
|
||||
// put back to English because the sweep leaves it on its last locale.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed,
|
||||
module_preferences_json, locale)
|
||||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL, 'en')
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
module_preferences_json = NULL,
|
||||
dashboard_widgets_json = NULL,
|
||||
locale = 'en'`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_HEADER_LOCALES.username,
|
||||
E2E_HEADER_LOCALES.email,
|
||||
await hashPassword(E2E_HEADER_LOCALES.password),
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
// The notification-dispatch journey's account. Seeded like the others;
|
||||
// its channels, devices, ledger rows and preferences are reset by
|
||||
// `e2e/setup/notification-fixture.ts` before every test, because the
|
||||
@@ -1784,6 +1830,9 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// The score-history journey's jar — see `E2E_SCORE_HISTORY`.
|
||||
await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH);
|
||||
|
||||
// The header-height sweep's jar — see `E2E_HEADER_LOCALES`.
|
||||
await capture(E2E_HEADER_LOCALES, HEADER_LOCALES_STORAGE_STATE_PATH);
|
||||
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
|
||||
|
||||
@@ -310,6 +310,11 @@ export default defineConfig({
|
||||
// other. It checks the phone width by resizing inside the desktop
|
||||
// project.
|
||||
"score-history.spec.ts",
|
||||
// The header-height sweep moves its account's language through every
|
||||
// locale; a second project would switch it under the first between
|
||||
// the switch and the measurement. It sets the 390 px phone width
|
||||
// itself inside the desktop project.
|
||||
"header-height-locales.spec.ts",
|
||||
],
|
||||
use: {
|
||||
// Pixel 5 — Chromium-based mobile profile so CI only needs
|
||||
|
||||
+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,326 @@
|
||||
/**
|
||||
* The backup of an account that has since been erased.
|
||||
*
|
||||
* An account is filled (the shared backup seed), its disaster-recovery file is
|
||||
* written by the exporter the weekly copy uses, and the account is then
|
||||
* deleted the way a person deletes it: `DELETE /api/settings/account` under a
|
||||
* 14-day grace window, and the hourly purge job run past the window's end.
|
||||
* What is left is what an operator has after the fact: the file, an admin
|
||||
* session, and a server that no longer knows the account.
|
||||
*
|
||||
* 1. The purge leaves nothing in the database that names the account.
|
||||
* 2. The admin upload refuses the file, because its owner is gone, and
|
||||
* stores nothing (the documented contract in `docs/ops/backup-restore.md`:
|
||||
* a restore writes into an account that exists under the same id).
|
||||
* 3. With the account row put back under its original id, the one way that
|
||||
* contract leaves, the upload and the restore bring every section back.
|
||||
* 4. Into a freshly created account, with the file's owner set to that
|
||||
* account, the restore brings every section back and nothing in the
|
||||
* database names the erased account afterwards.
|
||||
*
|
||||
* Everything runs through production code: the deletion route, the purge job,
|
||||
* the admin upload and restore routes and the restore job
|
||||
* (`restore-job-driver.ts` runs the queued job in-process).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
|
||||
import {
|
||||
OWNER_ID,
|
||||
createOwner,
|
||||
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),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/dispatch-localised", () => ({
|
||||
dispatchLocalisedNotification: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const SLOW = 240_000;
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.rateLimit.deleteMany();
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "14");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
async function signIn(prisma: PrismaClient, userId: string) {
|
||||
const session = await prisma.session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every text and JSON cell in the public schema that contains `needle`, as
|
||||
* `table.column (rows)`. Bytes are left out: ciphertext cannot be searched,
|
||||
* and the backup seed seals nothing that names its owner.
|
||||
*/
|
||||
async function cellsNaming(
|
||||
prisma: PrismaClient,
|
||||
needle: string,
|
||||
): Promise<string[]> {
|
||||
const columns = await prisma.$queryRaw<
|
||||
Array<{ table_name: string; column_name: string }>
|
||||
>`
|
||||
SELECT c.table_name, c.column_name
|
||||
FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
||||
WHERE c.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND c.table_name <> '_prisma_migrations'
|
||||
AND (c.data_type IN ('text', 'character varying', 'json', 'jsonb')
|
||||
OR c.udt_name IN ('_text', '_varchar'))
|
||||
ORDER BY c.table_name, c.column_name`;
|
||||
const hits: string[] = [];
|
||||
for (const { table_name, column_name } of columns) {
|
||||
if (table_name === "rate_limits") {
|
||||
// A rate-limit bucket (`record-write:<userId>`) is counting state, not
|
||||
// a record: it expires with its window and `rate-limit-cleanup` deletes
|
||||
// it. Allowed only while that holds, so a bucket that outlives its
|
||||
// account by more than a day still shows up.
|
||||
const lasting = await prisma.$queryRaw<Array<{ key: string }>>`
|
||||
SELECT key FROM rate_limits
|
||||
WHERE key LIKE '%' || ${needle} || '%'
|
||||
AND reset_at > (NOW() AT TIME ZONE 'UTC') + interval '1 day'`;
|
||||
hits.push(...lasting.map((row) => `rate_limits.key: ${row.key}`));
|
||||
continue;
|
||||
}
|
||||
// Identifiers come from the catalogue above, never from input.
|
||||
const rows = await prisma.$queryRawUnsafe<
|
||||
Array<{ n: bigint; sample: string | null }>
|
||||
>(
|
||||
`SELECT count(*) AS n, min(left("${column_name}"::text, 80)) AS sample
|
||||
FROM "${table_name}" WHERE "${column_name}"::text LIKE '%' || $1 || '%'`,
|
||||
needle,
|
||||
);
|
||||
const n = Number(rows[0]?.n ?? 0);
|
||||
if (n > 0) {
|
||||
hits.push(`${table_name}.${column_name} (${n}): ${rows[0]?.sample}`);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** The account, its file, and the account erased by the real path. */
|
||||
async function eraseAfterExport(prisma: PrismaClient) {
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
username: "erasure-admin",
|
||||
email: "erasure-admin@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
await createOwner(prisma);
|
||||
await seedEveryTwoEndedModel(prisma);
|
||||
await seedColumnShapes(prisma);
|
||||
// The seed's illness symptom lives in a catalogue row of its own, which
|
||||
// the purge rightly keeps (catalogues are the instance's). Re-point the
|
||||
// link at a shipped symptom so the scan below reads the account alone.
|
||||
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 } });
|
||||
|
||||
let json = "";
|
||||
const exported = await streamFullBackupJson(
|
||||
prisma,
|
||||
OWNER_ID,
|
||||
(chunk) => {
|
||||
json += chunk;
|
||||
},
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
const counts: Record<string, number> = { ...exported };
|
||||
|
||||
// The person deletes their account; the window starts.
|
||||
await signIn(prisma, OWNER_ID);
|
||||
const { DELETE } = await import("@/app/api/settings/account/route");
|
||||
const { NextRequest } = await import("next/server");
|
||||
const deleted = await DELETE(
|
||||
new NextRequest("http://localhost/api/settings/account", {
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
|
||||
}),
|
||||
);
|
||||
expect(deleted.status, await deleted.clone().text()).toBe(200);
|
||||
expect(
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id: OWNER_ID } }))
|
||||
.deletionPurgeAt,
|
||||
).not.toBeNull();
|
||||
|
||||
// The window ends and the hourly job runs to completion.
|
||||
const { processDueAccountDeletions } =
|
||||
await import("@/lib/jobs/account-deletion-purge");
|
||||
const report = await processDueAccountDeletions(
|
||||
new Date(Date.now() + 15 * DAY_MS),
|
||||
);
|
||||
expect(report).toMatchObject({ due: 1, purged: 1, refused: 0, failed: 0 });
|
||||
expect(await prisma.user.findUnique({ where: { id: OWNER_ID } })).toBeNull();
|
||||
|
||||
cookieJar.clear();
|
||||
await signIn(prisma, admin.id);
|
||||
return { admin, json, counts };
|
||||
}
|
||||
|
||||
async function upload(body: string) {
|
||||
const { POST } = await import("@/app/api/admin/backups/upload/route");
|
||||
const res = await POST(
|
||||
new Request("http://localhost/api/admin/backups/upload", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body,
|
||||
}) as unknown as Parameters<typeof POST>[0],
|
||||
);
|
||||
return {
|
||||
status: res.status,
|
||||
body: (await res.json()) as {
|
||||
data: { id: string } | null;
|
||||
error: string | null;
|
||||
meta?: { reason?: string; errorCode?: string };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function restore(backupId: string) {
|
||||
const { POST } = await import("./restore-job-driver");
|
||||
const res = await POST(
|
||||
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 POST>[0],
|
||||
{ params: Promise.resolve({ id: backupId }) },
|
||||
);
|
||||
return {
|
||||
status: res.status,
|
||||
body: (await res.json()) as {
|
||||
data: { skipped: { links: number } } | null;
|
||||
error: string | null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function countsOf(prisma: PrismaClient, userId: string) {
|
||||
const { counts } = await buildFullBackupPayload(prisma, userId, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
return { ...counts } as Record<string, number>;
|
||||
}
|
||||
|
||||
describe("restoring the backup of an erased account", () => {
|
||||
it(
|
||||
"the purge leaves nothing that names the account, and the upload refuses its file",
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { json, counts } = await eraseAfterExport(prisma);
|
||||
expect(Object.values(counts).filter((n) => n > 0).length).toBeGreaterThan(
|
||||
40,
|
||||
);
|
||||
|
||||
expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]);
|
||||
|
||||
const refused = await upload(json);
|
||||
expect(refused.status, JSON.stringify(refused.body)).toBe(422);
|
||||
const denial = await prisma.auditLog.findFirstOrThrow({
|
||||
where: { action: "admin.backups.upload.denied" },
|
||||
});
|
||||
expect(JSON.parse(denial.details ?? "{}")).toMatchObject({
|
||||
reason: "owner_not_found",
|
||||
ownerId: OWNER_ID,
|
||||
});
|
||||
expect(await prisma.dataBackup.count()).toBe(0);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
it(
|
||||
"comes back whole once the account row exists again under its id",
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { json, counts } = await eraseAfterExport(prisma);
|
||||
await createOwner(prisma);
|
||||
|
||||
const stored = await upload(json);
|
||||
expect(stored.status, JSON.stringify(stored.body)).toBe(201);
|
||||
const restored = await restore(stored.body.data!.id);
|
||||
expect(restored.status, JSON.stringify(restored.body)).toBe(200);
|
||||
expect(restored.body.data!.skipped.links).toBe(0);
|
||||
expect(await countsOf(prisma, OWNER_ID)).toEqual(counts);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
it(
|
||||
"comes back whole into a freshly created account, and nothing names the erased one",
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { json, counts } = await eraseAfterExport(prisma);
|
||||
const fresh = await prisma.user.create({
|
||||
data: {
|
||||
username: "returning-person",
|
||||
email: "returning-person@example.test",
|
||||
},
|
||||
});
|
||||
// The file names its owner once, at the top; the restore targets the
|
||||
// account it names. Point it at the new account.
|
||||
const payload = JSON.parse(json) as { userId: string };
|
||||
expect(payload.userId).toBe(OWNER_ID);
|
||||
payload.userId = fresh.id;
|
||||
|
||||
const stored = await upload(JSON.stringify(payload));
|
||||
expect(stored.status, JSON.stringify(stored.body)).toBe(201);
|
||||
const restored = await restore(stored.body.data!.id);
|
||||
expect(restored.status, JSON.stringify(restored.body)).toBe(200);
|
||||
expect(restored.body.data!.skipped.links).toBe(0);
|
||||
|
||||
expect(await countsOf(prisma, fresh.id)).toEqual(counts);
|
||||
expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
@@ -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