mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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.
116 lines
4.2 KiB
TypeScript
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");
|
|
});
|
|
});
|