fix(release): harden v1.31 arrival contracts

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 00:40:20 +02:00
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",
});
});
});