mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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.
201 lines
6.9 KiB
TypeScript
201 lines
6.9 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
|
|
/**
|
|
* v1.4.19 phase A5 — `/settings/integrations` on a Pixel-5 viewport
|
|
* (393 CSS px) must:
|
|
*
|
|
* 1. Not produce horizontal page overflow. The new
|
|
* IntegrationStatusPill is `whitespace-nowrap` so the chip
|
|
* itself can grow as wide as its longest possible label
|
|
* ("Verbunden · vor 12 min" / "Error — reconnect"); the parent
|
|
* header is a `flex-wrap` so the pill drops to its own line if
|
|
* the title and pill together exceed the card width — this spec
|
|
* verifies neither path leaks into a horizontal scrollbar on the
|
|
* <html> element.
|
|
*
|
|
* 2. Render exactly one pill per integration card. Belt-and-braces
|
|
* guard for the card consolidation — the Vitest spec covers this
|
|
* on the SSR markup level, but the Pixel-5 layout is what matters
|
|
* in practice.
|
|
*
|
|
* Mobile-only spec — desktop project skips it.
|
|
*/
|
|
test.describe("/settings/integrations Pixel-5 layout", () => {
|
|
test.use({ storageState: STORAGE_STATE_PATH });
|
|
|
|
test.beforeEach(({}, testInfo) => {
|
|
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
|
|
});
|
|
|
|
test("Pixel-5 viewport: no horizontal page scroll, one pill per card", async ({
|
|
page,
|
|
}) => {
|
|
// Stub the three endpoints the Integrations section reads — both
|
|
// integrations connected, recent sync — so the cards render their
|
|
// post-connect branch (the visually densest layout).
|
|
await page.route("**/api/settings/global-services", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
telegramGlobal: true,
|
|
ntfyGlobal: true,
|
|
webPushGlobal: true,
|
|
apiGlobal: true,
|
|
moodLogGlobal: true,
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const recent = new Date(Date.now() - 12 * 60 * 1000).toISOString();
|
|
|
|
await page.route("**/api/integrations/status", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
threshold: 3,
|
|
integrations: [
|
|
{
|
|
integration: "withings",
|
|
state: "connected",
|
|
lastSuccessAt: recent,
|
|
lastAttemptAt: recent,
|
|
lastError: null,
|
|
consecutiveFailures: 0,
|
|
// v1.12.1 — the cards read connection state from this
|
|
// consolidated envelope (the per-provider routes were
|
|
// dropped from the section). The Withings pill keys on
|
|
// `connected`; the moodLog pill keys on `configured`.
|
|
configured: true,
|
|
connected: true,
|
|
connectedAt: "2026-04-01T12:00:00Z",
|
|
legacyLastSyncedAt: recent,
|
|
tokenExpiresAt: "2026-12-01T12:00:00Z",
|
|
tokenExpired: false,
|
|
scope: "user.info,user.metrics,user.activity",
|
|
hasActivityScope: true,
|
|
},
|
|
{
|
|
integration: "moodlog",
|
|
state: "connected",
|
|
lastSuccessAt: recent,
|
|
lastAttemptAt: recent,
|
|
lastError: null,
|
|
consecutiveFailures: 0,
|
|
configured: true,
|
|
enabled: true,
|
|
legacyLastSyncedAt: recent,
|
|
webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
entryCount: 42,
|
|
},
|
|
// v1.28.x — Strava OAuth card reads off this consolidated
|
|
// envelope; without a `strava` entry the new card's query key
|
|
// never resolves and the integrations page hangs.
|
|
{
|
|
integration: "strava",
|
|
state: "disconnected",
|
|
lastSuccessAt: null,
|
|
lastAttemptAt: null,
|
|
lastError: null,
|
|
connected: false,
|
|
configured: false,
|
|
available: false,
|
|
hasOwnCredentials: false,
|
|
},
|
|
],
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/withings/status", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
connected: true,
|
|
configured: true,
|
|
lastSyncedAt: recent,
|
|
connectedAt: "2026-04-01T12:00:00Z",
|
|
tokenExpired: false,
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/integrations/moodlog/status", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
configured: true,
|
|
enabled: true,
|
|
lastSyncedAt: recent,
|
|
entryCount: 42,
|
|
webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.goto("/settings/integrations", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Wait for the pills to mount (queries resolve after first paint).
|
|
// One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar,
|
|
// Oura, Strava, Nightscout. (The Garmin info note is not an OAuth card and
|
|
// carries no pill.)
|
|
const pills = page.locator('[data-testid="integration-status-pill"]');
|
|
await expect(pills).toHaveCount(8, { timeout: 10_000 });
|
|
|
|
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
|
|
// stays dormant until an operator adds credentials, so assert the one
|
|
// connected pill.
|
|
await expect(
|
|
page.locator(
|
|
'[data-testid="integration-status-pill"][data-state="connected"]',
|
|
),
|
|
).toHaveCount(1);
|
|
|
|
// The redundant v1.4.18 banner must be gone.
|
|
await expect(
|
|
page.locator('[data-testid="integration-status-banner"]'),
|
|
).toHaveCount(0);
|
|
|
|
// No horizontal page overflow. We allow a 1 px tolerance because
|
|
// sub-pixel rounding can flag a 0.5 px overshoot as 1 px.
|
|
const dims = await page.evaluate(() => ({
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
innerWidth: window.innerWidth,
|
|
}));
|
|
expect(
|
|
dims.scrollWidth,
|
|
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
|
|
).toBeLessThanOrEqual(dims.innerWidth + 1);
|
|
|
|
// Pill text remains legible at this viewport — assert non-empty
|
|
// bounding rect so we know it's painted, not display:none-d by
|
|
// some surprising container query.
|
|
for (let i = 0; i < 2; i++) {
|
|
const box = await pills.nth(i).boundingBox();
|
|
expect(box, `pill ${i} has no bounding box`).not.toBeNull();
|
|
expect(box!.width).toBeGreaterThan(0);
|
|
expect(box!.height).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|