mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
chore(release): v1.28.30 — the daily briefing stops silently skipping days
The recurring no-briefing day had a chain of causes, closed end to end: Every failure path in the nightly pregenerate + the comprehensive generator now annotates a queryable cause (several classes — features-error, payload-too-large, aborted, generator-failed — previously bumped a counter with zero trace). A failed nightly warm enqueues one bounded retry ~45 minutes later (singleton per user, existing budgets apply), so a transient provider hiccup heals before morning. A cached payload that carries NO briefing no longer satisfies the 24h POST short-circuit nor the unchanged-content hash gate — a once-stripped briefing could previously block its own regeneration indefinitely. The card surfaces the omission reason persistently (grounding strip, generation failure, not attempted) with a forcing retry. Documentation corrected against code truth: Withings credentials are per-user (the documented env path never existed; the dead example entries and manifest rows are gone, and WITHINGS_WEBHOOK_SECRET is now actually whitelisted in compose — stock deploys could never deliver it); the classic Fitbit portal no longer accepts new registrations; the backup-restore example no longer mints a fresh encryption key and uses pnpm dlx tsx; the TLS guide describes the real client model (two CA-level SPKI pins, any-match, leaf renewals need no build); scaling/admin references fixed; the operator-shared subscription provider is documented.
This commit is contained in:
1 parent
14a517387e
commit
a4feabec81
26 files changed
+1190
-148
No files matched your search
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* v1.28.30 — wire-contract for the explicit regenerate.
|
||||
*
|
||||
* House rule: generation = nightly cron + explicit button. The button's
|
||||
* mutation therefore MUST issue `POST { force: true }` — without `force`
|
||||
* the route's 24 h short-circuit re-serves the cached payload and the
|
||||
* button reads as doing nothing (one half of the recurring
|
||||
* "no briefing today" chain). The read path stays a plain GET.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const apiFetchRaw = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiFetchRaw: (...a: unknown[]) => apiFetchRaw(...a),
|
||||
}));
|
||||
|
||||
import { fetchAdvisor } from "../use-insights-advisor";
|
||||
|
||||
function jsonResponse(data: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiFetchRaw.mockResolvedValue(
|
||||
jsonResponse({ insights: { dailyBriefing: null }, cached: false }),
|
||||
);
|
||||
});
|
||||
|
||||
describe("fetchAdvisor — explicit regenerate forces a generation", () => {
|
||||
it("POSTs force: true on the regenerate path", async () => {
|
||||
const result = await fetchAdvisor({ force: true });
|
||||
|
||||
expect(apiFetchRaw).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = apiFetchRaw.mock.calls[0] as [
|
||||
string,
|
||||
{ method: string; body?: string },
|
||||
];
|
||||
expect(url).toBe("/api/insights/generate");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body ?? "{}")).toEqual({ force: true });
|
||||
expect(result.outcome).toBe("fresh");
|
||||
});
|
||||
|
||||
it("uses a read-only GET (no body, no force) on the read path", async () => {
|
||||
await fetchAdvisor();
|
||||
|
||||
expect(apiFetchRaw).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = apiFetchRaw.mock.calls[0] as [
|
||||
string,
|
||||
{ method: string; body?: string },
|
||||
];
|
||||
expect(url).toBe("/api/insights/generate");
|
||||
expect(init.method).toBe("GET");
|
||||
expect(init.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lifts briefingOmittedReason through the payload for the card", async () => {
|
||||
apiFetchRaw.mockResolvedValue(
|
||||
jsonResponse({
|
||||
insights: { dailyBriefing: null },
|
||||
cached: true,
|
||||
briefingOmittedReason: "ungrounded",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchAdvisor();
|
||||
expect(result.payload?.briefingOmittedReason).toBe("ungrounded");
|
||||
expect(result.payload?.dailyBriefing).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -92,8 +92,12 @@ export interface InsightAdvisorPayload {
|
||||
* v1.28.28 (#470) — set by the POST when the number-grounding gate stripped
|
||||
* the freshly generated briefing (after its one corrective retry). Without
|
||||
* it the 200 carried a silently-null briefing and the card's "no briefing
|
||||
* yet" made the regenerate button read as doing nothing. Only ever present
|
||||
* on a force-regenerate response; absent on the read path.
|
||||
* yet" made the regenerate button read as doing nothing.
|
||||
*
|
||||
* v1.28.30 — also carried by the read-only GET, backed by a dated
|
||||
* server-side marker: a briefing the nightly warm stripped now renders
|
||||
* the same honest "withheld" state on every read until the next
|
||||
* successful generation, not only on the regenerate response.
|
||||
*/
|
||||
briefingOmittedReason?: "ungrounded" | null;
|
||||
}
|
||||
@@ -169,7 +173,14 @@ interface AdvisorFetchResult {
|
||||
outcome: AdvisorFetchOutcome;
|
||||
}
|
||||
|
||||
async function fetchAdvisor(
|
||||
/**
|
||||
* Exported as a test seam (like `nextAdvisorPollInterval`): the explicit
|
||||
* regenerate MUST post `force: true` — generation happens on the nightly
|
||||
* cron and on explicit intent only, and a force-less button silently
|
||||
* re-reads the 24 h cache (the "button does nothing" half of the
|
||||
* no-briefing-today chain). The wire-contract test pins it.
|
||||
*/
|
||||
export async function fetchAdvisor(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<AdvisorFetchResult> {
|
||||
const controller = new AbortController();
|
||||
@@ -409,9 +420,10 @@ export function useInsightsAdvisorQuery(
|
||||
generationFailed: query.data?.payload?.generationFailed ?? false,
|
||||
// Absent → no specific lever hint; the generic failed-description holds.
|
||||
generationFailureClass: query.data?.payload?.generationFailureClass ?? null,
|
||||
// v1.28.28 (#470) — only a force-regenerate response carries the field
|
||||
// (setQueryData writes that response into this cache); the read path's
|
||||
// GET never does, so the hint clears on the next successful read.
|
||||
// v1.28.28 (#470) — a force-regenerate response carries the field via
|
||||
// setQueryData; since v1.28.30 the GET carries it too (marker-backed),
|
||||
// so the "withheld" state persists across reads and clears on the next
|
||||
// successful generation.
|
||||
briefingOmittedReason: query.data?.payload?.briefingOmittedReason ?? null,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user