mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
test(integration): stop cases reading what another case left behind
- the AI-state cases take their baseline in a world of their own instead of from the S0 case; - the live-window case drops a rollup tier the rollup case built; - the job readers start each case on an empty queue, and the no-schema case starts pg-boss again after it; - the server move case reseeds the server it emptied; - the extremes cases rebuild the tier one of them breaks on purpose; - the first Google Health backfill seeds its own history; - the batch budget warms the process caches as the reads do; - the HOT-update count is read as a difference: since Postgres 15 the transaction view also shows an earlier transaction's unflushed counts; - the MANAGE matrix checks its effect set after all cases rather than in a case of its own that a shuffle could run first.
This commit is contained in:
1 parent
d7046c9737
commit
2c33c2a0a0
9 files changed
+106
-31
No files matched your search
@@ -257,7 +257,20 @@ async function readAll(
|
||||
return out;
|
||||
}
|
||||
|
||||
let baseline: Record<string, { status: number; body: unknown }> | null = null;
|
||||
type Reads = Record<string, { status: number; body: unknown }>;
|
||||
let baseline: Reads | null = null;
|
||||
|
||||
/**
|
||||
* The all-on reads, taken once in a world of their own and then cleared, so
|
||||
* a state case compares against them whichever case runs first.
|
||||
*/
|
||||
async function readBaseline(): Promise<Reads> {
|
||||
if (baseline === null) {
|
||||
baseline = await readAll(await enterState("S0", seed), "S0");
|
||||
await resetWorld();
|
||||
}
|
||||
return baseline;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetWorld();
|
||||
@@ -265,8 +278,7 @@ beforeEach(async () => {
|
||||
|
||||
describe("data routes under every AI state", () => {
|
||||
it("S0 (all on) is the baseline, and every read answers", async () => {
|
||||
const world = await enterState("S0", seed);
|
||||
baseline = await readAll(world, "S0");
|
||||
const baseline = await readBaseline();
|
||||
for (const [name, { status }] of Object.entries(baseline)) {
|
||||
expect(status, name).toBe(200);
|
||||
}
|
||||
@@ -288,12 +300,12 @@ describe("data routes under every AI state", () => {
|
||||
it.each(["S1", "S2", "S3", "S4", "S5", "S6", "S8"] as const)(
|
||||
"%s answers every read with the baseline's data",
|
||||
async (state) => {
|
||||
expect(baseline, "the S0 baseline runs first").not.toBeNull();
|
||||
const base = await readBaseline();
|
||||
const world = await enterState(state, seed);
|
||||
const reads = await readAll(world, state);
|
||||
for (const [name, { status, body }] of Object.entries(reads)) {
|
||||
expect(status, `${state} ${name}`).toBe(200);
|
||||
expect(body, `${state} ${name}`).toEqual(baseline![name].body);
|
||||
expect(body, `${state} ${name}`).toEqual(base[name].body);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* Means are compared to nine significant digits: the tier sums the same day
|
||||
* values in a different order. Extremes are compared exactly.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { Prisma, type MeasurementType } from "@/generated/prisma/client";
|
||||
@@ -246,6 +246,12 @@ beforeAll(async () => {
|
||||
}, 300_000);
|
||||
|
||||
describe("readAllTimeExtremes over the DAY tier", () => {
|
||||
// A clean tier over whatever rows are left for every case: some cases
|
||||
// break a tier row on purpose, and the next case must not read it.
|
||||
beforeEach(async () => {
|
||||
await recomputeUserRollups(USER, { granularities: ["DAY"] });
|
||||
}, 120_000);
|
||||
|
||||
it("reads the tier for every type", async () => {
|
||||
const covered = await prisma.measurementRollup.groupBy({
|
||||
by: ["type"],
|
||||
|
||||
@@ -58,7 +58,7 @@ import v8 from "node:v8";
|
||||
import vm from "node:vm";
|
||||
|
||||
import type { Job } from "pg-boss";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { WideEvent } from "@/lib/logging/types";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
@@ -324,10 +324,6 @@ async function seed(points: number): Promise<void> {
|
||||
fake.upperBoundRequests = 0;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await seed(POINTS);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
@@ -357,6 +353,9 @@ function nextSlice(): AdmissionPayload | null {
|
||||
|
||||
describe(`Google Health backfill over ${DAYS} days of heart rate (#1220)`, () => {
|
||||
it("completes slice by slice, imports every reading once, and keeps the hourly sync running", async () => {
|
||||
// Its own history, as the case below seeds its own: neither reads what
|
||||
// the other left behind, whichever runs first.
|
||||
await seed(POINTS);
|
||||
const { drainIntegrationBackfillAdmission } =
|
||||
await import("@/lib/jobs/integration-backfill-drain");
|
||||
const { handleGoogleHealthSync } =
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* are failed through its own API.
|
||||
*/
|
||||
import { PgBoss } from "pg-boss";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
readActiveJobsStartedBefore,
|
||||
@@ -47,7 +47,7 @@ async function failOneJob(
|
||||
return jobId;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
async function startBoss(): Promise<void> {
|
||||
boss = new PgBoss({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
// No maintenance or monitoring timers — the test drives every transition.
|
||||
@@ -57,7 +57,15 @@ beforeAll(async () => {
|
||||
await boss.start();
|
||||
await boss.createQueue(QUEUE);
|
||||
await boss.createQueue(OTHER_QUEUE);
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
beforeAll(startBoss, 60_000);
|
||||
|
||||
// Every case reads the queue it filled itself: no job a case before it sent,
|
||||
// failed or left active is still there to be counted or fetched.
|
||||
beforeEach(async () => {
|
||||
await getPrismaClient().$executeRawUnsafe(`DELETE FROM pgboss.job`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await boss?.stop({ graceful: false, close: true });
|
||||
@@ -244,6 +252,8 @@ describe("no queue schema at all", () => {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER SCHEMA pgboss_gone RENAME TO pgboss`,
|
||||
);
|
||||
// Running again for whichever case comes next.
|
||||
await startBoss();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -138,11 +138,14 @@ function byInstant(at: string) {
|
||||
String(a.type ?? "").localeCompare(String(b.type ?? ""));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
/** This server: the seeded catalogue and the owner whose files are read. */
|
||||
async function seedServer(): Promise<void> {
|
||||
await truncateAllTables(prisma);
|
||||
await seedJsonImportCatalogue(prisma);
|
||||
({ ownerId } = await seedJsonImportOwner(prisma));
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(seedServer);
|
||||
|
||||
afterAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
@@ -350,6 +353,10 @@ describe("the JSON import takes the app's own files back", () => {
|
||||
});
|
||||
|
||||
describe("a full backup moved to another server", () => {
|
||||
// The case empties the database to stand in for another server. Put this
|
||||
// server back for the cases that run after it.
|
||||
afterAll(seedServer);
|
||||
|
||||
it("comes back equal, the account's own tags, groups and rated factor included", async () => {
|
||||
const original = await fullBackupOf(ownerId);
|
||||
expect(original.customMoodTags.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
@@ -55,18 +55,27 @@ describe("an update that changes nothing", () => {
|
||||
const row = await seedReading();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
// Read as a difference within the transaction. Since Postgres 15 the
|
||||
// `pg_stat_xact_*` views also carry counts an earlier transaction on the
|
||||
// same connection has not flushed yet (stats flush at most once a
|
||||
// second), so an update from the case before this one could show up.
|
||||
const hot = await prisma.$transaction(async (tx) => {
|
||||
const read = async () => {
|
||||
const [stats] = await tx.$queryRaw<Array<{ hot: bigint; upd: bigint }>>`
|
||||
SELECT n_tup_hot_upd AS hot, n_tup_upd AS upd
|
||||
FROM pg_stat_xact_user_tables WHERE relname = 'measurements'`;
|
||||
return { hot: Number(stats?.hot ?? 0), upd: Number(stats?.upd ?? 0) };
|
||||
};
|
||||
const before = await read();
|
||||
await tx.measurement.update({
|
||||
where: { id: row.id },
|
||||
data: { value: 72.3, deletedAt: null, syncVersion: { increment: 1 } },
|
||||
});
|
||||
const [stats] = await tx.$queryRaw<Array<{ hot: bigint; upd: bigint }>>`
|
||||
SELECT n_tup_hot_upd AS hot, n_tup_upd AS upd
|
||||
FROM pg_stat_xact_user_tables WHERE relname = 'measurements'`;
|
||||
return stats;
|
||||
const after = await read();
|
||||
return { hot: after.hot - before.hot, upd: after.upd - before.upd };
|
||||
});
|
||||
expect(Number(hot.upd)).toBe(1);
|
||||
expect(Number(hot.hot)).toBe(1);
|
||||
expect(hot.upd).toBe(1);
|
||||
expect(hot.hot).toBe(1);
|
||||
|
||||
const after = await prisma.measurement.findUniqueOrThrow({
|
||||
where: { id: row.id },
|
||||
|
||||
@@ -428,15 +428,35 @@ describe("statement budgets on the hot routes (real Postgres)", () => {
|
||||
externalId: `budget-hr-${i}`,
|
||||
};
|
||||
});
|
||||
const m = await countStatements(async () => {
|
||||
const res = await (POST as unknown as Handler)(
|
||||
const post = (body: unknown) =>
|
||||
(POST as unknown as Handler)(
|
||||
new NextRequest("http://localhost/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ entries }),
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
);
|
||||
// One reading a month earlier first, as the reads warm up: it fills the
|
||||
// process-level caches a running server holds, which this case would
|
||||
// otherwise pay for whenever it runs before the reads do.
|
||||
const warmAt = daysAgo(30).toISOString();
|
||||
const warm = await post({
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
|
||||
value: 60,
|
||||
unit: "count/min",
|
||||
startDate: warmAt,
|
||||
endDate: warmAt,
|
||||
externalId: "budget-hr-warm",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(warm.status, "batch warm-up").toBe(200);
|
||||
await evictUserCaches();
|
||||
const m = await countStatements(async () => {
|
||||
const res = await post({ entries });
|
||||
return {
|
||||
status: res.status,
|
||||
body: (await res.json()) as { data: { inserted: number } },
|
||||
|
||||
@@ -157,6 +157,9 @@ export function defineRequestClockCases(expectedZone: string): void {
|
||||
});
|
||||
|
||||
it("cuts every window of the live aggregate on the request's clock", async () => {
|
||||
// The live path is the one without a tier; drop whatever the rollup
|
||||
// case built if it ran first.
|
||||
await prisma.measurementRollup.deleteMany({ where: { userId } });
|
||||
await expectWindows("live");
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
* the generation leg asserts both halves — nothing enqueued for the manager,
|
||||
* something enqueued for the owner doing the identical thing.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -276,6 +276,8 @@ const STRICT_DRIVER_KEYS = new Set<string>();
|
||||
* It is the executable answer to "did this route actually do its work?".
|
||||
*/
|
||||
const REAL_EFFECT_KEYS = new Set<string>();
|
||||
/** The drivers whose effect case started in this run, filtered or not. */
|
||||
const EFFECT_CASES_STARTED = new Set<string>();
|
||||
const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
|
||||
"/src/app/api/**/route.ts",
|
||||
);
|
||||
@@ -309,6 +311,7 @@ function manageContract<C>(name: string, c: ManageCase<C>) {
|
||||
STRICT_DRIVER_KEYS.add(matrixKey(c.contract));
|
||||
describe(name, () => {
|
||||
it("changes the owner's record and names the manager as actor", async () => {
|
||||
EFFECT_CASES_STARTED.add(matrixKey(c.contract));
|
||||
const owner = await makeUser("owner");
|
||||
const manager = await makeUser("manager");
|
||||
const ctx = await c.prepare(owner.id);
|
||||
@@ -385,6 +388,7 @@ function writeEffectContract<C>(name: string, c: ManageCase<C>) {
|
||||
});
|
||||
|
||||
it("writes for the owner and audits the WRITE actor", async () => {
|
||||
EFFECT_CASES_STARTED.add(matrixKey(c.contract));
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("writer");
|
||||
const ctx = await c.prepare(owner.id);
|
||||
@@ -3023,10 +3027,15 @@ describe("the complete MANAGE handler matrix", () => {
|
||||
expect(STRICT_DRIVER_KEYS.size).toBe(93);
|
||||
expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("executes every registered driver through its owned effect and actor audit", () => {
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(93);
|
||||
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
// Every registered driver went through its owned effect and actor audit.
|
||||
// After all cases, not as one of them: the set fills as the effect cases
|
||||
// run, and a case placed last in the file was read first whenever the order
|
||||
// was shuffled. A run filtered down to some cases has nothing to say here.
|
||||
afterAll(() => {
|
||||
if (EFFECT_CASES_STARTED.size !== STRICT_DRIVER_KEYS.size) return;
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(93);
|
||||
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
Reference in new issue
Block a user