mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
test(workouts): prove the insight row's constraints against real Postgres
The one-paragraph-per-workout claim is a unique index, the cascade is an FK, and the daily-cap window is a SQL fact — a mocked upsert reports success for all three regardless.
This commit is contained in:
1 parent
87cda9b7a7
commit
ac9170945d
1 file changed
+247
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 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 an index over `(user_id, generated_at)`.
|
||||
* Whether the window actually selects the right rows is a SQL fact.
|
||||
*
|
||||
* 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";
|
||||
|
||||
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("WorkoutInsight — the daily-cap count", () => {
|
||||
it("counts only this user's rows inside the window", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const mine = await createUser("wi-cap-mine");
|
||||
const other = await createUser("wi-cap-other");
|
||||
|
||||
// Three of mine today, one of mine yesterday, one belonging to someone else.
|
||||
const today = new Date("2026-07-18T09:00:00Z");
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const w = await createWorkout(
|
||||
mine.id,
|
||||
new Date(today.getTime() + i * 3600_000),
|
||||
`ext-today-${i}`,
|
||||
);
|
||||
await prisma.workoutInsight.create({
|
||||
data: insightData(
|
||||
mine.id,
|
||||
w.id,
|
||||
new Date(today.getTime() + i * 3600_000),
|
||||
),
|
||||
});
|
||||
}
|
||||
const yesterdayWorkout = await createWorkout(
|
||||
mine.id,
|
||||
new Date("2026-07-17T09:00:00Z"),
|
||||
"ext-yesterday",
|
||||
);
|
||||
await prisma.workoutInsight.create({
|
||||
data: insightData(
|
||||
mine.id,
|
||||
yesterdayWorkout.id,
|
||||
new Date("2026-07-17T09:30:00Z"),
|
||||
),
|
||||
});
|
||||
const otherWorkout = await createWorkout(
|
||||
other.id,
|
||||
new Date("2026-07-18T09:00:00Z"),
|
||||
"ext-other",
|
||||
);
|
||||
await prisma.workoutInsight.create({
|
||||
data: insightData(
|
||||
other.id,
|
||||
otherWorkout.id,
|
||||
new Date("2026-07-18T10:00:00Z"),
|
||||
),
|
||||
});
|
||||
|
||||
const dayStart = new Date("2026-07-18T00:00:00Z");
|
||||
const count = await prisma.workoutInsight.count({
|
||||
where: { userId: mine.id, generatedAt: { gte: dayStart } },
|
||||
});
|
||||
|
||||
// Not 4 (yesterday's is outside the window) and not 5 (the other user's is
|
||||
// outside the tenancy narrow).
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user