mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
test(integrations): cover the verdict ladder and the coverage guarantee
The load-bearing one is the panel-coverage test: render the panel with an envelope carrying every integration key and require each configured one to surface as a card or as a fallback row. Drop the fallback row and it goes red, which is the whole point — the previous shape of this bug was a provider on the wire that no screen rendered, and no card-shaped test could have caught it. Alongside it: the ladder table with the month-silent fixture that resolves to stalled, the 24-hour and 7-day boundaries asserted against literals rather than the constants they check, the per-metric stale flag firing only under a healthy provider, and a structural guard that the verdict-to-pill mapping is defined in exactly one module. The card suites migrate with the semantics: a transient failure now expects the warning pill everywhere, and the Apple Health suite trades its bespoke badge states for pill assertions. The e2e mocks gain the verdict shape, the nightscout entry, and an assertion that the card-less row is on the page.
This commit is contained in:
1 parent
ce74fd52a2
commit
10593a11a8
13 files changed
+1245
-183
No files matched your search
@@ -13,6 +13,8 @@ vi.mock("@/lib/db", () => ({
|
||||
fitbitConnection: { findUnique: vi.fn(async () => null) },
|
||||
googleHealthConnection: { findUnique: vi.fn(async () => null) },
|
||||
moodEntry: { count: vi.fn(async () => 0) },
|
||||
measurement: { groupBy: vi.fn(async () => []) },
|
||||
workout: { groupBy: vi.fn(async () => []) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -31,10 +33,11 @@ const ledger: Record<string, unknown> = {
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({
|
||||
integration,
|
||||
...ledger,
|
||||
...(perIntegrationLedger[integration] ?? ledger),
|
||||
})),
|
||||
getPersistentFailureThreshold: () => 5,
|
||||
}));
|
||||
const perIntegrationLedger: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
vi.mock("@/lib/withings/client", () => ({ hasActivityScope: () => false }));
|
||||
vi.mock("@/lib/moodlog-secret", () => ({ readMoodLogSecret: () => null }));
|
||||
@@ -50,6 +53,7 @@ vi.mock("@/lib/oura/credentials", () => ({
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ATTEMPT_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict";
|
||||
|
||||
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const whoopFind = prisma.whoopConnection.findUnique as ReturnType<typeof vi.fn>;
|
||||
@@ -60,6 +64,10 @@ type Entry = {
|
||||
configured?: boolean;
|
||||
available?: boolean;
|
||||
hasOwnCredentials?: boolean;
|
||||
hasToken?: boolean;
|
||||
allowPrivateHost?: boolean;
|
||||
syncHealth?: { verdict: string; since: string | null };
|
||||
metricFreshness?: Array<{ type: string; lastSeenAt: string; stale: boolean }>;
|
||||
};
|
||||
|
||||
async function fetchEntries(): Promise<Entry[]> {
|
||||
@@ -71,6 +79,9 @@ async function fetchEntries(): Promise<Entry[]> {
|
||||
describe("/api/integrations/status — Polar/Oura fold (04-M2)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const key of Object.keys(perIntegrationLedger)) {
|
||||
delete perIntegrationLedger[key];
|
||||
}
|
||||
polarAvailable.mockResolvedValue(true);
|
||||
ouraAvailable.mockResolvedValue(false);
|
||||
});
|
||||
@@ -139,3 +150,95 @@ describe("/api/integrations/status — WHOOP identity ownership", () => {
|
||||
expect(whoop?.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The envelope is the unit of coverage for liveness. Every entry carries the
|
||||
* verdict, whether or not a card renders it — a card-level verdict misses a
|
||||
* card-less provider by construction, which is exactly how a month-dead sync
|
||||
* stayed invisible.
|
||||
*/
|
||||
describe("/api/integrations/status — sync-health verdict on every entry", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const key of Object.keys(perIntegrationLedger)) {
|
||||
delete perIntegrationLedger[key];
|
||||
}
|
||||
polarAvailable.mockResolvedValue(true);
|
||||
ouraAvailable.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("carries a syncHealth verdict on every entry", async () => {
|
||||
userFind.mockResolvedValue({});
|
||||
const entries = await fetchEntries();
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
for (const entry of entries) {
|
||||
expect(entry.syncHealth, entry.integration).toBeDefined();
|
||||
expect(typeof entry.syncHealth!.verdict).toBe("string");
|
||||
expect(Array.isArray(entry.metricFreshness)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a configured, month-silent card-less provider as stalled", async () => {
|
||||
// The live incident, on the wire: moodLog has no Settings card, so this
|
||||
// entry is the only thing that can tell anyone the sync has stopped.
|
||||
const lastAttemptAt = new Date(
|
||||
Date.now() - 28 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
perIntegrationLedger.moodlog = {
|
||||
state: "error_transient",
|
||||
lastSuccessAt: new Date(
|
||||
Date.now() - 55 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
lastAttemptAt,
|
||||
lastError: null,
|
||||
};
|
||||
userFind.mockResolvedValue({
|
||||
moodLogUrlEncrypted: "enc",
|
||||
moodLogEnabled: true,
|
||||
});
|
||||
|
||||
const moodlog = (await fetchEntries()).find(
|
||||
(entry) => entry.integration === "moodlog",
|
||||
)!;
|
||||
expect(moodlog.configured).toBe(true);
|
||||
expect(moodlog.syncHealth).toEqual({
|
||||
verdict: "stalled",
|
||||
since: lastAttemptAt,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an integration that attempted within the window off the stalled arm", async () => {
|
||||
perIntegrationLedger.moodlog = {
|
||||
state: "error_transient",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: new Date(
|
||||
Date.now() - (ATTEMPT_STALE_AFTER_MS - 60_000),
|
||||
).toISOString(),
|
||||
lastError: null,
|
||||
};
|
||||
userFind.mockResolvedValue({ moodLogUrlEncrypted: "enc" });
|
||||
|
||||
const moodlog = (await fetchEntries()).find(
|
||||
(entry) => entry.integration === "moodlog",
|
||||
)!;
|
||||
expect(moodlog.syncHealth!.verdict).toBe("failing");
|
||||
});
|
||||
|
||||
it("includes a nightscout entry with the fields the card's form reads", async () => {
|
||||
userFind.mockResolvedValue({
|
||||
nightscoutUrlEncrypted: "enc",
|
||||
nightscoutTokenEncrypted: "enc",
|
||||
nightscoutAllowPrivateHost: true,
|
||||
});
|
||||
|
||||
const nightscout = (await fetchEntries()).find(
|
||||
(entry) => entry.integration === "nightscout",
|
||||
);
|
||||
expect(nightscout).toBeDefined();
|
||||
expect(nightscout!.connected).toBe(true);
|
||||
expect(nightscout!.configured).toBe(true);
|
||||
expect(nightscout!.hasToken).toBe(true);
|
||||
expect(nightscout!.allowPrivateHost).toBe(true);
|
||||
expect(nightscout!.syncHealth).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user