mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
fix(release): harden v1.31 arrival contracts
This commit is contained in:
1 parent
44add3bc1e
commit
4591899657
98 files changed
+5560
-1600
No files matched your search
@@ -27,11 +27,7 @@ import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
function makeCapturingBoss() {
|
||||
const sent: { queue: string; userId: string }[] = [];
|
||||
const boss = {
|
||||
send: async (
|
||||
queue: string,
|
||||
payload: { userId?: string },
|
||||
_opts?: unknown,
|
||||
) => {
|
||||
send: async (queue: string, payload: { userId?: string }) => {
|
||||
if (payload.userId) sent.push({ queue, userId: payload.userId });
|
||||
return `job-${sent.length}`;
|
||||
},
|
||||
|
||||
@@ -13,12 +13,9 @@
|
||||
* and the toast surfaced "Layout konnte nicht gespeichert werden".
|
||||
*
|
||||
* The fix switched to `z.partialRecord(...)` so partial maps round-trip
|
||||
* cleanly. We exercise:
|
||||
* 1. Full layout with a single `chartOverlayPrefs` entry persists.
|
||||
* 2. Per-chart `comparisonBaseline` survives a Settings save (the
|
||||
* previous schema silently stripped it).
|
||||
* 3. Empty `chartOverlayPrefs` still persists (parity with fresh
|
||||
* accounts that have never opened a chart popover).
|
||||
* cleanly. Coverage also pins the newer nested `rangePoints` contract:
|
||||
* omission preserves the stored choice, valid overrides win, and values
|
||||
* outside the closed range-point set are rejected.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -222,6 +219,146 @@ describe("PUT /api/dashboard/widgets — Save persists full layouts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a stored per-chart rangePoints value when the full-layout payload omits it", async () => {
|
||||
const { userId } = await seedUser();
|
||||
const { PUT, GET } = await import("@/app/api/dashboard/widgets/route");
|
||||
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
dashboardWidgetsJson: {
|
||||
...DEFAULT_DASHBOARD_LAYOUT,
|
||||
chartOverlayPrefs: {
|
||||
bp: {
|
||||
showTrendIndicator: false,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
rangePoints: 90,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
});
|
||||
|
||||
const putRes = await (PUT as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/dashboard/widgets", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
buildClientPayload({
|
||||
chartOverlayPrefs: {
|
||||
bp: {
|
||||
showTrendIndicator: true,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "lastMonth",
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(putRes.status).toBe(200);
|
||||
|
||||
const getRes = await (GET as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/dashboard/widgets"),
|
||||
);
|
||||
const getBody = (await getRes.json()) as {
|
||||
data: {
|
||||
chartOverlayPrefs?: Record<string, { rangePoints?: number }>;
|
||||
};
|
||||
};
|
||||
expect(getBody.data.chartOverlayPrefs?.bp?.rangePoints).toBe(90);
|
||||
});
|
||||
|
||||
it("honors an explicit valid per-chart rangePoints override", async () => {
|
||||
const { userId } = await seedUser();
|
||||
const { PUT, GET } = await import("@/app/api/dashboard/widgets/route");
|
||||
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
dashboardWidgetsJson: {
|
||||
...DEFAULT_DASHBOARD_LAYOUT,
|
||||
chartOverlayPrefs: {
|
||||
bp: {
|
||||
showTrendIndicator: false,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
rangePoints: 90,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
});
|
||||
|
||||
const putRes = await (PUT as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/dashboard/widgets", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
buildClientPayload({
|
||||
chartOverlayPrefs: {
|
||||
bp: {
|
||||
showTrendIndicator: false,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
rangePoints: 7,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(putRes.status).toBe(200);
|
||||
|
||||
const getRes = await (GET as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/dashboard/widgets"),
|
||||
);
|
||||
const getBody = (await getRes.json()) as {
|
||||
data: {
|
||||
chartOverlayPrefs?: Record<string, { rangePoints?: number }>;
|
||||
};
|
||||
};
|
||||
expect(getBody.data.chartOverlayPrefs?.bp?.rangePoints).toBe(7);
|
||||
});
|
||||
|
||||
it("rejects an invalid per-chart rangePoints value", async () => {
|
||||
await seedUser();
|
||||
const { PUT } = await import("@/app/api/dashboard/widgets/route");
|
||||
|
||||
const res = await (PUT as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/dashboard/widgets", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
buildClientPayload({
|
||||
chartOverlayPrefs: {
|
||||
bp: {
|
||||
showTrendIndicator: false,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
rangePoints: 14,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string }> };
|
||||
};
|
||||
expect(body.details.issues.map((issue) => issue.path)).toContain(
|
||||
"chartOverlayPrefs.bp.rangePoints",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts an empty chartOverlayPrefs map (fresh account)", async () => {
|
||||
await seedUser();
|
||||
const { PUT } = await import("@/app/api/dashboard/widgets/route");
|
||||
|
||||
@@ -28,6 +28,8 @@ import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
// only the first call's process state is exercised here.
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-workout-batch-integration-32-bytes-min-1234567890";
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const { hashToken } = await import("@/lib/auth/hmac");
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { claimWorkoutInsightGeneration } from "@/lib/jobs/workout-insight-generate";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
@@ -245,3 +246,132 @@ describe("WorkoutInsight — the daily-cap count", () => {
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkoutInsightGenerationClaim — concurrent ownership and cap", () => {
|
||||
const now = new Date("2026-07-18T15:00:00.000Z");
|
||||
const dayStart = new Date("2026-07-17T22: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,
|
||||
dayStart,
|
||||
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,
|
||||
dayStart,
|
||||
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("counts a pre-migration insight row against the local-day cap", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await createUser("wi-legacy-cap");
|
||||
const legacyWorkout = await createWorkout(
|
||||
user.id,
|
||||
new Date("2026-07-18T07:00:00.000Z"),
|
||||
"ext-legacy",
|
||||
);
|
||||
await prisma.workoutInsight.create({
|
||||
data: insightData(
|
||||
user.id,
|
||||
legacyWorkout.id,
|
||||
new Date("2026-07-18T08:00:00.000Z"),
|
||||
),
|
||||
});
|
||||
const candidates = await Promise.all(
|
||||
Array.from({ length: 4 }, (_, index) =>
|
||||
createWorkout(
|
||||
user.id,
|
||||
new Date(`2026-07-18T${10 + index}:00:00.000Z`),
|
||||
`ext-new-${index}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const outcomes = await Promise.all(
|
||||
candidates.map((workout) =>
|
||||
claimWorkoutInsightGeneration({
|
||||
userId: user.id,
|
||||
workoutId: workout.id,
|
||||
localDate,
|
||||
dayStart,
|
||||
now,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
outcomes.filter((outcome) => outcome.status === "claimed"),
|
||||
).toHaveLength(3);
|
||||
expect(outcomes).toContainEqual({
|
||||
status: "skipped",
|
||||
reason: "daily_cap",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user