Merge branch 'v1422/fixes' into release/v1.42.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 13:28:29 +02:00
commit 3c4e053dcd
83 files changed
+1808 -258

No files matched your search

@@ -9,7 +9,7 @@
* prove both contracts against a real Postgres; a mocked client could not
* detect a missing atomic increment.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -180,3 +180,64 @@ describe("reserveBudget (real Postgres)", () => {
expect(row?.totalTokens).toBeGreaterThan(0);
});
});
/**
* The budget day turns at the account's own midnight, the boundary the daily
* briefing uses. On the UTC day a Berlin account's first generations after
* local midnight still counted against the previous day's spent ceiling.
*/
describe("the budget day is the account's local day", () => {
afterEach(() => {
vi.useRealTimers();
});
it("opens a fresh day at local midnight while the UTC day is still running", async () => {
const { buildDateKey, reserveBudget } =
await import("@/lib/ai/coach/budget");
const user = await getPrismaClient().user.create({
data: {
username: "budget-berlin",
email: "budget-berlin@example.test",
role: "USER",
timezone: "Europe/Berlin",
},
});
// 22:30 UTC on 2026-06-19 is 00:30 on 2026-06-20 in Berlin (CEST).
vi.useFakeTimers({
toFake: ["Date"],
now: new Date("2026-06-19T22:30:00.000Z"),
});
// The evening used the whole ceiling, booked to the day that just ended.
await getPrismaClient().coachUsage.create({
data: {
userId: user.id,
dateKey: "2026-06-19",
totalTokens: 1000,
operatorTokens: 1000,
messageCount: 3,
},
});
const dateKey = await buildDateKey(user.id);
expect(dateKey).toBe("2026-06-20");
const first = await reserveBudget(
user.id,
200,
dateKey,
1000,
"operator",
"job",
);
expect(first.allowed).toBe(true);
const rows = await getPrismaClient().coachUsage.findMany({
where: { userId: user.id },
orderBy: { dateKey: "asc" },
select: { dateKey: true, totalTokens: true },
});
expect(rows).toEqual([
{ dateKey: "2026-06-19", totalTokens: 1000 },
{ dateKey: "2026-06-20", totalTokens: 200 },
]);
});
});
+3 -1
View File
@@ -230,7 +230,9 @@ describe("POST /api/insights/chat — integration", () => {
// the reservation is refunded so the ledger stays at the cap.
const { userId } = await seedUserWithSession();
const prisma = getPrismaClient();
const dateKey = new Date().toISOString().slice(0, 10);
// The account's own day, the key the route reserves under.
const { buildDateKey } = await import("@/lib/ai/coach/budget");
const dateKey = await buildDateKey(userId);
// v1.21.0 — the daily cap is now provider-aware: the seeded user resolves
// to a user-egress chain, so the applicable ceiling is USER_PLAN_CAP. Seed
// the spend at that ceiling so the reservation trips the gate. (Seeding the
+4 -2
View File
@@ -483,7 +483,7 @@ describe("the reaper", () => {
const { readDailySpend, buildDateKey } =
await import("@/lib/ai/coach/budget");
expect(
(await readDailySpend(user.id, buildDateKey())).total,
(await readDailySpend(user.id, await buildDateKey(user.id))).total,
).toBeGreaterThan(0);
const { reapAiRuns } = await import("@/lib/documents/ai-runs/store");
@@ -493,7 +493,9 @@ describe("the reaper", () => {
timedOut: 0,
deleted: 0,
});
expect((await readDailySpend(user.id, buildDateKey())).total).toBe(0);
expect(
(await readDailySpend(user.id, await buildDateKey(user.id))).total,
).toBe(0);
const run = await getPrismaClient().documentAiRun.findUniqueOrThrow({
where: { id: runId },
@@ -168,6 +168,20 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => {
};
});
/** The follow-up slices a progressing backfill queues, recorded. */
const sent = vi.hoisted(() => ({
jobs: [] as Array<{ queue: string; data: unknown; options: unknown }>,
}));
vi.mock("@/lib/jobs/boss-instance", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/jobs/boss-instance")>()),
getGlobalBoss: () => ({
send: async (queue: string, data: unknown, options: unknown) => {
sent.jobs.push({ queue, data, options });
return `job-${sent.jobs.length}`;
},
}),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -212,6 +226,7 @@ beforeEach(async () => {
fake.requestsByPath = new Map();
fake.status429 = 0;
fake.status401 = 0;
sent.jobs = [];
await seed();
}, 120_000);
@@ -353,9 +368,19 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", ()
fake.requestsByPath.get("heart-rate/dataPoints") ?? 0;
return heartRatePagesSeen >= 3;
};
// It finished four collections, so it is progressing, not failing: it
// ends quietly and queues the next slice of the import.
await expect(
runGoogleHealthBackfillForUser(USER, stopAfterThreeHeartRatePages),
).rejects.toThrow(/incomplete/);
).resolves.toMatchObject({ continued: true });
expect(sent.jobs).toHaveLength(1);
expect(sent.jobs[0]).toMatchObject({
queue: "integration-backfill-admission",
data: {
kind: "google-health-backfill",
data: { userId: USER, slice: 1 },
},
});
const afterFirst =
await getPrismaClient().googleHealthConnection.findUnique({
@@ -373,9 +398,10 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", ()
);
expect(done).not.toContain("fetchHeartRate");
// Second attempt: the finished collections are not read again.
// Second attempt, the queued slice: the finished collections are not
// read again.
fake.requestsByPath = new Map();
await runGoogleHealthBackfillForUser(USER);
await runGoogleHealthBackfillForUser(USER, undefined, 1);
expect(fake.requestsByPath.get("exercise/dataPoints")).toBeUndefined();
expect(fake.requestsByPath.get("sleep/dataPoints")).toBeUndefined();
@@ -392,6 +418,29 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", ()
expect(afterSecond?.backfillProgress).toBeNull();
});
it("keeps failing a backfill whose slice finished nothing new", async () => {
const { runGoogleHealthBackfillForUser } =
await import("@/lib/jobs/google-health-backfill");
// A first slice finishes everything but the heart-rate stream.
const stopAtThirdPage = () =>
(fake.requestsByPath.get("heart-rate/dataPoints") ?? 0) >= 3;
await runGoogleHealthBackfillForUser(USER, stopAtThirdPage);
expect(sent.jobs).toHaveLength(1);
// The next slice cannot get through the heart-rate stream either: no
// collection finishes, so this one is stuck and says so.
fake.requestsByPath = new Map();
await expect(
runGoogleHealthBackfillForUser(USER, stopAtThirdPage, 1),
).rejects.toThrow(/incomplete/);
expect(sent.jobs).toHaveLength(1);
const row = await getPrismaClient().googleHealthConnection.findUnique({
where: { userId: USER },
});
expect(row?.backfillCompletedAt).toBeNull();
});
it("never runs the hourly poll or a manual sync beside a running backfill", async () => {
fake.holdAtPage = 2;
fake.held = new Promise<void>((resolve) => {
+87 -48
View File
@@ -35,6 +35,7 @@ import {
} from "@/lib/rollups/measurement-rollups";
import { aggregateBuckets } from "@/lib/rollups/measurement-read";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
import { windowWeighting } from "@/lib/measurements/day-statistic";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
@@ -65,6 +66,81 @@ afterEach(() => {
vi.restoreAllMocks();
});
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* The instant the summary fixtures count back from: noon UTC yesterday.
*
* They used to count back from `Date.now()`, with each reading a few more
* minutes earlier than the last. Run between midnight and about 01:15 UTC,
* the earliest of those minutes slid across midnight into the day before,
* so a day gained a reading in another hour, and for a type whose day is a
* mean of hourly means (pulse) the composed mean moved off the plain AVG the
* tests compared it with: the suite went red for an hour every night. Noon
* leaves every offset inside its own day, so the layout is the same at any
* time the suite runs.
*/
function fixtureAnchor(): number {
const now = new Date();
return (
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) -
DAY_MS +
12 * 60 * 60 * 1000
);
}
/**
* Live SQL over the raw rows for the summary's count / min / max / mean,
* with the mean each type documents: a plain AVG over readings, or, for a
* type whose day is a mean of hourly means (`windowWeighting(type) ===
* "day"`), the mean of those per-day values.
*/
async function liveSummaryRows(userId: string, since: Date) {
const prisma = getPrismaClient();
const rows = await prisma.$queryRaw<
Array<{
type: string;
count: bigint;
min_value: number;
max_value: number;
mean_value: number;
day_mean_value: number;
}>
>`
WITH hourly AS (
SELECT m."type", date_trunc('day', m."measured_at") AS day,
AVG(m."value") AS v
FROM measurements m
WHERE m."user_id" = ${userId} AND m."measured_at" >= ${since}
GROUP BY m."type", date_trunc('day', m."measured_at"),
date_trunc('hour', m."measured_at")
),
daily AS (
SELECT "type", day, AVG(v) AS v FROM hourly GROUP BY "type", day
),
by_day AS (
SELECT "type", AVG(v)::double precision AS day_mean_value
FROM daily GROUP BY "type"
)
SELECT
m."type"::text AS type,
COUNT(*) AS count,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
AVG(m."value")::double precision AS mean_value,
d.day_mean_value AS day_mean_value
FROM measurements m
JOIN by_day d ON d."type" = m."type"
WHERE m."user_id" = ${userId} AND m."measured_at" >= ${since}
GROUP BY m."type", d.day_mean_value
`;
return rows.map((row) => ({
...row,
expectedMean:
windowWeighting(row.type) === "day" ? row.day_mean_value : row.mean_value,
}));
}
describe("measurement rollups — integration", () => {
it("writes a DAY rollup row that matches the inserted measurements", async () => {
const prisma = getPrismaClient();
@@ -298,8 +374,8 @@ describe("measurement rollups — integration", () => {
// type families exercise the per-type composition path. The
// values are deliberately fractional so the round2 contract gets
// a workout.
const now = Date.now();
const dayMs = 24 * 60 * 60 * 1000;
const now = fixtureAnchor();
const dayMs = DAY_MS;
const seedRows: Array<{
userId: string;
type: "WEIGHT" | "PULSE";
@@ -336,26 +412,7 @@ describe("measurement rollups — integration", () => {
// the source of truth — the read-swap must produce the same
// count/min/max/mean byte-for-byte.
const ninetyDaysAgo = new Date(Date.now() - 90 * dayMs);
const live = await prisma.$queryRaw<
Array<{
type: string;
count: bigint;
min_value: number;
max_value: number;
mean_value: number;
}>
>`
SELECT
m."type"::text AS type,
COUNT(*) AS count,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
AVG(m."value")::double precision AS mean_value
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."measured_at" >= ${ninetyDaysAgo}
GROUP BY m."type"
`;
const live = await liveSummaryRows(user.id, ninetyDaysAgo);
const round2 = (n: number) => Math.round(n * 100) / 100;
@@ -366,9 +423,10 @@ describe("measurement rollups — integration", () => {
expect(summary.count).toBe(Number(liveRow.count));
expect(summary.min).toBe(round2(liveRow.min_value));
expect(summary.max).toBe(round2(liveRow.max_value));
// The composed weighted mean equals AVG(value) over the same
// rows; both run through `round2`.
expect(summary.mean).toBe(round2(liveRow.mean_value));
// The composed mean equals the type's documented mean over the same
// rows (AVG, or the mean of hourly-mean days); both run through
// `round2`.
expect(summary.mean).toBe(round2(liveRow.expectedMean));
}
});
@@ -741,8 +799,8 @@ describe("measurement rollups — integration", () => {
// 60 readings over 30 days, two types, deliberately spread so
// anomalies + slopes + windowed avgs all have signal.
const now = Date.now();
const dayMs = 24 * 60 * 60 * 1000;
const now = fixtureAnchor();
const dayMs = DAY_MS;
const seedRows: Array<{
userId: string;
type: "WEIGHT" | "PULSE";
@@ -777,26 +835,7 @@ describe("measurement rollups — integration", () => {
// Parallel live aggregation over the same 90-day window using the
// exact heavy-aggregate shape the cold-mount fallback runs.
const ninetyDaysAgo = new Date(now - 90 * dayMs);
const live = await prisma.$queryRaw<
Array<{
type: string;
count: bigint;
min_value: number;
max_value: number;
mean_value: number;
}>
>`
SELECT
m."type"::text AS type,
COUNT(*) AS count,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
AVG(m."value")::double precision AS mean_value
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."measured_at" >= ${ninetyDaysAgo}
GROUP BY m."type"
`;
const live = await liveSummaryRows(user.id, ninetyDaysAgo);
const aggregate = await buildComprehensiveAggregate(user.id);
@@ -811,7 +850,7 @@ describe("measurement rollups — integration", () => {
expect(summary.count).toBe(Number(liveRow.count));
expect(summary.min).toBe(round2(liveRow.min_value));
expect(summary.max).toBe(round2(liveRow.max_value));
expect(summary.mean).toBe(round2(liveRow.mean_value));
expect(summary.mean).toBe(round2(liveRow.expectedMean));
// Narrow aggregate fills the non-composable windowed columns.
// Slope tuples and avg7/avg30 are sourced from live SQL even on
// the rollup-fresh path so the values are present (not null) as
@@ -185,7 +185,7 @@ describe("429 rate-limit headers (real Postgres)", () => {
await getPrismaClient().coachUsage.create({
data: {
userId: TEST_USER_ID,
dateKey: buildDateKey(),
dateKey: await buildDateKey(TEST_USER_ID),
totalTokens: USER_PLAN_CAP,
messageCount: 1,
},
+252
View File
@@ -0,0 +1,252 @@
/**
* HealthLog works on a database whose default time zone is not UTC.
*
* Stored times are UTC in `timestamp` columns without a zone; Postgres reads
* `now()` through the session time zone. On a shared instance set to
* Europe/Berlin a fresh passkey challenge used to be expired before the
* prompt closed. This file sets the test database's default to Europe/Berlin
* (every connection opened afterwards starts in it) and drives the expiry
* paths through the application's own client, which pins UTC per
* connection. A second block takes the pin away and shows that the raw-SQL
* expiry checks hold on their own.
*/
import { Client } from "pg";
import { PrismaPg } from "@prisma/adapter-pg";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { PrismaClient } from "@/generated/prisma/client";
import { getPrismaClient, truncateAllTables } from "./setup";
const ZONE = "Europe/Berlin";
const USER_ID = "user-session-time-zone";
let databaseName = "";
async function rawClient(): Promise<Client> {
// No startup options: the connection takes the database default.
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
return client;
}
async function setDatabaseZone(zone: string | null): Promise<void> {
const client = await rawClient();
try {
if (!databaseName) {
const { rows } = await client.query<{ name: string }>(
"SELECT current_database() AS name",
);
databaseName = rows[0].name;
}
await client.query(
zone
? `ALTER DATABASE "${databaseName}" SET timezone TO '${zone}'`
: `ALTER DATABASE "${databaseName}" RESET timezone`,
);
} finally {
await client.end();
}
}
/** A Prisma client WITHOUT the pin: its sessions run in Europe/Berlin. */
function unpinnedClient(): PrismaClient {
return new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }),
});
}
async function challenge(
db: PrismaClient,
type: string,
expiresInMs = 60_000,
): Promise<string> {
const row = await db.authChallenge.create({
data: {
userId: USER_ID,
challenge: `material-${type}`,
type,
expiresAt: new Date(Date.now() + expiresInMs),
},
});
return row.id;
}
beforeAll(async () => {
// Before any pooled connection of this file opens.
await setDatabaseZone(ZONE);
});
afterAll(async () => {
// The container is shared with every other file of the run.
await setDatabaseZone(null);
});
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: { id: USER_ID, username: "tz-user", email: "tz@example.test" },
});
});
describe("on a database whose default zone is Europe/Berlin", () => {
it("a connection without the pin really runs in Europe/Berlin", async () => {
const client = await rawClient();
try {
const { rows } = await client.query<{ TimeZone: string }>(
"SHOW TimeZone",
);
expect(rows[0].TimeZone).toBe(ZONE);
} finally {
await client.end();
}
});
it("the application's pool runs in UTC and the boot check says so", async () => {
const { runBootTimeZoneCheck } = await import("@/lib/boot/time-zone-check");
await expect(runBootTimeZoneCheck(getPrismaClient())).resolves.toEqual({
state: "utc",
timeZone: "UTC",
});
const unpinned = unpinnedClient();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await expect(runBootTimeZoneCheck(unpinned)).resolves.toEqual({
state: "mismatch",
timeZone: ZONE,
});
expect(warn).toHaveBeenCalledTimes(1);
} finally {
warn.mockRestore();
await unpinned.$disconnect();
}
});
it("a fresh passkey registration challenge is accepted", async () => {
const { consumeRegistrationChallenge } = await import("@/lib/auth/passkey");
const { hashToken } = await import("@/lib/auth/hmac");
const sessionId = "session-tz";
const id = await challenge(
getPrismaClient(),
`passkey-registration:v1:${hashToken(sessionId)}`,
);
const claim = await consumeRegistrationChallenge({
challengeId: id,
userId: USER_ID,
sessionId,
});
expect(claim.ok).toBe(true);
if (claim.ok) {
// Stamped by the column default: UTC, not Berlin wall time.
expect(Math.abs(claim.createdAt.getTime() - Date.now())).toBeLessThan(
60_000,
);
}
});
it("a fresh login challenge is claimed, an expired one is not", async () => {
const { claimAuthChallenge } = await import("@/lib/auth/passkey");
const fresh = await challenge(getPrismaClient(), "authentication");
await expect(
claimAuthChallenge({ challengeId: fresh, type: "authentication" }),
).resolves.not.toBeNull();
const stale = await challenge(getPrismaClient(), "authentication", -1_000);
await expect(
claimAuthChallenge({ challengeId: stale, type: "authentication" }),
).resolves.toBeNull();
});
it("a session is valid until it expires and is stamped in UTC", async () => {
const { validateSessionFromCookieValue } =
await import("@/lib/auth/session");
const { hashToken } = await import("@/lib/auth/hmac");
const live = await getPrismaClient().session.create({
data: {
userId: USER_ID,
tokenHash: hashToken("hls_live-secret"),
expiresAt: new Date(Date.now() + 60 * 60_000),
},
});
await getPrismaClient().session.create({
data: {
userId: USER_ID,
tokenHash: hashToken("hls_expired-secret"),
expiresAt: new Date(Date.now() - 60_000),
},
});
expect(Math.abs(live.createdAt.getTime() - Date.now())).toBeLessThan(
60_000,
);
await expect(
validateSessionFromCookieValue("hls_live-secret"),
).resolves.not.toBeNull();
await expect(
validateSessionFromCookieValue("hls_expired-secret"),
).resolves.toBeNull();
});
it("the native sign-in handoff sees a session begun inside it as fresh", async () => {
// The handoff link is good only for a session created after it began:
// `session.createdAt >= startedAt`, with `startedAt` read from the
// database clock and `createdAt` stamped by the column default.
const { nativeHandoffDbNow } =
await import("@/lib/auth/native-web-handoff");
const startedAt = await nativeHandoffDbNow();
// The driver adapter reads a `timestamptz` as if its offset were +00:00,
// so on a Berlin session this instant came back two hours late.
expect(Math.abs(startedAt.getTime() - Date.now())).toBeLessThan(60_000);
const created = await getPrismaClient().$queryRaw<{ created_at: Date }[]>`
INSERT INTO sessions (id, user_id, token_hash, expires_at)
VALUES ('session-handoff', ${USER_ID}, 'handoff-hash',
(NOW() AT TIME ZONE 'UTC') + INTERVAL '1 hour')
RETURNING created_at
`;
const createdAt = created[0].created_at;
expect(createdAt.getTime()).toBeGreaterThanOrEqual(startedAt.getTime());
expect(createdAt.getTime() - startedAt.getTime()).toBeLessThan(60_000);
});
it("a rate-limit window resets one window from now", async () => {
const { checkRateLimit } = await import("@/lib/rate-limit");
const before = Date.now();
const result = await checkRateLimit("tz:window", 5, 60_000);
expect(result.allowed).toBe(true);
expect(result.resetAt - before).toBeGreaterThan(55_000);
expect(result.resetAt - before).toBeLessThan(65_000);
});
});
describe("without the per-connection pin", () => {
it("the raw-SQL challenge expiry still holds on a Berlin session", async () => {
const unpinned = unpinnedClient();
try {
const [{ TimeZone }] =
await unpinned.$queryRawUnsafe<{ TimeZone: string }[]>("SHOW TimeZone");
expect(TimeZone).toBe(ZONE);
vi.resetModules();
vi.doMock("@/lib/db", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/db")>()),
prisma: unpinned,
}));
const { claimAuthChallenge } = await import("@/lib/auth/passkey");
const fresh = await challenge(unpinned, "authentication");
await expect(
claimAuthChallenge({ challengeId: fresh, type: "authentication" }),
).resolves.not.toBeNull();
const stale = await challenge(unpinned, "authentication", -1_000);
await expect(
claimAuthChallenge({ challengeId: stale, type: "authentication" }),
).resolves.toBeNull();
} finally {
vi.doUnmock("@/lib/db");
await unpinned.$disconnect();
}
});
});