Files
HealthLog/tests/integration/arrival-reaction-claim.test.ts
T
Marc-André Bombeck 62eb431d66 release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0

* test: stabilize water capture re-entry
2026-07-22 04:14:15 +02:00

350 lines
11 KiB
TypeScript

/**
* The `ArrivalReaction` claim, against real Postgres.
*
* Unit mocks cannot prove any of this: the once-per-kind-per-day contract is a
* unique INDEX, the claim is an ON CONFLICT DO NOTHING whose semantics live in
* the database, and the losing side of a concurrent claim is a race only a real
* connection pool can produce. A mocked `createMany` returning `{count: 1}`
* would happily report a successful claim for every caller.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
async function createUser(username: string) {
return getPrismaClient().user.create({
data: { username, email: `${username}@example.test`, role: "USER" },
});
}
const LOCAL_DATE = "2026-07-14";
function marker(userId: string, kind: string, occurredAt: Date) {
return {
userId,
kind,
localDate: LOCAL_DATE,
occurredAt,
refId: null,
};
}
describe("ArrivalReaction — the day claim", () => {
it("admits the first claim and refuses the second for the same kind and day", async () => {
const prisma = getPrismaClient();
const user = await createUser("arrival-claim");
const first = await prisma.arrivalReaction.createMany({
data: [marker(user.id, "weight", new Date("2026-07-14T06:00:00Z"))],
skipDuplicates: true,
});
const second = await prisma.arrivalReaction.createMany({
data: [marker(user.id, "weight", new Date("2026-07-14T18:00:00Z"))],
skipDuplicates: true,
});
expect(first.count).toBe(1);
// The second weigh-in of the day does not get its own line. This IS the
// throttle — there is no code path to a second generation.
expect(second.count).toBe(0);
expect(await prisma.arrivalReaction.count()).toBe(1);
});
it("keeps kinds independent within one day", async () => {
const prisma = getPrismaClient();
const user = await createUser("arrival-kinds");
const at = new Date("2026-07-14T06:00:00Z");
for (const kind of ["weight", "sleep_night", "blood_pressure"]) {
const res = await prisma.arrivalReaction.createMany({
data: [marker(user.id, kind, at)],
skipDuplicates: true,
});
expect(res.count).toBe(1);
}
expect(await prisma.arrivalReaction.count()).toBe(3);
});
it("keeps users independent — one user's claim never blocks another's", async () => {
const prisma = getPrismaClient();
const a = await createUser("arrival-tenant-a");
const b = await createUser("arrival-tenant-b");
const at = new Date("2026-07-14T06:00:00Z");
const first = await prisma.arrivalReaction.createMany({
data: [marker(a.id, "weight", at)],
skipDuplicates: true,
});
const second = await prisma.arrivalReaction.createMany({
data: [marker(b.id, "weight", at)],
skipDuplicates: true,
});
expect(first.count).toBe(1);
expect(second.count).toBe(1);
});
it("exactly one of many concurrent claims wins", async () => {
const prisma = getPrismaClient();
const user = await createUser("arrival-race");
const at = new Date("2026-07-14T06:00:00Z");
// Ten seams firing at once, the shape a device batch storm takes.
const results = await Promise.all(
Array.from({ length: 10 }, () =>
prisma.arrivalReaction.createMany({
data: [marker(user.id, "workout", at)],
skipDuplicates: true,
}),
),
);
const winners = results.filter((r) => r.count === 1);
expect(winners).toHaveLength(1);
expect(await prisma.arrivalReaction.count()).toBe(1);
});
it("the marker moves forward on a later arrival and never backwards", async () => {
const prisma = getPrismaClient();
const user = await createUser("arrival-forward");
const early = new Date("2026-07-14T06:00:00Z");
const late = new Date("2026-07-14T18:00:00Z");
await prisma.arrivalReaction.createMany({
data: [marker(user.id, "weight", early)],
skipDuplicates: true,
});
const forward = await prisma.arrivalReaction.updateMany({
where: {
userId: user.id,
kind: "weight",
localDate: LOCAL_DATE,
occurredAt: { lt: late },
},
data: { occurredAt: late },
});
expect(forward.count).toBe(1);
const backward = await prisma.arrivalReaction.updateMany({
where: {
userId: user.id,
kind: "weight",
localDate: LOCAL_DATE,
occurredAt: { lt: early },
},
data: { occurredAt: early },
});
expect(backward.count).toBe(0);
const row = await prisma.arrivalReaction.findFirst({
where: { userId: user.id },
});
expect(row?.occurredAt.toISOString()).toBe(late.toISOString());
});
it("replaces a generated marker and rejects the stale generation owner", async () => {
const prisma = getPrismaClient();
const user = await createUser("arrival-replacement");
const early = new Date("2026-07-14T06:00:00Z");
const late = new Date("2026-07-14T18:00:00Z");
const providerInvokedAt = new Date("2026-07-14T06:01:00Z");
const existing = await prisma.arrivalReaction.create({
data: {
...marker(user.id, "weight", early),
refId: "old-ref",
lineEncrypted: Buffer.from("old-ciphertext"),
generatedAt: new Date("2026-07-14T06:02:00Z"),
generationClaimId: "old-claim",
generationClaimedAt: providerInvokedAt,
generationReservedTokens: 220,
generationBudgetDateKey: "2026-07-14",
generationProviderInvokedAt: providerInvokedAt,
},
});
await prisma.coachUsage.create({
data: {
userId: user.id,
dateKey: "2026-07-14",
totalTokens: 220,
messageCount: 1,
},
});
const { runDataArrival } = await import("@/lib/jobs/data-arrival");
const outcome = await runDataArrival(prisma as never, {
userId: user.id,
kind: "weight",
salience: "salient",
localDate: LOCAL_DATE,
occurredAt: late.toISOString(),
count: 1,
source: "withings",
refId: "new-ref",
});
expect(outcome).toMatchObject({ status: "processed", dedup: false });
const replaced = await prisma.arrivalReaction.findUniqueOrThrow({
where: { id: existing.id },
});
expect(replaced).toMatchObject({
occurredAt: late,
refId: "new-ref",
lineEncrypted: null,
generatedAt: null,
generationClaimId: null,
generationClaimedAt: null,
generationReservedTokens: null,
generationBudgetDateKey: null,
generationProviderInvokedAt: null,
});
const staleCommit = await prisma.arrivalReaction.updateMany({
where: {
id: existing.id,
generationClaimId: "old-claim",
generationProviderInvokedAt: providerInvokedAt,
},
data: {
lineEncrypted: Buffer.from("stale-ciphertext"),
generatedAt: new Date(),
},
});
expect(staleCommit.count).toBe(0);
const usage = await prisma.coachUsage.findUniqueOrThrow({
where: {
userId_dateKey: { userId: user.id, dateKey: "2026-07-14" },
},
});
expect(usage).toMatchObject({ totalTokens: 220, messageCount: 1 });
});
it("refunds a superseded pre-provider reservation", async () => {
const prisma = getPrismaClient();
const user = await createUser("arrival-reservation-refund");
const early = new Date("2026-07-14T06:00:00Z");
const late = new Date("2026-07-14T18:00:00Z");
await prisma.arrivalReaction.create({
data: {
...marker(user.id, "weight", early),
generationClaimId: "pre-provider-claim",
generationClaimedAt: new Date("2026-07-14T06:01:00Z"),
generationReservedTokens: 220,
generationBudgetDateKey: "2026-07-14",
},
});
await prisma.coachUsage.create({
data: {
userId: user.id,
dateKey: "2026-07-14",
totalTokens: 500,
messageCount: 2,
},
});
const { runDataArrival } = await import("@/lib/jobs/data-arrival");
await runDataArrival(prisma as never, {
userId: user.id,
kind: "weight",
salience: "salient",
localDate: LOCAL_DATE,
occurredAt: late.toISOString(),
count: 1,
source: "withings",
});
const usage = await prisma.coachUsage.findUniqueOrThrow({
where: {
userId_dateKey: { userId: user.id, dateKey: "2026-07-14" },
},
});
expect(usage).toMatchObject({ totalTokens: 280, messageCount: 1 });
});
it("stores the line as encrypted bytes, and tolerates its absence", async () => {
const prisma = getPrismaClient();
const user = await createUser("arrival-crypto");
const { encryptToBytes, decryptFromBytes } =
await import("@/lib/ai/coach/bytes-codec");
await prisma.arrivalReaction.createMany({
data: [marker(user.id, "weight", new Date("2026-07-14T06:00:00Z"))],
skipDuplicates: true,
});
// A provider-less install leaves the column null — the marker is the
// feature, the sentence is garnish.
const before = await prisma.arrivalReaction.findFirst({
where: { userId: user.id },
});
expect(before?.lineEncrypted).toBeNull();
expect(before?.generatedAt).toBeNull();
const line = "Last night is in — a steady base for the day.";
await prisma.arrivalReaction.update({
where: { id: before!.id },
data: { lineEncrypted: encryptToBytes(line), generatedAt: new Date() },
});
const after = await prisma.arrivalReaction.findFirst({
where: { userId: user.id },
});
expect(after?.lineEncrypted).not.toBeNull();
// Ciphertext at rest — the plaintext must not be readable off the column.
expect(Buffer.from(after!.lineEncrypted!).toString("utf8")).not.toContain(
"steady base",
);
expect(decryptFromBytes(after!.lineEncrypted!)).toBe(line);
});
it("cascades on user delete, leaving no orphan markers", async () => {
const prisma = getPrismaClient();
const user = await createUser("arrival-cascade");
await prisma.arrivalReaction.createMany({
data: [marker(user.id, "weight", new Date("2026-07-14T06:00:00Z"))],
skipDuplicates: true,
});
await prisma.user.delete({ where: { id: user.id } });
expect(await prisma.arrivalReaction.count()).toBe(0);
});
it("the retention window deletes only rows past it", async () => {
const prisma = getPrismaClient();
const user = await createUser("arrival-retention");
const now = new Date();
const old = new Date(now.getTime() - 20 * 86_400_000);
const recent = new Date(now.getTime() - 3 * 86_400_000);
await prisma.arrivalReaction.create({
data: {
...marker(user.id, "weight", now),
localDate: "2026-06-24",
createdAt: old,
},
});
await prisma.arrivalReaction.create({
data: {
...marker(user.id, "sleep_night", now),
localDate: "2026-07-11",
createdAt: recent,
},
});
const cutoff = new Date();
cutoff.setUTCDate(cutoff.getUTCDate() - 14);
const deleted = await prisma.arrivalReaction.deleteMany({
where: { createdAt: { lt: cutoff } },
});
expect(deleted.count).toBe(1);
const survivor = await prisma.arrivalReaction.findFirst();
expect(survivor?.kind).toBe("sleep_night");
});
});