Files
HealthLog/src/lib/strava/__tests__/client.test.ts
T
Marc-André Bombeck 75eb0637e6 feat(strava): add per-user BYO-OAuth workout source + Garmin indirect-path docs
Add Strava as a per-user BYO-OAuth workout source, mirroring the shipped
WHOOP/Oura/Polar pattern. Strava caps every new app at single-player mode, so
each self-hoster registers their own app and pastes the client id/secret into
Settings (falling back to a shared env app). Strava is a workout source only —
it exposes no daily metrics and no training-depth analytics are derived.

- Schema (migration 0233): per-user encrypted client id/secret + access/refresh
  tokens, plaintext athlete id, incremental cursor, and backfill marker on
  `User`; `STRAVA` added to `MeasurementSource`.
- Token model follows Oura's reactive-refresh-on-401 with a compare-and-set on
  the stored refresh ciphertext, because Strava rotates its refresh token on
  every refresh.
- Client over safeFetch: authorization-code + refresh, activity list + budget-
  aware per-activity detail (for calories/HR), honouring the 200/15min cap; each
  activity maps to one `Workout` row. Activity name/description stay in metadata
  as data, never surfaced or interpolated.
- Dedup reuses the existing source-agnostic two-layer picker: `STRAVA` slots
  into the workout ladder below the device-native wearables and above
  MANUAL/IMPORT, so an Apple-Watch + Strava twin collapses to one canonical row.
- Poll-cohort hourly sync (:17) + self-converging boot backfill, wired into the
  integration-sync registrar. Webhook deferred.
- OAuth routes (connect/callback/credentials/disconnect/status/test) with signed
  state, cross-account re-auth rejection, and a surgical `www.strava.com`
  connect-src gated to the Strava surfaces.
- Settings card + consolidated status envelope entry; env + compose whitelist;
  i18n across all six locales.
- Garmin: no direct connector is possible (business-partner-only). Add
  docs/integrations/garmin.md documenting the Apple Health / Google Health
  Connect path and Garmin's withheld proprietary scores, cross-linked from both
  platform docs, plus a non-connector info note in the Connections panel.

Regenerate docs/api/openapi.yaml for the new `STRAVA` MeasurementSource value.
2026-07-09 21:25:25 +02:00

116 lines
4.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
mapActivity,
summaryHasHeartRate,
type StravaSummaryActivity,
type StravaDetailedActivity,
} from "../client";
import { classifyStravaResponse } from "../response-classifier";
const baseSummary: StravaSummaryActivity = {
id: 123456,
name: "Morning Run",
distance: 5000,
moving_time: 1500,
elapsed_time: 1600,
total_elevation_gain: 42,
type: "Run",
sport_type: "TrailRun",
start_date: "2026-07-01T06:30:00Z",
};
describe("mapActivity", () => {
it("maps a summary-only activity (no detail → no calories)", () => {
const row = mapActivity({
...baseSummary,
average_heartrate: 148,
max_heartrate: 171,
});
expect(row).not.toBeNull();
expect(row!.externalId).toBe("123456");
expect(row!.sportType).toBe("TrailRun");
expect(row!.startedAt.toISOString()).toBe("2026-07-01T06:30:00.000Z");
// endedAt = start + elapsed_time (clock window), not moving_time.
expect(row!.endedAt.toISOString()).toBe("2026-07-01T06:56:40.000Z");
// durationSec prefers moving_time.
expect(row!.durationSec).toBe(1500);
expect(row!.totalDistanceM).toBe(5000);
expect(row!.elevationM).toBe(42);
expect(row!.avgHeartRate).toBe(148);
expect(row!.maxHeartRate).toBe(171);
// No detail → calories null.
expect(row!.totalEnergyKcal).toBeNull();
});
it("fills calories + HR from the DetailedActivity when the summary lacks them", () => {
const summary: StravaSummaryActivity = { ...baseSummary };
delete (summary as unknown as Record<string, unknown>).average_heartrate;
const detail: StravaDetailedActivity = {
...baseSummary,
average_heartrate: 150,
max_heartrate: 175,
calories: 480,
};
const row = mapActivity(summary, detail);
expect(row!.totalEnergyKcal).toBe(480);
expect(row!.avgHeartRate).toBe(150);
expect(row!.maxHeartRate).toBe(175);
});
it("keeps the activity name in metadata as data, never surfaced as a field", () => {
const row = mapActivity(baseSummary);
const meta = row!.metadata as Record<string, unknown>;
expect(meta.stravaName).toBe("Morning Run");
// The row shape has no top-level name/description — free-text stays in meta.
expect((row as unknown as Record<string, unknown>).name).toBeUndefined();
});
it("falls back to `type` then a generic label for the sport", () => {
const noSport: StravaSummaryActivity = { ...baseSummary };
delete (noSport as unknown as Record<string, unknown>).sport_type;
expect(mapActivity(noSport)!.sportType).toBe("Run");
const noneAtAll: StravaSummaryActivity = { ...baseSummary };
delete (noneAtAll as unknown as Record<string, unknown>).sport_type;
delete (noneAtAll as unknown as Record<string, unknown>).type;
expect(mapActivity(noneAtAll)!.sportType).toBe("workout");
});
it("returns null for an activity with no id or no start instant", () => {
expect(
mapActivity({ ...baseSummary, id: undefined as unknown as number }),
).toBeNull();
const noStart: StravaSummaryActivity = { ...baseSummary };
delete (noStart as unknown as Record<string, unknown>).start_date;
expect(mapActivity(noStart)).toBeNull();
});
it("drops negative / non-finite numeric fields rather than storing them", () => {
const row = mapActivity({
...baseSummary,
distance: -1,
average_heartrate: -5,
});
expect(row!.totalDistanceM).toBeNull();
expect(row!.avgHeartRate).toBeNull();
});
});
describe("summaryHasHeartRate", () => {
it("detects HR presence via flag or values", () => {
expect(summaryHasHeartRate({ id: 1, has_heartrate: true })).toBe(true);
expect(summaryHasHeartRate({ id: 1, average_heartrate: 140 })).toBe(true);
expect(summaryHasHeartRate({ id: 1 })).toBe(false);
});
});
describe("classifyStravaResponse", () => {
it("classifies the status classes the sync depends on", () => {
expect(classifyStravaResponse(200).classification).toBe("success");
expect(classifyStravaResponse(401).classification).toBe("reauth_required");
expect(classifyStravaResponse(429).classification).toBe("transient");
expect(classifyStravaResponse(500).classification).toBe("transient");
});
});