diff --git a/src/lib/garmin.test.ts b/src/lib/garmin.test.ts index 8646096..d2fa5fa 100644 --- a/src/lib/garmin.test.ts +++ b/src/lib/garmin.test.ts @@ -4,6 +4,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GarminTokens } from "@/types"; +// Helper to create mock fetch responses with both text() and json() methods +function mockJsonResponse(data: unknown, ok = true, status = 200) { + const jsonStr = JSON.stringify(data); + return { + ok, + status, + text: () => Promise.resolve(jsonStr), + json: () => Promise.resolve(data), + }; +} + import { daysUntilExpiry, fetchBodyBattery, @@ -245,13 +256,11 @@ describe("fetchHrvStatus", () => { }); it("returns Balanced when API returns BALANCED status", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - hrvSummary: { lastNightAvg: 45, weeklyAvg: 42, status: "BALANCED" }, - }), - }); + global.fetch = vi.fn().mockResolvedValue( + mockJsonResponse({ + hrvSummary: { lastNightAvg: 45, weeklyAvg: 42, status: "BALANCED" }, + }), + ); const result = await fetchHrvStatus("2024-01-15", "test-token"); @@ -267,13 +276,11 @@ describe("fetchHrvStatus", () => { }); it("returns Unbalanced when API returns UNBALANCED status", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - hrvSummary: { lastNightAvg: 25, weeklyAvg: 42, status: "UNBALANCED" }, - }), - }); + global.fetch = vi.fn().mockResolvedValue( + mockJsonResponse({ + hrvSummary: { lastNightAvg: 25, weeklyAvg: 42, status: "UNBALANCED" }, + }), + ); const result = await fetchHrvStatus("2024-01-15", "test-token"); @@ -281,10 +288,7 @@ describe("fetchHrvStatus", () => { }); it("returns Unknown when API returns no data", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({}), - }); + global.fetch = vi.fn().mockResolvedValue(mockJsonResponse({})); const result = await fetchHrvStatus("2024-01-15", "test-token"); @@ -292,10 +296,9 @@ describe("fetchHrvStatus", () => { }); it("returns Unknown when API returns null hrvSummary", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ hrvSummary: null }), - }); + global.fetch = vi + .fn() + .mockResolvedValue(mockJsonResponse({ hrvSummary: null })); const result = await fetchHrvStatus("2024-01-15", "test-token"); @@ -334,16 +337,14 @@ describe("fetchBodyBattery", () => { }); it("returns current and yesterday low values on success", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - bodyBatteryValuesArray: [ - { date: "2024-01-15", charged: 85, drained: 60 }, - ], - bodyBatteryStatList: [{ date: "2024-01-14", min: 25, max: 95 }], - }), - }); + global.fetch = vi.fn().mockResolvedValue( + mockJsonResponse({ + bodyBatteryValuesArray: [ + { date: "2024-01-15", charged: 85, drained: 60 }, + ], + bodyBatteryStatList: [{ date: "2024-01-14", min: 25, max: 95 }], + }), + ); const result = await fetchBodyBattery("2024-01-15", "test-token"); @@ -362,14 +363,12 @@ describe("fetchBodyBattery", () => { }); it("returns null values when data is missing", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - bodyBatteryValuesArray: [], - bodyBatteryStatList: [], - }), - }); + global.fetch = vi.fn().mockResolvedValue( + mockJsonResponse({ + bodyBatteryValuesArray: [], + bodyBatteryStatList: [], + }), + ); const result = await fetchBodyBattery("2024-01-15", "test-token"); @@ -380,10 +379,7 @@ describe("fetchBodyBattery", () => { }); it("returns null values when API returns empty object", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({}), - }); + global.fetch = vi.fn().mockResolvedValue(mockJsonResponse({})); const result = await fetchBodyBattery("2024-01-15", "test-token"); @@ -419,14 +415,12 @@ describe("fetchBodyBattery", () => { }); it("handles partial data - only current available", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - bodyBatteryValuesArray: [{ date: "2024-01-15", charged: 70 }], - bodyBatteryStatList: [], - }), - }); + global.fetch = vi.fn().mockResolvedValue( + mockJsonResponse({ + bodyBatteryValuesArray: [{ date: "2024-01-15", charged: 70 }], + bodyBatteryStatList: [], + }), + ); const result = await fetchBodyBattery("2024-01-15", "test-token"); @@ -449,16 +443,14 @@ describe("fetchIntensityMinutes", () => { }); it("returns 7-day intensity minutes total on success", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - weeklyTotal: { - moderateIntensityMinutes: 45, - vigorousIntensityMinutes: 30, - }, - }), - }); + global.fetch = vi.fn().mockResolvedValue( + mockJsonResponse({ + weeklyTotal: { + moderateIntensityMinutes: 45, + vigorousIntensityMinutes: 30, + }, + }), + ); const result = await fetchIntensityMinutes("test-token"); @@ -474,10 +466,7 @@ describe("fetchIntensityMinutes", () => { }); it("returns 0 when no intensity data available", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({}), - }); + global.fetch = vi.fn().mockResolvedValue(mockJsonResponse({})); const result = await fetchIntensityMinutes("test-token"); @@ -485,10 +474,9 @@ describe("fetchIntensityMinutes", () => { }); it("returns 0 when weeklyTotal is null", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ weeklyTotal: null }), - }); + global.fetch = vi + .fn() + .mockResolvedValue(mockJsonResponse({ weeklyTotal: null })); const result = await fetchIntensityMinutes("test-token"); @@ -496,16 +484,14 @@ describe("fetchIntensityMinutes", () => { }); it("handles only moderate intensity minutes", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - weeklyTotal: { - moderateIntensityMinutes: 60, - vigorousIntensityMinutes: 0, - }, - }), - }); + global.fetch = vi.fn().mockResolvedValue( + mockJsonResponse({ + weeklyTotal: { + moderateIntensityMinutes: 60, + vigorousIntensityMinutes: 0, + }, + }), + ); const result = await fetchIntensityMinutes("test-token"); @@ -513,16 +499,14 @@ describe("fetchIntensityMinutes", () => { }); it("handles only vigorous intensity minutes", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - weeklyTotal: { - moderateIntensityMinutes: 0, - vigorousIntensityMinutes: 45, - }, - }), - }); + global.fetch = vi.fn().mockResolvedValue( + mockJsonResponse({ + weeklyTotal: { + moderateIntensityMinutes: 0, + vigorousIntensityMinutes: 45, + }, + }), + ); const result = await fetchIntensityMinutes("test-token"); diff --git a/src/lib/garmin.ts b/src/lib/garmin.ts index b7b2ab2..b7ecf48 100644 --- a/src/lib/garmin.ts +++ b/src/lib/garmin.ts @@ -72,7 +72,15 @@ export async function fetchHrvStatus( return "Unknown"; } - const data = await response.json(); + const text = await response.text(); + if (!text.startsWith("{") && !text.startsWith("[")) { + logger.error( + { endpoint: "hrv-service", responseBody: text.slice(0, 1000) }, + "Garmin returned non-JSON response", + ); + return "Unknown"; + } + const data = JSON.parse(text); const status = data?.hrvSummary?.status; if (status === "BALANCED") { @@ -120,7 +128,15 @@ export async function fetchBodyBattery( return { current: null, yesterdayLow: null }; } - const data = await response.json(); + const text = await response.text(); + if (!text.startsWith("{") && !text.startsWith("[")) { + logger.error( + { endpoint: "bodyBattery", responseBody: text.slice(0, 1000) }, + "Garmin returned non-JSON response", + ); + return { current: null, yesterdayLow: null }; + } + const data = JSON.parse(text); const currentData = data?.bodyBatteryValuesArray?.[0]; const current = currentData?.charged ?? null; @@ -170,7 +186,15 @@ export async function fetchIntensityMinutes( return 0; } - const data = await response.json(); + const text = await response.text(); + if (!text.startsWith("{") && !text.startsWith("[")) { + logger.error( + { endpoint: "fitnessstats", responseBody: text.slice(0, 1000) }, + "Garmin returned non-JSON response", + ); + return 0; + } + const data = JSON.parse(text); const weeklyTotal = data?.weeklyTotal; if (!weeklyTotal) {