mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
test(integration): keep the stateful walks in order, give the rest their own rows
The migration before-and-after files, the dump directory, the geo backfill's three passes, the settings trip, the backup parity steps, the lab conversion record and the pulse method's dry run before its rebuild are each one sequence by design; they now say so and keep their order with shuffle: false. The CSV import's zone and crash cases wrote into the account the lab and medication walks count, and now use accounts of their own.
This commit is contained in:
1 parent
713806fe9b
commit
d7046c9737
9 files changed
+728
-662
No files matched your search
@@ -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 = "";
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Array<{ indexdef: string }>>`
|
||||
it("keeps the primary key on (user, type)", async () => {
|
||||
const pkey = await prisma.$queryRaw<Array<{ indexdef: string }>>`
|
||||
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();
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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"] } },
|
||||
|
||||
@@ -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<Dto>("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<Dto>("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<Dto>("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<Dto>("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<Dto>("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<Dto>("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<Dto>("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<Dto>("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<Dto>("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<Dto>(
|
||||
"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<Dto>("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<Dto>("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<Dto>("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<Dto>("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<Dto>(
|
||||
"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<Dto>("one", "GET", undefined, converted[0].id);
|
||||
expect(one.json.data!.converted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string>> {
|
||||
const tables = await prisma.$queryRaw<Array<{ name: string }>>`
|
||||
/** Every table except the settings row, as one digest per table. */
|
||||
async function snapshotTables(): Promise<Record<string, string>> {
|
||||
const tables = await prisma.$queryRaw<Array<{ name: string }>>`
|
||||
SELECT table_name AS name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
@@ -239,147 +244,147 @@ async function snapshotTables(): Promise<Record<string, string>> {
|
||||
AND table_name NOT IN ('app_settings', '_prisma_migrations')
|
||||
ORDER BY table_name
|
||||
`;
|
||||
const out: Record<string, string> = {};
|
||||
for (const { name } of tables) {
|
||||
if (!/^[a-z0-9_]+$/.test(name)) throw new Error(`table name ${name}`);
|
||||
const [r] = await prisma.$queryRawUnsafe<Array<{ n: number; d: string }>>(
|
||||
`SELECT count(*)::int AS n,
|
||||
const out: Record<string, string> = {};
|
||||
for (const { name } of tables) {
|
||||
if (!/^[a-z0-9_]+$/.test(name)) throw new Error(`table name ${name}`);
|
||||
const [r] = await prisma.$queryRawUnsafe<Array<{ n: number; d: string }>>(
|
||||
`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<Array<{ j: Record<string, unknown> }>>`
|
||||
/** The settings row without the dry-run column. */
|
||||
async function settingsWithoutDryRun() {
|
||||
const [r] = await prisma.$queryRaw<Array<{ j: Record<string, unknown> }>>`
|
||||
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<unknown>) {
|
||||
const base = process.memoryUsage().heapUsed;
|
||||
let peak = base;
|
||||
const timer = setInterval(() => {
|
||||
async function peakHeapMb(work: () => Promise<unknown>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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<number, unknown>();
|
||||
const meBefore = new Map<number, unknown>();
|
||||
let stored: Map<string, unknown>;
|
||||
let defaults: Map<string, unknown>;
|
||||
// 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<number, unknown>();
|
||||
const meBefore = new Map<number, unknown>();
|
||||
let stored: Map<string, unknown>;
|
||||
let defaults: Map<string, unknown>;
|
||||
|
||||
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([]);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user