mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
perf(insights): wire six sibling status routes to the shared timeout-stub helper
Bring blood-pressure, weight, general, pulse, mood, and
medication-compliance into parity with bmi-status. On a 20 s
provider stall every route now persists a sentinel auditLog row
keyed to today's Berlin day so the next mount short-circuits at
the cache lookup instead of re-racing the same provider call on
every cold visit. Eliminates the ~14 s warm response on every
dashboard open after a one-off provider hiccup.
The seven routes share one persist path through
`persistTimeoutStubAndReturn`; ~120 LOC of duplicate
`auditLog.create` calls collapse into the helper.
medication-compliance returns a richer `{summary, medications}`
envelope rather than the standard `{text}` shape, so it calls
the helper for the persist side-effect and maps `summary` ←
`text` on the way out. Its cache-read also gains a stub-row
recogniser so the second-mount short-circuit fires on the
helper's `text` + `timeout: true` payload.
Tests cover both arms per route: the persist-on-timeout path
asserts a single auditLog.create with `timeout: true`, model
`timeout-stub`, and today's Berlin dateKey; the
short-circuit-on-stub path asserts a subsequent mount returns
the cached stub text without invoking the provider.
This commit is contained in:
1 parent
e4d46de22d
commit
134566466d
13 files changed
+663
-38
No files matched your search
@@ -109,6 +109,106 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateBloodPressureStatusForUser — v1.4.41 timeout-stub persistence", () => {
|
||||
it("persists a sentinel row keyed to today when the provider times out", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: new Date() },
|
||||
{ type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: new Date() },
|
||||
] as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: vi.fn(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
/* never resolves */
|
||||
}),
|
||||
),
|
||||
} as never);
|
||||
|
||||
vi.useFakeTimers();
|
||||
const promise = generateBloodPressureStatusForUser("user-1", {
|
||||
locale: "en",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
const result = await promise;
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBeTruthy();
|
||||
|
||||
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
|
||||
expect(createCalls.length).toBe(1);
|
||||
const details = (createCalls[0][0] as { data: { details: string } }).data
|
||||
.details;
|
||||
const parsed = JSON.parse(details) as {
|
||||
dateKey?: string;
|
||||
text?: string;
|
||||
timeout?: boolean;
|
||||
model?: string;
|
||||
};
|
||||
expect(parsed.text).toBeTruthy();
|
||||
expect(parsed.timeout).toBe(true);
|
||||
expect(parsed.model).toBe("timeout-stub");
|
||||
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(new Date());
|
||||
const y = parts.find((p) => p.type === "year")!.value;
|
||||
const m = parts.find((p) => p.type === "month")!.value;
|
||||
const d = parts.find((p) => p.type === "day")!.value;
|
||||
const todayKey = `${y}-${m}-${d}`;
|
||||
const stubRow = {
|
||||
createdAt: new Date(),
|
||||
details: JSON.stringify({
|
||||
dateKey: todayKey,
|
||||
locale: "en",
|
||||
text: "Blood pressure assessment fallback…",
|
||||
timeout: true,
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
|
||||
const providerCall = vi.fn();
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: providerCall,
|
||||
} as never);
|
||||
|
||||
const result = await generateBloodPressureStatusForUser("user-1", {
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(providerCall).not.toHaveBeenCalled();
|
||||
expect(result.text).toBe("Blood pressure assessment fallback…");
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
|
||||
it("strips metric: tokens out of the cached text before persisting", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
|
||||
@@ -100,6 +100,102 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateGeneralStatusForUser — v1.4.41 timeout-stub persistence", () => {
|
||||
it("persists a sentinel row keyed to today when the provider times out", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: vi.fn(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
/* never resolves */
|
||||
}),
|
||||
),
|
||||
} as never);
|
||||
|
||||
vi.useFakeTimers();
|
||||
const promise = generateGeneralStatusForUser("user-1", { locale: "en" });
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
const result = await promise;
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBeTruthy();
|
||||
|
||||
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
|
||||
expect(createCalls.length).toBe(1);
|
||||
const details = (createCalls[0][0] as { data: { details: string } }).data
|
||||
.details;
|
||||
const parsed = JSON.parse(details) as {
|
||||
dateKey?: string;
|
||||
text?: string;
|
||||
timeout?: boolean;
|
||||
model?: string;
|
||||
};
|
||||
expect(parsed.text).toBeTruthy();
|
||||
expect(parsed.timeout).toBe(true);
|
||||
expect(parsed.model).toBe("timeout-stub");
|
||||
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(new Date());
|
||||
const y = parts.find((p) => p.type === "year")!.value;
|
||||
const m = parts.find((p) => p.type === "month")!.value;
|
||||
const d = parts.find((p) => p.type === "day")!.value;
|
||||
const todayKey = `${y}-${m}-${d}`;
|
||||
const stubRow = {
|
||||
createdAt: new Date(),
|
||||
details: JSON.stringify({
|
||||
dateKey: todayKey,
|
||||
locale: "en",
|
||||
text: "General status fallback…",
|
||||
timeout: true,
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
|
||||
const providerCall = vi.fn();
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: providerCall,
|
||||
} as never);
|
||||
|
||||
const result = await generateGeneralStatusForUser("user-1", {
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(providerCall).not.toHaveBeenCalled();
|
||||
expect(result.text).toBe("General status fallback…");
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
|
||||
it("strips metric: tokens out of the cached text before persisting", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
|
||||
@@ -108,6 +108,114 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload"
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub persistence", () => {
|
||||
it("persists a sentinel row keyed to today when the provider times out", async () => {
|
||||
const now = new Date();
|
||||
const medication = {
|
||||
id: "med-1",
|
||||
name: "Ramipril",
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
createdAt: new Date(now.getTime() - 60 * dayMs),
|
||||
schedules: [{ id: "s1", time: "08:00" }],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
medication,
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: vi.fn(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
/* never resolves */
|
||||
}),
|
||||
),
|
||||
} as never);
|
||||
|
||||
vi.useFakeTimers();
|
||||
const promise = generateMedicationComplianceStatusForUser("user-1", {
|
||||
locale: "en",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
const result = await promise;
|
||||
vi.useRealTimers();
|
||||
|
||||
// Route returns the richer `{summary, medications}` shape — the
|
||||
// helper writes the persist row and the route maps `summary` ←
|
||||
// `text` on the way out.
|
||||
expect(result.summary).toBeTruthy();
|
||||
expect(result.medications).toEqual([]);
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBeTruthy();
|
||||
|
||||
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
|
||||
expect(createCalls.length).toBe(1);
|
||||
const details = (createCalls[0][0] as { data: { details: string } }).data
|
||||
.details;
|
||||
const parsed = JSON.parse(details) as {
|
||||
dateKey?: string;
|
||||
text?: string;
|
||||
timeout?: boolean;
|
||||
model?: string;
|
||||
};
|
||||
expect(parsed.text).toBeTruthy();
|
||||
expect(parsed.timeout).toBe(true);
|
||||
expect(parsed.model).toBe("timeout-stub");
|
||||
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub short-circuit", () => {
|
||||
it("subsequent mounts short-circuit when a stub row with text+timeout:true exists", async () => {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(new Date());
|
||||
const y = parts.find((p) => p.type === "year")!.value;
|
||||
const m = parts.find((p) => p.type === "month")!.value;
|
||||
const d = parts.find((p) => p.type === "day")!.value;
|
||||
const todayKey = `${y}-${m}-${d}`;
|
||||
const stubRow = {
|
||||
createdAt: new Date(),
|
||||
details: JSON.stringify({
|
||||
dateKey: todayKey,
|
||||
locale: "en",
|
||||
text: "Medication compliance fallback…",
|
||||
timeout: true,
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
|
||||
const providerCall = vi.fn();
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: providerCall,
|
||||
} as never);
|
||||
|
||||
const result = await generateMedicationComplianceStatusForUser("user-1", {
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(providerCall).not.toHaveBeenCalled();
|
||||
expect(result.summary).toBe("Medication compliance fallback…");
|
||||
expect(result.medications).toEqual([]);
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
|
||||
it("strips metric: tokens out of the cached summary + per-medication text", async () => {
|
||||
const now = new Date();
|
||||
|
||||
@@ -88,6 +88,97 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateMoodStatusForUser — v1.4.41 timeout-stub persistence", () => {
|
||||
it("persists a sentinel row keyed to today when the provider times out", async () => {
|
||||
const t = new Date();
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{
|
||||
date: t.toISOString().slice(0, 10),
|
||||
score: 4,
|
||||
tags: [],
|
||||
moodLoggedAt: t,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: vi.fn(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
/* never resolves */
|
||||
}),
|
||||
),
|
||||
} as never);
|
||||
|
||||
vi.useFakeTimers();
|
||||
const promise = generateMoodStatusForUser("user-1", { locale: "en" });
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
const result = await promise;
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBeTruthy();
|
||||
|
||||
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
|
||||
expect(createCalls.length).toBe(1);
|
||||
const details = (createCalls[0][0] as { data: { details: string } }).data
|
||||
.details;
|
||||
const parsed = JSON.parse(details) as {
|
||||
dateKey?: string;
|
||||
text?: string;
|
||||
timeout?: boolean;
|
||||
model?: string;
|
||||
};
|
||||
expect(parsed.text).toBeTruthy();
|
||||
expect(parsed.timeout).toBe(true);
|
||||
expect(parsed.model).toBe("timeout-stub");
|
||||
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(new Date());
|
||||
const y = parts.find((p) => p.type === "year")!.value;
|
||||
const m = parts.find((p) => p.type === "month")!.value;
|
||||
const d = parts.find((p) => p.type === "day")!.value;
|
||||
const todayKey = `${y}-${m}-${d}`;
|
||||
const stubRow = {
|
||||
createdAt: new Date(),
|
||||
details: JSON.stringify({
|
||||
dateKey: todayKey,
|
||||
locale: "en",
|
||||
text: "Mood fallback text…",
|
||||
timeout: true,
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
|
||||
const providerCall = vi.fn();
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: providerCall,
|
||||
} as never);
|
||||
|
||||
const result = await generateMoodStatusForUser("user-1", { locale: "en" });
|
||||
|
||||
expect(providerCall).not.toHaveBeenCalled();
|
||||
expect(result.text).toBe("Mood fallback text…");
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
|
||||
it("strips metric: tokens out of the cached text before persisting", async () => {
|
||||
const t = new Date();
|
||||
|
||||
@@ -7,6 +7,13 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
* cached-style envelope so the InsightStatusCard renders the
|
||||
* fallback text instead of spinning behind React-Query's default
|
||||
* retries.
|
||||
*
|
||||
* v1.4.41 — the timeout branch now persists a sentinel keyed to
|
||||
* today (see `persistTimeoutStubAndReturn`) so subsequent mounts
|
||||
* short-circuit at the cache lookup. The persisted row carries
|
||||
* `timeout: true` + `model: "timeout-stub"` so the daily pre-warm
|
||||
* job can recognise and overwrite the stub instead of treating it
|
||||
* as a real assessment.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -51,6 +58,9 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => {
|
||||
{ value: 72, measuredAt: new Date() },
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
|
||||
// Provider stalls indefinitely.
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
@@ -69,9 +79,19 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => {
|
||||
expect(result.cached).toBe(true);
|
||||
expect(typeof result.text).toBe("string");
|
||||
expect(result.text?.length ?? 0).toBeGreaterThan(0);
|
||||
// The route must NOT persist a fallback into the audit cache —
|
||||
// a transient provider stall should not poison tomorrow's cache
|
||||
// hit.
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
// v1.4.41 — the timeout branch now persists a sentinel row so
|
||||
// subsequent mounts short-circuit at the cache lookup. The
|
||||
// daily pre-warm worker overwrites the stub by detecting
|
||||
// `timeout: true` + `model: "timeout-stub"`.
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const createArg = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as {
|
||||
data: { details: string };
|
||||
};
|
||||
const parsed = JSON.parse(createArg.data.details) as {
|
||||
timeout?: boolean;
|
||||
model?: string;
|
||||
};
|
||||
expect(parsed.timeout).toBe(true);
|
||||
expect(parsed.model).toBe("timeout-stub");
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,53 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePulseStatusForUser — v1.4.41 timeout-stub short-circuit", () => {
|
||||
// The persist-on-timeout path is covered by
|
||||
// `pulse-status-timeout.test.ts`. This block pins the second-mount
|
||||
// behaviour: when a sentinel row already exists for today the cache
|
||||
// lookup short-circuits and the provider is never invoked.
|
||||
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(new Date());
|
||||
const y = parts.find((p) => p.type === "year")!.value;
|
||||
const m = parts.find((p) => p.type === "month")!.value;
|
||||
const d = parts.find((p) => p.type === "day")!.value;
|
||||
const todayKey = `${y}-${m}-${d}`;
|
||||
const stubRow = {
|
||||
createdAt: new Date(),
|
||||
details: JSON.stringify({
|
||||
dateKey: todayKey,
|
||||
locale: "en",
|
||||
text: "Pulse fallback text…",
|
||||
timeout: true,
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
|
||||
const providerCall = vi.fn();
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: providerCall,
|
||||
} as never);
|
||||
|
||||
const result = await generatePulseStatusForUser("user-1", { locale: "en" });
|
||||
|
||||
expect(providerCall).not.toHaveBeenCalled();
|
||||
expect(result.text).toBe("Pulse fallback text…");
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
|
||||
it("strips metric: tokens out of the cached text before persisting", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
|
||||
@@ -82,6 +82,94 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateWeightStatusForUser — v1.4.41 timeout-stub persistence", () => {
|
||||
it("persists a sentinel row keyed to today when the provider times out", async () => {
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
|
||||
// Provider stalls indefinitely so the timeout race wins.
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: vi.fn(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
/* never resolves */
|
||||
}),
|
||||
),
|
||||
} as never);
|
||||
|
||||
vi.useFakeTimers();
|
||||
const promise = generateWeightStatusForUser("user-1", { locale: "en" });
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
const result = await promise;
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBeTruthy();
|
||||
|
||||
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
|
||||
expect(createCalls.length).toBe(1);
|
||||
const details = (createCalls[0][0] as { data: { details: string } }).data
|
||||
.details;
|
||||
const parsed = JSON.parse(details) as {
|
||||
dateKey?: string;
|
||||
text?: string;
|
||||
timeout?: boolean;
|
||||
model?: string;
|
||||
};
|
||||
expect(parsed.text).toBeTruthy();
|
||||
expect(parsed.timeout).toBe(true);
|
||||
expect(parsed.model).toBe("timeout-stub");
|
||||
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(new Date());
|
||||
const y = parts.find((p) => p.type === "year")!.value;
|
||||
const m = parts.find((p) => p.type === "month")!.value;
|
||||
const d = parts.find((p) => p.type === "day")!.value;
|
||||
const todayKey = `${y}-${m}-${d}`;
|
||||
const stubRow = {
|
||||
createdAt: new Date(),
|
||||
details: JSON.stringify({
|
||||
dateKey: todayKey,
|
||||
locale: "en",
|
||||
text: "Weight is a directional metric…",
|
||||
timeout: true,
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
|
||||
const providerCall = vi.fn();
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: providerCall,
|
||||
} as never);
|
||||
|
||||
const result = await generateWeightStatusForUser("user-1", {
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(providerCall).not.toHaveBeenCalled();
|
||||
expect(result.text).toBe("Weight is a directional metric…");
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
|
||||
it("strips metric: tokens out of the cached text before persisting", async () => {
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
withTimeout,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
@@ -542,12 +543,18 @@ export async function generateBloodPressureStatusForUser(
|
||||
);
|
||||
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: getNoKeyBloodPressureStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
// v1.4.37 — persist a sentinel row keyed to today so the next
|
||||
// mount short-circuits at the cache lookup above instead of
|
||||
// re-racing the same 20 s provider call on every cold visit.
|
||||
// See `persistTimeoutStubAndReturn` for the full rationale.
|
||||
return persistTimeoutStubAndReturn({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
locale,
|
||||
providerType: provider.type,
|
||||
stubText: getNoKeyBloodPressureStatusText(locale),
|
||||
});
|
||||
}
|
||||
|
||||
const result = raced.value;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
withTimeout,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
@@ -390,12 +391,18 @@ export async function generateGeneralStatusForUser(
|
||||
);
|
||||
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: getNoKeyGeneralStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
// v1.4.37 — persist a sentinel row keyed to today so the next
|
||||
// mount short-circuits at the cache lookup above instead of
|
||||
// re-racing the same 20 s provider call on every cold visit.
|
||||
// See `persistTimeoutStubAndReturn` for the full rationale.
|
||||
return persistTimeoutStubAndReturn({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
locale,
|
||||
providerType: provider.type,
|
||||
stubText: getNoKeyGeneralStatusText(locale),
|
||||
});
|
||||
}
|
||||
|
||||
const result = raced.value;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
withTimeout,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
// 360 daily days + 24 monthly windows ≈ 1080 days of intake history.
|
||||
@@ -104,6 +105,8 @@ export async function generateMedicationComplianceStatusForUser(
|
||||
const parsed = JSON.parse(latestCache.details) as {
|
||||
dateKey?: string;
|
||||
summary?: string;
|
||||
text?: string;
|
||||
timeout?: boolean;
|
||||
medications?: MedicationSummaryItem[];
|
||||
};
|
||||
|
||||
@@ -126,6 +129,25 @@ export async function generateMedicationComplianceStatusForUser(
|
||||
updatedAt: latestCache.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// v1.4.41 — timeout-stub rows persist `text` + `timeout: true`
|
||||
// instead of the standard `summary` + `medications` envelope.
|
||||
// Recognise the stub so the second mount short-circuits at the
|
||||
// cache lookup instead of re-racing the provider call.
|
||||
if (
|
||||
parsed.dateKey === todayKey &&
|
||||
parsed.timeout === true &&
|
||||
typeof parsed.text === "string" &&
|
||||
parsed.text.trim().length > 0
|
||||
) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
summary: parsed.text,
|
||||
medications: [],
|
||||
cached: true,
|
||||
updatedAt: latestCache.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid cache payload
|
||||
}
|
||||
@@ -332,12 +354,28 @@ export async function generateMedicationComplianceStatusForUser(
|
||||
);
|
||||
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
// v1.4.37 — persist a sentinel row keyed to today so the next
|
||||
// mount short-circuits at the cache lookup above instead of
|
||||
// re-racing the same 20 s provider call on every cold visit.
|
||||
// See `persistTimeoutStubAndReturn` for the full rationale.
|
||||
// This route returns a richer shape (`summary`, `medications`)
|
||||
// than the standard `text` envelope, so the helper is called
|
||||
// for its persist side-effect and the route maps `summary` ←
|
||||
// `text` on the way out.
|
||||
const stubReturn = await persistTimeoutStubAndReturn({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
locale,
|
||||
providerType: provider.type,
|
||||
stubText: getNoKeyMedicationComplianceStatusText(locale),
|
||||
});
|
||||
return {
|
||||
hasProvider: true,
|
||||
summary: getNoKeyMedicationComplianceStatusText(locale),
|
||||
summary: stubReturn.text,
|
||||
medications: [],
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
updatedAt: stubReturn.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
withTimeout,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
@@ -447,12 +448,18 @@ export async function generateMoodStatusForUser(
|
||||
);
|
||||
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: getNoKeyMoodStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
// v1.4.37 — persist a sentinel row keyed to today so the next
|
||||
// mount short-circuits at the cache lookup above instead of
|
||||
// re-racing the same 20 s provider call on every cold visit.
|
||||
// See `persistTimeoutStubAndReturn` for the full rationale.
|
||||
return persistTimeoutStubAndReturn({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
locale,
|
||||
providerType: provider.type,
|
||||
stubText: getNoKeyMoodStatusText(locale),
|
||||
});
|
||||
}
|
||||
|
||||
const result = raced.value;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
withTimeout,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
@@ -315,8 +316,10 @@ export async function generatePulseStatusForUser(
|
||||
// (or upstream failure) return the no-key fallback text in a
|
||||
// cached-style envelope so the status card renders deterministically
|
||||
// instead of spinning behind React-Query's default retry ladder.
|
||||
// The fallback is NOT persisted to the audit cache — a transient
|
||||
// upstream stall would otherwise poison tomorrow's hit.
|
||||
// v1.4.41 — the timeout branch now persists a sentinel keyed to
|
||||
// today (see `persistTimeoutStubAndReturn`) so subsequent mounts
|
||||
// short-circuit at the cache lookup instead of re-racing the
|
||||
// upstream call. The daily pre-warm job overwrites the stub.
|
||||
const raced = await withTimeout(
|
||||
() =>
|
||||
provider.generateCompletion({
|
||||
@@ -335,12 +338,18 @@ export async function generatePulseStatusForUser(
|
||||
);
|
||||
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: getNoKeyPulseStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
// v1.4.37 — persist a sentinel row keyed to today so the next
|
||||
// mount short-circuits at the cache lookup above instead of
|
||||
// re-racing the same 20 s provider call on every cold visit.
|
||||
// See `persistTimeoutStubAndReturn` for the full rationale.
|
||||
return persistTimeoutStubAndReturn({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
locale,
|
||||
providerType: provider.type,
|
||||
stubText: getNoKeyPulseStatusText(locale),
|
||||
});
|
||||
}
|
||||
|
||||
const result = raced.value;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
withTimeout,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
@@ -440,12 +441,18 @@ export async function generateWeightStatusForUser(
|
||||
);
|
||||
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: getNoKeyWeightStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
// v1.4.37 — persist a sentinel row keyed to today so the next
|
||||
// mount short-circuits at the cache lookup above instead of
|
||||
// re-racing the same 20 s provider call on every cold visit.
|
||||
// See `persistTimeoutStubAndReturn` for the full rationale.
|
||||
return persistTimeoutStubAndReturn({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
locale,
|
||||
providerType: provider.type,
|
||||
stubText: getNoKeyWeightStatusText(locale),
|
||||
});
|
||||
}
|
||||
|
||||
const result = raced.value;
|
||||
|
||||
Reference in new issue
Block a user