Files
HealthLog/src/lib/integrations/__tests__/http-status-classifier.test.ts
T
Marc-André Bombeck a3704f1633 Lift shared HTTP-status classifier for OAuth integrations
Polar and Oura response classifiers were byte-identical copies of the
status-driven verdict, the typed API error, and the invalid_grant probe.
Extract classifyHttpStatus, an IntegrationApiError base carrying the vendor
label, and a vendor-scoped isOAuthInvalidGrant into a shared module; Polar
and Oura now re-export under their existing names and types so call sites
and instanceof checks are unchanged.

Collapse the triplicated classifyXFailure mapping (Polar, Oura, Nightscout)
onto one toFailureKind helper. Behaviour is identical: same classifications,
same failure kinds, same error types.
2026-06-14 21:08:45 +02:00

205 lines
6.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
classifyHttpStatus,
classifyIntegrationError,
IntegrationApiError,
isOAuthInvalidGrant,
} from "../http-status-classifier";
import { toFailureKind } from "../status";
describe("classifyHttpStatus", () => {
it("classifies 2xx as success", () => {
for (const status of [200, 201, 204, 299]) {
const v = classifyHttpStatus(status);
expect(v.classification).toBe("success");
expect(v.httpStatus).toBe(status);
expect(v.reason).toBe("ok");
}
});
it("classifies 429 as transient (rate-limit)", () => {
const v = classifyHttpStatus(429);
expect(v.classification).toBe("transient");
expect(v.reason).toBe("http_429");
});
it("classifies 401 and 403 as reauth_required", () => {
expect(classifyHttpStatus(401).classification).toBe("reauth_required");
expect(classifyHttpStatus(403).classification).toBe("reauth_required");
expect(classifyHttpStatus(401).reason).toBe("http_401");
});
it("classifies other 4xx as persistent", () => {
for (const status of [400, 404, 422]) {
const v = classifyHttpStatus(status);
expect(v.classification).toBe("persistent");
expect(v.reason).toBe(`http_${status}`);
}
});
it("classifies 5xx as transient", () => {
for (const status of [500, 502, 503]) {
expect(classifyHttpStatus(status).classification).toBe("transient");
}
});
it("classifies an unknown / 3xx status as transient with _unknown reason", () => {
const v = classifyHttpStatus(302);
expect(v.classification).toBe("transient");
expect(v.reason).toBe("http_302_unknown");
});
});
describe("IntegrationApiError", () => {
it("carries the vendor label in the message prefix and on the field", () => {
const err = new IntegrationApiError({
vendor: "Acme",
verb: "fetch",
classification: "reauth_required",
httpStatus: 401,
reason: "http_401",
});
expect(err.message).toBe("Acme fetch error: 401");
expect(err.vendor).toBe("Acme");
expect(err.classification).toBe("reauth_required");
});
it("appends the upstream error segment and caps at 1024 chars", () => {
const err = new IntegrationApiError({
vendor: "Acme",
verb: "token",
classification: "persistent",
httpStatus: 400,
reason: "http_400",
upstreamError: "invalid_grant",
});
expect(err.message).toBe("Acme token error: 400 - invalid_grant");
const huge = new IntegrationApiError({
vendor: "Acme",
verb: "fetch",
classification: "transient",
httpStatus: 500,
reason: "http_500",
upstreamError: "x".repeat(5000),
});
expect(huge.message.length).toBe(1024);
});
});
describe("isOAuthInvalidGrant", () => {
it("lifts a 400 invalid_grant from the upstreamError field", () => {
const err = new IntegrationApiError({
vendor: "Acme",
verb: "token",
classification: "persistent",
httpStatus: 400,
reason: "http_400",
upstreamError: "invalid_grant",
});
expect(isOAuthInvalidGrant(err, "Acme")).toBe(true);
});
it("does not lift a non-400 or a non-invalid_grant body", () => {
const wrongStatus = new IntegrationApiError({
vendor: "Acme",
verb: "token",
classification: "reauth_required",
httpStatus: 401,
reason: "http_401",
upstreamError: "invalid_grant",
});
expect(isOAuthInvalidGrant(wrongStatus, "Acme")).toBe(false);
const wrongBody = new IntegrationApiError({
vendor: "Acme",
verb: "token",
classification: "persistent",
httpStatus: 400,
reason: "http_400",
upstreamError: "invalid_client",
});
expect(isOAuthInvalidGrant(wrongBody, "Acme")).toBe(false);
});
it("falls back to the vendor-scoped message shape across a lost prototype", () => {
expect(
isOAuthInvalidGrant(
new Error("Acme token error: 400 - invalid_grant"),
"Acme",
),
).toBe(true);
// Wrong vendor label in the message → no match.
expect(
isOAuthInvalidGrant(
new Error("Other token error: 400 - invalid_grant"),
"Acme",
),
).toBe(false);
});
it("returns false for a plain non-API error", () => {
expect(isOAuthInvalidGrant(new Error("network down"), "Acme")).toBe(false);
expect(isOAuthInvalidGrant("boom", "Acme")).toBe(false);
});
});
describe("classifyIntegrationError", () => {
it("reads the classification off an IntegrationApiError", () => {
const err = new IntegrationApiError({
vendor: "Acme",
verb: "fetch",
classification: "persistent",
httpStatus: 404,
reason: "http_404",
});
expect(classifyIntegrationError(err, "Acme")).toBe("persistent");
});
it("parses the status out of a legacy vendor-scoped message", () => {
expect(
classifyIntegrationError(new Error("Acme fetch error: 503"), "Acme"),
).toBe("transient");
expect(
classifyIntegrationError(new Error("Acme fetch error: 401"), "Acme"),
).toBe("reauth_required");
});
it("defaults to transient for an unparseable error", () => {
expect(classifyIntegrationError(new Error("network down"), "Acme")).toBe(
"transient",
);
expect(classifyIntegrationError("boom", "Acme")).toBe("transient");
});
});
describe("toFailureKind", () => {
it("maps reauth_required and persistent through unchanged", () => {
expect(toFailureKind("reauth_required")).toBe("reauth_required");
expect(toFailureKind("persistent")).toBe("persistent");
});
it("collapses success and transient onto transient", () => {
expect(toFailureKind("transient")).toBe("transient");
expect(toFailureKind("success")).toBe("transient");
});
it("maps the boundary HTTP codes end-to-end", () => {
expect(toFailureKind(classifyHttpStatus(401).classification)).toBe(
"reauth_required",
);
expect(toFailureKind(classifyHttpStatus(403).classification)).toBe(
"reauth_required",
);
expect(toFailureKind(classifyHttpStatus(404).classification)).toBe(
"persistent",
);
expect(toFailureKind(classifyHttpStatus(500).classification)).toBe(
"transient",
);
expect(toFailureKind(classifyHttpStatus(429).classification)).toBe(
"transient",
);
});
});