mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
487 lines
16 KiB
TypeScript
487 lines
16 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
vi.mock("@/lib/api-handler", () => ({
|
|
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
|
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
|
}));
|
|
|
|
vi.mock("@/lib/integrations/connection-health-load", () => ({
|
|
loadConnectionHealth: vi.fn().mockResolvedValue({}),
|
|
}));
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
user: { findUnique: vi.fn() },
|
|
withingsConnection: { findUnique: vi.fn(async () => null) },
|
|
whoopConnection: { findUnique: vi.fn(async () => null) },
|
|
fitbitConnection: { findUnique: vi.fn(async () => null) },
|
|
googleHealthConnection: { findUnique: vi.fn(async () => null) },
|
|
moodEntry: { count: vi.fn(async () => 0) },
|
|
$queryRaw: vi.fn(async () => []),
|
|
workout: { groupBy: vi.fn(async () => []) },
|
|
},
|
|
}));
|
|
|
|
// v1.42 — the per-source type walk that replaced the measurement groupBy.
|
|
vi.mock("@/lib/measurements/live-types", () => ({
|
|
listLiveMeasurementTypes: vi.fn(async () => []),
|
|
}));
|
|
|
|
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
|
|
|
vi.mock("@/lib/api-response", () => ({
|
|
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
|
}));
|
|
|
|
const ledger: Record<string, unknown> = {
|
|
state: "connected",
|
|
lastSuccessAt: null,
|
|
lastAttemptAt: null,
|
|
lastError: null,
|
|
failingSince: null,
|
|
};
|
|
vi.mock("@/lib/integrations/status", () => ({
|
|
getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({
|
|
integration,
|
|
...(perIntegrationLedger[integration] ?? ledger),
|
|
})),
|
|
getPersistentFailureThreshold: () => 5,
|
|
}));
|
|
const perIntegrationLedger: Record<string, Record<string, unknown>> = {};
|
|
|
|
vi.mock("@/lib/withings/client", () => ({ hasActivityScope: () => false }));
|
|
|
|
const polarAvailable = vi.fn(async () => true);
|
|
const ouraAvailable = vi.fn(async () => false);
|
|
vi.mock("@/lib/polar/credentials", () => ({
|
|
getPolarClientCredentials: () => polarAvailable(),
|
|
}));
|
|
vi.mock("@/lib/oura/credentials", async () => ({
|
|
readOuraScopeDenied: (
|
|
await vi.importActual<typeof import("@/lib/oura/credentials")>(
|
|
"@/lib/oura/credentials",
|
|
)
|
|
).readOuraScopeDenied,
|
|
getOuraClientCredentials: () => ouraAvailable(),
|
|
}));
|
|
|
|
import { GET } from "../route";
|
|
import { prisma } from "@/lib/db";
|
|
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
|
|
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>;
|
|
|
|
type Entry = {
|
|
integration: string;
|
|
state?: string;
|
|
connected?: boolean;
|
|
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 fetchEnvelope(): Promise<{
|
|
integrations: Entry[];
|
|
metricFreshnessDegraded: boolean;
|
|
}> {
|
|
return (await (GET as unknown as () => Promise<{ data: unknown }>)())
|
|
.data as {
|
|
integrations: Entry[];
|
|
metricFreshnessDegraded: boolean;
|
|
};
|
|
}
|
|
|
|
async function fetchEntries(): Promise<Entry[]> {
|
|
return (await fetchEnvelope()).integrations;
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
it("includes polar + oura entries on the consolidated envelope", async () => {
|
|
userFind.mockResolvedValue({
|
|
polarAccessTokenEncrypted: "tok",
|
|
polarClientIdEncrypted: "id",
|
|
polarClientSecretEncrypted: "sec",
|
|
ouraAccessTokenEncrypted: null,
|
|
ouraClientIdEncrypted: null,
|
|
ouraClientSecretEncrypted: null,
|
|
});
|
|
|
|
const entries = await fetchEntries();
|
|
const keys = entries.map((e) => e.integration);
|
|
expect(keys).toEqual(expect.arrayContaining(["polar", "oura"]));
|
|
|
|
const polar = entries.find((e) => e.integration === "polar")!;
|
|
expect(polar.connected).toBe(true);
|
|
expect(polar.configured).toBe(true);
|
|
expect(polar.available).toBe(true);
|
|
expect(polar.hasOwnCredentials).toBe(true);
|
|
|
|
const oura = entries.find((e) => e.integration === "oura")!;
|
|
expect(oura.connected).toBe(false);
|
|
expect(oura.available).toBe(false);
|
|
expect(oura.hasOwnCredentials).toBe(false);
|
|
});
|
|
|
|
it("reports polar disconnected when no access token is stored", async () => {
|
|
userFind.mockResolvedValue({
|
|
polarAccessTokenEncrypted: null,
|
|
polarClientIdEncrypted: null,
|
|
polarClientSecretEncrypted: null,
|
|
});
|
|
const entries = await fetchEntries();
|
|
const polar = entries.find((e) => e.integration === "polar")!;
|
|
expect(polar.connected).toBe(false);
|
|
expect(polar.configured).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("/api/integrations/status — WHOOP identity ownership", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
userFind.mockResolvedValue({
|
|
whoopClientIdEncrypted: "id",
|
|
whoopClientSecretEncrypted: "secret",
|
|
});
|
|
});
|
|
|
|
it("reports an identity-cleared duplicate row as disconnected", async () => {
|
|
whoopFind.mockResolvedValue({
|
|
whoopUserId: null,
|
|
tokenExpiresAt: new Date(Date.now() + 3_600_000),
|
|
lastSyncedAt: null,
|
|
createdAt: new Date("2026-06-01T00:00:00Z"),
|
|
backfillCompletedAt: null,
|
|
});
|
|
|
|
const entries = await fetchEntries();
|
|
const whoop = entries.find((entry) => entry.integration === "whoop");
|
|
|
|
expect(whoop?.connected).toBe(false);
|
|
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 provider as stalled", async () => {
|
|
// The class the verdict exists for: the ledger state has been frozen
|
|
// since the last write, so only the age of `lastAttemptAt` can say the
|
|
// sync has stopped attempting.
|
|
const lastAttemptAt = new Date(
|
|
Date.now() - 28 * 24 * 60 * 60 * 1000,
|
|
).toISOString();
|
|
perIntegrationLedger.nightscout = {
|
|
state: "error_transient",
|
|
lastSuccessAt: new Date(
|
|
Date.now() - 55 * 24 * 60 * 60 * 1000,
|
|
).toISOString(),
|
|
lastAttemptAt,
|
|
lastError: null,
|
|
};
|
|
userFind.mockResolvedValue({ nightscoutUrlEncrypted: "enc" });
|
|
|
|
const nightscout = (await fetchEntries()).find(
|
|
(entry) => entry.integration === "nightscout",
|
|
)!;
|
|
expect(nightscout.configured).toBe(true);
|
|
expect(nightscout.syncHealth).toEqual({
|
|
verdict: "stalled",
|
|
since: lastAttemptAt,
|
|
});
|
|
});
|
|
|
|
it("keeps an integration that attempted within the window off the stalled arm", async () => {
|
|
perIntegrationLedger.nightscout = {
|
|
state: "error_transient",
|
|
lastSuccessAt: null,
|
|
lastAttemptAt: new Date(
|
|
Date.now() - (ATTEMPT_STALE_AFTER_MS - 60_000),
|
|
).toISOString(),
|
|
lastError: null,
|
|
};
|
|
userFind.mockResolvedValue({ nightscoutUrlEncrypted: "enc" });
|
|
|
|
const nightscout = (await fetchEntries()).find(
|
|
(entry) => entry.integration === "nightscout",
|
|
)!;
|
|
expect(nightscout.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();
|
|
});
|
|
});
|
|
|
|
describe("/api/integrations/status — a provider that has delivered nothing for weeks", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
for (const key of Object.keys(perIntegrationLedger)) {
|
|
delete perIntegrationLedger[key];
|
|
}
|
|
userFind.mockResolvedValue({});
|
|
whoopFind.mockResolvedValue(null);
|
|
polarAvailable.mockResolvedValue(true);
|
|
ouraAvailable.mockResolvedValue(false);
|
|
});
|
|
|
|
it("reports it as failing and dates the verdict from the streak, not the retry", async () => {
|
|
const failingSince = new Date(
|
|
Date.now() - 17 * 24 * 60 * 60 * 1000,
|
|
).toISOString();
|
|
perIntegrationLedger.nightscout = {
|
|
state: "error_transient",
|
|
// The hourly cron is still running and a sibling leg can still stamp a
|
|
// success, so neither of these tells the user anything.
|
|
lastSuccessAt: new Date(Date.now() - 20 * 60_000).toISOString(),
|
|
lastAttemptAt: new Date(Date.now() - 4 * 60_000).toISOString(),
|
|
failingSince,
|
|
lastError: "Nightscout sync HTTP 502",
|
|
};
|
|
userFind.mockResolvedValue({ nightscoutUrlEncrypted: "enc" });
|
|
|
|
const nightscout = (await fetchEntries()).find(
|
|
(entry) => entry.integration === "nightscout",
|
|
)!;
|
|
expect(nightscout.syncHealth).toEqual({
|
|
verdict: "failing",
|
|
since: failingSince,
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The freshness read is fail-soft, and it used to be fail-silent with it.
|
|
*
|
|
* `getSourceMetricFreshness` is one query for all eight providers, wrapped in a
|
|
* `.catch(() => ({}))` so a groupBy hiccup never 500s the whole settings
|
|
* surface. The fallback makes every entry's `metricFreshness` an empty array —
|
|
* which is byte-identical to what a user with nothing recorded sees. So the
|
|
* honesty signal built to say "this metric has gone quiet" answered "nothing
|
|
* has gone quiet" when it had in fact failed to look.
|
|
*
|
|
* `metricFreshnessDegraded` separates the two. It stays a flag rather than a
|
|
* nullable array per entry because the query fails for all eight providers at
|
|
* once or for none of them.
|
|
*/
|
|
describe("/api/integrations/status — the freshness read failed", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
for (const key of Object.keys(perIntegrationLedger)) {
|
|
delete perIntegrationLedger[key];
|
|
}
|
|
userFind.mockResolvedValue({});
|
|
whoopFind.mockResolvedValue(null);
|
|
polarAvailable.mockResolvedValue(true);
|
|
ouraAvailable.mockResolvedValue(false);
|
|
});
|
|
|
|
it("reports the degradation instead of passing an empty list off as an answer", async () => {
|
|
vi.mocked(listLiveMeasurementTypes).mockRejectedValue(
|
|
new Error("statement timeout"),
|
|
);
|
|
|
|
const envelope = await fetchEnvelope();
|
|
|
|
expect(envelope.metricFreshnessDegraded).toBe(true);
|
|
// Still fail-soft: the rest of the envelope survives, all eight entries
|
|
// present, because a freshness hiccup is never worth the settings surface.
|
|
expect(envelope.integrations).toHaveLength(8);
|
|
for (const entry of envelope.integrations) {
|
|
expect(entry.metricFreshness).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("reports no degradation when the read simply found nothing", async () => {
|
|
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([]);
|
|
(prisma.workout.groupBy as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
|
|
const envelope = await fetchEnvelope();
|
|
|
|
// The same empty lists as above, and this is the case they honestly mean.
|
|
expect(envelope.metricFreshnessDegraded).toBe(false);
|
|
for (const entry of envelope.integrations) {
|
|
expect(entry.metricFreshness).toEqual([]);
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* v1.38.19 — the never-connected provider on the wire.
|
|
*
|
|
* The ledger's synthetic "no row" snapshot reads `unknown`, and the envelope
|
|
* publishes it verbatim. A client that reads `state` must see a value that
|
|
* cannot be mistaken for a live connection, while `syncHealth.verdict` keeps
|
|
* saying `disconnected` for the same entry.
|
|
*/
|
|
describe("/api/integrations/status — a provider with no ledger row", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
for (const key of Object.keys(perIntegrationLedger)) {
|
|
delete perIntegrationLedger[key];
|
|
}
|
|
polarAvailable.mockResolvedValue(true);
|
|
ouraAvailable.mockResolvedValue(false);
|
|
});
|
|
|
|
it("publishes state unknown beside connected false and a disconnected verdict", async () => {
|
|
perIntegrationLedger.whoop = {
|
|
state: "unknown",
|
|
lastSuccessAt: null,
|
|
lastAttemptAt: null,
|
|
lastError: null,
|
|
failingSince: null,
|
|
};
|
|
userFind.mockResolvedValue({});
|
|
whoopFind.mockResolvedValue(null);
|
|
|
|
const whoop = (await fetchEntries()).find(
|
|
(e) => e.integration === "whoop",
|
|
)!;
|
|
expect(whoop.state).toBe("unknown");
|
|
expect(whoop.connected).toBe(false);
|
|
expect(whoop.syncHealth?.verdict).toBe("disconnected");
|
|
});
|
|
});
|
|
|
|
describe("/api/integrations/status — what a provider withholds by contract", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("names the readings the Withings plan withholds and the Oura collections the grant lacks", async () => {
|
|
(
|
|
prisma.withingsConnection.findUnique as ReturnType<typeof vi.fn>
|
|
).mockResolvedValueOnce({
|
|
tokenExpiresAt: new Date(Date.now() + 3_600_000),
|
|
lastSyncedAt: null,
|
|
createdAt: new Date(),
|
|
scope: "user.metrics,user.activity",
|
|
apiPlanState: {
|
|
version: 1,
|
|
measures: {
|
|
unavailable: [155, 54],
|
|
checkedAt: new Date().toISOString(),
|
|
},
|
|
ecg: { unavailable: ["heart"], checkedAt: new Date().toISOString() },
|
|
},
|
|
});
|
|
userFind.mockResolvedValue({
|
|
ouraAccessTokenEncrypted: "tok",
|
|
ouraScopeDenied: ["resilience", "spo2", "not_a_collection"],
|
|
});
|
|
|
|
const entries = (await fetchEntries()) as Array<
|
|
Entry & { planUnavailable?: string[]; scopeReconnect?: string[] }
|
|
>;
|
|
const withings = entries.find((e) => e.integration === "withings")!;
|
|
const oura = entries.find((e) => e.integration === "oura")!;
|
|
|
|
expect(withings.planUnavailable).toEqual(["spo2", "vascularAge", "ecg"]);
|
|
expect(oura.scopeReconnect).toEqual(["spo2", "resilience"]);
|
|
});
|
|
|
|
it("publishes empty lists when nothing is withheld", async () => {
|
|
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
|
|
|
|
const entries = (await fetchEntries()) as Array<
|
|
Entry & { planUnavailable?: string[]; scopeReconnect?: string[] }
|
|
>;
|
|
expect(
|
|
entries.find((e) => e.integration === "withings")!.planUnavailable,
|
|
).toEqual([]);
|
|
expect(
|
|
entries.find((e) => e.integration === "oura")!.scopeReconnect,
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("/api/integrations/status — the unified connection health", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
for (const key of Object.keys(perIntegrationLedger)) {
|
|
delete perIntegrationLedger[key];
|
|
}
|
|
});
|
|
|
|
it("puts each provider's resolved health on its own entry, null where not connected", async () => {
|
|
const { loadConnectionHealth } =
|
|
await import("@/lib/integrations/connection-health-load");
|
|
const ouraHealth = { state: "limited", reason: "permission_limited" };
|
|
vi.mocked(loadConnectionHealth).mockResolvedValueOnce({
|
|
withings: null,
|
|
whoop: null,
|
|
fitbit: null,
|
|
"google-health": null,
|
|
polar: null,
|
|
oura: ouraHealth,
|
|
strava: null,
|
|
nightscout: null,
|
|
"apple-health": null,
|
|
} as never);
|
|
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
|
|
|
|
const entries = (await fetchEntries()) as Array<
|
|
Entry & { health?: unknown }
|
|
>;
|
|
expect(entries.find((e) => e.integration === "oura")?.health).toEqual(
|
|
ouraHealth,
|
|
);
|
|
for (const entry of entries.filter((e) => e.integration !== "oura")) {
|
|
expect(entry.health).toBeNull();
|
|
}
|
|
});
|
|
});
|