mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The insight claim row is now the daily-cap ledger. Its workout_id is nullable with ON DELETE SET NULL (migration 0367), so deleting a workout no longer frees the day's slot. The cap counts claims that reached the provider plus live leases, and drops the separate count of insight rows. A daily workout-insight-claim-cleanup queue removes claim rows untouched for seven days.
402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
/**
|
|
* The `WorkoutInsight` row's structural claims, against real Postgres.
|
|
*
|
|
* Unit mocks cannot prove any of this, and each one is load-bearing:
|
|
*
|
|
* - **One paragraph per workout** is a unique INDEX. A mocked `upsert` will
|
|
* happily accept a second row for the same workout; the database is what
|
|
* actually refuses, and it is the half of the double-post defence that
|
|
* holds when the singleton queue key does not.
|
|
* - **The cascade** is an FK constraint. Workout deletes are HARD deletes, so
|
|
* a paragraph that outlived its workout would be an orphaned description of
|
|
* a session the user believes they erased. That is a data-retention claim,
|
|
* not a tidiness one, and only the database enforces it.
|
|
* - **The daily-cap count** reads the claim ledger, which outlives a deleted
|
|
* workout (`ON DELETE SET NULL`). Whether a delete frees a slot is an FK
|
|
* fact, so only the database can prove it does not.
|
|
*
|
|
* Every test writes through Prisma the way the worker does, so a schema change
|
|
* that dropped a constraint fails here rather than in production.
|
|
*/
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { claimWorkoutInsightGeneration } from "@/lib/jobs/workout-insight-generate";
|
|
import { cleanupOldWorkoutInsightClaims } from "@/lib/jobs/workout-insight-claim-cleanup";
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
});
|
|
|
|
async function createUser(username: string) {
|
|
return getPrismaClient().user.create({
|
|
data: { username, email: `${username}@example.test`, role: "USER" },
|
|
});
|
|
}
|
|
|
|
async function createWorkout(
|
|
userId: string,
|
|
startedAt: Date,
|
|
externalId: string,
|
|
) {
|
|
return getPrismaClient().workout.create({
|
|
data: {
|
|
userId,
|
|
sportType: "cycling",
|
|
startedAt,
|
|
endedAt: new Date(startedAt.getTime() + 45 * 60_000),
|
|
durationSec: 2700,
|
|
source: "APPLE_HEALTH",
|
|
externalId,
|
|
},
|
|
});
|
|
}
|
|
|
|
function insightData(userId: string, workoutId: string, generatedAt: Date) {
|
|
return {
|
|
userId,
|
|
workoutId,
|
|
paragraphEncrypted: Buffer.from("ciphertext-stand-in", "utf8"),
|
|
inputHash: "hash-1",
|
|
promptVersion: "1.0.0",
|
|
providerType: "local",
|
|
locale: "en",
|
|
generatedAt,
|
|
};
|
|
}
|
|
|
|
describe("WorkoutInsight — one paragraph per workout", () => {
|
|
it("refuses a second row for the same workout", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("wi-unique");
|
|
const workout = await createWorkout(
|
|
user.id,
|
|
new Date("2026-07-18T06:00:00Z"),
|
|
"ext-1",
|
|
);
|
|
|
|
await prisma.workoutInsight.create({
|
|
data: insightData(user.id, workout.id, new Date("2026-07-18T06:50:00Z")),
|
|
});
|
|
|
|
// The durable half of the double-post defence. A watch that posted the same
|
|
// session twice and somehow cleared the queue's singleton key still cannot
|
|
// buy a second paragraph.
|
|
await expect(
|
|
prisma.workoutInsight.create({
|
|
data: {
|
|
...insightData(user.id, workout.id, new Date("2026-07-18T07:00:00Z")),
|
|
inputHash: "hash-2",
|
|
},
|
|
}),
|
|
).rejects.toThrow();
|
|
|
|
expect(await prisma.workoutInsight.count()).toBe(1);
|
|
});
|
|
|
|
it("upserts in place rather than creating a twin", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("wi-upsert");
|
|
const workout = await createWorkout(
|
|
user.id,
|
|
new Date("2026-07-18T06:00:00Z"),
|
|
"ext-1",
|
|
);
|
|
const data = insightData(
|
|
user.id,
|
|
workout.id,
|
|
new Date("2026-07-18T06:50:00Z"),
|
|
);
|
|
|
|
await prisma.workoutInsight.upsert({
|
|
where: { workoutId: workout.id },
|
|
create: data,
|
|
update: data,
|
|
});
|
|
await prisma.workoutInsight.upsert({
|
|
where: { workoutId: workout.id },
|
|
create: { ...data, inputHash: "hash-2" },
|
|
update: { ...data, inputHash: "hash-2" },
|
|
});
|
|
|
|
expect(await prisma.workoutInsight.count()).toBe(1);
|
|
const row = await prisma.workoutInsight.findUniqueOrThrow({
|
|
where: { workoutId: workout.id },
|
|
});
|
|
expect(row.inputHash).toBe("hash-2");
|
|
});
|
|
|
|
it("keeps two workouts on the same day independent", async () => {
|
|
// Two sessions in one day are two events and both deserve a paragraph —
|
|
// unlike the day-scoped arrival marker, which can only be claimed once.
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("wi-two");
|
|
const morning = await createWorkout(
|
|
user.id,
|
|
new Date("2026-07-18T06:00:00Z"),
|
|
"ext-am",
|
|
);
|
|
const evening = await createWorkout(
|
|
user.id,
|
|
new Date("2026-07-18T18:00:00Z"),
|
|
"ext-pm",
|
|
);
|
|
|
|
await prisma.workoutInsight.create({
|
|
data: insightData(user.id, morning.id, new Date("2026-07-18T06:50:00Z")),
|
|
});
|
|
await prisma.workoutInsight.create({
|
|
data: insightData(user.id, evening.id, new Date("2026-07-18T18:50:00Z")),
|
|
});
|
|
|
|
expect(await prisma.workoutInsight.count()).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("WorkoutInsight — cascade", () => {
|
|
it("disappears with its workout", async () => {
|
|
// Workout deletes are HARD deletes. A paragraph describing a session the
|
|
// user erased must not survive it.
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("wi-cascade");
|
|
const workout = await createWorkout(
|
|
user.id,
|
|
new Date("2026-07-18T06:00:00Z"),
|
|
"ext-1",
|
|
);
|
|
await prisma.workoutInsight.create({
|
|
data: insightData(user.id, workout.id, new Date("2026-07-18T06:50:00Z")),
|
|
});
|
|
|
|
await prisma.workout.delete({ where: { id: workout.id } });
|
|
|
|
expect(await prisma.workoutInsight.count()).toBe(0);
|
|
});
|
|
|
|
it("disappears with its user", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("wi-user-cascade");
|
|
const workout = await createWorkout(
|
|
user.id,
|
|
new Date("2026-07-18T06:00:00Z"),
|
|
"ext-1",
|
|
);
|
|
await prisma.workoutInsight.create({
|
|
data: insightData(user.id, workout.id, new Date("2026-07-18T06:50:00Z")),
|
|
});
|
|
|
|
await prisma.user.delete({ where: { id: user.id } });
|
|
|
|
expect(await prisma.workoutInsight.count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("WorkoutInsightGenerationClaim — concurrent ownership and cap", () => {
|
|
const now = new Date("2026-07-18T15:00:00.000Z");
|
|
const localDate = "2026-07-18";
|
|
|
|
it("atomically grants at most four different workouts for one user-local day", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("wi-claim-cap");
|
|
const workouts = await Promise.all(
|
|
Array.from({ length: 8 }, (_, index) =>
|
|
createWorkout(
|
|
user.id,
|
|
new Date(now.getTime() - (index + 1) * 3_600_000),
|
|
`ext-concurrent-${index}`,
|
|
),
|
|
),
|
|
);
|
|
|
|
const outcomes = await Promise.all(
|
|
workouts.map((workout) =>
|
|
claimWorkoutInsightGeneration({
|
|
userId: user.id,
|
|
workoutId: workout.id,
|
|
localDate,
|
|
now,
|
|
}),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
outcomes.filter((outcome) => outcome.status === "claimed"),
|
|
).toHaveLength(4);
|
|
expect(
|
|
outcomes.filter(
|
|
(outcome) =>
|
|
outcome.status === "skipped" && outcome.reason === "daily_cap",
|
|
),
|
|
).toHaveLength(4);
|
|
expect(
|
|
await prisma.workoutInsightGenerationClaim.count({
|
|
where: { userId: user.id, localDate },
|
|
}),
|
|
).toBe(4);
|
|
});
|
|
|
|
it("reclaims a stale pre-provider claim without creating a second row", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("wi-stale-claim");
|
|
const workout = await createWorkout(
|
|
user.id,
|
|
new Date("2026-07-18T13:00:00.000Z"),
|
|
"ext-stale",
|
|
);
|
|
await prisma.workoutInsightGenerationClaim.create({
|
|
data: {
|
|
userId: user.id,
|
|
workoutId: workout.id,
|
|
localDate,
|
|
claimId: "dead-worker",
|
|
claimedAt: new Date("2026-07-18T14:00:00.000Z"),
|
|
},
|
|
});
|
|
|
|
const outcome = await claimWorkoutInsightGeneration({
|
|
userId: user.id,
|
|
workoutId: workout.id,
|
|
localDate,
|
|
now,
|
|
});
|
|
|
|
expect(outcome.status).toBe("claimed");
|
|
if (outcome.status !== "claimed") throw new Error("claim not recovered");
|
|
expect(outcome.claimId).not.toBe("dead-worker");
|
|
const rows = await prisma.workoutInsightGenerationClaim.findMany({
|
|
where: { workoutId: workout.id },
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]?.claimId).toBe(outcome.claimId);
|
|
});
|
|
|
|
it("keeps a deleted workout's slot spent, so a re-add hits the cap", async () => {
|
|
// Generate four, delete them all, add a fifth: the deletes must not buy
|
|
// the day's slots back. The claim row outlives its workout with a null
|
|
// workout id and is still counted.
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("wi-delete-cap");
|
|
const first = await Promise.all(
|
|
Array.from({ length: 4 }, (_, index) =>
|
|
createWorkout(
|
|
user.id,
|
|
new Date(`2026-07-18T${10 + index}:00:00.000Z`),
|
|
`ext-first-${index}`,
|
|
),
|
|
),
|
|
);
|
|
for (const workout of first) {
|
|
const outcome = await claimWorkoutInsightGeneration({
|
|
userId: user.id,
|
|
workoutId: workout.id,
|
|
localDate,
|
|
now,
|
|
});
|
|
expect(outcome.status).toBe("claimed");
|
|
// The worker marks the provider call and completes the claim.
|
|
await prisma.workoutInsightGenerationClaim.update({
|
|
where: { workoutId: workout.id },
|
|
data: {
|
|
providerInvokedAt: now,
|
|
completedAt: now,
|
|
claimId: null,
|
|
claimedAt: null,
|
|
},
|
|
});
|
|
await prisma.workoutInsight.create({
|
|
data: insightData(user.id, workout.id, now),
|
|
});
|
|
}
|
|
|
|
await prisma.workout.deleteMany({
|
|
where: { id: { in: first.map((w) => w.id) } },
|
|
});
|
|
|
|
expect(await prisma.workoutInsight.count()).toBe(0);
|
|
const orphans = await prisma.workoutInsightGenerationClaim.findMany({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(orphans).toHaveLength(4);
|
|
expect(orphans.every((row) => row.workoutId === null)).toBe(true);
|
|
|
|
const readded = await createWorkout(
|
|
user.id,
|
|
new Date("2026-07-18T14:30:00.000Z"),
|
|
"ext-readded",
|
|
);
|
|
const outcome = await claimWorkoutInsightGeneration({
|
|
userId: user.id,
|
|
workoutId: readded.id,
|
|
localDate,
|
|
now,
|
|
});
|
|
expect(outcome).toEqual({ status: "skipped", reason: "daily_cap" });
|
|
});
|
|
|
|
it("does not count a lapsed lease that never reached the provider", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("wi-lapsed-lease");
|
|
const lapsed = await Promise.all(
|
|
Array.from({ length: 4 }, (_, index) =>
|
|
createWorkout(
|
|
user.id,
|
|
new Date(Date.UTC(2026, 6, 18, 6 + index)),
|
|
`ext-lapsed-${index}`,
|
|
),
|
|
),
|
|
);
|
|
for (const workout of lapsed) {
|
|
await prisma.workoutInsightGenerationClaim.create({
|
|
data: {
|
|
userId: user.id,
|
|
workoutId: workout.id,
|
|
localDate,
|
|
claimId: `dead-${workout.id}`,
|
|
claimedAt: new Date("2026-07-18T12:00:00.000Z"),
|
|
},
|
|
});
|
|
}
|
|
const fresh = await createWorkout(
|
|
user.id,
|
|
new Date("2026-07-18T14:00:00.000Z"),
|
|
"ext-fresh",
|
|
);
|
|
|
|
const outcome = await claimWorkoutInsightGeneration({
|
|
userId: user.id,
|
|
workoutId: fresh.id,
|
|
localDate,
|
|
now,
|
|
});
|
|
expect(outcome.status).toBe("claimed");
|
|
});
|
|
});
|
|
|
|
describe("WorkoutInsightGenerationClaim — retention", () => {
|
|
it("drops rows untouched for a week and keeps the rest", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("wi-claim-retention");
|
|
const now = new Date();
|
|
const old = await prisma.workoutInsightGenerationClaim.create({
|
|
data: { userId: user.id, workoutId: null, localDate: "2000-01-01" },
|
|
});
|
|
const recent = await prisma.workoutInsightGenerationClaim.create({
|
|
data: { userId: user.id, workoutId: null, localDate: "2000-01-02" },
|
|
});
|
|
// `updated_at` is Prisma-managed; age one row through raw SQL.
|
|
await prisma.$executeRaw`
|
|
UPDATE "workout_insight_generation_claims"
|
|
SET "updated_at" = ${new Date(now.getTime() - 8 * 86_400_000)}
|
|
WHERE "id" = ${old.id}
|
|
`;
|
|
|
|
const deleted = await cleanupOldWorkoutInsightClaims(prisma, now);
|
|
|
|
expect(deleted).toBe(1);
|
|
const left = await prisma.workoutInsightGenerationClaim.findMany({
|
|
select: { id: true },
|
|
});
|
|
expect(left).toEqual([{ id: recent.id }]);
|
|
});
|
|
});
|