Log raw response when Garmin returns non-JSON
All checks were successful
Deploy / deploy (push) Successful in 1m40s

Garmin is returning HTML error pages instead of JSON data. This
change reads the response as text first, checks if it starts with
{ or [, and logs the first 1000 chars of the response body if not.

This will help diagnose what page Garmin is returning (login, captcha,
rate limit, etc).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 13:20:28 +00:00
parent 85b535f04a
commit 98293f5ab5
2 changed files with 101 additions and 93 deletions

View File

@@ -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");

View File

@@ -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) {