diff --git a/tests/integration/backup-parity.test.ts b/tests/integration/backup-parity.test.ts index 3e83e614c..e8b8d8484 100644 --- a/tests/integration/backup-parity.test.ts +++ b/tests/integration/backup-parity.test.ts @@ -1113,7 +1113,10 @@ async function recreateOwner(prisma: PrismaClient) { // ── The cases ──────────────────────────────────────────────────────────────── -describe("backup parity", () => { +// One account through the numbered steps above, in order: each restore and +// export reads what the step before it left. The order holds under a +// shuffled run. +describe("backup parity", { shuffle: false }, () => { const prisma = getPrismaClient(); let adminId = ""; let drJson = ""; diff --git a/tests/integration/database-backup.test.ts b/tests/integration/database-backup.test.ts index e82f4965f..8e3ec5b76 100644 --- a/tests/integration/database-backup.test.ts +++ b/tests/integration/database-backup.test.ts @@ -147,7 +147,10 @@ afterAll(() => { rmSync(work, { recursive: true, force: true }); }); -describe("the nightly database dump", () => { +// One backup directory through the cases in order: the first dump is the one +// the retention, failure and restore cases count and restore. The order holds +// under a shuffled run. +describe("the nightly database dump", { shuffle: false }, () => { let dumpFile = ""; it("writes a checked dump and manifest, records the run and copies both off-host", async () => { diff --git a/tests/integration/geo-backfill-attempt.test.ts b/tests/integration/geo-backfill-attempt.test.ts index 8c71bf735..f2899238d 100644 --- a/tests/integration/geo-backfill-attempt.test.ts +++ b/tests/integration/geo-backfill-attempt.test.ts @@ -71,7 +71,10 @@ beforeAll(async () => { await prisma.$executeRawUnsafe("ANALYZE audit_logs"); }, 120_000); -describe("runGeoBackfill", () => { +// Three passes in order: the second must find what the first marked, the +// third runs past the wait the first one set. The order holds under a +// shuffled run. +describe("runGeoBackfill", { shuffle: false }, () => { it("reads its candidates through the partial index", async () => { const before = await indexScans(); const summary = await runGeoBackfill(prisma, NOW); diff --git a/tests/integration/healthkit-type-sync-migration.test.ts b/tests/integration/healthkit-type-sync-migration.test.ts index 5876d2370..3d6e14c4c 100644 --- a/tests/integration/healthkit-type-sync-migration.test.ts +++ b/tests/integration/healthkit-type-sync-migration.test.ts @@ -106,84 +106,93 @@ afterAll(async () => { if (column.udt_name === "measurement_type") await runMigration(); }); -describe("migration 0401 over enum-typed ledger rows", () => { - it("starts from the enum column", async () => { - expect((await typeColumn()).udt_name).toBe("measurement_type"); - }); +// Before the migration, the migration, after it: the cases are its steps, +// in order, under a shuffled run too. +describe( + "migration 0401 over enum-typed ledger rows", + { shuffle: false }, + () => { + it("starts from the enum column", async () => { + expect((await typeColumn()).udt_name).toBe("measurement_type"); + }); - it("keeps every row, its key and its dates, and seeds the background date", async () => { - await runMigration(); + it("keeps every row, its key and its dates, and seeds the background date", async () => { + await runMigration(); - expect(await typeColumn()).toEqual({ data_type: "text", udt_name: "text" }); - const rows = await readRows(); - expect(rows.map((r) => r.type)).toEqual([ - "PULSE", - "RESPIRATORY_RATE", - "WEIGHT", - ]); - const byType = Object.fromEntries(rows.map((r) => [r.type, r])); - for (const [type, at] of Object.entries(RECEIVED)) { - expect(byType[type].last_received_at.toISOString()).toBe( - `${at.replace(" ", "T")}.000Z`, + expect(await typeColumn()).toEqual({ + data_type: "text", + udt_name: "text", + }); + const rows = await readRows(); + expect(rows.map((r) => r.type)).toEqual([ + "PULSE", + "RESPIRATORY_RATE", + "WEIGHT", + ]); + const byType = Object.fromEntries(rows.map((r) => [r.type, r])); + for (const [type, at] of Object.entries(RECEIVED)) { + expect(byType[type].last_received_at.toISOString()).toBe( + `${at.replace(" ", "T")}.000Z`, + ); + } + expect(byType.WEIGHT.last_trigger).toBe("background"); + expect(byType.WEIGHT.last_new_sample_at?.toISOString()).toBe( + "2026-09-01T06:00:00.000Z", ); - } - expect(byType.WEIGHT.last_trigger).toBe("background"); - expect(byType.WEIGHT.last_new_sample_at?.toISOString()).toBe( - "2026-09-01T06:00:00.000Z", - ); - expect(byType.PULSE.last_new_sample_at).toBeNull(); - // Only a row whose latest arrival was a background one knows a date. - expect(byType.WEIGHT.last_background_at?.toISOString()).toBe( - "2026-09-01T06:00:00.000Z", - ); - expect(byType.PULSE.last_background_at).toBeNull(); - expect(byType.RESPIRATORY_RATE.last_background_at).toBeNull(); - }); + expect(byType.PULSE.last_new_sample_at).toBeNull(); + // Only a row whose latest arrival was a background one knows a date. + expect(byType.WEIGHT.last_background_at?.toISOString()).toBe( + "2026-09-01T06:00:00.000Z", + ); + expect(byType.PULSE.last_background_at).toBeNull(); + expect(byType.RESPIRATORY_RATE.last_background_at).toBeNull(); + }); - it("keeps the primary key on (user, type)", async () => { - const pkey = await prisma.$queryRaw>` + it("keeps the primary key on (user, type)", async () => { + const pkey = await prisma.$queryRaw>` SELECT indexdef FROM pg_indexes WHERE tablename = 'healthkit_type_syncs' AND indexname = 'healthkit_type_syncs_pkey' `; - expect(pkey[0]?.indexdef).toContain("(user_id, type)"); - await expect( - prisma.$executeRawUnsafe( - `INSERT INTO "healthkit_type_syncs" ("user_id", "type", "last_received_at") VALUES ('${USER}', 'WEIGHT', now())`, - ), - ).rejects.toThrow(); - }); + expect(pkey[0]?.indexdef).toContain("(user_id, type)"); + await expect( + prisma.$executeRawUnsafe( + `INSERT INTO "healthkit_type_syncs" ("user_id", "type", "last_received_at") VALUES ('${USER}', 'WEIGHT', now())`, + ), + ).rejects.toThrow(); + }); - it("lets the writer upsert a measurement type and record a dose", async () => { - const at = new Date("2026-10-01T09:00:00.000Z"); - await recordHealthKitTypeArrivals(prisma, { - userId: USER, - counts: new Map([ - ["WEIGHT", { accepted: 1, newSamples: 1 }], - ["MEDICATION_DOSE", { accepted: 2, newSamples: 2 }], - ]), - trigger: "foreground", - at, + it("lets the writer upsert a measurement type and record a dose", async () => { + const at = new Date("2026-10-01T09:00:00.000Z"); + await recordHealthKitTypeArrivals(prisma, { + userId: USER, + counts: new Map([ + ["WEIGHT", { accepted: 1, newSamples: 1 }], + ["MEDICATION_DOSE", { accepted: 2, newSamples: 2 }], + ]), + trigger: "foreground", + at, + }); + const rows = await prisma.healthKitTypeSync.findMany({ + where: { userId: USER }, + orderBy: { type: "asc" }, + }); + expect(rows.map((r) => r.type)).toEqual([ + "MEDICATION_DOSE", + "PULSE", + "RESPIRATORY_RATE", + "WEIGHT", + ]); + const weight = rows.find((r) => r.type === "WEIGHT")!; + expect(weight.lastReceivedAt.toISOString()).toBe(at.toISOString()); + expect(weight.lastTrigger).toBe("foreground"); + // The foreground arrival leaves the seeded background date standing. + expect(weight.lastBackgroundAt?.toISOString()).toBe( + "2026-09-01T06:00:00.000Z", + ); + const dose = rows.find((r) => r.type === "MEDICATION_DOSE")!; + expect(dose.lastNewSampleAt?.toISOString()).toBe(at.toISOString()); + expect(dose.lastBackgroundAt).toBeNull(); }); - const rows = await prisma.healthKitTypeSync.findMany({ - where: { userId: USER }, - orderBy: { type: "asc" }, - }); - expect(rows.map((r) => r.type)).toEqual([ - "MEDICATION_DOSE", - "PULSE", - "RESPIRATORY_RATE", - "WEIGHT", - ]); - const weight = rows.find((r) => r.type === "WEIGHT")!; - expect(weight.lastReceivedAt.toISOString()).toBe(at.toISOString()); - expect(weight.lastTrigger).toBe("foreground"); - // The foreground arrival leaves the seeded background date standing. - expect(weight.lastBackgroundAt?.toISOString()).toBe( - "2026-09-01T06:00:00.000Z", - ); - const dose = rows.find((r) => r.type === "MEDICATION_DOSE")!; - expect(dose.lastNewSampleAt?.toISOString()).toBe(at.toISOString()); - expect(dose.lastBackgroundAt).toBeNull(); - }); -}); + }, +); diff --git a/tests/integration/hrv-rmssd-fitbit-google-migration.test.ts b/tests/integration/hrv-rmssd-fitbit-google-migration.test.ts index cbbadf508..3a1a06e40 100644 --- a/tests/integration/hrv-rmssd-fitbit-google-migration.test.ts +++ b/tests/integration/hrv-rmssd-fitbit-google-migration.test.ts @@ -180,7 +180,9 @@ beforeAll(async () => { await runMigration(); }, 120_000); -describe("migration 0420", () => { +// The migration's effects, then the refold and the rerun that change them: +// the cases read in order, under a shuffled run too. +describe("migration 0420", { shuffle: false }, () => { it("moves every Fitbit and Google Health SDNN row to HRV_RMSSD, tombstones included", async () => { const moved = await prisma.measurement.findMany({ where: { userId: USER, source: { in: ["FITBIT", "GOOGLE_HEALTH"] } }, diff --git a/tests/integration/labs-unit-conversion.test.ts b/tests/integration/labs-unit-conversion.test.ts index 89af81ce8..a5b06f235 100644 --- a/tests/integration/labs-unit-conversion.test.ts +++ b/tests/integration/labs-unit-conversion.test.ts @@ -130,244 +130,251 @@ type Dto = { rangeStatus: string; }; -describe("POST /api/labs", () => { - it("converts a reading in another unit and keeps what the report printed", async () => { - const { status, json } = await call("labs", "POST", { - biomarkerId: "bm-glucose", - value: 5.6, - unit: "mmol/L", - sourceReferenceLow: 3.9, - sourceReferenceHigh: 6.0, - takenAt: "2026-05-01T08:00:00.000Z", - }); - expect(status).toBe(201); - const dto = json.data!; - expect(dto.converted).toBe(true); - expect(dto.unit).toBe("mg/dL"); - expect(dto.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); - expect(dto.sourceValue).toBe(5.6); - expect(dto.sourceUnit).toBe("mmol/L"); - // The report's own window moved with the value. - expect(dto.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9); - expect(dto.sourceReferenceHigh).toBeCloseTo(6.0 * MGDL_PER_MMOL, 9); - // And the verdict is judged against it: 5.6 of 3.9-6.0 is in range. - expect(dto.rangeStatus).toBe("in-range"); +// One record through the cases in order: the OCR commit is committed again +// as a duplicate, and the edits and the list read the readings the earlier +// cases wrote. The order holds under a shuffled run. +describe("lab unit conversion, one record", { shuffle: false }, () => { + describe("POST /api/labs", () => { + it("converts a reading in another unit and keeps what the report printed", async () => { + const { status, json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 5.6, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 6.0, + takenAt: "2026-05-01T08:00:00.000Z", + }); + expect(status).toBe(201); + const dto = json.data!; + expect(dto.converted).toBe(true); + expect(dto.unit).toBe("mg/dL"); + expect(dto.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); + expect(dto.sourceValue).toBe(5.6); + expect(dto.sourceUnit).toBe("mmol/L"); + // The report's own window moved with the value. + expect(dto.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9); + expect(dto.sourceReferenceHigh).toBeCloseTo(6.0 * MGDL_PER_MMOL, 9); + // And the verdict is judged against it: 5.6 of 3.9-6.0 is in range. + expect(dto.rangeStatus).toBe("in-range"); - const row = await prisma.labResult.findUniqueOrThrow({ - where: { id: dto.id }, + const row = await prisma.labResult.findUniqueOrThrow({ + where: { id: dto.id }, + }); + expect(row.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); + expect(row.sourceValue).toBe(5.6); + expect(row.sourceUnit).toBe("mmol/L"); + }); + + it("flags a converted reading outside the printed window as such", async () => { + const { json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 7.2, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 6.0, + takenAt: "2026-05-02T08:00:00.000Z", + }); + expect(json.data!.rangeStatus).toBe("above"); + }); + + it("stores a reading in the marker's unit as stated, with no conversion record", async () => { + const { json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 95, + unit: "mg/dL", + takenAt: "2026-05-03T08:00:00.000Z", + }); + const dto = json.data!; + expect(dto.converted).toBe(false); + expect(dto.value).toBe(95); + expect(dto.sourceValue).toBeNull(); + expect(dto.sourceUnit).toBeNull(); + }); + + it("links a marker that had no key yet, the first time it is needed", async () => { + const before = await prisma.biomarker.findUniqueOrThrow({ + where: { id: "bm-glucose" }, + }); + // The marker was created without a key in this test; the first conversion + // above has already filled it. + expect(before.analyteKey).toBe("fasting-glucose"); + }); + + it("still refuses a pair that cannot be converted, now with the reason", async () => { + const { status, json } = await call("labs", "POST", { + biomarkerId: "bm-free", + value: 5.6, + unit: "mmol/L", + takenAt: "2026-05-04T08:00:00.000Z", + }); + expect(status).toBe(422); + expect(json.meta).toMatchObject({ + errorCode: "labs.unit.mismatch", + markerUnit: "mg/dL", + readingUnit: "mmol/L", + reason: "unknown_analyte", + }); + expect( + await prisma.labResult.count({ where: { biomarkerId: "bm-free" } }), + ).toBe(0); }); - expect(row.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); - expect(row.sourceValue).toBe(5.6); - expect(row.sourceUnit).toBe("mmol/L"); }); - it("flags a converted reading outside the printed window as such", async () => { - const { json } = await call("labs", "POST", { - biomarkerId: "bm-glucose", - value: 7.2, - unit: "mmol/L", - sourceReferenceLow: 3.9, - sourceReferenceHigh: 6.0, - takenAt: "2026-05-02T08:00:00.000Z", - }); - expect(json.data!.rangeStatus).toBe("above"); - }); - - it("stores a reading in the marker's unit as stated, with no conversion record", async () => { - const { json } = await call("labs", "POST", { - biomarkerId: "bm-glucose", - value: 95, - unit: "mg/dL", - takenAt: "2026-05-03T08:00:00.000Z", - }); - const dto = json.data!; - expect(dto.converted).toBe(false); - expect(dto.value).toBe(95); - expect(dto.sourceValue).toBeNull(); - expect(dto.sourceUnit).toBeNull(); - }); - - it("links a marker that had no key yet, the first time it is needed", async () => { - const before = await prisma.biomarker.findUniqueOrThrow({ - where: { id: "bm-glucose" }, - }); - // The marker was created without a key in this test; the first conversion - // above has already filled it. - expect(before.analyteKey).toBe("fasting-glucose"); - }); - - it("still refuses a pair that cannot be converted, now with the reason", async () => { - const { status, json } = await call("labs", "POST", { - biomarkerId: "bm-free", - value: 5.6, - unit: "mmol/L", - takenAt: "2026-05-04T08:00:00.000Z", - }); - expect(status).toBe(422); - expect(json.meta).toMatchObject({ - errorCode: "labs.unit.mismatch", - markerUnit: "mg/dL", - readingUnit: "mmol/L", - reason: "unknown_analyte", - }); - expect( - await prisma.labResult.count({ where: { biomarkerId: "bm-free" } }), - ).toBe(0); - }); -}); - -describe("the OCR commit", () => { - const row = { - analyte: "Glucose", - value: 5.0, - unit: "mmol/L", - referenceText: "3.9 - 5.6", - takenAt: "2026-06-01T08:00:00.000Z", - }; - - it("converts the row and reports it", async () => { - const { status, json } = await call<{ - inserted: Dto[]; - skipped: unknown[]; - }>("ocr", "POST", { rows: [row] }); - expect(status).toBe(200); - expect(json.data!.skipped).toEqual([]); - const [inserted] = json.data!.inserted; - expect(inserted.converted).toBe(true); - expect(inserted.unit).toBe("mg/dL"); - expect(inserted.value).toBeCloseTo(5.0 * MGDL_PER_MMOL, 9); - expect(inserted.sourceValue).toBe(5.0); - expect(inserted.sourceUnit).toBe("mmol/L"); - expect(inserted.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9); - expect(inserted.sourceReferenceHigh).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); - }); - - it("treats the same scan committed again as a duplicate", async () => { - const { json } = await call<{ - inserted: unknown[]; - skipped: Array<{ reason: string }>; - }>("ocr", "POST", { rows: [row] }); - expect(json.data!.inserted).toEqual([]); - expect(json.data!.skipped.map((s) => s.reason)).toEqual(["duplicate"]); - }); - - it("tells the scan review which analyte the marker is, and flags a re-scan as a duplicate", async () => { - const { annotateRow } = await import("@/lib/labs/ocr-extract"); - const scanned = { + describe("the OCR commit", () => { + const row = { analyte: "Glucose", value: 5.0, - valueText: null, unit: "mmol/L", - referenceLow: null, - referenceHigh: null, - referenceText: null, - takenAt: "2026-06-01", - confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + referenceText: "3.9 - 5.6", + takenAt: "2026-06-01T08:00:00.000Z", }; - const annotated = await annotateRow(USER, scanned, "2026-06-01", "UTC"); - expect(annotated).toMatchObject({ - biomarkerMatch: "existing", - markerUnit: "mg/dL", - markerAnalyteKey: "fasting-glucose", + + it("converts the row and reports it", async () => { + const { status, json } = await call<{ + inserted: Dto[]; + skipped: unknown[]; + }>("ocr", "POST", { rows: [row] }); + expect(status).toBe(200); + expect(json.data!.skipped).toEqual([]); + const [inserted] = json.data!.inserted; + expect(inserted.converted).toBe(true); + expect(inserted.unit).toBe("mg/dL"); + expect(inserted.value).toBeCloseTo(5.0 * MGDL_PER_MMOL, 9); + expect(inserted.sourceValue).toBe(5.0); + expect(inserted.sourceUnit).toBe("mmol/L"); + expect(inserted.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9); + expect(inserted.sourceReferenceHigh).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); }); - // The converted reading from the commit above holds 90.09 mg/dL; the scan - // says 5.0 mmol/L, which is what the report printed for it. - expect(annotated.duplicateOf).not.toBeNull(); - // A different value is not a duplicate. - const other = await annotateRow( - USER, - { ...scanned, value: 6.5 }, - "2026-06-01", - "UTC", - ); - expect(other.duplicateOf).toBeNull(); + it("treats the same scan committed again as a duplicate", async () => { + const { json } = await call<{ + inserted: unknown[]; + skipped: Array<{ reason: string }>; + }>("ocr", "POST", { rows: [row] }); + expect(json.data!.inserted).toEqual([]); + expect(json.data!.skipped.map((s) => s.reason)).toEqual(["duplicate"]); + }); - // A marker the registry knows nothing about reports no analyte. - const free = await annotateRow( - USER, - { ...scanned, analyte: "My own marker", unit: "mg/dL" }, - "2026-06-01", - "UTC", - ); - expect(free.markerAnalyteKey).toBeNull(); + it("tells the scan review which analyte the marker is, and flags a re-scan as a duplicate", async () => { + const { annotateRow } = await import("@/lib/labs/ocr-extract"); + const scanned = { + analyte: "Glucose", + value: 5.0, + valueText: null, + unit: "mmol/L", + referenceLow: null, + referenceHigh: null, + referenceText: null, + takenAt: "2026-06-01", + confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + }; + const annotated = await annotateRow(USER, scanned, "2026-06-01", "UTC"); + expect(annotated).toMatchObject({ + biomarkerMatch: "existing", + markerUnit: "mg/dL", + markerAnalyteKey: "fasting-glucose", + }); + // The converted reading from the commit above holds 90.09 mg/dL; the scan + // says 5.0 mmol/L, which is what the report printed for it. + expect(annotated.duplicateOf).not.toBeNull(); + + // A different value is not a duplicate. + const other = await annotateRow( + USER, + { ...scanned, value: 6.5 }, + "2026-06-01", + "UTC", + ); + expect(other.duplicateOf).toBeNull(); + + // A marker the registry knows nothing about reports no analyte. + const free = await annotateRow( + USER, + { ...scanned, analyte: "My own marker", unit: "mg/dL" }, + "2026-06-01", + "UTC", + ); + expect(free.markerAnalyteKey).toBeNull(); + }); + + it("skips a row whose unit cannot be converted, as before", async () => { + const { json } = await call<{ + inserted: unknown[]; + skipped: Array<{ reason: string }>; + }>("ocr", "POST", { + rows: [ + { + analyte: "My own marker", + value: 3, + unit: "mmol/L", + takenAt: "2026-06-02T08:00:00.000Z", + }, + ], + }); + expect(json.data!.inserted).toEqual([]); + expect(json.data!.skipped.map((s) => s.reason)).toEqual([ + "unit_mismatch", + ]); + }); }); - it("skips a row whose unit cannot be converted, as before", async () => { - const { json } = await call<{ - inserted: unknown[]; - skipped: Array<{ reason: string }>; - }>("ocr", "POST", { - rows: [ + describe("editing and reading back", () => { + it("clears the conversion record when the value is edited", async () => { + const created = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 6.0, + unit: "mmol/L", + takenAt: "2026-07-01T08:00:00.000Z", + }); + const id = created.json.data!.id; + expect(created.json.data!.converted).toBe(true); + + const edited = await call("one", "PUT", { value: 110 }, id); + expect(edited.status).toBe(200); + expect(edited.json.data!.value).toBe(110); + expect(edited.json.data!.converted).toBe(false); + expect(edited.json.data!.sourceValue).toBeNull(); + expect(edited.json.data!.sourceUnit).toBeNull(); + }); + + it("keeps the conversion record when only the note is edited", async () => { + const created = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 6.1, + unit: "mmol/L", + takenAt: "2026-07-02T08:00:00.000Z", + }); + const id = created.json.data!.id; + const storedValue = created.json.data!.value; + // The web edit dialog sends the form back whole, the unchanged value + // included; only a changed value clears the record. + const edited = await call( + "one", + "PUT", { - analyte: "My own marker", - value: 3, - unit: "mmol/L", - takenAt: "2026-06-02T08:00:00.000Z", + value: storedValue, + takenAt: "2026-07-02T09:00:00.000Z", + note: "fasting", }, - ], + id, + ); + expect(edited.status).toBe(200); + expect(edited.json.data!.value).toBe(storedValue); + expect(edited.json.data!.converted).toBe(true); + expect(edited.json.data!.sourceValue).toBe(6.1); + expect(edited.json.data!.sourceUnit).toBe("mmol/L"); }); - expect(json.data!.inserted).toEqual([]); - expect(json.data!.skipped.map((s) => s.reason)).toEqual(["unit_mismatch"]); - }); -}); -describe("editing and reading back", () => { - it("clears the conversion record when the value is edited", async () => { - const created = await call("labs", "POST", { - biomarkerId: "bm-glucose", - value: 6.0, - unit: "mmol/L", - takenAt: "2026-07-01T08:00:00.000Z", + it("returns the fields on the list and on the single reading", async () => { + const list = await call<{ results: Dto[] }>("labs", "GET"); + const converted = list.json.data!.results.filter((r) => r.converted); + expect(converted.length).toBeGreaterThan(0); + for (const r of converted) { + expect(r.sourceValue).not.toBeNull(); + expect(r.sourceUnit).not.toBeNull(); + } + const one = await call("one", "GET", undefined, converted[0].id); + expect(one.json.data!.converted).toBe(true); }); - const id = created.json.data!.id; - expect(created.json.data!.converted).toBe(true); - - const edited = await call("one", "PUT", { value: 110 }, id); - expect(edited.status).toBe(200); - expect(edited.json.data!.value).toBe(110); - expect(edited.json.data!.converted).toBe(false); - expect(edited.json.data!.sourceValue).toBeNull(); - expect(edited.json.data!.sourceUnit).toBeNull(); - }); - - it("keeps the conversion record when only the note is edited", async () => { - const created = await call("labs", "POST", { - biomarkerId: "bm-glucose", - value: 6.1, - unit: "mmol/L", - takenAt: "2026-07-02T08:00:00.000Z", - }); - const id = created.json.data!.id; - const storedValue = created.json.data!.value; - // The web edit dialog sends the form back whole, the unchanged value - // included; only a changed value clears the record. - const edited = await call( - "one", - "PUT", - { - value: storedValue, - takenAt: "2026-07-02T09:00:00.000Z", - note: "fasting", - }, - id, - ); - expect(edited.status).toBe(200); - expect(edited.json.data!.value).toBe(storedValue); - expect(edited.json.data!.converted).toBe(true); - expect(edited.json.data!.sourceValue).toBe(6.1); - expect(edited.json.data!.sourceUnit).toBe("mmol/L"); - }); - - it("returns the fields on the list and on the single reading", async () => { - const list = await call<{ results: Dto[] }>("labs", "GET"); - const converted = list.json.data!.results.filter((r) => r.converted); - expect(converted.length).toBeGreaterThan(0); - for (const r of converted) { - expect(r.sourceValue).not.toBeNull(); - expect(r.sourceUnit).not.toBeNull(); - } - const one = await call("one", "GET", undefined, converted[0].id); - expect(one.json.data!.converted).toBe(true); }); }); diff --git a/tests/integration/pulse-day-mean-method.test.ts b/tests/integration/pulse-day-mean-method.test.ts index 974d8c397..30bab5bfb 100644 --- a/tests/integration/pulse-day-mean-method.test.ts +++ b/tests/integration/pulse-day-mean-method.test.ts @@ -211,27 +211,32 @@ async function readersOfD1(method: PulseDayMeanMethod) { }; } -describe("every reader follows the active method", () => { - it("weighs every hour alike under hour_mean_v1", async () => { - const seen = await readersOfD1("hour_mean_v1"); - for (const [reader, value] of Object.entries(seen)) { - expect(value, reader).toBeCloseTo(V1, 6); - } +// One instance through the cases in order: the readers, the dry run, the +// rebuild after apply (which reads the dry run's record) and the large +// account last, whose rows every earlier count leaves out. The order holds +// under a shuffled run. +describe("the pulse day-mean method, one instance", { shuffle: false }, () => { + describe("every reader follows the active method", () => { + it("weighs every hour alike under hour_mean_v1", async () => { + const seen = await readersOfD1("hour_mean_v1"); + for (const [reader, value] of Object.entries(seen)) { + expect(value, reader).toBeCloseTo(V1, 6); + } + }); + + it("weighs the lone reading's hour a third under weighted_hour_mean_v2", async () => { + const seen = await readersOfD1("weighted_hour_mean_v2"); + for (const [reader, value] of Object.entries(seen)) { + expect(value, reader).toBeCloseTo(V2, 6); + } + expect(Number(V2.toFixed(1))).toBe(60.9); + setActivePulseDayMeanMethod("hour_mean_v1"); + }); }); - it("weighs the lone reading's hour a third under weighted_hour_mean_v2", async () => { - const seen = await readersOfD1("weighted_hour_mean_v2"); - for (const [reader, value] of Object.entries(seen)) { - expect(value, reader).toBeCloseTo(V2, 6); - } - expect(Number(V2.toFixed(1))).toBe(60.9); - setActivePulseDayMeanMethod("hour_mean_v1"); - }); -}); - -/** Every table except the settings row, as one digest per table. */ -async function snapshotTables(): Promise> { - const tables = await prisma.$queryRaw>` + /** Every table except the settings row, as one digest per table. */ + async function snapshotTables(): Promise> { + const tables = await prisma.$queryRaw>` SELECT table_name AS name FROM information_schema.tables WHERE table_schema = 'public' @@ -239,147 +244,147 @@ async function snapshotTables(): Promise> { AND table_name NOT IN ('app_settings', '_prisma_migrations') ORDER BY table_name `; - const out: Record = {}; - for (const { name } of tables) { - if (!/^[a-z0-9_]+$/.test(name)) throw new Error(`table name ${name}`); - const [r] = await prisma.$queryRawUnsafe>( - `SELECT count(*)::int AS n, + const out: Record = {}; + for (const { name } of tables) { + if (!/^[a-z0-9_]+$/.test(name)) throw new Error(`table name ${name}`); + const [r] = await prisma.$queryRawUnsafe>( + `SELECT count(*)::int AS n, coalesce(md5(string_agg(t::text, '|' ORDER BY t::text)), '') AS d FROM "${name}" t`, - ); - out[name] = `${r.n}:${r.d}`; + ); + out[name] = `${r.n}:${r.d}`; + } + return out; } - return out; -} -/** The settings row without the dry-run column. */ -async function settingsWithoutDryRun() { - const [r] = await prisma.$queryRaw }>>` + /** The settings row without the dry-run column. */ + async function settingsWithoutDryRun() { + const [r] = await prisma.$queryRaw }>>` SELECT to_jsonb(s) - 'pulse_day_mean_dry_run_json' - 'updated_at' AS j FROM app_settings s WHERE s.id = 'singleton' `; - return r.j; -} + return r.j; + } -describe("the dry run", () => { - it("writes its result and nothing else", async () => { - const tablesBefore = await snapshotTables(); - const settingsBefore = await settingsWithoutDryRun(); - expect(tablesBefore.measurement_rollups).not.toMatch(/^0:/); + describe("the dry run", () => { + it("writes its result and nothing else", async () => { + const tablesBefore = await snapshotTables(); + const settingsBefore = await settingsWithoutDryRun(); + expect(tablesBefore.measurement_rollups).not.toMatch(/^0:/); - const outcome = await handlePulseDayMean(job("dry_run")); - expect(outcome.ok).toBe(true); + const outcome = await handlePulseDayMean(job("dry_run")); + expect(outcome.ok).toBe(true); - expect(await snapshotTables()).toEqual(tablesBefore); - expect(await settingsWithoutDryRun()).toEqual(settingsBefore); + expect(await snapshotTables()).toEqual(tablesBefore); + expect(await settingsWithoutDryRun()).toEqual(settingsBefore); - const stored = await prisma.appSettings.findUniqueOrThrow({ - where: { id: "singleton" }, - }); - const run = pulseDayMeanStoredRunsSchema.parse( - stored.pulseDayMeanDryRunJson, - ); - expect(run.status).toBe("done"); - expect(run.finishedAt).not.toBeNull(); - expect(run.result).toEqual({ - accounts: 2, - daysCompared: 3, - daysChangedOver1Bpm: 2, - maxChangeBpm: 10, - distribution: [ - { bucket: "0-1", days: 1 }, - { bucket: "1-2", days: 1 }, - { bucket: "2-5", days: 0 }, - { bucket: "5+", days: 1 }, - ], - perAccount: [ - { account: "#1", days: 2, changedOver1Bpm: 1 }, - { account: "#2", days: 1, changedOver1Bpm: 1 }, - ], - }); - // No account id leaves the job. - expect(JSON.stringify(stored.pulseDayMeanDryRunJson)).not.toContain(A); - expect(stored.pulseDayMeanMethod).toBe("hour_mean_v1"); - }); -}); - -describe("the rebuild after apply", () => { - const dayMean = async (userId: string, day: number) => - ( - await readCanonicalRollupBuckets({ - userId, - type: "PULSE", - granularity: "DAY", - from: new Date(day), - to: new Date(day), - toInclusive: true, - }) - )[0]?.mean; - - it("rewrites the PULSE rollups under the stored method", async () => { - expect(await dayMean(A, D1)).toBeCloseTo(V1, 6); - expect(await dayMean(B, D1)).toBeCloseTo(80, 6); - - await prisma.appSettings.update({ - where: { id: "singleton" }, - data: { pulseDayMeanMethod: "weighted_hour_mean_v2" }, - }); - const outcome = await handlePulseDayMean(job("rebuild")); - expect(outcome.ok).toBe(true); - // The rebuild is on the record beside the dry run, with what it did. - const { readPulseDayMeanStatus } = - await import("@/lib/jobs/pulse-day-mean"); - const status = await readPulseDayMeanStatus(); - expect(status.lastRebuild).toMatchObject({ status: "done" }); - expect(status.lastRebuild?.accounts).toBeGreaterThan(0); - expect(status.lastDryRun?.status).toBe("done"); - - expect(await dayMean(A, D1)).toBeCloseTo(V2, 6); - expect(await dayMean(A, D2)).toBeCloseTo(70, 6); - expect(await dayMean(B, D1)).toBeCloseTo(70, 6); - - await prisma.appSettings.update({ - where: { id: "singleton" }, - data: { pulseDayMeanMethod: "hour_mean_v1" }, + const stored = await prisma.appSettings.findUniqueOrThrow({ + where: { id: "singleton" }, + }); + const run = pulseDayMeanStoredRunsSchema.parse( + stored.pulseDayMeanDryRunJson, + ); + expect(run.status).toBe("done"); + expect(run.finishedAt).not.toBeNull(); + expect(run.result).toEqual({ + accounts: 2, + daysCompared: 3, + daysChangedOver1Bpm: 2, + maxChangeBpm: 10, + distribution: [ + { bucket: "0-1", days: 1 }, + { bucket: "1-2", days: 1 }, + { bucket: "2-5", days: 0 }, + { bucket: "5+", days: 1 }, + ], + perAccount: [ + { account: "#1", days: 2, changedOver1Bpm: 1 }, + { account: "#2", days: 1, changedOver1Bpm: 1 }, + ], + }); + // No account id leaves the job. + expect(JSON.stringify(stored.pulseDayMeanDryRunJson)).not.toContain(A); + expect(stored.pulseDayMeanMethod).toBe("hour_mean_v1"); }); }); -}); -describe("a large account under the production heap", () => { - const BIG = "pulse-method-big"; - const YEARS = 2; - /** The production container's heap ceiling. */ - const PROD_HEAP_MB = 524; - /** - * The seed's transactions run past Prisma's 5 s default under a loaded - * host: a month took at most 0.55 s on a quiet machine and 9.9 s at a load - * average near 95, which expired the transaction. The bound is the - * fixture's, not the code's under test. - */ - const SEED_TRANSACTION = { maxWait: 30_000, timeout: 120_000 }; + describe("the rebuild after apply", () => { + const dayMean = async (userId: string, day: number) => + ( + await readCanonicalRollupBuckets({ + userId, + type: "PULSE", + granularity: "DAY", + from: new Date(day), + to: new Date(day), + toInclusive: true, + }) + )[0]?.mean; - beforeAll(async () => { - await prisma.user.create({ - data: { id: BIG, username: BIG, timezone: "UTC" }, + it("rewrites the PULSE rollups under the stored method", async () => { + expect(await dayMean(A, D1)).toBeCloseTo(V1, 6); + expect(await dayMean(B, D1)).toBeCloseTo(80, 6); + + await prisma.appSettings.update({ + where: { id: "singleton" }, + data: { pulseDayMeanMethod: "weighted_hour_mean_v2" }, + }); + const outcome = await handlePulseDayMean(job("rebuild")); + expect(outcome.ok).toBe(true); + // The rebuild is on the record beside the dry run, with what it did. + const { readPulseDayMeanStatus } = + await import("@/lib/jobs/pulse-day-mean"); + const status = await readPulseDayMeanStatus(); + expect(status.lastRebuild).toMatchObject({ status: "done" }); + expect(status.lastRebuild?.accounts).toBeGreaterThan(0); + expect(status.lastDryRun?.status).toBe("done"); + + expect(await dayMean(A, D1)).toBeCloseTo(V2, 6); + expect(await dayMean(A, D2)).toBeCloseTo(70, 6); + expect(await dayMean(B, D1)).toBeCloseTo(70, 6); + + await prisma.appSettings.update({ + where: { id: "singleton" }, + data: { pulseDayMeanMethod: "hour_mean_v1" }, + }); }); - // One reading a minute for two years, about a million rows, and every - // seventh hour holding a single reading so the two methods disagree. - // - // Written a month at a time, each month in its own transaction with the - // statement timeout lifted for that transaction only and a transaction - // timeout sized for a loaded host (`SEED_TRANSACTION`): as one statement - // the million-row insert outran the timeout on CI's shared Postgres, and - // both timeouts guard the code under test, not this fixture. - const end = new Date(); - end.setUTCHours(0, 0, 0, 0); - const start = new Date(end.getTime() - YEARS * 365 * DAY); - const CHUNK = 30 * DAY; - for (let from = start.getTime(); from < end.getTime(); from += CHUNK) { - const chunkFrom = new Date(from); - const chunkTo = new Date(Math.min(from + CHUNK, end.getTime())); - await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SET LOCAL statement_timeout = 0`; - await tx.$executeRaw` + }); + + describe("a large account under the production heap", () => { + const BIG = "pulse-method-big"; + const YEARS = 2; + /** The production container's heap ceiling. */ + const PROD_HEAP_MB = 524; + /** + * The seed's transactions run past Prisma's 5 s default under a loaded + * host: a month took at most 0.55 s on a quiet machine and 9.9 s at a load + * average near 95, which expired the transaction. The bound is the + * fixture's, not the code's under test. + */ + const SEED_TRANSACTION = { maxWait: 30_000, timeout: 120_000 }; + + beforeAll(async () => { + await prisma.user.create({ + data: { id: BIG, username: BIG, timezone: "UTC" }, + }); + // One reading a minute for two years, about a million rows, and every + // seventh hour holding a single reading so the two methods disagree. + // + // Written a month at a time, each month in its own transaction with the + // statement timeout lifted for that transaction only and a transaction + // timeout sized for a loaded host (`SEED_TRANSACTION`): as one statement + // the million-row insert outran the timeout on CI's shared Postgres, and + // both timeouts guard the code under test, not this fixture. + const end = new Date(); + end.setUTCHours(0, 0, 0, 0); + const start = new Date(end.getTime() - YEARS * 365 * DAY); + const CHUNK = 30 * DAY; + for (let from = start.getTime(); from < end.getTime(); from += CHUNK) { + const chunkFrom = new Date(from); + const chunkTo = new Date(Math.min(from + CHUNK, end.getTime())); + await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SET LOCAL statement_timeout = 0`; + await tx.$executeRaw` INSERT INTO measurements (id, user_id, type, unit, source, value, measured_at, created_at, updated_at) SELECT 'big-' || (extract(epoch FROM ts)::bigint / 60)::text, ${BIG}, 'PULSE', 'bpm', 'APPLE_HEALTH', 60 + ((extract(epoch FROM ts)::bigint / 60) % 40), @@ -391,60 +396,63 @@ describe("a large account under the production heap", () => { ) AS s(ts) WHERE extract(hour FROM ts)::int % 7 <> 3 OR extract(minute FROM ts)::int = 0 `; + }, SEED_TRANSACTION); + } + await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SET LOCAL statement_timeout = 0`; + await tx.$executeRaw`ANALYZE measurements`; }, SEED_TRANSACTION); - } - await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SET LOCAL statement_timeout = 0`; - await tx.$executeRaw`ANALYZE measurements`; - }, SEED_TRANSACTION); - }, 600_000); + }, 600_000); - async function peakHeapMb(work: () => Promise) { - const base = process.memoryUsage().heapUsed; - let peak = base; - const timer = setInterval(() => { + async function peakHeapMb(work: () => Promise) { + const base = process.memoryUsage().heapUsed; + let peak = base; + const timer = setInterval(() => { + peak = Math.max(peak, process.memoryUsage().heapUsed); + }, 25); + try { + await work(); + } finally { + clearInterval(timer); + } peak = Math.max(peak, process.memoryUsage().heapUsed); - }, 25); - try { - await work(); - } finally { - clearInterval(timer); + return { + grewMb: (peak - base) / 1_048_576, + peakMb: peak / 1_048_576, + }; } - peak = Math.max(peak, process.memoryUsage().heapUsed); - return { - grewMb: (peak - base) / 1_048_576, - peakMb: peak / 1_048_576, - }; - } - it("stays flat through the dry run and the rebuild", async () => { - const rows = await prisma.measurement.count({ where: { userId: BIG } }); - expect(rows).toBeGreaterThan(900_000); + it("stays flat through the dry run and the rebuild", async () => { + const rows = await prisma.measurement.count({ where: { userId: BIG } }); + expect(rows).toBeGreaterThan(900_000); - const dry = await peakHeapMb(() => computePulseDayMeanDryRun()); - await prisma.appSettings.update({ - where: { id: "singleton" }, - data: { pulseDayMeanMethod: "weighted_hour_mean_v2" }, - }); - const rebuild = await peakHeapMb(() => handlePulseDayMean(job("rebuild"))); - process.stderr.write( - `[pulse-day-mean heap] rows=${rows} dryRun grew=${dry.grewMb.toFixed(1)} MB peak=${dry.peakMb.toFixed(1)} MB; rebuild grew=${rebuild.grewMb.toFixed(1)} MB peak=${rebuild.peakMb.toFixed(1)} MB\n`, - ); - // A window's aggregate rows, never the readings: a few MB at most. The - // bound leaves room for the test runner's own churn and is still a small - // slice of the production ceiling. - expect(dry.grewMb).toBeLessThan(64); - expect(rebuild.grewMb).toBeLessThan(64); - expect(dry.grewMb).toBeLessThan(PROD_HEAP_MB / 8); + const dry = await peakHeapMb(() => computePulseDayMeanDryRun()); + await prisma.appSettings.update({ + where: { id: "singleton" }, + data: { pulseDayMeanMethod: "weighted_hour_mean_v2" }, + }); + const rebuild = await peakHeapMb(() => + handlePulseDayMean(job("rebuild")), + ); + process.stderr.write( + `[pulse-day-mean heap] rows=${rows} dryRun grew=${dry.grewMb.toFixed(1)} MB peak=${dry.peakMb.toFixed(1)} MB; rebuild grew=${rebuild.grewMb.toFixed(1)} MB peak=${rebuild.peakMb.toFixed(1)} MB\n`, + ); + // A window's aggregate rows, never the readings: a few MB at most. The + // bound leaves room for the test runner's own churn and is still a small + // slice of the production ceiling. + expect(dry.grewMb).toBeLessThan(64); + expect(rebuild.grewMb).toBeLessThan(64); + expect(dry.grewMb).toBeLessThan(PROD_HEAP_MB / 8); - const days = await readCanonicalRollupBuckets({ - userId: BIG, - type: "PULSE", - granularity: "DAY", - from: new Date(Date.now() - 400 * DAY), - to: new Date(), - toInclusive: true, - }); - expect(days.length).toBeGreaterThan(300); - }, 900_000); + const days = await readCanonicalRollupBuckets({ + userId: BIG, + type: "PULSE", + granularity: "DAY", + from: new Date(Date.now() - 400 * DAY), + to: new Date(), + toInclusive: true, + }); + expect(days.length).toBeGreaterThan(300); + }, 900_000); + }); }); diff --git a/tests/integration/record-csv-import.test.ts b/tests/integration/record-csv-import.test.ts index de25d0251..74ed9dffb 100644 --- a/tests/integration/record-csv-import.test.ts +++ b/tests/integration/record-csv-import.test.ts @@ -93,7 +93,9 @@ beforeAll(async () => { }); }); -describe("lab CSV import", () => { +// One walk: a dry run, the import, the same file again. Each step reads what +// the one before it wrote, so the order holds under a shuffled run. +describe("lab CSV import", { shuffle: false }, () => { it("a dry run writes nothing and reports the plan", async () => { const result = await importLabsCsv({ userId: USER, @@ -192,6 +194,14 @@ describe("lab CSV import", () => { }); describe("lab CSV import in a zone of the file's own", () => { + // An account of its own, so its two draws are not in the other cases' counts. + const ZONE_USER = "csv-import-zone-user"; + beforeAll(async () => { + await prisma.user.create({ + data: { id: ZONE_USER, username: ZONE_USER, timezone: TZ }, + }); + }); + it("cuts the duplicate day in the zone the file is read in", async () => { // Read in New York, these are draws on two days (1 and 2 March). In the // account's Berlin they both fall on 2 March, which used to make the @@ -202,7 +212,7 @@ describe("lab CSV import in a zone of the file's own", () => { "Zone marker;7;mg/dL;2025-03-02T02:00", ].join("\n"); const result = await importLabsCsv({ - userId: USER, + userId: ZONE_USER, tz: TZ, stream: textToStream(csv), maxBytes: 1_000_000, @@ -384,6 +394,15 @@ describe("medication CSV import", () => { }); describe("medication CSV import, a crash between medication and category", () => { + // An account of its own: the medication import case counts every + // medication its account holds. + const CRASH_USER = "csv-import-crash-user"; + beforeAll(async () => { + await prisma.user.create({ + data: { id: CRASH_USER, username: CRASH_USER, timezone: TZ }, + }); + }); + const CSV = ["name,dose,times", "Atorvastatin,20 mg,20:00"].join("\n"); const query = medicationsCsvQuerySchema.parse({ name: "0", @@ -396,7 +415,7 @@ describe("medication CSV import, a crash between medication and category", () => categoryFault.on = true; await expect( importMedicationsCsv({ - userId: USER, + userId: CRASH_USER, tz: TZ, stream: textToStream(CSV), maxBytes: 100_000, @@ -406,12 +425,12 @@ describe("medication CSV import, a crash between medication and category", () => categoryFault.on = false; expect( await prisma.medication.count({ - where: { userId: USER, name: "Atorvastatin" }, + where: { userId: CRASH_USER, name: "Atorvastatin" }, }), ).toBe(0); const retry = await importMedicationsCsv({ - userId: USER, + userId: CRASH_USER, tz: TZ, stream: textToStream(CSV), maxBytes: 100_000, @@ -419,7 +438,7 @@ describe("medication CSV import, a crash between medication and category", () => }); expect(retry).toMatchObject({ inserted: 1, duplicates: 0 }); const med = await prisma.medication.findFirstOrThrow({ - where: { userId: USER, name: "Atorvastatin" }, + where: { userId: CRASH_USER, name: "Atorvastatin" }, select: { id: true }, }); expect( diff --git a/tests/integration/settings-round-trip.test.ts b/tests/integration/settings-round-trip.test.ts index 0045ba1a8..b961fbad2 100644 --- a/tests/integration/settings-round-trip.test.ts +++ b/tests/integration/settings-round-trip.test.ts @@ -996,182 +996,194 @@ describe("every setting a person can choose", () => { expect(new Set(listed).size).toBe(listed.length); }); - describe("set, read back, backed up, restored, read back again", () => { - let owner: User; - let session: SessionFixture; - const before = new Map(); - const meBefore = new Map(); - let stored: Map; - let defaults: Map; + // One trip, in the order of its name: each leg reads what the leg before it + // wrote. The order holds under a shuffled run. + describe( + "set, read back, backed up, restored, read back again", + { shuffle: false }, + () => { + let owner: User; + let session: SessionFixture; + const before = new Map(); + const meBefore = new Map(); + let stored: Map; + let defaults: Map; - beforeAll(async () => { - await truncateAllTables(prisma); - owner = await makeUser({ onboardingCompletedAt: null }); - session = await makeSession(owner); - defaults = columnsOf(await makeUser({ onboardingCompletedAt: null })); - }); + beforeAll(async () => { + await truncateAllTables(prisma); + owner = await makeUser({ onboardingCompletedAt: null }); + session = await makeSession(owner); + defaults = columnsOf(await makeUser({ onboardingCompletedAt: null })); + }); - it("stores each setting through its write route and answers it on its read route", async () => { - const failures: string[] = []; - for (const [index, entry] of entries(owner).entries()) { - const label = entry.columns.join(", "); - let written: RouteResult; - let read: RouteResult; - try { - written = await call(entry.write, session); - if (written.status >= 300) { - failures.push( - `${label}: write answered ${written.status} ${JSON.stringify(written.body).slice(0, 300)}`, - ); + it("stores each setting through its write route and answers it on its read route", async () => { + const failures: string[] = []; + for (const [index, entry] of entries(owner).entries()) { + const label = entry.columns.join(", "); + let written: RouteResult; + let read: RouteResult; + try { + written = await call(entry.write, session); + if (written.status >= 300) { + failures.push( + `${label}: write answered ${written.status} ${JSON.stringify(written.body).slice(0, 300)}`, + ); + continue; + } + read = await call(entry.read.call, session); + } catch (error) { + failures.push(`${label}: ${String(error).slice(0, 400)}`); continue; } - read = await call(entry.read.call, session); - } catch (error) { - failures.push(`${label}: ${String(error).slice(0, 400)}`); - continue; + if (read.status !== 200) { + failures.push(`${label}: read answered ${read.status}`); + continue; + } + const value = entry.read.pick(read); + before.set(index, value); + if ( + "expected" in entry && + !isDeepStrictEqual(value, entry.expected) + ) { + failures.push( + `${label}: read back ${JSON.stringify(value)}, wrote ${JSON.stringify(entry.expected)}`, + ); + } } - if (read.status !== 200) { - failures.push(`${label}: read answered ${read.status}`); - continue; + expect(failures).toEqual([]); + }); + + it("publishes the settings the account payload carries", async () => { + const me = await callRoute(getMe, { + method: "GET", + route: "/api/auth/me", + auth: session, + }); + expect(me.status).toBe(200); + const failures: string[] = []; + for (const [index, entry] of entries(owner).entries()) { + if (!entry.me) continue; + const value = entry.me.pick(me.data); + meBefore.set(index, value); + if ( + "expected" in entry.me && + !isDeepStrictEqual(value, entry.me.expected) + ) { + failures.push( + `${entry.columns.join(", ")}: /api/auth/me has ${JSON.stringify(value)}`, + ); + } } - const value = entry.read.pick(read); - before.set(index, value); - if ("expected" in entry && !isDeepStrictEqual(value, entry.expected)) { - failures.push( - `${label}: read back ${JSON.stringify(value)}, wrote ${JSON.stringify(entry.expected)}`, - ); - } - } - expect(failures).toEqual([]); - }); - - it("publishes the settings the account payload carries", async () => { - const me = await callRoute(getMe, { - method: "GET", - route: "/api/auth/me", - auth: session, + expect(failures).toEqual([]); }); - expect(me.status).toBe(200); - const failures: string[] = []; - for (const [index, entry] of entries(owner).entries()) { - if (!entry.me) continue; - const value = entry.me.pick(me.data); - meBefore.set(index, value); - if ( - "expected" in entry.me && - !isDeepStrictEqual(value, entry.me.expected) - ) { - failures.push( - `${entry.columns.join(", ")}: /api/auth/me has ${JSON.stringify(value)}`, - ); - } - } - expect(failures).toEqual([]); - }); - it("moves every setting column off its default", async () => { - stored = columnsOf( - await prisma.user.findUniqueOrThrow({ where: { id: owner.id } }), - ); - const untouched = SETTING_COLUMNS.filter( - (column) => - comparable(stored.get(column)) === comparable(defaults.get(column)), - ); - expect( - untouched, - "these columns still hold what a fresh account holds", - ).toEqual([]); - }); - - it("comes back from a backup restored into a recreated account", async () => { - const exported = await callRoute(exportFullBackup, { - method: "GET", - route: "/api/export/full-backup", - auth: session, - }); - expect(exported.status).toBe(200); - - // The account is recreated under the same id, the way an operator - // brings an account back on a new host, and nothing else of it remains. - await prisma.user.delete({ where: { id: owner.id } }); - const fresh = await makeUser({ - id: owner.id, - username: owner.username, - email: owner.email, - onboardingCompletedAt: null, - }); - const admin = await makeAdmin(); - const operator = await makeSession(admin); - - const uploaded = await callRoute<{ id: string }>(uploadBackup, { - method: "POST", - route: "/api/admin/backups/upload", - body: exported.body, - auth: operator, - }); - expect( - uploaded.status, - JSON.stringify(uploaded.body).slice(0, 400), - ).toBeLessThan(300); - const backupId = uploaded.data.id; - - const restored = await callRoute(restoreBackup, { - method: "POST", - route: "/api/admin/backups/{id}/restore", - params: { id: backupId }, - body: { confirm: "RESTORE" }, - auth: operator, - contract: { - skip: "the restore driver answers 200 with the job's report in place of the route's queued 202", - }, - }); - expect(restored.status, JSON.stringify(restored.body).slice(0, 400)).toBe( - 200, - ); - - const after = columnsOf( - await prisma.user.findUniqueOrThrow({ where: { id: fresh.id } }), - ); - const lostInStorage = SETTING_COLUMNS.filter( - (column) => - !SEALED.has(column) && - comparable(after.get(column)) !== comparable(stored.get(column)), - ); - expect( - lostInStorage.map( + it("moves every setting column off its default", async () => { + stored = columnsOf( + await prisma.user.findUniqueOrThrow({ where: { id: owner.id } }), + ); + const untouched = SETTING_COLUMNS.filter( (column) => - `${column}: ${comparable(stored.get(column))} -> ${comparable(after.get(column))}`, - ), - "these columns came back with a different stored value", - ).toEqual([]); - - const restoredSession = await makeSession(fresh); - const failures: string[] = []; - for (const [index, entry] of entries(fresh).entries()) { - const read = await call(entry.read.call, restoredSession); - const value = - read.status === 200 ? entry.read.pick(read) : `status ${read.status}`; - if (!isDeepStrictEqual(value, before.get(index))) { - failures.push( - `${entry.columns.join(", ")}: read ${JSON.stringify(before.get(index))} before, ${JSON.stringify(value)} after`, - ); - } - } - const me = await callRoute(getMe, { - method: "GET", - route: "/api/auth/me", - auth: restoredSession, + comparable(stored.get(column)) === comparable(defaults.get(column)), + ); + expect( + untouched, + "these columns still hold what a fresh account holds", + ).toEqual([]); }); - for (const [index, entry] of entries(fresh).entries()) { - if (!entry.me) continue; - const value = entry.me.pick(me.data); - if (!isDeepStrictEqual(value, meBefore.get(index))) { - failures.push( - `${entry.columns.join(", ")}: /api/auth/me had ${JSON.stringify(meBefore.get(index))}, now ${JSON.stringify(value)}`, - ); + + it("comes back from a backup restored into a recreated account", async () => { + const exported = await callRoute(exportFullBackup, { + method: "GET", + route: "/api/export/full-backup", + auth: session, + }); + expect(exported.status).toBe(200); + + // The account is recreated under the same id, the way an operator + // brings an account back on a new host, and nothing else of it remains. + await prisma.user.delete({ where: { id: owner.id } }); + const fresh = await makeUser({ + id: owner.id, + username: owner.username, + email: owner.email, + onboardingCompletedAt: null, + }); + const admin = await makeAdmin(); + const operator = await makeSession(admin); + + const uploaded = await callRoute<{ id: string }>(uploadBackup, { + method: "POST", + route: "/api/admin/backups/upload", + body: exported.body, + auth: operator, + }); + expect( + uploaded.status, + JSON.stringify(uploaded.body).slice(0, 400), + ).toBeLessThan(300); + const backupId = uploaded.data.id; + + const restored = await callRoute(restoreBackup, { + method: "POST", + route: "/api/admin/backups/{id}/restore", + params: { id: backupId }, + body: { confirm: "RESTORE" }, + auth: operator, + contract: { + skip: "the restore driver answers 200 with the job's report in place of the route's queued 202", + }, + }); + expect( + restored.status, + JSON.stringify(restored.body).slice(0, 400), + ).toBe(200); + + const after = columnsOf( + await prisma.user.findUniqueOrThrow({ where: { id: fresh.id } }), + ); + const lostInStorage = SETTING_COLUMNS.filter( + (column) => + !SEALED.has(column) && + comparable(after.get(column)) !== comparable(stored.get(column)), + ); + expect( + lostInStorage.map( + (column) => + `${column}: ${comparable(stored.get(column))} -> ${comparable(after.get(column))}`, + ), + "these columns came back with a different stored value", + ).toEqual([]); + + const restoredSession = await makeSession(fresh); + const failures: string[] = []; + for (const [index, entry] of entries(fresh).entries()) { + const read = await call(entry.read.call, restoredSession); + const value = + read.status === 200 + ? entry.read.pick(read) + : `status ${read.status}`; + if (!isDeepStrictEqual(value, before.get(index))) { + failures.push( + `${entry.columns.join(", ")}: read ${JSON.stringify(before.get(index))} before, ${JSON.stringify(value)} after`, + ); + } } - } - expect(failures).toEqual([]); - }); - }); + const me = await callRoute(getMe, { + method: "GET", + route: "/api/auth/me", + auth: restoredSession, + }); + for (const [index, entry] of entries(fresh).entries()) { + if (!entry.me) continue; + const value = entry.me.pick(me.data); + if (!isDeepStrictEqual(value, meBefore.get(index))) { + failures.push( + `${entry.columns.join(", ")}: /api/auth/me had ${JSON.stringify(meBefore.get(index))}, now ${JSON.stringify(value)}`, + ); + } + } + expect(failures).toEqual([]); + }); + }, + ); });