diff --git a/tests/integration/workout-insight-claim.test.ts b/tests/integration/workout-insight-claim.test.ts new file mode 100644 index 000000000..9d4120124 --- /dev/null +++ b/tests/integration/workout-insight-claim.test.ts @@ -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); + }); +});