Compare commits
123 Commits
1329387528
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b238b3f54 | |||
| 5ebb0f756d | |||
| 49ebd289a4 | |||
| 10cdc4176a | |||
| 6effb79097 | |||
| 78c494affb | |||
| 0e6cf00dba | |||
| 8c9285f33c | |||
| dcbb1d8bb0 | |||
| 431315bac3 | |||
| 042579a396 | |||
| e2b2bf9611 | |||
| 19c9aecdeb | |||
| bee3d7e493 | |||
| d45489714d | |||
| e6cb21c65a | |||
| f6712497ba | |||
| 8508e89ad7 | |||
| 27af77418e | |||
| a2f24273ce | |||
| a394bbb770 | |||
| eb24dcd89d | |||
| c40f6634f2 | |||
| 6f6d1ea5e0 | |||
| 7d371219b7 | |||
| c6e2c5c00e | |||
| 1d878e0f46 | |||
| b5cd47c219 | |||
| 67f4ec27ab | |||
| fe344867c0 | |||
| 0e3295b746 | |||
| f9b1d454bc | |||
| 83a59d0fdd | |||
| 3cf5d4b747 | |||
| 273731442f | |||
| 7e960e3ce1 | |||
| 687dc2bca2 | |||
| 668fb9efb8 | |||
| 940295510e | |||
| a5fffff737 | |||
| d48bbfd43e | |||
| a147f5cffa | |||
| aef58fd529 | |||
| 3bf11ae6bf | |||
| b2c705ce29 | |||
| 6e01037832 | |||
| f7e7b68078 | |||
| 3a7f305221 | |||
| 0a7aa3a5e0 | |||
| 1046e9f7a0 | |||
| 101ef639ab | |||
| 1fb9271aaa | |||
| 39aa121420 | |||
| 35d9933d1b | |||
| 32fdfc1c72 | |||
| 7204a48c1c | |||
| 9d65c50068 | |||
| aac9359b02 | |||
| a828055c30 | |||
| 7f5618ce49 | |||
| d3b26d7d41 | |||
| 0211dffa1e | |||
| 12cbfdbbee | |||
| 92284d3b96 | |||
| 45f2921971 | |||
| 77f9588c2d | |||
| a12fe3e810 | |||
| 4d2cbe4883 | |||
| 7346e2eadd | |||
| c9e089a2b2 | |||
| 1d4b1cccfd | |||
| 379e7cc990 | |||
| 501d951e5b | |||
| 2fbe762290 | |||
| 84a7417781 | |||
| 7d68af5b3c | |||
| a1eaa42d42 | |||
| 35eff03509 | |||
| a622af1bfa | |||
| 7f4f6d3493 | |||
| 049ed16b69 | |||
| 002858c1cb | |||
| 6a3f0201af | |||
| 50d65b40c2 | |||
| d1d2d612a5 | |||
| 4c939be6d6 | |||
| f7bf7e9c79 | |||
| 8353cd148b | |||
| d7202eb9bb | |||
| ce5057a309 | |||
| efbe6a6760 | |||
| e2533bd1a8 | |||
| ab8cdea214 | |||
| e8c340a00e | |||
| c5faaf5a17 | |||
| 65a2dc90d0 | |||
| 3b2b5d0735 | |||
| d308201803 | |||
| 099e746119 | |||
| db2e8e487d | |||
| f93aa331d9 | |||
| d73c841ab7 | |||
| fe4978dbea | |||
| 85577c9d26 | |||
| 487df1f9b9 | |||
| e422f86925 | |||
| 136567d964 | |||
| 2fad83fe9a | |||
| f122f28206 | |||
| 1b9088bd03 | |||
| 284ec3142d | |||
| 8c94c94c3f | |||
| 9cbcf62aed | |||
| df197f45fd | |||
| 6c161f2206 | |||
| 7985000440 | |||
| de48c44858 | |||
| 0936d98161 | |||
| 961a7d8aca | |||
| 0ff6793854 | |||
| 644d87f1dc | |||
| 0f2101bce5 | |||
| 15c2b6a2b6 |
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: geniusrun-dev
|
||||
description: Use when working on the geniusrun repo (backend Go services, classification rule engine, mcp-garmin integration, or frontend) to stay consistent with established conventions.
|
||||
description: Use when working on the geniusrun repo (backend Go services, classification rule engine, Garmin integration, or frontend) to stay consistent with established conventions.
|
||||
---
|
||||
|
||||
# geniusrun-dev
|
||||
|
||||
## Project overview
|
||||
|
||||
geniusrun is a personal web app that pulls running activities from Garmin Connect (via the `mcp-garmin` MCP server), classifies each run into a user-defined "workout kind" (Easy, Tempo, Threshold, Interval, ...), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified.
|
||||
geniusrun is a personal web app that pulls running activities from Garmin Connect (via an embedded Python wrapper around `garminconnect`, spoken to over a JSON-lines subprocess protocol — see the Garmin integration section below), classifies each run into a user-defined "workout kind" (Easy, Tempo, Threshold, Interval, ...), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified.
|
||||
|
||||
**MVP scope boundary**: sync + classification + review queue + progression charts. A future training-recommendation engine (analyzing aerobic/anaerobic training-effect balance across kinds to suggest what to train next) is explicitly deferred — the schema stores the metrics it would need, but nothing consumes them yet.
|
||||
|
||||
@@ -17,8 +17,7 @@ geniusrun is a personal web app that pulls running activities from Garmin Connec
|
||||
backend/
|
||||
cmd/geniusrund/ main server entrypoint
|
||||
cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds
|
||||
cmd/mcpspike/ throwaway MCP-client spike, safe to delete
|
||||
internal/garmin/ MCP client wrapper (auth, get_activities, get_activity_splits, get_activity_details)
|
||||
internal/garmin/ Garmin Connect client: spawns an embedded Python wrapper (pyscript/wrapper.py, garminconnect) over a JSON-lines subprocess protocol (auth, get_activities, get_activity_splits, get_activity_details, get_workout_by_id)
|
||||
internal/garmin/mock/ fake Client for tests
|
||||
internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery)
|
||||
internal/store/ SQLite layer + embedded migrations
|
||||
@@ -54,16 +53,16 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
|
||||
- **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance — the device already knows which laps were work vs rest when the activity was recorded as a structured workout.
|
||||
- **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap (cardiac drift). Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery.
|
||||
|
||||
## mcp-garmin integration
|
||||
## Garmin integration (direct wrapper, no MCP)
|
||||
|
||||
`internal/garmin` is a Go MCP **client** (via `github.com/mark3labs/mcp-go`'s stdio transport) that spawns `mcp-garmin`'s `server.py` as a subprocess. Key things learned the hard way, that would otherwise get rediscovered:
|
||||
`internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
|
||||
|
||||
- **`authenticate()`/`complete_mfa()` return plain strings**, not structured JSON (`"Authenticated successfully."`, `"MFA required. ..."`, `"Authentication failed: ..."`). `internal/garmin/client.go`'s `parseAuthResult` pattern-matches these.
|
||||
- **The 10s "MFA required" timeout in mcp-garmin's `authenticate()` is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (mcp-garmin now logs this to stderr for exactly this reason).
|
||||
- **mcp-garmin persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var (default `~/.garth`) passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing.
|
||||
- **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
|
||||
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` emits a JSON log line for exactly this reason, forwarded into the Go log stream by `forwardWrapperStderr`).
|
||||
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var (the Go→Python subprocess protocol variable — the process-level config var is `GENIUSRUN_TOKENSTORE_PATH`) passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing.
|
||||
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
|
||||
- **`get_activity_details()` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries, despite what its docstring used to say. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
|
||||
- **`get_activity_splits()`** (added to mcp-garmin, wraps `garminconnect`'s existing `get_activity_splits`) is the one that returns actual lap/split summaries (`lapDTOs`).
|
||||
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
|
||||
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
|
||||
|
||||
## Data model conventions
|
||||
|
||||
@@ -75,7 +74,7 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
|
||||
|
||||
## Dev workflow
|
||||
|
||||
- Backend: `cd backend && go run ./cmd/geniusrund` (needs `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` env vars for the mcp-garmin subprocess paths; see `internal/config/config.go` for all knobs). Garmin credentials are **not** env vars — they live in the `profile` singleton row (`internal/store.Profile`), set via the frontend's Profile page or directly through `PUT /api/profile`.
|
||||
- Backend: `cd backend && go run ./cmd/geniusrund`. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GENIUSRUN_PYTHON_PATH` if you need a specific one (e.g. a venv with `garminconnect` installed) — see `internal/config/config.go` for all knobs. Garmin credentials are **not** env vars — they live in the `profile` singleton row (`internal/store.Profile`), set via the frontend's Profile page or directly through `PUT /api/profile`.
|
||||
- Frontend: `cd frontend && npm run dev` (set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`).
|
||||
- No live Garmin account needed for frontend/UI work: `go run ./cmd/seedsample -db /tmp/sample.db` seeds realistic activities/laps/kinds and runs them through the real classification engine, then point `geniusrund` at that DB.
|
||||
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess.
|
||||
@@ -87,4 +86,4 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
|
||||
- `internal/classify` tests are pure (no DB/network): construct a `MetricContext` + `RuleKind`s directly.
|
||||
- `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — this is deliberate, not mocked, since the migration/SQL correctness is exactly what needs catching.
|
||||
- `internal/api` tests use `httptest` against a `Server` wired to a temp DB + `mock.Client`.
|
||||
- The MCP/Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via `cmd/mcpspike` or the real `geniusrund` auth endpoints.
|
||||
- The Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via the real `geniusrund` auth endpoints.
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -20,6 +20,15 @@ frontend/dist/
|
||||
backend/mcpspike
|
||||
backend/cmd/mcpspike/mcpspike
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
|
||||
# Claude Code session-local runtime state
|
||||
.claude/*.lock
|
||||
|
||||
# Garmin token stores hold live OAuth tokens -- never track them.
|
||||
backend/.garmin/
|
||||
# SQLite sidecar files
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
# IDE state beyond the shared bits already ignored above
|
||||
.idea/
|
||||
|
||||
75
CLAUDE.md
75
CLAUDE.md
@@ -4,23 +4,24 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project overview
|
||||
|
||||
geniusrun (Go module `geniusrun/backend`) is a personal web app that pulls running activities from Garmin Connect (via the `mcp-garmin` MCP server), classifies each run into one of a fixed set of running-specific "workout kinds" (Easy, Long, 60' Threshold, 30' Threshold, Tempo, Intervals, MAS Test, Race), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified.
|
||||
geniusrun (Go module `geniusrun/backend`) is a personal web app that pulls running activities from Garmin Connect (via an embedded Python wrapper around `garminconnect`, spoken to over a JSON-lines subprocess protocol — see the Garmin integration section below), classifies each run into one of a fixed set of running-specific "workout kinds" (Easy, Long, 60' Threshold, 30' Threshold, Tempo, Intervals, MAS Test, Race), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified.
|
||||
|
||||
All Garmin credentials and every tunable analysis-engine parameter (HR zones, phase-detection minutes, pace-artifact filtering, chart colors, etc.) live in a single-row `profile` table, edited from the frontend's Profile screen — there is no multi-profile support, and `internal/config`'s env vars are limited to process-level plumbing (listen addr, DB path, mcp-garmin subprocess paths).
|
||||
All Garmin credentials and every tunable analysis-engine parameter (HR zones, phase-detection minutes, pace-artifact filtering, chart colors, etc.) live in a `profile` row scoped to the logged-in user, edited from the frontend's Profile screen. Every table holding synced/tunable data (`profile`, `workout_kinds`/`workout_type_paces`, `activities` and everything under it, `sync_state`/`sync_runs`) is scoped to a `user_id`, so each OIDC account gets its own fully isolated dataset — see Authentication below. There's no admin UI or profile switcher, and `internal/config`'s env vars are limited to process-level plumbing (listen addr, DB path, the Garmin wrapper's Python interpreter path).
|
||||
|
||||
The "Training plan" tab (`frontend/src/pages/Plan.tsx`) is an intentional empty stub — a future training-recommendation engine (analyzing training-effect balance across kinds, and a pace/HR-zone "delta" between declared targets and workout-derived reality) is deferred; see `docs/superpowers/specs/` and `docs/superpowers/plans/` for the design history behind what's already built and what's still open.
|
||||
The "Training plan" tab (`frontend/src/pages/Plan.tsx`) is an intentional empty stub — a future training-recommendation engine (analyzing training-effect balance across kinds, and a pace/HR-zone "delta" between declared targets and workout-derived reality) is deferred; see `docs/superpowers/specs/` and `docs/superpowers/plans/` for the design history behind what's already built and what's still open, and `docs/IDEAS.md` for informal, not-yet-formalized ideas for what's next — check it at the start of a session for context on where to pick up.
|
||||
|
||||
## Commands
|
||||
|
||||
Backend (from `backend/`):
|
||||
- Run the server: `./start.sh` (wraps `go run ./cmd/geniusrund` with the local mcp-garmin subprocess paths and DB path already set) or `go run ./cmd/geniusrund` directly if you export `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` yourself.
|
||||
- Run the server: `./start.sh` (wraps `go run ./cmd/geniusrund` with the DB path already set) or `go run ./cmd/geniusrund` directly. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GENIUSRUN_PYTHON_PATH` if you need a specific one (e.g. a venv with `garminconnect` installed).
|
||||
- Build/vet: `go build ./...` && `go vet ./...`
|
||||
- Format: `gofmt -l .` must report nothing before committing.
|
||||
- All tests: `go test ./...`
|
||||
- Single package: `go test ./internal/store/...`
|
||||
- Single test: `go test ./internal/store/... -run TestProfile -v`
|
||||
- Seed sample data (no live Garmin account needed): `go run ./cmd/seedsample -db /tmp/sample.db`, then point `geniusrund` at that DB via `GENIUSRUN_DB_PATH`.
|
||||
- Migrations live in `internal/store/migrations/`; add a new numbered file, never edit an already-applied one (the runner tracks applied filenames in a `schema_migrations` table).
|
||||
- The schema lives in one file, `internal/store/schema.sql`, applied in full on every `Open()` (idempotent -- skipped if the `users` table already exists). There is no migration history: this is a pre-production app with no compatibility obligation to older database files, so edit `schema.sql` directly rather than appending a migration. After changing it, regenerate the schema doc: `go run ./cmd/dumpschema` (writes `docs/DATABASE.md` from the live schema, so it can't drift out of sync).
|
||||
- **This app is still under active development, not yet in production.** A breaking schema change (new non-nullable column, renamed/removed column, changed constraint, etc.) is expected to require deleting the existing local DB file and letting `Open()` recreate it fresh from the current `schema.sql` -- this is the normal, accepted remedy during this phase, not a workaround to avoid. Do not add `ALTER TABLE` migration logic, backfill scripts, or any other backward-compatibility shim for an existing DB file to accommodate a schema change. Real migration tooling (Flyway) is planned once the first version ships to production; until then, every schema change is free to be breaking.
|
||||
|
||||
Frontend (from `frontend/`):
|
||||
- Run dev server: `./start.sh` (puts Homebrew's `node@22` on `PATH` and runs `npm install` if `node_modules` is missing) or `npm run dev` directly. Set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`.
|
||||
@@ -28,11 +29,15 @@ Frontend (from `frontend/`):
|
||||
- Lint: `npm run lint` (oxlint)
|
||||
- No frontend test suite exists yet.
|
||||
|
||||
## Authentication
|
||||
## Authentication & per-user data model
|
||||
|
||||
geniusrun requires login via an existing Keycloak realm (OIDC Authorization Code flow, backend-driven -- the browser never sees a Keycloak token, only geniusrun's own signed session cookie). This is an access gate only: every authenticated+authorized user reaches the same single profile/dataset described above, there is no per-user data scoping. Access additionally requires a specific realm role (`GENIUSRUN_OIDC_REQUIRED_ROLE`, default `geniusrun-user`) -- a successful Keycloak login alone is not sufficient if the realm is shared with other apps/users.
|
||||
geniusrun requires login via an existing Keycloak realm (OIDC Authorization Code flow, backend-driven -- the browser never sees a Keycloak token, only geniusrun's own signed session cookie). Access additionally requires a specific realm role (`GENIUSRUN_OIDC_REQUIRED_ROLE`, default `geniusrun-user`) -- a successful Keycloak login alone is not sufficient if the realm is shared with other apps/users.
|
||||
|
||||
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_PUBLIC_BASE_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the full design and `internal/auth` for the implementation.
|
||||
Beyond the login gate, every authenticated+authorized OIDC subject maps 1:1 to its own geniusrun `users` row and its own fully isolated dataset — there is no shared profile/dataset across accounts, no admin UI, and no profile switcher. `internal/api/usercontext.go`'s `resolveUser` middleware resolves the session's OIDC subject to a `store.User` (never blocking by itself); `requireProvisionedUser` 403s every data route until one exists. Every handler derives `userID` **only** from `userIDFromContext(r.Context())` — never a URL param, query string, or request body field; this is the entire security boundary the isolation depends on. See `docs/superpowers/plans/2026-07-25-per-user-profile.md` for the full design/implementation history, including the store/API-layer scoping pattern and the bugs it took to get right (a mid-transaction SQLite `PRAGMA foreign_keys` no-op, and a chi middleware-ordering panic).
|
||||
|
||||
A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install.
|
||||
|
||||
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_BACKEND_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_FRONTEND_URL` (defaults to `GENIUSRUN_BACKEND_URL` -- set this when the frontend and backend are different origins, e.g. local dev), `GENIUSRUN_OIDC_REQUIRED_ROLE`. Session duration is application configuration, not an env var: `session.duration` (hours, default `720`), editable on the `/config` page and applied on the next backend restart. See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer).
|
||||
|
||||
## Repo layout
|
||||
|
||||
@@ -40,16 +45,17 @@ Required env vars (`config.Load()` fails fast if any are unset, same pattern as
|
||||
backend/
|
||||
cmd/geniusrund/ main server entrypoint
|
||||
cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds
|
||||
cmd/mcpspike/ throwaway MCP-client spike, safe to delete
|
||||
internal/garmin/ MCP client wrapper (auth, get_activities, get_activity_splits, get_activity_details, get_workout_by_id)
|
||||
cmd/dumpschema/ regenerates docs/DATABASE.md from the live schema -- run after editing schema.sql
|
||||
internal/garmin/ Garmin Connect client: spawns an embedded Python wrapper (pyscript/wrapper.py, garminconnect) over a JSON-lines subprocess protocol (auth, get_activities, get_activity_splits, get_activity_details, get_workout_by_id)
|
||||
internal/garmin/mock/ fake Client for tests
|
||||
internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery)
|
||||
internal/store/ SQLite layer + embedded migrations
|
||||
internal/sync/ orchestrates fetch -> store -> classify
|
||||
internal/api/ HTTP handlers (chi router)
|
||||
internal/store/ SQLite layer; schema.sql is the whole schema (no migration history), users.go owns account provisioning
|
||||
internal/sync/ orchestrates fetch -> store -> classify; one Service instance per user
|
||||
internal/api/ HTTP handlers (chi router); usercontext.go/setup.go own the per-user access boundary
|
||||
internal/config/ env var config loading (process-level only, not user-tunable params)
|
||||
frontend/
|
||||
src/pages/ ReviewQueue ("Activities" tab), Dashboard ("Progression" tab), Plan (stub), Profile (reached via the profile-name button, not a tab)
|
||||
src/CreateProfile.tsx "Create your profile" screen shown to a brand-new, unprovisioned OIDC session
|
||||
src/components/charts/ Recharts wrappers (ExpectedVsActualChart, ProgressionChart)
|
||||
src/components/ ColorField, PaceField, NullableNumberField, RawDataModal, TrainingTypesCard
|
||||
src/api/client.ts thin typed fetch client
|
||||
@@ -74,45 +80,50 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
|
||||
- Leaf conditions: `{metric, op, value}`. `op` is `==`, `!=`, `>`, `>=`, `<`, `<=`, or `between` (value is a 2-element array).
|
||||
- Supported metrics (see `internal/sync/mapping.go`'s `buildMetricContext`): `avg_pace_sec_per_km`, `avg_hr`, `avg_hr_pct_max`, `max_hr`, `duration_seconds`, `distance_meters`, `elevation_gain_m`, `aerobic_training_effect`, `anaerobic_training_effect`, `vo2max_value`, `is_race`, `lap_interval_pattern` (0/1), `lap_pace_stddev`, `lap_hr_drift_bpm_per_min`, `lap_hr_recovery_bpm_per_min`.
|
||||
- **Scoring**: each leaf gets a margin-based confidence in ~[0,1] (`between` = distance from center; comparisons = logistic squash of margin past the threshold). Branches aggregate via `min` (AND) / `max` (OR) — no ML, fully explainable.
|
||||
- **`needs_review` triggers** (in `classify.Classify`): zero kinds matched, 2+ kinds matched, or exactly one matched below `min_confidence` (default from `GENIUSRUN_MIN_CONFIDENCE`, 0.6). All three populate `Candidates` for the review UI.
|
||||
- **`needs_review` triggers** (in `classify.Classify`): zero kinds matched, 2+ kinds matched, or exactly one matched below `min_confidence` (hard-coded `classify.DefaultMinConfidence`, 0.6). All three populate `Candidates` for the review UI.
|
||||
- **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance.
|
||||
- **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap. Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery.
|
||||
- Max HR for `avg_hr_pct_max` comes from `profile.max_heart_rate`, not a static config value — nil/unset omits that metric from the context rather than erroring.
|
||||
- **Race** is special-cased: `is_race` is derived at sync time from Garmin's own `eventType.typeKey == "race"` (see `internal/sync/mapping.go`), not a rule the user tunes. Once an activity is assigned Race (or manually overridden to any kind), `POST /api/reclassify` (`handleReclassifyAll`) skips it — Race is a hard Garmin fact and manual assignments are the user's definitive word, neither is ever silently overwritten by a global reclassify.
|
||||
|
||||
## mcp-garmin integration
|
||||
## Garmin integration (direct wrapper, no MCP)
|
||||
|
||||
`internal/garmin` is a Go MCP **client** (via `github.com/mark3labs/mcp-go`'s stdio transport) that spawns `mcp-garmin`'s `server.py` as a subprocess. Key things learned the hard way, that would otherwise get rediscovered:
|
||||
`internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed` — see `docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout (stderr carries JSON log lines, re-emitted through the Go application logger): `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
|
||||
|
||||
- **`authenticate()`/`complete_mfa()` return plain strings**, not structured JSON (`"Authenticated successfully."`, `"MFA required. ..."`, `"Authentication failed: ..."`). `internal/garmin/client.go`'s `parseAuthResult` pattern-matches these.
|
||||
- **The 10s "MFA required" timeout in mcp-garmin's `authenticate()` is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (mcp-garmin now logs this to stderr for exactly this reason).
|
||||
- **mcp-garmin persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var (default `~/.garth`) passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing.
|
||||
- **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
|
||||
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` emits a JSON log line for exactly this reason, forwarded into the Go log stream by `forwardWrapperStderr`).
|
||||
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun is multi-tenant, this token-store root (`config.GarminTokenStoreRoot`, read from `GENIUSRUN_TOKENSTORE_PATH`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `.garmin` directory relative to the working directory -- deliberately independent of `GENIUSRUN_DB_PATH`, so the DB file and the token-store root can each be pointed at their own directory -- rather than leaving per-user namespacing silently skipped.
|
||||
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
|
||||
- **`get_activity_details()` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries, despite what its docstring used to say. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
|
||||
- **`get_activity_splits()`** returns the actual lap/split summaries (`lapDTOs`).
|
||||
- **`get_workout_by_id()`** returns a structured Garmin workout's flattened steps (target pace/HR per step); `internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each lap's expected pace/HR band.
|
||||
- Garmin credentials are set via `garmin.Client.UpdateCredentials` when the profile is saved, which resets the client's "started" state and terminates any already-spawned subprocess so the next call respawns fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow.
|
||||
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
|
||||
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
|
||||
- **`get_workout_by_id`** returns a structured Garmin workout's flattened steps (target pace/HR per step); `internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each lap's expected pace/HR band.
|
||||
- Each user has their own lazily-built, cached `garmin.Client` (`api.Server.garminFor`, double-checked-locked, keyed by `userID`). Saving that user's profile calls `UpdateCredentials` on their own cached client instance, which resets only that client's "started" state and terminates only that user's already-spawned subprocess so the next call respawns fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow, and no risk of one user's credential change touching another's session.
|
||||
|
||||
## Data model conventions
|
||||
|
||||
See `docs/DATABASE.md` for the full, always-current schema (every table/column/index, regenerated via `go run ./cmd/dumpschema` -- see Commands above). The conventions below explain the *why* behind it; the doc itself is the ground truth for the *what*.
|
||||
|
||||
- **Every table is scoped to a `user_id`, one way or another.** `users` (keyed by OIDC subject) anchors it. `profile`, `workout_kinds`, `activities`, `sync_state`, `sync_runs` each carry their own `user_id` column and are queried with an explicit `WHERE user_id = ?`. `laps`, `activity_samples`, `kind_assignments`, and `workout_type_paces` have **no `user_id` column of their own** — they're always accessed through a specific owning row (an activity or a workout kind) via a `JOIN`/subquery back to that row's `user_id`, since they're never queried except through that owner. Every `internal/store` method that touches any of this takes an explicit `userID` parameter used in a real `WHERE`/`JOIN` clause — accepting the parameter without using it to filter would be a real cross-user leak, not a style nit.
|
||||
- `kind_assignments` is **append-only** — always INSERT, never UPDATE. Re-classifying after a rule edit, or a manual override, keeps full history; `current_kind_assignment` (a view) picks the latest row per activity by `id`. `assignment_source` distinguishes `manual` overrides (locked against future global reclassifies) from rule-engine assignments.
|
||||
- **Raw-JSON is the source of truth for anything not actively computed on.** `activities.raw_json`/`details_raw_json`/`workout_raw_json` store the full original Garmin JSON. Fields that are a pure untransformed copy of something already in `raw_json` (e.g. `activity_name`, `activity_type`, lap `duration_seconds`/`avg_hr`) were dropped from their own columns entirely (migration `0013_dedup_activity_lap_columns.sql`) and are instead decoded fresh at API-response time by `internal/api/display_fields.go` (`decodeActivityDisplayFields`/`decodeLapDisplayFields`) — don't reintroduce a stored column for something derivable from `raw_json` alone.
|
||||
- `garmin_activity_id` is the natural idempotency key for `UpsertActivity` (`ON CONFLICT ... DO UPDATE`), safe to re-run on every sync pass.
|
||||
- `sync_state` (singleton row) tracks a **backfill watermark** (`earliest_synced_date`, `backfill_complete`) — since Garmin history is immutable once recorded, `Service.Backfill` uses this to resume from where it left off (or no-op entirely if the configured horizon is already fully covered). `Backfill` reads `Profile.BackfillHorizonDays` fresh on every call (not a fixed `Config` field), so widening it in the Profile page takes effect on the very next sync, no restart needed. "Sync now" (`POST /api/sync/run`) calls `Backfill` then `IncrementalSync` then `FillPendingDetails` in one pass — there's no separate "full backfill" trigger. "Reset all" (`POST /api/sync/reset`) deletes every activity (cascading to laps/samples/kind_assignments) and rewinds the watermark — the only way to get already-synced activities re-processed against newer schema fields, since `FillPendingDetails` only ever touches activities whose details were never fetched.
|
||||
- Live sync progress is exposed via `Service.Progress()` (in-memory, mutex-guarded `Done`/`Total` counters, reset to zero when idle) and surfaced through `GET /api/sync/status`. The frontend's `GarminConnection` banner polls this and shows "syncing: N/M activities" live.
|
||||
- The 8-type taxonomy (`workout_kinds`) is a fixed, closed set with a fixed display order (`priority DESC, name`) — there's no create/delete UI, only rule/pace/HR-zone/color editing per type. `workout_type_paces` holds each type's target pace range + expected HR zone, informational only (never read by the classification engine) — no history, overwritten in place, since the synced activity log is the history.
|
||||
- **Raw-JSON is the source of truth for anything not actively computed on.** `activities.raw_json`/`details_raw_json`/`workout_raw_json` store the full original Garmin JSON. Fields that are a pure untransformed copy of something already in `raw_json` (e.g. `activity_name`, `activity_type`, lap `duration_seconds`/`avg_hr`) are deliberately not modeled as their own columns and are instead decoded fresh at API-response time by `internal/api/display_fields.go` (`decodeActivityDisplayFields`/`decodeLapDisplayFields`) — don't reintroduce a stored column for something derivable from `raw_json` alone.
|
||||
- `(user_id, garmin_activity_id)` is the natural idempotency key for `UpsertActivity` (`ON CONFLICT ... DO UPDATE`), safe to re-run on every sync pass — scoped by user so two different users' Garmin accounts can never collide even if their activity IDs coincided.
|
||||
- `sync_state` (one row per user) tracks a **backfill watermark** (`earliest_synced_date`, `backfill_complete`) — since Garmin history is immutable once recorded, `Service.Backfill` uses this to resume from where it left off (or no-op entirely if the configured horizon is already fully covered). `Backfill` reads `Profile.BackfillHorizonDays` fresh on every call (not a fixed `Config` field), so widening it in the Profile page takes effect on the very next sync, no restart needed. "Sync now" (`POST /api/sync/run`) calls `Backfill` then `IncrementalSync` then `FillPendingDetails` in one pass — there's no separate "full backfill" trigger. "Reset all" (`POST /api/sync/reset`) deletes every activity **belonging to that user** (cascading to laps/samples/kind_assignments) and rewinds their watermark — the only way to get already-synced activities re-processed against newer schema fields, since `FillPendingDetails` only ever touches activities whose details were never fetched.
|
||||
- Live sync progress is exposed via `Service.Progress()` (in-memory, mutex-guarded `Done`/`Total` counters, reset to zero when idle) and surfaced through `GET /api/sync/status`. Since `sync.Service` is one instance per user, this is naturally per-user too. The frontend's `GarminConnection` banner polls this and shows "syncing: N/M activities" live.
|
||||
- The 8-type taxonomy (`workout_kinds`) is a fixed, closed set with a fixed display order (`priority DESC, name`) — there's no create/delete UI, only rule/pace/HR-zone/color editing per type. Each user gets their own independently-tunable copy of the same 8 kinds, seeded by `store.ProvisionUser` on signup (`UNIQUE(user_id, name)`, not a global `UNIQUE(name)` — the same kind name is expected across different users). `workout_type_paces` holds each type's target pace range + expected HR zone, informational only (never read by the classification engine) — no history, overwritten in place, since the synced activity log is the history.
|
||||
- Chart colors (pace/HR line colors, warmup/effort/recovery/cooldown phase colors) are user-editable `profile` columns, not hardcoded, consumed by `ExpectedVsActualChart`.
|
||||
- Pace-artifact filtering (`profile.min_representative_pace_sec_per_km`/`min_representative_time_seconds`) drops brief slow-pace blips (GPS/motion settling at recording start) from the Review Queue chart unless they persist long enough to be a real stop/walk break.
|
||||
|
||||
## Dev workflow
|
||||
|
||||
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess.
|
||||
- No live Garmin account needed for frontend/UI work: `cmd/seedsample` seeds realistic activities/laps/kinds through the real classification engine.
|
||||
- No live Garmin account needed for frontend/UI work: `cmd/seedsample` provisions one fixed `"seedsample-user"` account (via `store.ProvisionUser`) then seeds realistic activities/laps/kinds under it through the real classification engine.
|
||||
- **Verify UI/frontend changes with a live click-through against the actual running dev servers (real backend + `npm run dev`), never by mocking network requests (e.g. Playwright route interception) to fake a logged-in session.** This repo's OIDC login gate makes a fully-mocked session tempting, but it's fragile in exactly the way that matters: a real backend is often already running (e.g. started from an IDE) on the same port a mocked test assumes is free, so any request the mock doesn't cover falls through to that real, live backend instead of erroring cleanly -- discovered when an incomplete route-mock's unmocked calls 401'd against a real GoLand-launched `geniusrund` and triggered `client.ts`'s 401-redirect-reload loop. If a live click-through isn't possible in the current environment (no real credentials, no browser tool available), say so explicitly rather than substituting a mocked simulation.
|
||||
|
||||
## Testing conventions
|
||||
|
||||
- Table-driven Go tests throughout; test cases are inline, no separate fixture files.
|
||||
- `internal/classify` tests are pure (no DB/network): construct a `MetricContext` + `RuleKind`s directly.
|
||||
- `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — deliberate, not mocked, since migration/SQL correctness is exactly what needs catching.
|
||||
- `internal/api` tests use `httptest` against a `Server` wired to a temp DB + `mock.Client`.
|
||||
- The MCP/Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via `cmd/mcpspike` or the real `geniusrund` auth endpoints.
|
||||
- `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — deliberate, not mocked, since schema/SQL correctness is exactly what needs catching. Since every table is `user_id`-scoped, tests provision a user first (`db.ProvisionUser`) and thread that `userID` into every call.
|
||||
- `internal/api` tests use `httptest` against a `Server` wired to a temp DB + `mock.Client`; `newTestServer` auto-provisions a `"test-user"` account matching the session cookie `doJSON` mints, so most handler tests don't need to think about provisioning at all. Tests that specifically need an *unprovisioned* session (e.g. the setup flow, or `resolveUser`'s not-found path) build a bare `NewServer` directly instead of using `newTestServer`.
|
||||
- **Cross-user isolation is tested adversarially, not just in parallel** — `internal/store/isolation_test.go` and `internal/api/isolation_test.go` provision two real users and attempt real reads/writes against the *other* user's real row IDs, asserting on not-found/403/empty-result rather than merely checking two separately-created rows don't collide. Any new per-user-scoped feature should get the same treatment, not just a "two users each see their own data" happy-path test.
|
||||
- The Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via the real `geniusrund` auth endpoints.
|
||||
|
||||
10
backend/.idea/.gitignore
generated
vendored
10
backend/.idea/.gitignore
generated
vendored
@@ -1,10 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
9
backend/.idea/backend.iml
generated
9
backend/.idea/backend.iml
generated
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
10
backend/.idea/go.imports.xml
generated
10
backend/.idea/go.imports.xml
generated
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GoImports">
|
||||
<option name="excludedPackages">
|
||||
<array>
|
||||
<option value="golang.org/x/net/context" />
|
||||
</array>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
8
backend/.idea/modules.xml
generated
8
backend/.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
backend/.idea/vcs.xml
generated
6
backend/.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
104
backend/cmd/dumpschema/main.go
Normal file
104
backend/cmd/dumpschema/main.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Command dumpschema regenerates docs/DATABASE.md from the real, live
|
||||
// database schema -- it opens a fresh temp database through store.Open
|
||||
// (the exact same code path geniusrund itself uses) and introspects
|
||||
// sqlite_master, so the generated documentation can never drift from what
|
||||
// the app actually creates. Run it after any change to
|
||||
// internal/store/schema.sql:
|
||||
//
|
||||
// go run ./cmd/dumpschema
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
applog "geniusrun/backend/internal/log"
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
type schemaEntry struct {
|
||||
typ string // "table" or "view"
|
||||
name string
|
||||
tblName string
|
||||
sql string
|
||||
}
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(applog.NewLogger("info", os.Stdout))
|
||||
tmpDir, err := os.MkdirTemp("", "geniusrun-dumpschema")
|
||||
must(err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
db, err := store.Open(filepath.Join(tmpDir, "schema-check.db"))
|
||||
must(err)
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT type, name, tbl_name, sql FROM sqlite_master
|
||||
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY rowid`)
|
||||
must(err)
|
||||
defer rows.Close()
|
||||
|
||||
var tables, views []schemaEntry
|
||||
indexesByTable := map[string][]schemaEntry{}
|
||||
|
||||
for rows.Next() {
|
||||
var e schemaEntry
|
||||
must(rows.Scan(&e.typ, &e.name, &e.tblName, &e.sql))
|
||||
switch e.typ {
|
||||
case "table":
|
||||
tables = append(tables, e)
|
||||
case "view":
|
||||
views = append(views, e)
|
||||
case "index":
|
||||
indexesByTable[e.tblName] = append(indexesByTable[e.tblName], e)
|
||||
}
|
||||
}
|
||||
must(rows.Err())
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# geniusrun database schema\n\n")
|
||||
b.WriteString("Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit. ")
|
||||
b.WriteString("The source of truth is `backend/internal/store/schema.sql`; regenerate this file after changing it.\n\n")
|
||||
|
||||
b.WriteString("## Tables\n\n")
|
||||
for _, t := range tables {
|
||||
fmt.Fprintf(&b, "- [`%s`](#%s)\n", t.name, t.name)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
for _, t := range tables {
|
||||
fmt.Fprintf(&b, "## `%s`\n\n", t.name)
|
||||
fmt.Fprintf(&b, "```sql\n%s;\n```\n\n", t.sql)
|
||||
if idxs := indexesByTable[t.name]; len(idxs) > 0 {
|
||||
b.WriteString("Indexes:\n\n```sql\n")
|
||||
for _, idx := range idxs {
|
||||
fmt.Fprintf(&b, "%s;\n", idx.sql)
|
||||
}
|
||||
b.WriteString("```\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(views) > 0 {
|
||||
b.WriteString("## Views\n\n")
|
||||
for _, v := range views {
|
||||
fmt.Fprintf(&b, "### `%s`\n\n", v.name)
|
||||
fmt.Fprintf(&b, "```sql\n%s;\n```\n\n", v.sql)
|
||||
}
|
||||
}
|
||||
|
||||
outPath := "../docs/DATABASE.md"
|
||||
must(os.WriteFile(outPath, []byte(b.String()), 0644))
|
||||
applog.App().Info("wrote schema doc", "path", outPath)
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
applog.App().Error("dumpschema failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -15,86 +16,104 @@ import (
|
||||
"geniusrun/backend/internal/auth"
|
||||
"geniusrun/backend/internal/config"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
applog "geniusrun/backend/internal/log"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
// fatal logs through the application JSON logger and exits -- the
|
||||
// structured replacement for log.Fatalf, so even startup failures come
|
||||
// out as JSON lines.
|
||||
func fatal(msg string, err error) {
|
||||
applog.App().Error(msg, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db, err := store.Open(cfg.DBPath)
|
||||
func main() {
|
||||
// Bootstrap logger at info so failures loading the env config itself
|
||||
// (which carries the real log level) are still emitted as JSON.
|
||||
slog.SetDefault(applog.NewLogger("warn", os.Stdout))
|
||||
|
||||
envCfg, err := config.LoadEnv()
|
||||
if err != nil {
|
||||
log.Fatalf("open database: %v", err)
|
||||
fatal("load env config", err)
|
||||
}
|
||||
slog.SetDefault(applog.NewLogger(envCfg.LogLevel, os.Stdout))
|
||||
|
||||
db, err := store.Open(envCfg.DBPath)
|
||||
if err != nil {
|
||||
fatal("open database", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if cfg.LegacyOwnerOIDCSub != "" {
|
||||
if err := db.ClaimLegacyOwner(context.Background(), cfg.LegacyOwnerOIDCSub); err != nil {
|
||||
log.Fatalf("claim legacy owner: %v", err)
|
||||
values, err := db.ConfigValues(context.Background())
|
||||
if err != nil {
|
||||
fatal("read app config", err)
|
||||
}
|
||||
// Every registry key is mandatory in the DB: seed missing ones with
|
||||
// their defaults so the config table is always fully populated (no
|
||||
// code-side fallbacks anywhere downstream).
|
||||
for _, k := range config.AppRegistry() {
|
||||
if _, ok := values[k.Key]; !ok {
|
||||
if err := db.SetConfigValue(context.Background(), k.Key, k.Default); err != nil {
|
||||
fatal("seed app config default", err)
|
||||
}
|
||||
values[k.Key] = k.Default
|
||||
}
|
||||
}
|
||||
appCfg, err := config.LoadApp(values)
|
||||
if err != nil {
|
||||
fatal("load app config", err)
|
||||
}
|
||||
|
||||
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
|
||||
IssuerURL: cfg.OIDCIssuerURL,
|
||||
ClientID: cfg.OIDCClientID,
|
||||
ClientSecret: cfg.OIDCClientSecret,
|
||||
RedirectURL: cfg.OIDCRedirectURL,
|
||||
RequiredRole: cfg.OIDCRequiredRole,
|
||||
IssuerURL: envCfg.OIDCIssuerURL,
|
||||
ClientID: envCfg.OIDCClientID,
|
||||
ClientSecret: envCfg.OIDCClientSecret,
|
||||
RedirectURL: envCfg.OIDCRedirectURL,
|
||||
RequiredRole: envCfg.OIDCRequiredRole,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("oidc: %v", err)
|
||||
fatal("oidc verifier setup", err)
|
||||
}
|
||||
|
||||
server := api.NewServer(db, garmin.NewClient, garmin.Config{
|
||||
PythonPath: cfg.GarminPythonPath,
|
||||
ServerPath: cfg.GarminServerPath,
|
||||
TokenStorePath: cfg.GarminTokenStoreRoot,
|
||||
}, appsync.Config{
|
||||
MinConfidence: cfg.MinConfidence,
|
||||
}, authVerifier, api.SessionConfig{
|
||||
Secret: cfg.SessionSecret,
|
||||
Duration: cfg.SessionDuration,
|
||||
Secure: cfg.SessionSecure,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
})
|
||||
server := api.NewServer(
|
||||
db,
|
||||
garmin.NewClient,
|
||||
garmin.ClientConfig{
|
||||
PythonPath: envCfg.PythonPath,
|
||||
TokenStorePath: envCfg.TokenStoreRoot,
|
||||
},
|
||||
garmin.SyncConfig{},
|
||||
authVerifier,
|
||||
api.SessionConfig{
|
||||
Secret: envCfg.SessionSecret,
|
||||
Duration: appCfg.SessionDuration,
|
||||
SetupTimeout: appCfg.SetupTimeout,
|
||||
Secure: envCfg.SessionSecure,
|
||||
BackendURL: envCfg.BackendURL,
|
||||
FrontendURL: envCfg.FrontendURL,
|
||||
})
|
||||
|
||||
for _, e := range envCfg.DisplayEnv() {
|
||||
server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value})
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go runIncrementalSyncLoop(ctx, server, cfg.IncrementalSyncEvery)
|
||||
|
||||
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
|
||||
httpServer := &http.Server{Addr: envCfg.BackendAddr, Handler: server.Router()}
|
||||
go func() {
|
||||
log.Printf("geniusrund listening on %s", cfg.Addr)
|
||||
applog.App().Info("geniusrund listening", "addr", envCfg.BackendAddr)
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("http server: %v", err)
|
||||
fatal("http server", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("shutting down...")
|
||||
applog.App().Info("shutting down")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("http server shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// runIncrementalSyncLoop periodically syncs new activities for every
|
||||
// provisioned user in the background so the frontend doesn't need to
|
||||
// trigger every sync manually.
|
||||
func runIncrementalSyncLoop(ctx context.Context, server *api.Server, every time.Duration) {
|
||||
ticker := time.NewTicker(every)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
server.RunIncrementalSyncForAllUsers(ctx)
|
||||
}
|
||||
applog.App().Error("http server shutdown", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
// Throwaway spike to validate that mark3labs/mcp-go's stdio client can drive
|
||||
// mcp-garmin (spawn, initialize handshake, call tools, parse results).
|
||||
// Not part of the production build — delete once internal/garmin is built.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/mcp-go/client"
|
||||
"github.com/mark3labs/mcp-go/client/transport"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
pythonPath := flag.String("python", "", "path to mcp-garmin .venv python executable")
|
||||
serverPath := flag.String("server", "", "path to mcp-garmin server.py")
|
||||
limit := flag.Int("limit", 5, "activity limit for get_activities")
|
||||
startDate := flag.String("start", "", "start date YYYY-MM-DD for get_activities (default: 365 days ago)")
|
||||
endDate := flag.String("end", "", "end date YYYY-MM-DD for get_activities (default: today)")
|
||||
flag.Parse()
|
||||
|
||||
if *pythonPath == "" || *serverPath == "" {
|
||||
log.Fatal("usage: mcpspike -python <path to .venv/bin/python> -server <path to server.py>")
|
||||
}
|
||||
|
||||
email := os.Getenv("GARMIN_EMAIL")
|
||||
password := os.Getenv("GARMIN_PASSWORD")
|
||||
if email == "" || password == "" {
|
||||
log.Fatal("GARMIN_EMAIL and GARMIN_PASSWORD must be set in the environment")
|
||||
}
|
||||
|
||||
env := []string{
|
||||
"GARMIN_EMAIL=" + email,
|
||||
"GARMIN_PASSWORD=" + password,
|
||||
"PYTHONUNBUFFERED=1",
|
||||
}
|
||||
|
||||
c, err := client.NewStdioMCPClient(*pythonPath, env, *serverPath)
|
||||
if err != nil {
|
||||
log.Fatalf("spawn subprocess: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if stdio, ok := c.GetTransport().(*transport.Stdio); ok {
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := stdio.Stderr().Read(buf)
|
||||
if n > 0 {
|
||||
fmt.Fprint(os.Stderr, string(buf[:n]))
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
log.Println("warning: could not get stdio transport to forward subprocess stderr")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
initReq := mcp.InitializeRequest{}
|
||||
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
|
||||
initReq.Params.ClientInfo = mcp.Implementation{Name: "mcpspike", Version: "0.0.1"}
|
||||
|
||||
initRes, err := c.Initialize(ctx, initReq)
|
||||
if err != nil {
|
||||
log.Fatalf("initialize handshake failed: %v", err)
|
||||
}
|
||||
fmt.Printf("initialized OK: server=%s version=%s protocol=%s\n",
|
||||
initRes.ServerInfo.Name, initRes.ServerInfo.Version, initRes.ProtocolVersion)
|
||||
|
||||
tools, err := c.ListTools(ctx, mcp.ListToolsRequest{})
|
||||
if err != nil {
|
||||
log.Fatalf("list tools failed: %v", err)
|
||||
}
|
||||
fmt.Printf("server exposes %d tools:\n", len(tools.Tools))
|
||||
for _, t := range tools.Tools {
|
||||
fmt.Printf(" - %s: %s\n", t.Name, t.Description)
|
||||
}
|
||||
|
||||
fmt.Println("\ncalling authenticate()...")
|
||||
authRes, err := callTool(ctx, c, "authenticate", nil)
|
||||
if err != nil {
|
||||
log.Fatalf("authenticate call failed: %v", err)
|
||||
}
|
||||
fmt.Printf("authenticate() -> %s\n", authRes)
|
||||
|
||||
if containsMFAPrompt(authRes) {
|
||||
fmt.Print("MFA required. Enter code: ")
|
||||
var code string
|
||||
fmt.Scanln(&code)
|
||||
mfaRes, err := callTool(ctx, c, "complete_mfa", map[string]any{"code": code})
|
||||
if err != nil {
|
||||
log.Fatalf("complete_mfa call failed: %v", err)
|
||||
}
|
||||
fmt.Printf("complete_mfa() -> %s\n", mfaRes)
|
||||
}
|
||||
|
||||
start := *startDate
|
||||
if start == "" {
|
||||
start = time.Now().AddDate(0, 0, -365).Format("2006-01-02")
|
||||
}
|
||||
end := *endDate
|
||||
if end == "" {
|
||||
end = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
fmt.Printf("\ncalling get_activities(start_date=%s, end_date=%s, limit=%d)...\n", start, end, *limit)
|
||||
actRes, err := callTool(ctx, c, "get_activities", map[string]any{
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"limit": *limit,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("get_activities call failed: %v", err)
|
||||
}
|
||||
|
||||
var activities []map[string]any
|
||||
if err := json.Unmarshal([]byte(actRes), &activities); err != nil {
|
||||
fmt.Printf("get_activities() returned non-JSON (likely an error string): %s\n", actRes)
|
||||
return
|
||||
}
|
||||
pretty, _ := json.MarshalIndent(activities, "", " ")
|
||||
fmt.Printf("get_activities() parsed OK, %d activities, %d bytes:\n%s\n", len(activities), len(actRes), truncate(string(pretty), 4000))
|
||||
|
||||
if len(activities) == 0 {
|
||||
fmt.Println("\nno activities in range, skipping get_activity_details")
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer a running activity if one is present, for the most relevant lap/split shape.
|
||||
chosen := activities[0]
|
||||
for _, a := range activities {
|
||||
if at, ok := a["activityType"].(map[string]any); ok {
|
||||
if tk, _ := at["typeKey"].(string); tk == "running" || tk == "trail_running" || tk == "treadmill_running" {
|
||||
chosen = a
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var activityID string
|
||||
switch v := chosen["activityId"].(type) {
|
||||
case float64:
|
||||
activityID = strconv.FormatFloat(v, 'f', -1, 64)
|
||||
default:
|
||||
activityID = fmt.Sprint(v)
|
||||
}
|
||||
fmt.Printf("\ncalling get_activity_details(activity_id=%s) [activityName=%v]...\n", activityID, chosen["activityName"])
|
||||
|
||||
splitsRes, err := callTool(ctx, c, "get_activity_splits", map[string]any{"activity_id": activityID})
|
||||
if err != nil {
|
||||
log.Fatalf("get_activity_splits call failed: %v", err)
|
||||
}
|
||||
|
||||
var splits map[string]any
|
||||
if err := json.Unmarshal([]byte(splitsRes), &splits); err != nil {
|
||||
fmt.Printf("get_activity_splits() returned non-JSON: %s\n", splitsRes)
|
||||
return
|
||||
}
|
||||
keys := make([]string, 0, len(splits))
|
||||
for k := range splits {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
fmt.Printf("get_activity_splits() top-level keys: %v\n", keys)
|
||||
prettySplits, _ := json.MarshalIndent(splits, "", " ")
|
||||
fmt.Printf("get_activity_splits() parsed OK, %d bytes:\n%s\n", len(splitsRes), truncate(string(prettySplits), 8000))
|
||||
|
||||
fmt.Println("\ncalling get_activity_details() again to inspect metricDescriptors + a small metrics sample...")
|
||||
detailsRes, err := callTool(ctx, c, "get_activity_details", map[string]any{"activity_id": activityID})
|
||||
if err != nil {
|
||||
log.Fatalf("get_activity_details call failed: %v", err)
|
||||
}
|
||||
var details map[string]any
|
||||
if err := json.Unmarshal([]byte(detailsRes), &details); err != nil {
|
||||
fmt.Printf("get_activity_details() returned non-JSON: %s\n", detailsRes)
|
||||
return
|
||||
}
|
||||
if descriptors, ok := details["metricDescriptors"]; ok {
|
||||
pretty, _ := json.MarshalIndent(descriptors, "", " ")
|
||||
fmt.Printf("metricDescriptors:\n%s\n", pretty)
|
||||
} else {
|
||||
fmt.Println("no metricDescriptors key found")
|
||||
}
|
||||
if rows, ok := details["activityDetailMetrics"].([]any); ok && len(rows) > 0 {
|
||||
sampleN := 5
|
||||
if len(rows) < sampleN {
|
||||
sampleN = len(rows)
|
||||
}
|
||||
pretty, _ := json.MarshalIndent(rows[:sampleN], "", " ")
|
||||
fmt.Printf("activityDetailMetrics sample (first %d of %d rows):\n%s\n", sampleN, len(rows), pretty)
|
||||
}
|
||||
}
|
||||
|
||||
func callTool(ctx context.Context, c *client.Client, name string, args map[string]any) (string, error) {
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Name = name
|
||||
req.Params.Arguments = args
|
||||
|
||||
res, err := c.CallTool(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res.IsError {
|
||||
return "", fmt.Errorf("tool %s returned an error result", name)
|
||||
}
|
||||
var out string
|
||||
for _, content := range res.Content {
|
||||
if tc, ok := content.(mcp.TextContent); ok {
|
||||
out += tc.Text
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func containsMFAPrompt(s string) bool {
|
||||
for _, needle := range []string{"MFA required", "MFA code", "complete_mfa"} {
|
||||
if len(s) >= len(needle) {
|
||||
for i := 0; i+len(needle) <= len(s); i++ {
|
||||
if s[i:i+len(needle)] == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "...(truncated)"
|
||||
}
|
||||
@@ -9,23 +9,25 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/classify"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
applog "geniusrun/backend/internal/log"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(applog.NewLogger("info", os.Stdout))
|
||||
dbPath := flag.String("db", "geniusrun_sample.db", "path to the SQLite database to seed")
|
||||
flag.Parse()
|
||||
|
||||
ctx := context.Background()
|
||||
db, err := store.Open(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
fatal("open db", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
@@ -73,8 +75,8 @@ func main() {
|
||||
IsActive: true,
|
||||
}))
|
||||
|
||||
m := &mock.Client{}
|
||||
svc := appsync.NewService(m, db, userID, appsync.Config{MinConfidence: 0.6}, nil)
|
||||
m := &garmin.MockClient{}
|
||||
svc := garmin.NewSync(m, db, userID, garmin.SyncConfig{MinConfidence: 0.6}, nil)
|
||||
|
||||
today := time.Now()
|
||||
activityIDs := []int64{}
|
||||
@@ -234,10 +236,17 @@ func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, userID, activ
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
fatal("seedsample", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fatal logs through the application JSON logger and exits -- the
|
||||
// structured replacement for log.Fatal.
|
||||
func fatal(msg string, err error) {
|
||||
applog.App().Error(msg, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string) int64 {
|
||||
kinds, err := db.ListWorkoutKinds(ctx, userID, false)
|
||||
must(err)
|
||||
@@ -246,6 +255,7 @@ func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string
|
||||
return k.ID
|
||||
}
|
||||
}
|
||||
log.Fatalf("seedsample: no workout kind named %q found for the seeded user (did ProvisionUser seed the taxonomy?)", name)
|
||||
applog.App().Error("no workout kind with that name for the seeded user (did ProvisionUser seed the taxonomy?)", "kind", name)
|
||||
os.Exit(1)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ require (
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/mark3labs/mcp-go v0.56.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
@@ -14,16 +13,11 @@ require (
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
@@ -1,53 +1,25 @@
|
||||
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mark3labs/mcp-go v0.56.0 h1:7aCj2wODCskMi08f923ADG+EfELZBdiKILny415cIS8=
|
||||
github.com/mark3labs/mcp-go v0.56.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
@@ -57,12 +29,8 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -9,109 +11,248 @@ import (
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
// activityListItem is one row of the activities list, enriched with its
|
||||
// current classification so the frontend can show what kind it's assigned
|
||||
// to and whether that assignment is locked (see Locked's doc comment).
|
||||
type activityListItem struct {
|
||||
activityResponse
|
||||
WorkoutKindID *int64 `json:"workout_kind_id"`
|
||||
WorkoutKindName *string `json:"workout_kind_name"`
|
||||
AssignmentSource *string `json:"assignment_source"`
|
||||
AssignmentStatus *string `json:"assignment_status"`
|
||||
// Locked is true when a global reclassify pass will never touch this
|
||||
// activity again: a manual assignment is the user's definitive word, and
|
||||
// a Race assignment comes from a hard Garmin fact, not a retunable rule.
|
||||
Locked bool `json:"locked"`
|
||||
// defaultActivitiesPageSize matches the frontend's initial/incremental
|
||||
// page size for its infinite-scroll list.
|
||||
const defaultActivitiesPageSize = 10
|
||||
|
||||
type activityItem struct {
|
||||
store.KindAssignment
|
||||
Activity activityResponse `json:"activity"`
|
||||
Laps []lapResponse `json:"laps"`
|
||||
Samples []store.Sample `json:"samples"`
|
||||
}
|
||||
|
||||
// handleListActivities backs the Activities page: every activity that has been
|
||||
// classified at least once, not just ones still needing review, so the page
|
||||
// can show each activity's current kind (or "Unclassified") and let the user
|
||||
// lock/unlock it. It's cursor-paginated: fetching every activity's laps and
|
||||
// (especially) per-second samples is expensive once there are many of them,
|
||||
// so that work only happens for the requested page, not the full backlog.
|
||||
// Sorting/cursor filtering still needs each activity's summary row (a cheap
|
||||
// indexed lookup, no laps/samples), which happens for the whole backlog --
|
||||
// only the heavy per-item fetches are deferred to the page actually being
|
||||
// returned. The optional kind_id/unclassified filters are applied before
|
||||
// that cursor slicing too, so a filtered view still only loads (and
|
||||
// chart-renders) one page at a time instead of the whole matching backlog.
|
||||
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
q := r.URL.Query()
|
||||
filter := store.ActivityFilter{
|
||||
FromDate: q.Get("from"),
|
||||
ToDate: q.Get("to"),
|
||||
}
|
||||
if limit, err := strconv.Atoi(q.Get("limit")); err == nil {
|
||||
filter.Limit = limit
|
||||
}
|
||||
if offset, err := strconv.Atoi(q.Get("offset")); err == nil {
|
||||
filter.Offset = offset
|
||||
limit := defaultActivitiesPageSize
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
cursor := r.URL.Query().Get("before") // activity StartTimeUTC of the last item on the previous page
|
||||
|
||||
activities, err := s.DB.ListActivities(r.Context(), userID, filter)
|
||||
var kindIDFilter *int64
|
||||
if v := r.URL.Query().Get("kind_id"); v != "" {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
kindIDFilter = &n
|
||||
}
|
||||
}
|
||||
unclassifiedOnly := r.URL.Query().Get("unclassified") == "true"
|
||||
|
||||
queue, err := s.DB.AllCurrentAssignments(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
type withActivity struct {
|
||||
assignment store.KindAssignment
|
||||
activity store.Activity
|
||||
}
|
||||
kindNames := make(map[int64]string, len(kinds))
|
||||
for _, k := range kinds {
|
||||
kindNames[k.ID] = k.Name
|
||||
}
|
||||
|
||||
resp := make([]activityListItem, 0, len(activities))
|
||||
for _, a := range activities {
|
||||
item := activityListItem{activityResponse: toActivityResponse(a)}
|
||||
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), userID, a.ID)
|
||||
all := make([]withActivity, 0, len(queue))
|
||||
for _, a := range queue {
|
||||
if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) {
|
||||
continue
|
||||
}
|
||||
if unclassifiedOnly && a.WorkoutKindID != nil {
|
||||
continue
|
||||
}
|
||||
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
source, status := assignment.AssignmentSource, assignment.Status
|
||||
item.AssignmentSource, item.AssignmentStatus = &source, &status
|
||||
if assignment.WorkoutKindID != nil {
|
||||
item.WorkoutKindID = assignment.WorkoutKindID
|
||||
if name, found := kindNames[*assignment.WorkoutKindID]; found {
|
||||
item.WorkoutKindName = &name
|
||||
}
|
||||
}
|
||||
item.Locked = source == store.AssignmentSourceManual ||
|
||||
(item.WorkoutKindName != nil && *item.WorkoutKindName == "Race")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
resp = append(resp, item)
|
||||
all = append(all, withActivity{assignment: a, activity: activity})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
||||
// Most recent run first, by when the activity actually happened (not
|
||||
// when the rule engine flagged it), so the queue reads like a log.
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
return all[i].activity.StartTimeUTC > all[j].activity.StartTimeUTC
|
||||
})
|
||||
|
||||
total := len(all)
|
||||
|
||||
if cursor != "" {
|
||||
idx := 0
|
||||
for idx < len(all) && all[idx].activity.StartTimeUTC >= cursor {
|
||||
idx++
|
||||
}
|
||||
all = all[idx:]
|
||||
}
|
||||
hasMore := len(all) > limit
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
|
||||
items := make([]activityItem, 0, len(all))
|
||||
for _, wa := range all {
|
||||
laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Per-second telemetry, not just per-lap averages, so the chart can
|
||||
// show real within-lap variation instead of one flat segment per lap
|
||||
// (most activities only have a handful of laps).
|
||||
samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
items = append(items, activityItem{
|
||||
KindAssignment: wa.assignment,
|
||||
Activity: toActivityResponse(wa.activity),
|
||||
Laps: toLapResponses(laps),
|
||||
Samples: samples,
|
||||
})
|
||||
}
|
||||
|
||||
var nextCursor *string
|
||||
if hasMore && len(items) > 0 {
|
||||
c := items[len(items)-1].Activity.StartTimeUTC
|
||||
nextCursor = &c
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"next_cursor": nextCursor,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleAssignActivity(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
activity, ok, err := s.DB.GetActivity(r.Context(), userID, id)
|
||||
var body struct {
|
||||
WorkoutKindID int64 `json:"workout_kind_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.WorkoutKindID == 0 {
|
||||
writeError(w, http.StatusBadRequest, "workout_kind_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, body.WorkoutKindID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
} else if !ok {
|
||||
writeError(w, http.StatusBadRequest, "workout kind not found")
|
||||
return
|
||||
}
|
||||
if kind.Name == "Race" {
|
||||
writeError(w, http.StatusBadRequest, "Race is assigned automatically from Garmin metadata and cannot be set manually")
|
||||
return
|
||||
}
|
||||
|
||||
kindID := body.WorkoutKindID
|
||||
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||
ActivityID: activityID,
|
||||
WorkoutKindID: &kindID,
|
||||
AssignmentSource: store.AssignmentSourceManual,
|
||||
Status: store.AssignmentStatusAssigned,
|
||||
CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "assigned"})
|
||||
}
|
||||
|
||||
// handleUnassignActivity manually clears an activity's kind back to
|
||||
// Unclassified. Like handleAssignActivity, it's a deliberate manual choice --
|
||||
// recorded as source=manual (with no kind) so the rule engine leaves it alone
|
||||
// on a later reclassify pass, exactly as it would for a manual kind
|
||||
// assignment. The frontend only offers this while the activity is unlocked
|
||||
// (locked activities must be unlocked first, same precondition as picking a
|
||||
// different kind), but the backend doesn't re-enforce that here, matching
|
||||
// handleAssignActivity's own lack of a lock precondition check.
|
||||
func (s *Server) handleUnassignActivity(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||
ActivityID: activityID,
|
||||
WorkoutKindID: nil,
|
||||
AssignmentSource: store.AssignmentSourceManual,
|
||||
Status: store.AssignmentStatusNeedsReview,
|
||||
CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "unassigned"})
|
||||
}
|
||||
|
||||
// handleUnlockActivity reverts a manual assignment back to rule_engine
|
||||
// sourcing, keeping the same kind, so a later reclassify pass (which always
|
||||
// skips manual assignments) is free to change it again. It's the inverse of
|
||||
// handleAssignActivity, not a delete: the kind stays visible as-is until
|
||||
// something actually reclassifies it.
|
||||
func (s *Server) handleUnlockActivity(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "activity not found")
|
||||
writeError(w, http.StatusNotFound, "activity has no assignment yet")
|
||||
return
|
||||
}
|
||||
if current.AssignmentSource != store.AssignmentSourceManual {
|
||||
writeError(w, http.StatusBadRequest, "activity is not manually locked")
|
||||
return
|
||||
}
|
||||
|
||||
laps, err := s.DB.LapsForActivity(r.Context(), userID, id)
|
||||
if err != nil {
|
||||
status := store.AssignmentStatusAssigned
|
||||
if current.WorkoutKindID == nil {
|
||||
status = store.AssignmentStatusNeedsReview
|
||||
}
|
||||
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||
ActivityID: activityID,
|
||||
WorkoutKindID: current.WorkoutKindID,
|
||||
AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||
Status: status,
|
||||
CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{"activity": toActivityResponse(activity), "laps": toLapResponses(laps)}
|
||||
if hasAssignment {
|
||||
resp["assignment"] = assignment
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "unlocked"})
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
@@ -16,18 +18,19 @@ import (
|
||||
"geniusrun/backend/internal/auth"
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/log"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func newCtx() context.Context { return context.Background() }
|
||||
|
||||
var testSessionConfig = SessionConfig{
|
||||
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
|
||||
Duration: time.Hour,
|
||||
Secure: false,
|
||||
PublicBaseURL: "https://geniusrun.example.com",
|
||||
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
|
||||
Duration: time.Hour,
|
||||
SetupTimeout: 15 * time.Minute,
|
||||
Secure: false,
|
||||
BackendURL: "https://geniusrun.example.com",
|
||||
FrontendURL: "https://app.geniusrun.example.com",
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
|
||||
@@ -43,9 +46,9 @@ func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
m := &mock.Client{}
|
||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
return s, db, userID
|
||||
}
|
||||
|
||||
@@ -63,7 +66,7 @@ func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||
if err != nil {
|
||||
t.Fatalf("mint test session cookie: %v", err)
|
||||
}
|
||||
@@ -193,7 +196,7 @@ func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewQueueResolve(t *testing.T) {
|
||||
func TestActivitiesAssign(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
@@ -225,9 +228,9 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("review queue status = %d", rec.Code)
|
||||
t.Fatalf("activities list status = %d", rec.Code)
|
||||
}
|
||||
var page struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
@@ -236,43 +239,43 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||
if len(page.Items) != 1 || page.Total != 1 {
|
||||
t.Fatalf("expected 1 item in review queue (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String())
|
||||
t.Fatalf("expected 1 item in activities list (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String())
|
||||
}
|
||||
laps, _ := page.Items[0]["laps"].([]any)
|
||||
if len(laps) != 1 {
|
||||
t.Fatalf("expected 1 lap in review queue item, got %d: %s", len(laps), rec.Body.String())
|
||||
t.Fatalf("expected 1 lap in activities list item, got %d: %s", len(laps), rec.Body.String())
|
||||
}
|
||||
samples, _ := page.Items[0]["samples"].([]any)
|
||||
if len(samples) != 1 {
|
||||
t.Fatalf("expected 1 sample in review queue item, got %d: %s", len(samples), rec.Body.String())
|
||||
t.Fatalf("expected 1 sample in activities list item, got %d: %s", len(samples), rec.Body.String())
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID})
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindID})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("resolve status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("assign status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// The activity stays listed after resolving -- the Activities page shows
|
||||
// every activity, locked or not, not just ones still needing review.
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||
if len(page.Items) != 1 || page.Total != 1 {
|
||||
t.Fatalf("expected activity to remain listed after resolve, got %d items, total=%d", len(page.Items), page.Total)
|
||||
t.Fatalf("expected activity to remain listed after assign, got %d items, total=%d", len(page.Items), page.Total)
|
||||
}
|
||||
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
|
||||
t.Fatalf("expected AssignmentSource=manual after resolve, got %v", page.Items[0]["AssignmentSource"])
|
||||
t.Fatalf("expected AssignmentSource=manual after assign, got %v", page.Items[0]["AssignmentSource"])
|
||||
}
|
||||
if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
|
||||
t.Fatalf("expected WorkoutKindID=%d after resolve, got %v", kindID, got)
|
||||
t.Fatalf("expected WorkoutKindID=%d after assign, got %v", kindID, got)
|
||||
}
|
||||
|
||||
// Unlocking reverts the source to rule_engine but keeps the same kind,
|
||||
// so a later reclassify pass is free to change it again.
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unlock status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
|
||||
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
|
||||
@@ -282,18 +285,18 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
}
|
||||
|
||||
// Unlocking an already-unlocked activity is rejected.
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Manually unassigning clears the kind back to Unclassified and locks
|
||||
// that decision (source=manual), same as resolving to a specific kind.
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unassign", nil)
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unassign", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unassign status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
|
||||
t.Fatalf("expected AssignmentSource=manual after unassign, got %v", page.Items[0]["AssignmentSource"])
|
||||
@@ -303,7 +306,7 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
}
|
||||
|
||||
// It also shows up under the Unclassified filter now.
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/?unclassified=true", nil)
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/activities/?unclassified=true", nil)
|
||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||
if len(page.Items) != 1 || page.Total != 1 {
|
||||
t.Fatalf("expected unassigned activity to show up as unclassified, got %d items, total=%d", len(page.Items), page.Total)
|
||||
@@ -311,18 +314,18 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
|
||||
// Unassigning locks it, so unlocking works again (reverting to
|
||||
// rule_engine sourcing with no kind, i.e. needs_review).
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unlock after unassign status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
|
||||
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
||||
func TestActivities_PaginatesByCursor(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
@@ -352,7 +355,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
||||
}
|
||||
getPage := func(query string) page {
|
||||
t.Helper()
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil)
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -400,7 +403,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||
func TestActivities_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
@@ -444,7 +447,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||
}
|
||||
getPage := func(query string) page {
|
||||
t.Helper()
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil)
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -487,7 +490,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
||||
func TestAssignActivity_RejectsRaceKind(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
@@ -500,9 +503,9 @@ func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
||||
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": raceKind.ID})
|
||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": raceKind.ID})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("resolve to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("assign to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,57 +576,6 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
easyID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||
}
|
||||
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
|
||||
manualActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
|
||||
raceActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
|
||||
|
||||
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var items []activityListItem
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
locked := map[int64]bool{}
|
||||
kindName := map[int64]string{}
|
||||
for _, it := range items {
|
||||
locked[it.ID] = it.Locked
|
||||
if it.WorkoutKindName != nil {
|
||||
kindName[it.ID] = *it.WorkoutKindName
|
||||
}
|
||||
}
|
||||
if locked[ruleEngineActivity] {
|
||||
t.Errorf("rule-engine activity should not be locked")
|
||||
}
|
||||
if !locked[manualActivity] {
|
||||
t.Errorf("manual activity should be locked")
|
||||
}
|
||||
if !locked[raceActivity] {
|
||||
t.Errorf("race activity should be locked")
|
||||
}
|
||||
if kindName[raceActivity] != "Race" {
|
||||
t.Errorf("race activity workout_kind_name = %q, want %q", kindName[raceActivity], "Race")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
@@ -633,7 +585,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/sync/reset", nil)
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/sync/reset", nil)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -654,9 +606,59 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||
}
|
||||
|
||||
// "Full backfill" no longer exists as an endpoint -- superseded by reset.
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/sync/backfill", nil)
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/garmin/sync/backfill", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("/api/sync/backfill status = %d, want 404 (removed)", rec.Code)
|
||||
t.Errorf("/api/garmin/sync/backfill status = %d, want 404 (removed)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
workoutID := int64(555)
|
||||
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
|
||||
GarminActivityID: 1, WorkoutID: &workoutID,
|
||||
StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity: %v", err)
|
||||
}
|
||||
// ActivitiesMissingWorkout only surfaces activities whose details have
|
||||
// already been fetched (see store.ActivitiesMissingWorkout) -- an
|
||||
// activity is only eligible for a workout fetch once fillActivityDetails
|
||||
// has given it laps to align target pace/HR bands against.
|
||||
if err := db.SetActivityDetails(ctx, userID, activityID, "{}"); err != nil {
|
||||
t.Fatalf("SetActivityDetails: %v", err)
|
||||
}
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/garmin/sync/status", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
InProgress bool `json:"in_progress"`
|
||||
Progress struct {
|
||||
Phase string `json:"Phase"`
|
||||
Done int `json:"Done"`
|
||||
Total int `json:"Total"`
|
||||
} `json:"progress"`
|
||||
ActivitiesPendingDetails int `json:"activities_pending_details"`
|
||||
WorkoutsPending int `json:"workouts_pending"`
|
||||
}
|
||||
unmarshalBody(t, rec, &resp)
|
||||
|
||||
if resp.InProgress {
|
||||
t.Error("in_progress = true, want false (nothing running)")
|
||||
}
|
||||
if resp.Progress.Phase != "idle" {
|
||||
t.Errorf("progress.Phase = %q, want %q", resp.Progress.Phase, "idle")
|
||||
}
|
||||
if resp.ActivitiesPendingDetails != 1 {
|
||||
t.Errorf("activities_pending_details = %d, want 1", resp.ActivitiesPendingDetails)
|
||||
}
|
||||
if resp.WorkoutsPending != 1 {
|
||||
t.Errorf("workouts_pending = %d, want 1", resp.WorkoutsPending)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,7 +716,7 @@ func itoa(v int64) string {
|
||||
}
|
||||
|
||||
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
s, db, _ := newTestServer(t)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||||
@@ -732,10 +734,18 @@ func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
||||
got.GarminEmail = "runner@example.com"
|
||||
got.GarminPassword = "hunter2"
|
||||
got.RollingWindowDays = 120
|
||||
rec = doJSON(t, router, http.MethodPut, "/api/profile", got)
|
||||
// Name rides along in the profile payload but lives on the users row.
|
||||
rec = doJSON(t, router, http.MethodPut, "/api/profile", struct {
|
||||
store.Profile
|
||||
Name string
|
||||
}{got, "Renamed Runner"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||||
if err != nil || !found || u.Name != "Renamed Runner" {
|
||||
t.Fatalf("users.name after profile PUT = %q (found=%v err=%v), want Renamed Runner", u.Name, found, err)
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||||
var updated store.Profile
|
||||
@@ -763,6 +773,135 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||||
var before sessionMeResponse
|
||||
unmarshalBody(t, rec, &before)
|
||||
if before.GarminConnected {
|
||||
t.Fatal("expected a fresh account to report garmin_connected=false")
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // garmin.MockClient defaults to AuthSuccess
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||||
var after sessionMeResponse
|
||||
unmarshalBody(t, rec, &after)
|
||||
if !after.GarminConnected {
|
||||
t.Fatal("expected garmin_connected=true after a successful auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) {
|
||||
s, _, userID := newTestServer(t)
|
||||
client, err := s.clientFor(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("garminFor: %v", err)
|
||||
}
|
||||
mockClient, ok := client.(*garmin.MockClient)
|
||||
if !ok {
|
||||
t.Fatalf("expected *garmin.MockClient, got %T", client)
|
||||
}
|
||||
mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||||
var me sessionMeResponse
|
||||
unmarshalBody(t, rec, &me)
|
||||
if me.GarminConnected {
|
||||
t.Fatal("expected garmin_connected=false after mfa_required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
router := s.Router()
|
||||
|
||||
// Force the per-user Garmin client to be built and cached.
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
client, err := s.clientFor(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("garminFor: %v", err)
|
||||
}
|
||||
mockClient, ok := client.(*garmin.MockClient)
|
||||
if !ok {
|
||||
t.Fatalf("expected *garmin.MockClient, got %T", client)
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || found {
|
||||
t.Fatalf("expected user gone after delete, found=%v err=%v", found, err)
|
||||
}
|
||||
if !mockClient.ClosedCalled {
|
||||
t.Error("expected the cached garmin client to be Close()d on profile deletion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfile_RejectsWhileSyncInProgress(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
s.mu.Lock()
|
||||
s.userSyncRunning[userID] = true
|
||||
s.mu.Unlock()
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || !found {
|
||||
t.Fatalf("expected user to survive a rejected delete, found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) {
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
tokenStoreRoot := t.TempDir()
|
||||
userTokenDir := filepath.Join(tokenStoreRoot, strconv.FormatInt(userID, 10))
|
||||
if err := os.MkdirAll(userTokenDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(userTokenDir, "session.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
m := &garmin.MockClient{}
|
||||
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(userTokenDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected token store dir %q to be removed, stat err = %v", userTokenDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
|
||||
t.Helper()
|
||||
s, db, _ := newTestServer(t)
|
||||
@@ -828,7 +967,7 @@ func TestSessionCallback_AuthorizedSetsSessionCookieAndRedirectsHome(t *testing.
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" {
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/" {
|
||||
t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
var sessionCookie *http.Cookie
|
||||
@@ -864,7 +1003,7 @@ func TestSessionCallback_UnauthorizedRedirectsWithoutSessionCookie(t *testing.T)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?auth_error=forbidden" {
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/?auth_error=forbidden" {
|
||||
t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
@@ -879,7 +1018,7 @@ func TestSessionCallback_MissingTxnCookieRedirectsFailed(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?auth_error=failed" {
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/?auth_error=failed" {
|
||||
t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
@@ -922,3 +1061,146 @@ func TestSessionLogout_ClearsSessionCookieAndRedirectsToEndSession(t *testing.T)
|
||||
t.Fatalf("expected session cookie to be cleared, got %+v", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLogout_PostLogoutRedirectUsesFrontendURL(t *testing.T) {
|
||||
verifier := &authmock.Verifier{} // EndSessionResult unset: echoes back postLogoutRedirectURL unchanged
|
||||
s, _ := newTestServerWithAuth(t, verifier)
|
||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/session/logout", nil)
|
||||
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != testSessionConfig.FrontendURL+"/" {
|
||||
t.Fatalf("status = %d, Location = %q, want post_logout_redirect_uri = %q", rec.Code, rec.Header().Get("Location"), testSessionConfig.FrontendURL+"/")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionLogout_PassesIDTokenHintFromSessionCookie confirms the raw ID
|
||||
// token carried in the session cookie (minted at callback time) is handed
|
||||
// back to EndSessionURL on logout, so Keycloak can skip its own
|
||||
// logout-confirmation prompt instead of leaving the user a chance to cancel
|
||||
// out of it after their geniusrun account is already deleted.
|
||||
func TestSessionLogout_PassesIDTokenHintFromSessionCookie(t *testing.T) {
|
||||
verifier := &authmock.Verifier{EndSessionResult: "https://keycloak.example.com/logout"}
|
||||
s, _ := newTestServerWithAuth(t, verifier)
|
||||
|
||||
cookie, err := auth.MintSessionCookie(
|
||||
auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"},
|
||||
"raw-id-token-jwt", // travels in the cookie apart from Claims
|
||||
testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("mint session cookie: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/session/logout", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if verifier.LastIDTokenHint != "raw-id-token-jwt" {
|
||||
t.Errorf("LastIDTokenHint = %q, want %q", verifier.LastIDTokenHint, "raw-id-token-jwt")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionCallback_MintsSessionCookieCarryingIDToken confirms the raw ID
|
||||
// token from a completed OIDC callback ends up in the session cookie (not
|
||||
// just Sub/Name/Email), since that's the only place logout can later read
|
||||
// it back from to build id_token_hint.
|
||||
func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) {
|
||||
verifier := &authmock.Verifier{
|
||||
CallbackResult: auth.LoginResult{
|
||||
Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"},
|
||||
Authorized: true,
|
||||
IDToken: "raw-id-token-jwt",
|
||||
},
|
||||
}
|
||||
s, _ := newTestServerWithAuth(t, verifier)
|
||||
|
||||
txnCookie, err := auth.MintTxnCookie(auth.TxnState{State: "s1", CodeVerifier: "v1"}, testSessionConfig.Secret, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint txn cookie: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil)
|
||||
req.AddCookie(txnCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.SessionCookieName {
|
||||
sessionCookie = c
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("expected a session cookie to be set")
|
||||
}
|
||||
idToken, err := auth.IDTokenFromSessionCookie(sessionCookie, testSessionConfig.Secret)
|
||||
if err != nil {
|
||||
t.Fatalf("read id token from session cookie: %v", err)
|
||||
}
|
||||
if idToken != "raw-id-token-jwt" {
|
||||
t.Errorf("cookie id token = %q, want %q", idToken, "raw-id-token-jwt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(applog.NewLogger("info", &buf))
|
||||
defer slog.SetDefault(prev)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
|
||||
var entry map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
|
||||
}
|
||||
if entry["type"] != "http" || entry["package"] != "api" || entry["file"] != "server.go" || entry["method"] != "loggingMiddleware" {
|
||||
t.Errorf("schema fields = %v, want type=http package=api file=server.go method=loggingMiddleware", entry)
|
||||
}
|
||||
if _, hasMsg := entry["msg"]; hasMsg {
|
||||
t.Errorf("expected no msg on the http record, got %v", entry["msg"])
|
||||
}
|
||||
if entry["http_method"] != "GET" || entry["path"] != "/api/health" {
|
||||
t.Errorf("http_method/path = %v/%v, want GET//api/health", entry["http_method"], entry["path"])
|
||||
}
|
||||
if entry["status"] != float64(http.StatusOK) {
|
||||
t.Errorf("status = %v, want 200", entry["status"])
|
||||
}
|
||||
if _, ok := entry["duration_ms"]; !ok {
|
||||
t.Error("expected a duration_ms field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) {
|
||||
s, db, _ := newTestServer(t)
|
||||
db.Close() // force a downstream DB call to fail with a 500
|
||||
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(applog.NewLogger("info", &buf))
|
||||
defer slog.SetDefault(prev)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/profile", nil)
|
||||
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||
if err != nil {
|
||||
t.Fatalf("mint session cookie: %v", err)
|
||||
}
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected the request itself to 500 after closing the DB, got %d", rec.Code)
|
||||
}
|
||||
var entry map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
|
||||
}
|
||||
if entry["level"] != "WARN" {
|
||||
t.Errorf("level = %v, want WARN for a 5xx response", entry["level"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"geniusrun/backend/internal/garmin"
|
||||
)
|
||||
|
||||
type authResponse struct {
|
||||
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func authStatusString(s garmin.AuthStatus) string {
|
||||
switch s {
|
||||
case garmin.AuthSuccess:
|
||||
return "authenticated"
|
||||
case garmin.AuthMFARequired:
|
||||
return "mfa_required"
|
||||
case garmin.AuthFailed:
|
||||
return "failed"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) recordAuthResult(userID int64, res garmin.AuthResult) {
|
||||
s.mu.Lock()
|
||||
s.userAuthStatus[userID] = res.Status
|
||||
s.userAuthMessage[userID] = res.Message
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
client, err := s.garminFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := client.Authenticate(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAuthResult(userID, res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Code == "" {
|
||||
writeError(w, http.StatusBadRequest, "code is required")
|
||||
return
|
||||
}
|
||||
|
||||
client, err := s.garminFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := client.CompleteMFA(r.Context(), body.Code)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAuthResult(userID, res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
s.mu.Lock()
|
||||
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
|
||||
}
|
||||
92
backend/internal/api/config.go
Normal file
92
backend/internal/api/config.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"geniusrun/backend/internal/config"
|
||||
)
|
||||
|
||||
// EnvVar is one read-only environment-configuration entry, already
|
||||
// display-safe (masking happens in config.Config.DisplayEnv).
|
||||
type EnvVar struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type configEntry struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Default string `json:"default"`
|
||||
Overridden bool `json:"overridden"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// configView assembles the GET/PUT response: every registry key with its
|
||||
// stored value (the DB is fully seeded with defaults at startup; the
|
||||
// registry default only fills in here for a key added since the last
|
||||
// boot, e.g. under httptest where main's seeding never ran), plus the env
|
||||
// snapshot. Overridden means "differs from the default", since every key
|
||||
// always has a row.
|
||||
func (s *Server) configView(r *http.Request) (map[string]any, error) {
|
||||
values, err := s.DB.ConfigValues(r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registry := config.AppRegistry()
|
||||
app := make([]configEntry, 0, len(registry))
|
||||
for _, k := range registry {
|
||||
value, ok := values[k.Key]
|
||||
if !ok {
|
||||
value = k.Default
|
||||
}
|
||||
app = append(app, configEntry{
|
||||
Key: k.Key, Value: value, Default: k.Default,
|
||||
Overridden: value != k.Default, Description: k.Description,
|
||||
})
|
||||
}
|
||||
envVars := s.EnvVars
|
||||
if envVars == nil {
|
||||
envVars = []EnvVar{}
|
||||
}
|
||||
return map[string]any{"application": app, "environment": envVars}, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
resp, err := s.configView(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handlePutConfig updates application configuration. Cold: saved values
|
||||
// take effect on the next backend restart; the response is just the
|
||||
// refreshed view, same shape as GET.
|
||||
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
// Validate every pair before writing anything -- all-or-nothing.
|
||||
for key, value := range body {
|
||||
if err := config.ValidateAppValue(key, value); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
for key, value := range body {
|
||||
if err := s.DB.SetConfigValue(r.Context(), key, value); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
resp, err := s.configView(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
111
backend/internal/api/config_test.go
Normal file
111
backend/internal/api/config_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
type configTestResponse struct {
|
||||
Application []struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Default string `json:"default"`
|
||||
Overridden bool `json:"overridden"`
|
||||
Description string `json:"description"`
|
||||
} `json:"application"`
|
||||
Environment []struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
} `json:"environment"`
|
||||
}
|
||||
|
||||
func TestConfig_GetDefaultsAndEnvSnapshot(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
s.EnvVars = []EnvVar{{Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: "•••• (set)"}}
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp configTestResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(resp.Application) != 2 {
|
||||
t.Fatalf("expected 2 app-config entries, got %d: %+v", len(resp.Application), resp.Application)
|
||||
}
|
||||
byKey := map[string]struct {
|
||||
value, def string
|
||||
overridden bool
|
||||
}{}
|
||||
for _, e := range resp.Application {
|
||||
if e.Description == "" {
|
||||
t.Errorf("entry %q has no description", e.Key)
|
||||
}
|
||||
byKey[e.Key] = struct {
|
||||
value, def string
|
||||
overridden bool
|
||||
}{e.Value, e.Default, e.Overridden}
|
||||
}
|
||||
if e := byKey["session.duration"]; e.value != "720" || e.def != "720" || e.overridden {
|
||||
t.Fatalf("session.duration default entry = %+v", e)
|
||||
}
|
||||
if e := byKey["session.setup_timeout"]; e.value != "15" || e.def != "15" || e.overridden {
|
||||
t.Fatalf("session.setup_timeout default entry = %+v", e)
|
||||
}
|
||||
if len(resp.Environment) != 1 || resp.Environment[0].Value != "•••• (set)" {
|
||||
t.Fatalf("env snapshot not passed through: %+v", resp.Environment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_PutPersistsValidatesAllOrNothing(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "168"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("valid PUT status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp configTestResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Application[0].Value != "168" || !resp.Application[0].Overridden {
|
||||
t.Fatalf("override not reflected: %+v", resp.Application[0])
|
||||
}
|
||||
|
||||
if rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"bogus.key": "1"}); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown key status = %d, want 400", rec.Code)
|
||||
}
|
||||
rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "zero"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid value status = %d, want 400", rec.Code)
|
||||
}
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/config", nil)
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Application[0].Value != "168" {
|
||||
t.Fatalf("rejected PUT still changed the value: %+v", resp.Application[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Same gate as every data route: an unprovisioned session gets 403, per
|
||||
// the repo's adversarial-isolation testing convention.
|
||||
func TestConfig_RequiresProvisionedUser(t *testing.T) {
|
||||
db, err := store.Open(t.TempDir() + "/config_gate_test.db")
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
m := &garmin.MockClient{}
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
if rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("GET status = %d, want 403", rec.Code)
|
||||
}
|
||||
if rec := doJSON(t, s.Router(), http.MethodPut, "/api/config", map[string]string{"session.duration": "1"}); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("PUT status = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
200
backend/internal/api/garmin.go
Normal file
200
backend/internal/api/garmin.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"geniusrun/backend/internal/garmin"
|
||||
applog "geniusrun/backend/internal/log"
|
||||
)
|
||||
|
||||
// detailFillBatchSize bounds how many activities' details/splits are fetched
|
||||
// per sync trigger, matching the sequential rate-limited fetch in
|
||||
// internal/sync.Service.FillPendingDetails.
|
||||
const detailFillBatchSize = 50
|
||||
|
||||
type authResponse struct {
|
||||
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func authStatusString(s garmin.AuthStatus) string {
|
||||
switch s {
|
||||
case garmin.AuthSuccess:
|
||||
return "authenticated"
|
||||
case garmin.AuthMFARequired:
|
||||
return "mfa_required"
|
||||
case garmin.AuthFailed:
|
||||
return "failed"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// recordAuthResult updates the in-memory auth status/message for userID,
|
||||
// and -- on a successful authentication -- persists that this account has
|
||||
// connected to Garmin at least once (store.MarkGarminConnected), which is
|
||||
// what the login gate actually checks (the in-memory auth status resets on
|
||||
// every backend restart; this doesn't).
|
||||
func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.AuthResult) {
|
||||
s.mu.Lock()
|
||||
s.userAuthStatus[userID] = res.Status
|
||||
s.userAuthMessage[userID] = res.Message
|
||||
s.mu.Unlock()
|
||||
|
||||
if res.Status == garmin.AuthSuccess {
|
||||
if err := s.DB.MarkGarminConnected(ctx, userID); err != nil {
|
||||
applog.App().Error("mark garmin connected", "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleGarminAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
client, err := s.clientFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := client.Authenticate(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAuthResult(r.Context(), userID, res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
func (s *Server) handleGarminAuthMFA(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Code == "" {
|
||||
writeError(w, http.StatusBadRequest, "code is required")
|
||||
return
|
||||
}
|
||||
|
||||
client, err := s.clientFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := client.CompleteMFA(r.Context(), body.Code)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAuthResult(r.Context(), userID, res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
func (s *Server) handleGarminAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
s.mu.Lock()
|
||||
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
|
||||
}
|
||||
|
||||
// handleGarminSyncRun does a full sync pass: Backfill first (resumes from the
|
||||
// watermark, so widening the configured history horizon between clicks is
|
||||
// picked up automatically), then IncrementalSync (catches anything new since
|
||||
// the latest known activity), then fills in details for whatever's still
|
||||
// missing them. Activities already fully processed are left untouched --
|
||||
// see internal/sync.Service.FillPendingDetails. Recorded as a single
|
||||
// FullSync run so "last sync" reports the combined activity count, not just
|
||||
// whichever of Backfill/IncrementalSync happened to finish last.
|
||||
func (s *Server) handleGarminSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||
return svc.FullSync(ctx, detailFillBatchSize)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
// handleGarminSyncReset wipes every synced activity (and its laps/samples/kind
|
||||
// assignments) and rewinds the backfill watermark, so the next Sync Now
|
||||
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
|
||||
// gates this behind a confirmation.
|
||||
func (s *Server) handleGarminSyncReset(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||
return svc.ResetAll(ctx)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
func (s *Server) handleGarminSyncRuns(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, runs)
|
||||
}
|
||||
|
||||
func (s *Server) handleGarminSyncStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
pendingDetails, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
pendingWorkouts, err := s.DB.CountActivitiesMissingWorkout(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
inProgress := s.userSyncRunning[userID]
|
||||
s.mu.Unlock()
|
||||
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
progress := svc.Progress()
|
||||
|
||||
resp := map[string]any{
|
||||
"in_progress": inProgress,
|
||||
"progress": progress,
|
||||
"activities_pending_details": pendingDetails,
|
||||
"workouts_pending": pendingWorkouts,
|
||||
}
|
||||
if ok {
|
||||
resp["last_run"] = run
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -10,9 +10,7 @@ import (
|
||||
"geniusrun/backend/internal/auth"
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
// doJSONAs is doJSON but for an explicit session Sub, for tests that need
|
||||
@@ -33,7 +31,7 @@ func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||
if err != nil {
|
||||
t.Fatalf("mint test session cookie: %v", err)
|
||||
}
|
||||
@@ -43,7 +41,7 @@ func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) {
|
||||
func TestIsolation_AssignCannotTargetOtherUsersActivity(t *testing.T) {
|
||||
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
|
||||
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
|
||||
if err != nil {
|
||||
@@ -58,21 +56,26 @@ func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity: %v", err)
|
||||
}
|
||||
_ = userB
|
||||
|
||||
router := s.Router()
|
||||
|
||||
// userA (the default doJSON identity) can see it.
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+itoa(activityID), nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("userA GetActivity status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
kindsB, err := db.ListWorkoutKinds(newCtx(), userB, false)
|
||||
if err != nil || len(kindsB) == 0 {
|
||||
t.Fatalf("ListWorkoutKinds(b): len=%d err=%v", len(kindsB), err)
|
||||
}
|
||||
kindB := kindsB[0]
|
||||
if kindB.Name == "Race" { // Race is rejected before the ownership check
|
||||
kindB = kindsB[1]
|
||||
}
|
||||
|
||||
// userB, given the exact same activity id, gets 404 -- not another
|
||||
// user's data, and not a 500 that would leak existence either way.
|
||||
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/activities/"+itoa(activityID), nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("userB GetActivity(userA's id) status = %d, want 404, body = %s", rec.Code, rec.Body.String())
|
||||
// userB, given userA's real activity id (and a kind userB legitimately
|
||||
// owns), must not be able to write an assignment onto it.
|
||||
rec := doJSONAs(t, s.Router(), "user-b", http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindB.ID})
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("userB assigning userA's activity succeeded (status %d), body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok, err := db.CurrentAssignment(newCtx(), userA.ID, activityID); err != nil {
|
||||
t.Fatalf("CurrentAssignment: %v", err)
|
||||
} else if ok {
|
||||
t.Fatalf("userB's rejected assign still created an assignment on userA's activity")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +109,7 @@ func TestIsolation_WorkoutKindUpdateCannotTargetOtherUsersKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
|
||||
func TestIsolation_ActivitiesListOnlyShowsOwnActivities(t *testing.T) {
|
||||
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
|
||||
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
|
||||
if err != nil {
|
||||
@@ -125,7 +128,7 @@ func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
|
||||
t.Fatalf("InsertKindAssignment(b): %v", err)
|
||||
}
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/review-queue/", nil) // as userA
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // as userA
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -135,7 +138,7 @@ func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||
if page.Total != 0 || len(page.Items) != 0 {
|
||||
t.Fatalf("expected userA's review queue to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items))
|
||||
t.Fatalf("expected userA's activities list to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,11 +148,82 @@ func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
m := &mock.Client{}
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsolation_DeleteProfileOnlyDeletesOwnAccount confirms one user
|
||||
// deleting their own profile never touches another user's account, even
|
||||
// though DeleteUser is keyed purely by the session-resolved userID.
|
||||
func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
|
||||
s, db, userA := newTestServer(t) // provisions "test-user" (userA)
|
||||
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
|
||||
t.Fatalf("ProvisionUser(b): %v", err)
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSONAs(t, router, "user-b", http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("userB delete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
if _, found, err := db.GetUserBySub(newCtx(), "user-b"); err != nil || found {
|
||||
t.Fatalf("expected userB gone after their own delete, found=%v err=%v", found, err)
|
||||
}
|
||||
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||||
if err != nil || !found || u.ID != userA {
|
||||
t.Fatalf("expected userA to survive userB's deletion, found=%v err=%v id=%d want=%d", found, err, u.ID, userA)
|
||||
}
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) // as userA
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsolation_GarminConnectedNeverLeaksAcrossUsers confirms one user's
|
||||
// successful Garmin auth never flips garmin_connected for another user.
|
||||
func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
|
||||
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
|
||||
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
|
||||
t.Fatalf("ProvisionUser(b): %v", err)
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // as userA
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/session/me", nil)
|
||||
var meB sessionMeResponse
|
||||
unmarshalBody(t, rec, &meB)
|
||||
if meB.GarminConnected {
|
||||
t.Fatal("userA's successful Garmin auth leaked into userB's garmin_connected flag")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsolation_SetupSessionsNeverLeakAcrossSubjects confirms one OIDC
|
||||
// subject's pending ephemeral Garmin session is invisible to another
|
||||
// subject -- e.g. subject B completing MFA must not accidentally continue
|
||||
// subject A's in-progress attempt.
|
||||
func TestIsolation_SetupSessionsNeverLeakAcrossSubjects(t *testing.T) {
|
||||
s, _, _ := newUnprovisionedServer(t)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||
"garmin_email": "a@example.com", "garmin_password": "pw-a",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login(a) status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = doJSONAs(t, router, "user-b", http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "000000"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("user-b mfa (no login attempt of their own) status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
@@ -35,6 +36,14 @@ func validateProfile(p store.Profile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// profilePayload is the profile API's request/response shape: the profiles
|
||||
// row plus Name, which lives on the users row (the account's single
|
||||
// human-facing name) but is edited from the same Profile screen.
|
||||
type profilePayload struct {
|
||||
store.Profile
|
||||
Name string
|
||||
}
|
||||
|
||||
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
p, err := s.DB.GetProfile(r.Context(), userID)
|
||||
@@ -42,26 +51,35 @@ func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
u, _ := userFromContext(r.Context())
|
||||
writeJSON(w, http.StatusOK, profilePayload{Profile: p, Name: u.Name})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
var p store.Profile
|
||||
var p profilePayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if err := validateProfile(p); err != nil {
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
if err := validateProfile(p.Profile); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.DB.UpdateProfile(r.Context(), userID, p); err != nil {
|
||||
if err := s.DB.UpdateProfile(r.Context(), userID, p.Profile); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
client, err := s.garminFor(r.Context(), userID)
|
||||
if err := s.DB.UpdateUserName(r.Context(), userID, strings.TrimSpace(p.Name)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
client, err := s.clientFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -73,5 +91,31 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
writeJSON(w, http.StatusOK, profilePayload{Profile: updated, Name: strings.TrimSpace(p.Name)})
|
||||
}
|
||||
|
||||
// handleDeleteProfile permanently deletes the signed-in user's entire
|
||||
// geniusrun account (profile, workout kinds/paces, activities and
|
||||
// everything under them, sync state/runs -- see schema.sql's ON DELETE
|
||||
// CASCADE from users(id)) and tears down their cached Garmin client and
|
||||
// token-store directory. It does not touch the session cookie itself --
|
||||
// the frontend follows a successful call with a real logout navigation
|
||||
// (see docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
|
||||
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
|
||||
s.mu.Lock()
|
||||
inProgress := s.userSyncRunning[userID]
|
||||
s.mu.Unlock()
|
||||
if inProgress {
|
||||
writeError(w, http.StatusConflict, "a sync is in progress for this account; wait for it to finish before deleting your profile")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.DB.DeleteUser(r.Context(), userID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.removeUserClient(userID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
// defaultReviewQueuePageSize matches the frontend's initial/incremental
|
||||
// page size for its infinite-scroll list.
|
||||
const defaultReviewQueuePageSize = 10
|
||||
|
||||
type reviewQueueItem struct {
|
||||
store.KindAssignment
|
||||
Activity activityResponse `json:"activity"`
|
||||
Laps []lapResponse `json:"laps"`
|
||||
Samples []store.Sample `json:"samples"`
|
||||
}
|
||||
|
||||
// handleReviewQueue backs the Activities page: every activity that has been
|
||||
// classified at least once, not just ones still needing review, so the page
|
||||
// can show each activity's current kind (or "Unclassified") and let the user
|
||||
// lock/unlock it. It's cursor-paginated: fetching every activity's laps and
|
||||
// (especially) per-second samples is expensive once there are many of them,
|
||||
// so that work only happens for the requested page, not the full backlog.
|
||||
// Sorting/cursor filtering still needs each activity's summary row (a cheap
|
||||
// indexed lookup, no laps/samples), which happens for the whole backlog --
|
||||
// only the heavy per-item fetches are deferred to the page actually being
|
||||
// returned. The optional kind_id/unclassified filters are applied before
|
||||
// that cursor slicing too, so a filtered view still only loads (and
|
||||
// chart-renders) one page at a time instead of the whole matching backlog.
|
||||
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
limit := defaultReviewQueuePageSize
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
cursor := r.URL.Query().Get("before") // activity StartTimeUTC of the last item on the previous page
|
||||
|
||||
var kindIDFilter *int64
|
||||
if v := r.URL.Query().Get("kind_id"); v != "" {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
kindIDFilter = &n
|
||||
}
|
||||
}
|
||||
unclassifiedOnly := r.URL.Query().Get("unclassified") == "true"
|
||||
|
||||
queue, err := s.DB.AllCurrentAssignments(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
type withActivity struct {
|
||||
assignment store.KindAssignment
|
||||
activity store.Activity
|
||||
}
|
||||
all := make([]withActivity, 0, len(queue))
|
||||
for _, a := range queue {
|
||||
if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) {
|
||||
continue
|
||||
}
|
||||
if unclassifiedOnly && a.WorkoutKindID != nil {
|
||||
continue
|
||||
}
|
||||
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
all = append(all, withActivity{assignment: a, activity: activity})
|
||||
}
|
||||
|
||||
// Most recent run first, by when the activity actually happened (not
|
||||
// when the rule engine flagged it), so the queue reads like a log.
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
return all[i].activity.StartTimeUTC > all[j].activity.StartTimeUTC
|
||||
})
|
||||
|
||||
total := len(all)
|
||||
|
||||
if cursor != "" {
|
||||
idx := 0
|
||||
for idx < len(all) && all[idx].activity.StartTimeUTC >= cursor {
|
||||
idx++
|
||||
}
|
||||
all = all[idx:]
|
||||
}
|
||||
hasMore := len(all) > limit
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
|
||||
items := make([]reviewQueueItem, 0, len(all))
|
||||
for _, wa := range all {
|
||||
laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Per-second telemetry, not just per-lap averages, so the chart can
|
||||
// show real within-lap variation instead of one flat segment per lap
|
||||
// (most activities only have a handful of laps).
|
||||
samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
items = append(items, reviewQueueItem{
|
||||
KindAssignment: wa.assignment,
|
||||
Activity: toActivityResponse(wa.activity),
|
||||
Laps: toLapResponses(laps),
|
||||
Samples: samples,
|
||||
})
|
||||
}
|
||||
|
||||
var nextCursor *string
|
||||
if hasMore && len(items) > 0 {
|
||||
c := items[len(items)-1].Activity.StartTimeUTC
|
||||
nextCursor = &c
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"next_cursor": nextCursor,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
WorkoutKindID int64 `json:"workout_kind_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.WorkoutKindID == 0 {
|
||||
writeError(w, http.StatusBadRequest, "workout_kind_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, body.WorkoutKindID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
} else if !ok {
|
||||
writeError(w, http.StatusBadRequest, "workout kind not found")
|
||||
return
|
||||
}
|
||||
if kind.Name == "Race" {
|
||||
writeError(w, http.StatusBadRequest, "Race is assigned automatically from Garmin metadata and cannot be set manually")
|
||||
return
|
||||
}
|
||||
|
||||
kindID := body.WorkoutKindID
|
||||
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||
ActivityID: activityID,
|
||||
WorkoutKindID: &kindID,
|
||||
AssignmentSource: store.AssignmentSourceManual,
|
||||
Status: store.AssignmentStatusAssigned,
|
||||
CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
|
||||
}
|
||||
|
||||
// handleUnassignReview manually clears an activity's kind back to
|
||||
// Unclassified. Like handleResolveReview, it's a deliberate manual choice --
|
||||
// recorded as source=manual (with no kind) so the rule engine leaves it alone
|
||||
// on a later reclassify pass, exactly as it would for a manual kind
|
||||
// assignment. The frontend only offers this while the activity is unlocked
|
||||
// (locked activities must be unlocked first, same precondition as picking a
|
||||
// different kind), but the backend doesn't re-enforce that here, matching
|
||||
// handleResolveReview's own lack of a lock precondition check.
|
||||
func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||
ActivityID: activityID,
|
||||
WorkoutKindID: nil,
|
||||
AssignmentSource: store.AssignmentSourceManual,
|
||||
Status: store.AssignmentStatusNeedsReview,
|
||||
CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "unassigned"})
|
||||
}
|
||||
|
||||
// handleUnlockReview reverts a manual assignment back to rule_engine
|
||||
// sourcing, keeping the same kind, so a later reclassify pass (which always
|
||||
// skips manual assignments) is free to change it again. It's the inverse of
|
||||
// handleResolveReview, not a delete: the kind stays visible as-is until
|
||||
// something actually reclassifies it.
|
||||
func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "activity has no assignment yet")
|
||||
return
|
||||
}
|
||||
if current.AssignmentSource != store.AssignmentSourceManual {
|
||||
writeError(w, http.StatusBadRequest, "activity is not manually locked")
|
||||
return
|
||||
}
|
||||
|
||||
status := store.AssignmentStatusAssigned
|
||||
if current.WorkoutKindID == nil {
|
||||
status = store.AssignmentStatusNeedsReview
|
||||
}
|
||||
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||
ActivityID: activityID,
|
||||
WorkoutKindID: current.WorkoutKindID,
|
||||
AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||
Status: status,
|
||||
CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "unlocked"})
|
||||
}
|
||||
@@ -4,146 +4,85 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
applog "geniusrun/backend/internal/log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/auth"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// Server wires the HTTP handlers to the app's dependencies. garmin.Client
|
||||
// and sync.Service are per-user (each user might have their own Garmin
|
||||
// account), built lazily on first use via GarminFactory and cached.
|
||||
type Server struct {
|
||||
DB *store.DB
|
||||
Auth auth.Verifier
|
||||
Session SessionConfig
|
||||
DB *store.DB
|
||||
Auth auth.Verifier
|
||||
SessionConfig SessionConfig
|
||||
|
||||
// GarminFactory builds a real (or fake, in tests) garmin.Client from a
|
||||
// fully-resolved per-user Config. Production wiring passes
|
||||
// garmin.NewClient; tests inject a factory returning a shared
|
||||
// *mock.Client (see newTestServer in api_test.go).
|
||||
GarminFactory func(garmin.Config) garmin.Client
|
||||
// GarminBase holds the plumbing shared by every user's garmin.Config
|
||||
// (subprocess paths + the token-store root directory); only
|
||||
GarminFactory func(garmin.ClientConfig) garmin.Client
|
||||
|
||||
// ClientConfig holds the plumbing shared by every user's garmin.Config
|
||||
// (the python interpreter path + the token-store root directory); only
|
||||
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
|
||||
// garminFor.
|
||||
GarminBase garmin.Config
|
||||
SyncConfig appsync.Config
|
||||
ClientConfig garmin.ClientConfig
|
||||
SyncConfig garmin.SyncConfig
|
||||
|
||||
// EnvVars is the read-only, display-safe environment-configuration
|
||||
// snapshot served by GET /api/config -- built once in main.go from
|
||||
// config.Config.DisplayEnv() (secrets already masked there); handlers
|
||||
// never call os.Getenv.
|
||||
EnvVars []EnvVar
|
||||
|
||||
mu sync.Mutex
|
||||
userGarmin map[int64]garmin.Client
|
||||
userSync map[int64]*appsync.Service
|
||||
userClient map[int64]garmin.Client
|
||||
userSync map[int64]*garmin.Sync
|
||||
userAuthStatus map[int64]garmin.AuthStatus
|
||||
userAuthMessage map[int64]string
|
||||
userSyncRunning map[int64]bool
|
||||
setupSession map[string]*setupSession
|
||||
}
|
||||
|
||||
// NewServer builds a Server.
|
||||
func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, garminBase garmin.Config, syncConfig appsync.Config, authVerifier auth.Verifier, session SessionConfig) *Server {
|
||||
func NewServer(db *store.DB, garminFactory func(garmin.ClientConfig) garmin.Client, garminBase garmin.ClientConfig, syncConfig garmin.SyncConfig, authVerifier auth.Verifier, sessionConfig SessionConfig) *Server {
|
||||
return &Server{
|
||||
DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig,
|
||||
Auth: authVerifier, Session: session,
|
||||
userGarmin: map[int64]garmin.Client{},
|
||||
userSync: map[int64]*appsync.Service{},
|
||||
DB: db,
|
||||
GarminFactory: garminFactory,
|
||||
ClientConfig: garminBase,
|
||||
SyncConfig: syncConfig,
|
||||
Auth: authVerifier,
|
||||
SessionConfig: sessionConfig,
|
||||
userClient: map[int64]garmin.Client{},
|
||||
userSync: map[int64]*garmin.Sync{},
|
||||
userAuthStatus: map[int64]garmin.AuthStatus{},
|
||||
userAuthMessage: map[int64]string{},
|
||||
userSyncRunning: map[int64]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// garminFor returns userID's garmin.Client, building and caching it (from
|
||||
// userID's own profile row) on first use.
|
||||
func (s *Server) garminFor(ctx context.Context, userID int64) (garmin.Client, error) {
|
||||
s.mu.Lock()
|
||||
if c, ok := s.userGarmin[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
profile, err := s.DB.GetProfile(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
|
||||
}
|
||||
cfg := s.GarminBase
|
||||
cfg.GarminEmail = profile.GarminEmail
|
||||
cfg.GarminPassword = profile.GarminPassword
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if c, ok := s.userGarmin[userID]; ok {
|
||||
return c, nil // built concurrently by another request between our unlock and re-lock
|
||||
}
|
||||
client := s.GarminFactory(cfg)
|
||||
s.userGarmin[userID] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// syncFor returns userID's sync.Service, building and caching it on first use.
|
||||
func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, error) {
|
||||
s.mu.Lock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return svc, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
client, err := s.garminFor(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
return svc, nil
|
||||
}
|
||||
svc := appsync.NewService(client, s.DB, userID, s.SyncConfig, nil)
|
||||
s.userSync[userID] = svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// RunIncrementalSyncForAllUsers is called on a timer (see main.go) to sync
|
||||
// every provisioned user in turn, replacing the old single-global-Service
|
||||
// background loop.
|
||||
func (s *Server) RunIncrementalSyncForAllUsers(ctx context.Context) {
|
||||
users, err := s.DB.ListUsers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("api: list users for incremental sync: %v", err)
|
||||
return
|
||||
}
|
||||
for _, u := range users {
|
||||
svc, err := s.syncFor(ctx, u.ID)
|
||||
if err != nil {
|
||||
log.Printf("api: sync service for user %d: %v", u.ID, err)
|
||||
continue
|
||||
}
|
||||
if err := svc.IncrementalSync(ctx); err != nil {
|
||||
log.Printf("api: incremental sync for user %d: %v", u.ID, err)
|
||||
continue
|
||||
}
|
||||
if err := svc.FillPendingDetails(ctx, 50); err != nil {
|
||||
log.Printf("api: fill pending details for user %d: %v", u.ID, err)
|
||||
}
|
||||
setupSession: map[string]*setupSession{},
|
||||
}
|
||||
}
|
||||
|
||||
// Router builds the HTTP routes.
|
||||
func (s *Server) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(loggingMiddleware)
|
||||
r.Use(corsMiddleware)
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/health", s.handleHealth)
|
||||
@@ -154,12 +93,19 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/session/callback", s.handleSessionCallback)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.RequireSession(s.Session.Secret))
|
||||
r.Use(auth.RequireSession(s.SessionConfig.Secret))
|
||||
r.Use(s.resolveUser)
|
||||
|
||||
r.Get("/session/me", s.handleSessionMe)
|
||||
r.Post("/session/logout", s.handleSessionLogout)
|
||||
r.Post("/setup", s.handleSetup)
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Post("/complete", s.handleSetupComplete)
|
||||
r.Route("/garmin", func(r chi.Router) {
|
||||
r.Post("/login", s.handleSetupGarminLogin)
|
||||
r.Post("/mfa", s.handleSetupGarminMFA)
|
||||
})
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(requireProvisionedUser)
|
||||
@@ -167,24 +113,30 @@ func (s *Server) Router() http.Handler {
|
||||
r.Route("/profile", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetProfile)
|
||||
r.Put("/", s.handleUpdateProfile)
|
||||
r.Delete("/", s.handleDeleteProfile)
|
||||
})
|
||||
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", s.handleAuthLogin)
|
||||
r.Post("/mfa", s.handleAuthMFA)
|
||||
r.Get("/status", s.handleAuthStatus)
|
||||
})
|
||||
r.Route("/garmin", func(r chi.Router) {
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", s.handleGarminAuthLogin)
|
||||
r.Post("/mfa", s.handleGarminAuthMFA)
|
||||
r.Get("/status", s.handleGarminAuthStatus)
|
||||
})
|
||||
|
||||
r.Route("/sync", func(r chi.Router) {
|
||||
r.Post("/run", s.handleGarminSyncRun)
|
||||
r.Post("/reset", s.handleGarminSyncReset)
|
||||
r.Get("/runs", s.handleGarminSyncRuns)
|
||||
r.Get("/status", s.handleGarminSyncStatus)
|
||||
})
|
||||
|
||||
r.Route("/sync", func(r chi.Router) {
|
||||
r.Post("/run", s.handleSyncRun)
|
||||
r.Post("/reset", s.handleSyncReset)
|
||||
r.Get("/runs", s.handleSyncRuns)
|
||||
r.Get("/status", s.handleSyncStatus)
|
||||
})
|
||||
|
||||
r.Route("/activities", func(r chi.Router) {
|
||||
r.Get("/", s.handleListActivities)
|
||||
r.Get("/{id}", s.handleGetActivity)
|
||||
r.Post("/{activityID}/assign", s.handleAssignActivity)
|
||||
r.Post("/{activityID}/unlock", s.handleUnlockActivity)
|
||||
r.Post("/{activityID}/unassign", s.handleUnassignActivity)
|
||||
})
|
||||
|
||||
r.Route("/workout-kinds", func(r chi.Router) {
|
||||
@@ -195,12 +147,8 @@ func (s *Server) Router() http.Handler {
|
||||
|
||||
r.Post("/reclassify", s.handleReclassifyAll)
|
||||
|
||||
r.Route("/review-queue", func(r chi.Router) {
|
||||
r.Get("/", s.handleReviewQueue)
|
||||
r.Post("/{activityID}/resolve", s.handleResolveReview)
|
||||
r.Post("/{activityID}/unlock", s.handleUnlockReview)
|
||||
r.Post("/{activityID}/unassign", s.handleUnassignReview)
|
||||
})
|
||||
r.Get("/config", s.handleGetConfig)
|
||||
r.Put("/config", s.handlePutConfig)
|
||||
|
||||
r.Get("/progression/{kindID}", s.handleProgression)
|
||||
})
|
||||
@@ -209,6 +157,37 @@ func (s *Server) Router() http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// loggingMiddleware logs one type=http JSON line per HTTP request. The
|
||||
// record's meaning is fully carried by its fields (http_method, path,
|
||||
// status, duration_ms), so it has no msg; "method" stays reserved for the
|
||||
// emitting function per the log schema, hence http_method for the verb.
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
start := time.Now()
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
level := slog.LevelInfo
|
||||
if ww.Status() >= 500 {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
slog.Default().LogAttrs(r.Context(), level, "",
|
||||
slog.String("type", "http"),
|
||||
slog.String("package", "api"),
|
||||
slog.String("file", "server.go"),
|
||||
slog.String("method", "loggingMiddleware"),
|
||||
slog.String("http_method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", ww.Status()),
|
||||
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// corsMiddleware allows the frontend dev server (a different port) to call
|
||||
// this API. Reflecting any origin back is safe even with credentials
|
||||
// enabled: this remains a single-operator app whose real access control is
|
||||
@@ -229,20 +208,89 @@ func corsMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
// clientFor returns userID's garmin.Client, building and caching it (from
|
||||
// userID's own profile row) on first use.
|
||||
func (s *Server) clientFor(ctx context.Context, userID int64) (garmin.Client, error) {
|
||||
s.mu.Lock()
|
||||
if c, ok := s.userClient[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
profile, err := s.DB.GetProfile(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
|
||||
}
|
||||
cfg := s.ClientConfig
|
||||
cfg.GarminEmail = profile.GarminEmail
|
||||
cfg.GarminPassword = profile.GarminPassword
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if c, ok := s.userClient[userID]; ok {
|
||||
return c, nil // built concurrently by another request between our unlock and re-lock
|
||||
}
|
||||
client := s.GarminFactory(cfg)
|
||||
s.userClient[userID] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("api: encode response: %v", err)
|
||||
// removeUserClient drops userID's cached garmin.Client/sync.Service (if
|
||||
// any) and every other per-user in-memory entry for userID, terminating the
|
||||
// client's subprocess and best-effort removing its on-disk token-store
|
||||
// directory. Called when a user's account has just been deleted from the
|
||||
// DB, so nothing in memory keeps referencing a userID that no longer
|
||||
// exists.
|
||||
func (s *Server) removeUserClient(userID int64) {
|
||||
s.mu.Lock()
|
||||
client, ok := s.userClient[userID]
|
||||
delete(s.userClient, userID)
|
||||
delete(s.userSync, userID)
|
||||
delete(s.userAuthStatus, userID)
|
||||
delete(s.userAuthMessage, userID)
|
||||
delete(s.userSyncRunning, userID)
|
||||
s.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
if err := client.Close(); err != nil {
|
||||
applog.App().Error("close garmin client for deleted user", "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
if s.ClientConfig.TokenStorePath == "" {
|
||||
return
|
||||
}
|
||||
tokenStoreDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if err := os.RemoveAll(tokenStoreDir); err != nil {
|
||||
applog.App().Error("remove token store dir for deleted user", "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
// syncFor returns userID's sync.Service, building and caching it on first use.
|
||||
func (s *Server) syncFor(ctx context.Context, userID int64) (*garmin.Sync, error) {
|
||||
s.mu.Lock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return svc, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
client, err := s.clientFor(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
return svc, nil
|
||||
}
|
||||
svc := garmin.NewSync(client, s.DB, userID, s.SyncConfig, nil)
|
||||
s.userSync[userID] = svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
|
||||
@@ -264,8 +312,30 @@ func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
if err := fn(context.Background()); err != nil {
|
||||
log.Printf("api: background sync error (user %d): %v", userID, err)
|
||||
applog.App().Error("background sync failed", "user_id", userID, "error", err)
|
||||
}
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
// setupTokenStoreDir returns the token-store directory an ephemeral setup
|
||||
// session for sub should use -- hashed rather than sub itself, since sub is
|
||||
// an opaque string from the identity provider and using it verbatim in a
|
||||
// filesystem path would be a directory-traversal risk if it ever contained
|
||||
// path separators.
|
||||
func setupTokenStoreDir(root, sub string) string {
|
||||
h := sha256.Sum256([]byte(sub))
|
||||
return filepath.Join(root, "setup", hex.EncodeToString(h[:]))
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
applog.App().Error("encode response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
applog "geniusrun/backend/internal/log"
|
||||
|
||||
"geniusrun/backend/internal/auth"
|
||||
)
|
||||
|
||||
@@ -14,20 +15,35 @@ import (
|
||||
type SessionConfig struct {
|
||||
Secret []byte
|
||||
Duration time.Duration
|
||||
Secure bool
|
||||
// PublicBaseURL is this app's own externally reachable origin (e.g.
|
||||
// "https://geniusrun.example.com", no trailing slash), used to build an
|
||||
// absolute post_logout_redirect_uri for the identity provider -- some
|
||||
// providers, including Keycloak, require this to be an absolute URL
|
||||
// matching one registered on the client, not a bare relative path.
|
||||
PublicBaseURL string
|
||||
// SetupTimeout evicts an unfinished onboarding Garmin setup session
|
||||
// once idle this long (app-config key session.setup_timeout, minutes;
|
||||
// distinct from Duration, the login cookie lifetime). Mandatory --
|
||||
// there is no code fallback; the default lives in the DB, seeded at
|
||||
// startup.
|
||||
SetupTimeout time.Duration
|
||||
Secure bool
|
||||
// BackendURL is this app's own externally reachable origin (e.g.
|
||||
// "https://geniusrun.example.com", no trailing slash) -- derives
|
||||
// OIDCRedirectURL (config.Config), the only thing that must stay pointed
|
||||
// at the backend itself, since that's where /api/session/callback is
|
||||
// actually served.
|
||||
BackendURL string
|
||||
// FrontendURL is the origin the browser should land on after any
|
||||
// user-facing redirect: the OIDC callback (success or failure) and the
|
||||
// post_logout_redirect_uri sent to the identity provider on logout. Some
|
||||
// providers, including Keycloak, require an absolute URL matching one
|
||||
// registered on the client, not a bare relative path -- see
|
||||
// config.Config.FrontendURL for why this can differ from BackendURL in a
|
||||
// split-origin deployment.
|
||||
FrontendURL string
|
||||
}
|
||||
|
||||
type sessionMeResponse struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
HasProfile bool `json:"has_profile"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
HasProfile bool `json:"has_profile"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
GarminConnected bool `json:"garmin_connected"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -36,7 +52,7 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
cookie, err := auth.MintTxnCookie(txn, s.Session.Secret, s.Session.Secure)
|
||||
cookie, err := auth.MintTxnCookie(txn, s.SessionConfig.Secret, s.SessionConfig.Secure)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -48,42 +64,58 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
||||
txnCookie, err := r.Cookie(auth.TxnCookieName)
|
||||
if err != nil {
|
||||
log.Printf("session callback: missing txn cookie: %v", err)
|
||||
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
|
||||
applog.App().Warn("missing txn cookie", "error", err)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.Session.Secure))
|
||||
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.SessionConfig.Secure))
|
||||
|
||||
txn, err := auth.ParseTxnCookie(txnCookie, s.Session.Secret)
|
||||
txn, err := auth.ParseTxnCookie(txnCookie, s.SessionConfig.Secret)
|
||||
if err != nil {
|
||||
log.Printf("session callback: failed to parse txn cookie: %v", err)
|
||||
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
|
||||
applog.App().Warn("failed to parse txn cookie", "error", err)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
|
||||
if err != nil {
|
||||
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", err)
|
||||
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
|
||||
applog.App().Error("callback failed (state mismatch, code exchange, or ID-token verification)", "error", err)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if !result.Authorized {
|
||||
http.Redirect(w, r, "/?auth_error=forbidden", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.Session.Secret, s.Session.Duration, s.Session.Secure)
|
||||
sessionCookie, err := auth.MintSessionCookie(result.Claims, result.IDToken, s.SessionConfig.Secret, s.SessionConfig.Duration, s.SessionConfig.Secure)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, sessionCookie)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/", http.StatusFound)
|
||||
}
|
||||
|
||||
// handleSessionLogout clears geniusrun's own session cookie and redirects
|
||||
// through Keycloak's end-session endpoint, passing the session's ID token
|
||||
// as id_token_hint (read back from the cookie via
|
||||
// auth.IDTokenFromSessionCookie -- it deliberately doesn't ride in Claims)
|
||||
// so Keycloak can skip its own logout-confirmation prompt -- otherwise a
|
||||
// user could cancel out of it and land back on the app with a Keycloak SSO
|
||||
// session but no geniusrun profile (already deleted, in the
|
||||
// profile-deletion case this exists for).
|
||||
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
|
||||
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.PublicBaseURL+"/"), http.StatusFound)
|
||||
claims, _ := auth.ClaimsFromContext(r.Context())
|
||||
s.removeSetupSession(claims.Sub)
|
||||
// Best-effort: an unreadable cookie just means logging out without the
|
||||
// hint, at worst showing Keycloak's own confirmation screen.
|
||||
var idToken string
|
||||
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil {
|
||||
idToken, _ = auth.IDTokenFromSessionCookie(cookie, s.SessionConfig.Secret)
|
||||
}
|
||||
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.SessionConfig.Secure))
|
||||
http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", idToken), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -97,7 +129,13 @@ func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
||||
resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
|
||||
if u, found := userFromContext(r.Context()); found {
|
||||
resp.HasProfile = true
|
||||
resp.DisplayName = u.DisplayName
|
||||
resp.DisplayName = u.Name
|
||||
profile, err := s.DB.GetProfile(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
resp.GarminConnected = profile.GarminConnectedAt != nil
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,120 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/auth"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
applog "geniusrun/backend/internal/log"
|
||||
)
|
||||
|
||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
// setupSession is a temporary, not-yet-persisted Garmin authentication
|
||||
// attempt made during onboarding, before any users/profile row exists --
|
||||
// keyed by OIDC subject (the only stable identifier available pre-account)
|
||||
// rather than a user id. Closed (never promoted into Server.userClient) once
|
||||
// /api/setup/complete actually creates the account -- its Client's
|
||||
// garmin.Config.TokenStorePath is permanently pinned to the ephemeral
|
||||
// setup/{hash} directory, so reusing the object after that directory is
|
||||
// renamed to the permanent {userID} one would respawn against a stale path
|
||||
// the next time anything closes and restarts its subprocess; a later
|
||||
// garminFor(ctx, userID) call builds a fresh client with the correct path
|
||||
// instead. Otherwise evicted lazily (the next setup-endpoint touch for that
|
||||
// subject checks staleness first) once idle past SessionConfig.SetupTimeout.
|
||||
type setupSession struct {
|
||||
Client garmin.Client
|
||||
Email, Password string
|
||||
Status garmin.AuthStatus
|
||||
Message string
|
||||
LastUsed time.Time
|
||||
}
|
||||
|
||||
// handleSetupGarminLogin authenticates against Garmin using an ephemeral,
|
||||
// not-yet-persisted session keyed by the OIDC subject -- no users/profile
|
||||
// row exists yet at this point (see setupSession in server.go).
|
||||
func (s *Server) handleSetupGarminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
if _, found := userFromContext(r.Context()); found {
|
||||
writeError(w, http.StatusConflict, "profile already exists for this account")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
GarminEmail string `json:"garmin_email"`
|
||||
GarminPassword string `json:"garmin_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.GarminEmail == "" || body.GarminPassword == "" {
|
||||
writeError(w, http.StatusBadRequest, "garmin_email and garmin_password are required")
|
||||
return
|
||||
}
|
||||
|
||||
sess := s.replaceSetupSession(claims.Sub, body.GarminEmail, body.GarminPassword)
|
||||
res, err := sess.Client.Authenticate(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordSetupAuthResult(claims.Sub, res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
// handleSetupGarminMFA continues an in-progress ephemeral Garmin login
|
||||
// (started by handleSetupGarminLogin) with an MFA code, on the same
|
||||
// session/subprocess -- never replaces it.
|
||||
func (s *Server) handleSetupGarminMFA(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Code == "" {
|
||||
writeError(w, http.StatusBadRequest, "code is required")
|
||||
return
|
||||
}
|
||||
|
||||
sess, ok := s.setupSessionFor(claims.Sub)
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "no Garmin connection attempt in progress; connect again")
|
||||
return
|
||||
}
|
||||
res, err := sess.Client.CompleteMFA(r.Context(), body.Code)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordSetupAuthResult(claims.Sub, res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
// handleSetupComplete is the single atomic commit point: only reachable
|
||||
// once the ephemeral session for this subject last reported
|
||||
// garmin.AuthSuccess. Provisions the account, persists the Garmin
|
||||
// credentials, marks it connected, closes the ephemeral client, and
|
||||
// renames its token-store directory into the permanent per-user path --
|
||||
// the next real clientFor(ctx, userID) builds a fresh client from scratch
|
||||
// against that now-permanent directory, whose subprocess's lazy
|
||||
// startup-login resumes the just-renamed, still-valid session without
|
||||
// needing to re-authenticate (a cheap local token-store resume, not a
|
||||
// fresh Garmin login).
|
||||
func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
@@ -30,10 +139,145 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
sess, ok := s.setupSessionFor(claims.Sub)
|
||||
if !ok || sess.Status != garmin.AuthSuccess {
|
||||
writeError(w, http.StatusConflict, "Garmin isn't connected yet -- connect before completing setup")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
profile, err := s.DB.GetProfile(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
profile.GarminEmail = sess.Email
|
||||
profile.GarminPassword = sess.Password
|
||||
if err := s.DB.UpdateProfile(r.Context(), userID, profile); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.DB.MarkGarminConnected(r.Context(), userID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// The ephemeral client's own garmin.Config.TokenStorePath was set once,
|
||||
// at construction time in replaceSetupSession, to the ephemeral
|
||||
// setup/{hash} directory being renamed below -- there's no setter to
|
||||
// correct it in place, so promoting this object into s.userGarmin would
|
||||
// leave a client whose subprocess respawns (e.g. on the very next
|
||||
// UpdateCredentials call from a Profile save) using that now-stale
|
||||
// path, recreating a setup/{hash} directory next to the real one.
|
||||
// Closing it here and leaving s.userGarmin empty for this user makes
|
||||
// the next garminFor(ctx, userID) call build a fresh client against the
|
||||
// correct, just-renamed {userID} directory instead -- its subprocess's
|
||||
// lazy startup-login resumes that session without a real Garmin
|
||||
// re-authentication.
|
||||
sess.Client.Close()
|
||||
|
||||
s.mu.Lock()
|
||||
delete(s.setupSession, claims.Sub)
|
||||
s.userAuthStatus[userID] = sess.Status
|
||||
s.userAuthMessage[userID] = sess.Message
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub)
|
||||
newDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
// A stale {userID} directory can survive from a previous account
|
||||
// with the same id -- pre-production the DB file is freely deleted
|
||||
// and recreated (ids restart at 1) while the token-store root lives
|
||||
// on, and os.Rename refuses to replace a non-empty directory. The
|
||||
// just-validated setup session must win, so clear the target first.
|
||||
if err := os.RemoveAll(newDir); err != nil {
|
||||
applog.App().Error("remove stale token store dir", "user_id", userID, "error", err)
|
||||
}
|
||||
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
|
||||
applog.App().Error("rename setup token store dir", "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
|
||||
}
|
||||
|
||||
// setupSessionFor returns sub's in-progress ephemeral Garmin session, if
|
||||
// any and not stale. A stale session is closed and evicted first, so the
|
||||
// caller always either gets a fresh, live session or none.
|
||||
func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.setupSession[sub]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Since(sess.LastUsed) > s.SessionConfig.SetupTimeout {
|
||||
sess.Client.Close()
|
||||
delete(s.setupSession, sub)
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
// replaceSetupSession closes and replaces sub's ephemeral Garmin session
|
||||
// (if any) with a freshly built one for the given credentials -- same
|
||||
// "close old, spawn new" semantics as garmin.Client.UpdateCredentials.
|
||||
func (s *Server) replaceSetupSession(sub, email, password string) *setupSession {
|
||||
s.mu.Lock()
|
||||
old, ok := s.setupSession[sub]
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
old.Client.Close()
|
||||
}
|
||||
|
||||
cfg := s.ClientConfig
|
||||
cfg.GarminEmail = email
|
||||
cfg.GarminPassword = password
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub)
|
||||
}
|
||||
sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()}
|
||||
|
||||
s.mu.Lock()
|
||||
s.setupSession[sub] = sess
|
||||
s.mu.Unlock()
|
||||
return sess
|
||||
}
|
||||
|
||||
// recordSetupAuthResult updates sub's ephemeral session after a login or
|
||||
// MFA attempt. A no-op if the session is gone (e.g. evicted concurrently).
|
||||
func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sess, ok := s.setupSession[sub]; ok {
|
||||
sess.Status = res.Status
|
||||
sess.Message = res.Message
|
||||
sess.LastUsed = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// removeSetupSession closes and drops sub's ephemeral Garmin session, if
|
||||
// any, and best-effort removes its token-store directory. Used when
|
||||
// abandoning onboarding (logout). handleSetupComplete closes the client the
|
||||
// same way but keeps (renames) the directory instead of removing it, since
|
||||
// that's the real, now-permanent session.
|
||||
func (s *Server) removeSetupSession(sub string) {
|
||||
s.mu.Lock()
|
||||
sess, ok := s.setupSession[sub]
|
||||
delete(s.setupSession, sub)
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sess.Client.Close()
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,82 +4,314 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) {
|
||||
// This test specifically needs an *unprovisioned* session, unlike every
|
||||
// other test in this package -- build the server without the
|
||||
// newTestServer helper's automatic ProvisionUser call.
|
||||
// newUnprovisionedServer builds a Server whose "test-user" OIDC subject
|
||||
// (the identity doJSON's cookie always mints) has no users/profile row yet
|
||||
// -- every test in this file needs that starting state, unlike
|
||||
// newTestServer's auto-provisioned default.
|
||||
func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *garmin.MockClient) {
|
||||
t.Helper()
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &mock.Client{}
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
return s, db, m
|
||||
}
|
||||
|
||||
func TestSetupGarminLogin_AuthenticatesWithoutCreatingAccount(t *testing.T) {
|
||||
s, db, _ := newUnprovisionedServer(t)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||||
var me sessionMeResponse
|
||||
unmarshalBody(t, rec, &me)
|
||||
if me.HasProfile {
|
||||
t.Fatal("expected a brand-new session to have no profile yet")
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/setup", map[string]any{"display_name": "Lucie"})
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("setup status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp authResponse
|
||||
unmarshalBody(t, rec, &resp)
|
||||
if resp.Status != "authenticated" {
|
||||
t.Fatalf("status = %q, want authenticated", resp.Status)
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||||
unmarshalBody(t, rec, &me)
|
||||
if !me.HasProfile || me.DisplayName != "Lucie" {
|
||||
t.Fatalf("session/me after setup = %+v, want HasProfile=true DisplayName=Lucie", me)
|
||||
}
|
||||
|
||||
u, found, err := db.GetUserBySub(newCtx(), "test-user") // doJSON's test cookie's Sub
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||||
}
|
||||
if u.DisplayName != "Lucie" {
|
||||
t.Errorf("stored DisplayName = %q, want Lucie", u.DisplayName)
|
||||
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
|
||||
t.Fatalf("expected no user created yet, found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
|
||||
func TestSetupGarminLogin_MFARequiredThenCompleteMFA(t *testing.T) {
|
||||
s, db, m := newUnprovisionedServer(t)
|
||||
m.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp authResponse
|
||||
unmarshalBody(t, rec, &resp)
|
||||
if resp.Status != "mfa_required" {
|
||||
t.Fatalf("status = %q, want mfa_required", resp.Status)
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("mfa status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
unmarshalBody(t, rec, &resp)
|
||||
if resp.Status != "authenticated" {
|
||||
t.Fatalf("status after mfa = %q, want authenticated", resp.Status)
|
||||
}
|
||||
|
||||
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
|
||||
t.Fatalf("expected no user created yet even after MFA success, found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupGarminMFA_RejectsWithoutPriorLoginAttempt(t *testing.T) {
|
||||
s, _, _ := newUnprovisionedServer(t)
|
||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupComplete_CreatesAccountWithGarminCredentialsAndClosesEphemeralClient(t *testing.T) {
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &mock.Client{}
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
tokenStoreRoot := t.TempDir()
|
||||
var factoryConfigs []garmin.ClientConfig
|
||||
garminFactory := func(cfg garmin.ClientConfig) garmin.Client {
|
||||
factoryConfigs = append(factoryConfigs, cfg)
|
||||
return m
|
||||
}
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""})
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||||
}
|
||||
profile, err := db.GetProfile(newCtx(), u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile: %v", err)
|
||||
}
|
||||
if profile.GarminEmail != "runner@example.com" || profile.GarminPassword != "hunter2" {
|
||||
t.Errorf("profile garmin creds = %+v, want email=runner@example.com password=hunter2", profile)
|
||||
}
|
||||
if profile.GarminConnectedAt == nil {
|
||||
t.Error("expected GarminConnectedAt to be set")
|
||||
}
|
||||
|
||||
if m.AuthenticateCalls != 1 {
|
||||
t.Errorf("AuthenticateCalls = %d, want 1 (only the original login, no redundant re-authentication)", m.AuthenticateCalls)
|
||||
}
|
||||
if !m.ClosedCalled {
|
||||
t.Error("expected the ephemeral client to be Close()d at setup completion, not promoted as-is -- its cfg.TokenStorePath still points at the ephemeral setup/{hash} dir, which would go stale the moment anything (e.g. a Profile save) later respawns it")
|
||||
}
|
||||
|
||||
// A later real use must build a genuinely fresh client, configured
|
||||
// against the permanent {userID} token store directory -- never the
|
||||
// stale ephemeral setup/{hash} one the closed client was carrying.
|
||||
if _, err := s.clientFor(newCtx(), u.ID); err != nil {
|
||||
t.Fatalf("garminFor: %v", err)
|
||||
}
|
||||
wantTokenStorePath := filepath.Join(tokenStoreRoot, itoa(u.ID))
|
||||
var gotTokenStorePath string
|
||||
for _, cfg := range factoryConfigs {
|
||||
if cfg.GarminEmail == "runner@example.com" {
|
||||
gotTokenStorePath = cfg.TokenStorePath
|
||||
}
|
||||
}
|
||||
if gotTokenStorePath != wantTokenStorePath {
|
||||
t.Errorf("garminFor built client with TokenStorePath = %q, want %q", gotTokenStorePath, wantTokenStorePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupComplete_RejectsWithoutSuccessfulGarminConnection(t *testing.T) {
|
||||
s, db, _ := newUnprovisionedServer(t)
|
||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
|
||||
t.Fatalf("expected no user created, found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupComplete_RejectsEmptyDisplayName(t *testing.T) {
|
||||
s, _, _ := newUnprovisionedServer(t)
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": ""})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"})
|
||||
func TestSetupGarminLogin_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
||||
s, _, _ := newTestServer(t) // pre-provisioned "test-user"
|
||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||
})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupComplete_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
||||
s, _, _ := newTestServer(t) // pre-provisioned "test-user"
|
||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Someone Else"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupComplete_RenamesTokenStoreDirectory(t *testing.T) {
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &garmin.MockClient{}
|
||||
tokenStoreRoot := t.TempDir()
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Simulate what a real subprocess would have written under the
|
||||
// ephemeral (subject-keyed) directory during that login call.
|
||||
oldDir := setupTokenStoreDir(tokenStoreRoot, "test-user")
|
||||
if err := os.MkdirAll(oldDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "session.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(oldDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected old setup token store dir %q to be gone, stat err = %v", oldDir, err)
|
||||
}
|
||||
newDir := filepath.Join(tokenStoreRoot, itoa(u.ID))
|
||||
if _, err := os.Stat(filepath.Join(newDir, "session.json")); err != nil {
|
||||
t.Fatalf("expected renamed token store dir %q to contain session.json: %v", newDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
|
||||
t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: os.Rename refuses to replace an existing directory, so a
|
||||
// stale {userID} token-store dir (left behind when the DB file was
|
||||
// recreated -- ids restart at 1 -- while the token-store root survived)
|
||||
// used to make setup completion silently keep the OLD tokens in place.
|
||||
// The fresh, just-validated session's directory must win.
|
||||
func TestSetupComplete_ReplacesStaleTokenStoreDir(t *testing.T) {
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "setup_stale_dir_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &garmin.MockClient{}
|
||||
tokenStoreRoot := t.TempDir()
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
router := s.Router()
|
||||
|
||||
// The ephemeral setup dir the wrapper would have written tokens into.
|
||||
setupDir := setupTokenStoreDir(tokenStoreRoot, "test-user")
|
||||
if err := os.MkdirAll(setupDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir setup dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(setupDir, "oauth_token"), []byte("fresh"), 0o600); err != nil {
|
||||
t.Fatalf("write fresh token: %v", err)
|
||||
}
|
||||
// A stale dir already occupying the permanent {userID} path (fresh DB
|
||||
// starts ids at 1).
|
||||
staleDir := filepath.Join(tokenStoreRoot, "1")
|
||||
if err := os.MkdirAll(staleDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir stale dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staleDir, "oauth_token"), []byte("stale"), 0o600); err != nil {
|
||||
t.Fatalf("write stale token: %v", err)
|
||||
}
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||||
}
|
||||
token, err := os.ReadFile(filepath.Join(tokenStoreRoot, itoa(u.ID), "oauth_token"))
|
||||
if err != nil {
|
||||
t.Fatalf("read token after complete: %v", err)
|
||||
}
|
||||
if string(token) != "fresh" {
|
||||
t.Fatalf("token content = %q, want the fresh setup session to replace the stale dir", token)
|
||||
}
|
||||
if _, err := os.Stat(setupDir); !os.IsNotExist(err) {
|
||||
t.Errorf("ephemeral setup dir still present after rename: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// detailFillBatchSize bounds how many activities' details/splits are fetched
|
||||
// per sync trigger, matching the sequential rate-limited fetch in
|
||||
// internal/sync.Service.FillPendingDetails.
|
||||
const detailFillBatchSize = 50
|
||||
|
||||
// handleSyncRun does a full sync pass: Backfill first (resumes from the
|
||||
// watermark, so widening the configured history horizon between clicks is
|
||||
// picked up automatically), then IncrementalSync (catches anything new since
|
||||
// the latest known activity), then fills in details for whatever's still
|
||||
// missing them. Activities already fully processed are left untouched --
|
||||
// see internal/sync.Service.FillPendingDetails. Recorded as a single
|
||||
// FullSync run so "last sync" reports the combined activity count, not just
|
||||
// whichever of Backfill/IncrementalSync happened to finish last.
|
||||
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||
return svc.FullSync(ctx, detailFillBatchSize)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
// handleSyncReset wipes every synced activity (and its laps/samples/kind
|
||||
// assignments) and rewinds the backfill watermark, so the next Sync Now
|
||||
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
|
||||
// gates this behind a confirmation.
|
||||
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||
return svc.ResetAll(ctx)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, runs)
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
inProgress := s.userSyncRunning[userID]
|
||||
s.mu.Unlock()
|
||||
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
progress := svc.Progress()
|
||||
|
||||
resp := map[string]any{
|
||||
"in_progress": inProgress,
|
||||
"detail_fill_progress": progress,
|
||||
"activities_pending_details": remaining,
|
||||
}
|
||||
if ok {
|
||||
resp["last_run"] = run
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -14,8 +14,8 @@ const resolvedUserContextKey userContextKey = iota
|
||||
// resolvedUser is the geniusrun account (if any) bound to the current
|
||||
// session's OIDC subject.
|
||||
type resolvedUser struct {
|
||||
ID int64
|
||||
DisplayName string
|
||||
ID int64
|
||||
Name string
|
||||
}
|
||||
|
||||
// resolveUser runs after auth.RequireSession on every request and looks up
|
||||
@@ -40,7 +40,7 @@ func (s *Server) resolveUser(next http.Handler) http.Handler {
|
||||
}
|
||||
ctx := r.Context()
|
||||
if found {
|
||||
ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, DisplayName: u.DisplayName})
|
||||
ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, Name: u.Name})
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
"geniusrun/backend/internal/auth"
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
|
||||
@@ -41,8 +39,8 @@ func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &mock.Client{}
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
var gotOK bool
|
||||
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -28,7 +28,7 @@ func TestRequireSession_NoCookie(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireSession_ValidCookie(t *testing.T) {
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, []byte(testSecret), time.Hour, false)
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, "", []byte(testSecret), time.Hour, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func TestRequireSession_ValidCookie(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireSession_ExpiredCookie(t *testing.T) {
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false)
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), -time.Hour, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
@@ -59,11 +59,11 @@ func TestRequireSession_ExpiredCookie(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireSession_TamperedCookie(t *testing.T) {
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false)
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
cookie.Value = cookie.Value[:len(cookie.Value)-1] + "x"
|
||||
cookie.Value = flipSignatureChar(cookie.Value)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -18,6 +18,7 @@ type Verifier struct {
|
||||
CallbackResult auth.LoginResult
|
||||
CallbackErr error
|
||||
EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged
|
||||
LastIDTokenHint string // records the idTokenHint passed to the last EndSessionURL call
|
||||
}
|
||||
|
||||
var _ auth.Verifier = (*Verifier)(nil)
|
||||
@@ -33,7 +34,8 @@ func (v *Verifier) HandleCallback(ctx context.Context, txn auth.TxnState, query
|
||||
return v.CallbackResult, nil
|
||||
}
|
||||
|
||||
func (v *Verifier) EndSessionURL(postLogoutRedirectURL string) string {
|
||||
func (v *Verifier) EndSessionURL(postLogoutRedirectURL, idTokenHint string) string {
|
||||
v.LastIDTokenHint = idTokenHint
|
||||
if v.EndSessionResult != "" {
|
||||
return v.EndSessionResult
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
@@ -24,13 +25,21 @@ type Verifier interface {
|
||||
HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error)
|
||||
// EndSessionURL builds the identity provider's logout URL, redirecting
|
||||
// back to postLogoutRedirectURL once Keycloak's own session is cleared.
|
||||
EndSessionURL(postLogoutRedirectURL string) string
|
||||
// idTokenHint, if non-empty, is passed as id_token_hint so Keycloak can
|
||||
// positively identify the session being ended and skip its own
|
||||
// logout-confirmation prompt (which would otherwise let the user cancel
|
||||
// out of logout after their geniusrun account is already deleted).
|
||||
EndSessionURL(postLogoutRedirectURL, idTokenHint string) string
|
||||
}
|
||||
|
||||
// LoginResult is what a completed callback exchange resolves to.
|
||||
type LoginResult struct {
|
||||
Claims Claims
|
||||
Authorized bool
|
||||
// IDToken is the raw Keycloak ID token JWT, kept apart from Claims:
|
||||
// it's only ever needed once more, at logout (id_token_hint), so it
|
||||
// rides in the session cookie but never in the per-request Claims.
|
||||
IDToken string
|
||||
}
|
||||
|
||||
// OIDCConfig configures NewOIDCVerifier. RequiredRole is checked against the
|
||||
@@ -55,12 +64,7 @@ type idTokenClaims struct {
|
||||
}
|
||||
|
||||
func (c idTokenClaims) hasRole(required string) bool {
|
||||
for _, r := range c.RealmAccess.Roles {
|
||||
if r == required {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.Contains(c.RealmAccess.Roles, required)
|
||||
}
|
||||
|
||||
type oidcVerifier struct {
|
||||
@@ -137,10 +141,11 @@ func (v *oidcVerifier) HandleCallback(ctx context.Context, txn TxnState, query u
|
||||
return LoginResult{
|
||||
Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email},
|
||||
Authorized: claims.hasRole(v.requiredRole),
|
||||
IDToken: rawIDToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string {
|
||||
func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL, idTokenHint string) string {
|
||||
var discovery struct {
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||
}
|
||||
@@ -154,6 +159,9 @@ func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string {
|
||||
q := u.Query()
|
||||
q.Set("client_id", v.oauth2Config.ClientID)
|
||||
q.Set("post_logout_redirect_uri", postLogoutRedirectURL)
|
||||
if idTokenHint != "" {
|
||||
q.Set("id_token_hint", idTokenHint)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,83 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newTestOIDCVerifier spins up a fake OIDC discovery endpoint (just enough
|
||||
// for oidc.NewProvider's discovery GET to succeed) and returns a real
|
||||
// oidcVerifier backed by it, for tests that exercise EndSessionURL without
|
||||
// a live Keycloak.
|
||||
func newTestOIDCVerifier(t *testing.T) Verifier {
|
||||
t.Helper()
|
||||
var issuerURL string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{
|
||||
"issuer": %[1]q,
|
||||
"authorization_endpoint": "%[1]s/auth",
|
||||
"token_endpoint": "%[1]s/token",
|
||||
"end_session_endpoint": "%[1]s/logout",
|
||||
"jwks_uri": "%[1]s/certs"
|
||||
}`, issuerURL)
|
||||
})
|
||||
mux.HandleFunc("/certs", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"keys":[]}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
issuerURL = srv.URL
|
||||
|
||||
verifier, err := NewOIDCVerifier(context.Background(), OIDCConfig{
|
||||
IssuerURL: issuerURL, ClientID: "geniusrun", ClientSecret: "secret", RedirectURL: issuerURL + "/callback",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewOIDCVerifier: %v", err)
|
||||
}
|
||||
return verifier
|
||||
}
|
||||
|
||||
func TestEndSessionURL_IncludesIDTokenHintWhenProvided(t *testing.T) {
|
||||
verifier := newTestOIDCVerifier(t)
|
||||
|
||||
got := verifier.EndSessionURL("https://app.example.com/", "raw-id-token-jwt")
|
||||
u, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse EndSessionURL result %q: %v", got, err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("id_token_hint") != "raw-id-token-jwt" {
|
||||
t.Errorf("id_token_hint = %q, want %q", q.Get("id_token_hint"), "raw-id-token-jwt")
|
||||
}
|
||||
if q.Get("client_id") != "geniusrun" {
|
||||
t.Errorf("client_id = %q, want geniusrun", q.Get("client_id"))
|
||||
}
|
||||
if q.Get("post_logout_redirect_uri") != "https://app.example.com/" {
|
||||
t.Errorf("post_logout_redirect_uri = %q, want https://app.example.com/", q.Get("post_logout_redirect_uri"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndSessionURL_OmitsIDTokenHintWhenEmpty(t *testing.T) {
|
||||
verifier := newTestOIDCVerifier(t)
|
||||
|
||||
got := verifier.EndSessionURL("https://app.example.com/", "")
|
||||
u, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse EndSessionURL result %q: %v", got, err)
|
||||
}
|
||||
if u.Query().Has("id_token_hint") {
|
||||
t.Errorf("expected no id_token_hint param when idTokenHint is empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDTokenClaims_HasRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
// Package auth implements geniusrun's own login gate: an OIDC Authorization
|
||||
// Code flow against an existing Keycloak realm (see oidc.go), backed by a
|
||||
// signed session cookie geniusrun mints itself (this file) and a chi
|
||||
// middleware that checks it (middleware.go). Keycloak's own tokens are never
|
||||
// stored or refreshed -- once HandleCallback verifies the ID token and role,
|
||||
// only this package's own cookie matters for subsequent requests.
|
||||
// middleware that checks it (middleware.go). Keycloak's own access/refresh
|
||||
// tokens are never stored or refreshed -- once HandleCallback verifies the
|
||||
// ID token and role, only this package's own cookie matters for subsequent
|
||||
// requests. The one exception is the raw ID token itself, carried opaquely
|
||||
// inside the signed session cookie (apart from Claims -- see
|
||||
// MintSessionCookie's idToken parameter and IDTokenFromSessionCookie)
|
||||
// solely so a later logout can pass it back to Keycloak as
|
||||
// id_token_hint -- letting Keycloak
|
||||
// skip its own logout-confirmation prompt for a session it can positively
|
||||
// identify, rather than leaving the user a chance to cancel out of it after
|
||||
// their geniusrun account (and profile) is already gone.
|
||||
package auth
|
||||
|
||||
import (
|
||||
@@ -26,7 +34,11 @@ const (
|
||||
)
|
||||
|
||||
// Claims identifies the authenticated user, carried in the signed session
|
||||
// cookie.
|
||||
// cookie and stashed in every request's context. Deliberately does NOT
|
||||
// carry the raw Keycloak ID token: that JWT lives in the cookie payload
|
||||
// separately (see MintSessionCookie/IDTokenFromSessionCookie) because it's
|
||||
// only ever needed once more, at logout, and has no business riding
|
||||
// through every handler's context.
|
||||
type Claims struct {
|
||||
Sub string
|
||||
Name string
|
||||
@@ -34,9 +46,10 @@ type Claims struct {
|
||||
}
|
||||
|
||||
type sessionClaims struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -55,12 +68,15 @@ type txnClaims struct {
|
||||
|
||||
// MintSessionCookie signs claims into a JWT valid for duration and wraps it
|
||||
// in a cookie. secure should be true whenever the app is served over HTTPS.
|
||||
func MintSessionCookie(claims Claims, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) {
|
||||
// idToken is the raw Keycloak ID token to carry for the eventual logout's
|
||||
// id_token_hint (empty is fine, e.g. in tests -- the hint is optional).
|
||||
func MintSessionCookie(claims Claims, idToken string, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) {
|
||||
now := time.Now()
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{
|
||||
Sub: claims.Sub,
|
||||
Name: claims.Name,
|
||||
Email: claims.Email,
|
||||
Sub: claims.Sub,
|
||||
Name: claims.Name,
|
||||
Email: claims.Email,
|
||||
IDToken: idToken,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(duration)),
|
||||
@@ -94,6 +110,20 @@ func ParseSessionCookie(cookie *http.Cookie, secret []byte) (Claims, error) {
|
||||
return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email}, nil
|
||||
}
|
||||
|
||||
// IDTokenFromSessionCookie verifies the cookie and returns the raw Keycloak
|
||||
// ID token it carries, for logout's id_token_hint. Only the logout handler
|
||||
// needs this -- everything else uses ParseSessionCookie's Claims.
|
||||
func IDTokenFromSessionCookie(cookie *http.Cookie, secret []byte) (string, error) {
|
||||
if cookie == nil {
|
||||
return "", fmt.Errorf("no session cookie")
|
||||
}
|
||||
var sc sessionClaims
|
||||
if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil {
|
||||
return "", fmt.Errorf("parse session token: %w", err)
|
||||
}
|
||||
return sc.IDToken, nil
|
||||
}
|
||||
|
||||
// MintTxnCookie signs an OIDC login transaction into a short-lived cookie.
|
||||
func MintTxnCookie(txn TxnState, secret []byte, secure bool) (*http.Cookie, error) {
|
||||
now := time.Now()
|
||||
|
||||
@@ -9,7 +9,7 @@ const testSecret = "test-secret-at-least-32-bytes-long!"
|
||||
|
||||
func TestMintAndParseSessionCookie(t *testing.T) {
|
||||
claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}
|
||||
cookie, err := MintSessionCookie(claims, []byte(testSecret), time.Hour, true)
|
||||
cookie, err := MintSessionCookie(claims, "raw-id-token-jwt", []byte(testSecret), time.Hour, true)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
@@ -24,10 +24,20 @@ func TestMintAndParseSessionCookie(t *testing.T) {
|
||||
if got != claims {
|
||||
t.Fatalf("got %+v, want %+v", got, claims)
|
||||
}
|
||||
|
||||
// The ID token rides in the cookie apart from Claims, retrievable only
|
||||
// through the dedicated logout-path helper.
|
||||
idToken, err := IDTokenFromSessionCookie(cookie, []byte(testSecret))
|
||||
if err != nil {
|
||||
t.Fatalf("IDTokenFromSessionCookie: %v", err)
|
||||
}
|
||||
if idToken != "raw-id-token-jwt" {
|
||||
t.Fatalf("idToken = %q, want raw-id-token-jwt", idToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSessionCookie_Expired(t *testing.T) {
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false)
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), -time.Hour, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
@@ -37,18 +47,46 @@ func TestParseSessionCookie_Expired(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseSessionCookie_Tampered(t *testing.T) {
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false)
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
cookie.Value = cookie.Value[:len(cookie.Value)-1] + "x"
|
||||
cookie.Value = flipSignatureChar(cookie.Value)
|
||||
if _, err := ParseSessionCookie(cookie, []byte(testSecret)); err == nil {
|
||||
t.Fatal("expected error for tampered cookie")
|
||||
}
|
||||
}
|
||||
|
||||
// flipSignatureChar corrupts a signed JWT for tamper tests by changing the
|
||||
// second-to-last character of its base64url signature, guaranteeing the
|
||||
// decoded signature bytes actually change. Two pitfalls to avoid here:
|
||||
// 1. Blindly overwriting a character with a fixed replacement (e.g. "x")
|
||||
// would occasionally be a no-op if that character was already there --
|
||||
// it's derived from the token's embedded timestamp, so this isn't as
|
||||
// rare as it sounds.
|
||||
// 2. Flipping the *last* character of the signature specifically (as this
|
||||
// helper used to) is flaky in a subtler way: HMAC-SHA256 produces a
|
||||
// 32-byte digest, which base64url-encodes to 43 characters with a
|
||||
// final 3-character group covering only a 2-byte remainder -- the
|
||||
// true last character encodes 4 real bits plus 2 unused padding bits.
|
||||
// Go's encoding/base64 ignores those padding bits when decoding
|
||||
// (non-strict by default), so about 1 in 4 replacement characters for
|
||||
// that position decode to byte-identical signature bytes, silently
|
||||
// passing the test without having tampered with anything. The
|
||||
// second-to-last character of that final group has no such unused
|
||||
// bits, so corrupting it is deterministic.
|
||||
func flipSignatureChar(s string) string {
|
||||
pos := len(s) - 2
|
||||
orig := s[pos]
|
||||
replacement := byte('x')
|
||||
if orig == replacement {
|
||||
replacement = 'y'
|
||||
}
|
||||
return s[:pos] + string(replacement) + s[pos+1:]
|
||||
}
|
||||
|
||||
func TestParseSessionCookie_WrongSecret(t *testing.T) {
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false)
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
|
||||
103
backend/internal/config/appconfig.go
Normal file
103
backend/internal/config/appconfig.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppKey describes one application-configuration key: instance-global,
|
||||
// stored (as an override) in the DB `config` table, read once at startup.
|
||||
// Cold -- a changed value applies on the next backend restart.
|
||||
type AppKey struct {
|
||||
Key string
|
||||
Default string
|
||||
Description string
|
||||
Validate func(value string) error
|
||||
}
|
||||
|
||||
// KeySessionDuration is the session cookie lifetime, in integer hours.
|
||||
const (
|
||||
KeySessionDuration = "session.duration"
|
||||
// KeySessionSetupTimeout bounds an *onboarding Garmin setup session*
|
||||
// (the ephemeral, pre-account login attempt), not the login cookie --
|
||||
// session.duration is how long a signed-in user stays signed in
|
||||
// (hours); session.setup_timeout is how long an unfinished setup
|
||||
// attempt survives untouched before its subprocess is torn down
|
||||
// (minutes).
|
||||
KeySessionSetupTimeout = "session.setup_timeout"
|
||||
)
|
||||
|
||||
var appRegistry = []AppKey{
|
||||
{
|
||||
Key: KeySessionDuration,
|
||||
Default: "720",
|
||||
Description: "Session cookie lifetime in hours",
|
||||
Validate: validatePositiveInt,
|
||||
},
|
||||
{
|
||||
Key: KeySessionSetupTimeout,
|
||||
Default: "15",
|
||||
Description: "Idle timeout in minutes for an unfinished onboarding Garmin setup session",
|
||||
Validate: validatePositiveInt,
|
||||
},
|
||||
}
|
||||
|
||||
// AppRegistry returns every known application-configuration key, in
|
||||
// display order.
|
||||
func AppRegistry() []AppKey { return appRegistry }
|
||||
|
||||
func validatePositiveInt(v string) error {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 {
|
||||
return fmt.Errorf("must be a positive integer, got %q", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAppValue rejects unknown keys and invalid values -- every write
|
||||
// path's guard, so the config table can never accumulate junk.
|
||||
func ValidateAppValue(key, value string) error {
|
||||
for _, k := range appRegistry {
|
||||
if k.Key == key {
|
||||
if err := k.Validate(value); err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unknown configuration key %q", key)
|
||||
}
|
||||
|
||||
// AppConfig is the typed application configuration, built from the DB's
|
||||
// config rows.
|
||||
type AppConfig struct {
|
||||
SessionDuration time.Duration
|
||||
SetupTimeout time.Duration
|
||||
}
|
||||
|
||||
// LoadApp builds the typed AppConfig from the config table's rows. Every
|
||||
// registry key is mandatory: main seeds missing keys with their defaults
|
||||
// at startup (see cmd/geniusrund), so a missing key here means that
|
||||
// seeding didn't run -- fail fast, same posture as LoadEnv() for env
|
||||
// vars, and likewise for an unknown or invalid stored value. Pure -- it
|
||||
// takes the raw map instead of a *store.DB so this package needs no store
|
||||
// dependency and the logic tests without a database.
|
||||
func LoadApp(values map[string]string) (AppConfig, error) {
|
||||
for key, value := range values {
|
||||
if err := ValidateAppValue(key, value); err != nil {
|
||||
return AppConfig{}, fmt.Errorf("app config: %w", err)
|
||||
}
|
||||
}
|
||||
for _, k := range appRegistry {
|
||||
if _, ok := values[k.Key]; !ok {
|
||||
return AppConfig{}, fmt.Errorf("app config: missing key %q (defaults are seeded into the DB at startup)", k.Key)
|
||||
}
|
||||
}
|
||||
hours, _ := strconv.Atoi(values[KeySessionDuration])
|
||||
setupMinutes, _ := strconv.Atoi(values[KeySessionSetupTimeout])
|
||||
return AppConfig{
|
||||
SessionDuration: time.Duration(hours) * time.Hour,
|
||||
SetupTimeout: time.Duration(setupMinutes) * time.Minute,
|
||||
}, nil
|
||||
}
|
||||
88
backend/internal/config/appconfig_test.go
Normal file
88
backend/internal/config/appconfig_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadApp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values map[string]string
|
||||
want time.Duration
|
||||
wantIdle time.Duration
|
||||
wantErr string
|
||||
}{
|
||||
// Every registry key is mandatory: main seeds the DB with defaults
|
||||
// at startup, so LoadApp always receives a complete map.
|
||||
{name: "seeded defaults", values: map[string]string{"session.duration": "720", "session.setup_timeout": "15"}, want: 720 * time.Hour, wantIdle: 15 * time.Minute},
|
||||
{name: "custom values", values: map[string]string{"session.duration": "168", "session.setup_timeout": "30"}, want: 168 * time.Hour, wantIdle: 30 * time.Minute},
|
||||
{name: "missing key fails fast", values: map[string]string{"session.duration": "720"}, wantErr: "missing key"},
|
||||
{name: "nil map fails fast", values: nil, wantErr: "missing key"},
|
||||
{name: "invalid stored value", values: map[string]string{"session.duration": "zero", "session.setup_timeout": "15"}, wantErr: "session.duration"},
|
||||
{name: "invalid setup timeout", values: map[string]string{"session.duration": "720", "session.setup_timeout": "0"}, wantErr: "session.setup_timeout"},
|
||||
{name: "unknown stored key", values: map[string]string{"session.duration": "720", "session.setup_timeout": "15", "bogus.key": "1"}, wantErr: "unknown configuration key"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := LoadApp(tt.values)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("LoadApp: %v", err)
|
||||
}
|
||||
if got.SessionDuration != tt.want {
|
||||
t.Fatalf("SessionDuration = %v, want %v", got.SessionDuration, tt.want)
|
||||
}
|
||||
if got.SetupTimeout != tt.wantIdle {
|
||||
t.Fatalf("SetupSessionIdleTimeout = %v, want %v", got.SetupTimeout, tt.wantIdle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAppValue(t *testing.T) {
|
||||
if err := ValidateAppValue("session.duration", "24"); err != nil {
|
||||
t.Fatalf("valid value rejected: %v", err)
|
||||
}
|
||||
if err := ValidateAppValue("session.duration", "-1"); err == nil {
|
||||
t.Fatal("negative hours accepted")
|
||||
}
|
||||
if err := ValidateAppValue("session.duration", "1.5"); err == nil {
|
||||
t.Fatal("non-integer accepted")
|
||||
}
|
||||
if err := ValidateAppValue("nope", "1"); err == nil {
|
||||
t.Fatal("unknown key accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayEnv_MasksSecrets(t *testing.T) {
|
||||
cfg := EnvConfig{
|
||||
BackendAddr: ":8080", OIDCClientSecret: "hunter2",
|
||||
SessionSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||
}
|
||||
entries := map[string]string{}
|
||||
for _, e := range cfg.DisplayEnv() {
|
||||
entries[e.Name] = e.Value
|
||||
}
|
||||
if entries["GENIUSRUN_BACKEND_ADDR"] != ":8080" {
|
||||
t.Errorf("GENIUSRUN_BACKEND_ADDR = %q", entries["GENIUSRUN_BACKEND_ADDR"])
|
||||
}
|
||||
if entries["GENIUSRUN_OIDC_CLIENT_SECRET"] != "•••• (set)" {
|
||||
t.Errorf("client secret not masked: %q", entries["GENIUSRUN_OIDC_CLIENT_SECRET"])
|
||||
}
|
||||
if entries["GENIUSRUN_SESSION_SECRET"] != "•••• (set)" {
|
||||
t.Errorf("session secret not masked: %q", entries["GENIUSRUN_SESSION_SECRET"])
|
||||
}
|
||||
empty := EnvConfig{}
|
||||
for _, e := range empty.DisplayEnv() {
|
||||
if e.Name == "GENIUSRUN_OIDC_CLIENT_SECRET" && e.Value != "(unset)" {
|
||||
t.Errorf("unset secret = %q, want (unset)", e.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// Package config loads geniusrund's runtime infrastructure configuration
|
||||
// from environment variables (paths and process settings that don't belong
|
||||
// in the user-editable profile). Garmin credentials and every tunable
|
||||
// analysis-engine parameter live in the profile (internal/store.Profile)
|
||||
// instead -- see docs/superpowers/specs/2026-07-17-profile-taxonomy-analysis-engine-design.md.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds geniusrund's process-level configuration.
|
||||
type Config struct {
|
||||
// Addr is the HTTP listen address, e.g. ":8080".
|
||||
Addr string
|
||||
// DBPath is the SQLite database file path.
|
||||
DBPath string
|
||||
|
||||
// GarminPythonPath is mcp-garmin's venv python executable.
|
||||
GarminPythonPath string
|
||||
// GarminServerPath is mcp-garmin's server.py.
|
||||
GarminServerPath string
|
||||
// GarminTokenStoreRoot is the root directory under which each user's
|
||||
// mcp-garmin session cache lives (one subdirectory per user id, e.g.
|
||||
// "<root>/3"). Read from the GARMIN_TOKENSTORE env var; if unset, it
|
||||
// defaults to a "garmin-tokenstores" directory next to DBPath so every
|
||||
// deployment gets per-user isolation automatically -- multi-tenant
|
||||
// operation always relies on this being a real, distinct-per-user path
|
||||
// (see api.Server.garminFor), so it can never be silently left empty.
|
||||
GarminTokenStoreRoot string
|
||||
|
||||
MinConfidence float64
|
||||
IncrementalSyncEvery time.Duration
|
||||
|
||||
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
|
||||
// reachable origin (e.g. "https://geniusrun.example.com") -- it derives
|
||||
// OIDCRedirectURL and whether session cookies can be marked Secure,
|
||||
// instead of requiring both to be configured separately and risking them
|
||||
// drifting out of sync.
|
||||
PublicBaseURL string
|
||||
OIDCIssuerURL string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
OIDCRedirectURL string
|
||||
OIDCRequiredRole string
|
||||
SessionSecret []byte
|
||||
SessionDuration time.Duration
|
||||
SessionSecure bool
|
||||
|
||||
// LegacyOwnerOIDCSub, if set, is used exactly once at startup (via
|
||||
// store.ClaimLegacyOwner) to bind this deployment's pre-existing
|
||||
// single-tenant data to one named OIDC subject after upgrading to
|
||||
// per-user profiles. Safe to leave set indefinitely -- ClaimLegacyOwner
|
||||
// no-ops once any user already exists.
|
||||
LegacyOwnerOIDCSub string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables, applying defaults
|
||||
// for anything optional. Returns an error if a required variable is unset.
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Addr: getEnvDefault("GENIUSRUN_ADDR", ":8080"),
|
||||
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
||||
GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"),
|
||||
GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"),
|
||||
GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"),
|
||||
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
|
||||
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
|
||||
PublicBaseURL: strings.TrimRight(os.Getenv("GENIUSRUN_PUBLIC_BASE_URL"), "/"),
|
||||
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
|
||||
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
||||
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
|
||||
SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour),
|
||||
LegacyOwnerOIDCSub: os.Getenv("GENIUSRUN_LEGACY_OWNER_OIDC_SUB"),
|
||||
}
|
||||
|
||||
if cfg.GarminTokenStoreRoot == "" {
|
||||
cfg.GarminTokenStoreRoot = filepath.Join(filepath.Dir(cfg.DBPath), "garmin-tokenstores")
|
||||
log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot)
|
||||
}
|
||||
|
||||
if cfg.GarminPythonPath == "" {
|
||||
return cfg, fmt.Errorf("MCP_GARMIN_PYTHON is required (path to mcp-garmin's venv python)")
|
||||
}
|
||||
if cfg.GarminServerPath == "" {
|
||||
return cfg, fmt.Errorf("MCP_GARMIN_SERVER is required (path to mcp-garmin's server.py)")
|
||||
}
|
||||
if cfg.PublicBaseURL == "" {
|
||||
return cfg, fmt.Errorf("GENIUSRUN_PUBLIC_BASE_URL is required (e.g. https://geniusrun.example.com)")
|
||||
}
|
||||
if cfg.OIDCIssuerURL == "" {
|
||||
return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required")
|
||||
}
|
||||
if cfg.OIDCClientID == "" {
|
||||
return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_ID is required")
|
||||
}
|
||||
if cfg.OIDCClientSecret == "" {
|
||||
return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_SECRET is required")
|
||||
}
|
||||
sessionSecret := os.Getenv("GENIUSRUN_SESSION_SECRET")
|
||||
if len(sessionSecret) < 32 {
|
||||
return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters")
|
||||
}
|
||||
cfg.SessionSecret = []byte(sessionSecret)
|
||||
cfg.OIDCRedirectURL = cfg.PublicBaseURL + "/api/session/callback"
|
||||
cfg.SessionSecure = strings.HasPrefix(cfg.PublicBaseURL, "https://")
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnvDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getEnvFloat(key string, def float64) float64 {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getEnvDuration(key string, def time.Duration) time.Duration {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func setRequiredEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("MCP_GARMIN_PYTHON", "/usr/bin/python3")
|
||||
t.Setenv("MCP_GARMIN_SERVER", "/opt/mcp-garmin/server.py")
|
||||
t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "https://geniusrun.example.com")
|
||||
t.Setenv("GENIUSRUN_OIDC_ISSUER_URL", "https://keycloak.example.com/realms/myrealm")
|
||||
t.Setenv("GENIUSRUN_OIDC_CLIENT_ID", "geniusrun")
|
||||
t.Setenv("GENIUSRUN_OIDC_CLIENT_SECRET", "client-secret")
|
||||
t.Setenv("GENIUSRUN_SESSION_SECRET", "a-session-secret-that-is-at-least-32-bytes-long")
|
||||
}
|
||||
|
||||
func TestLoad_DerivesOIDCSettingsFromPublicBaseURL(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.OIDCRedirectURL != "https://geniusrun.example.com/api/session/callback" {
|
||||
t.Errorf("OIDCRedirectURL = %q", cfg.OIDCRedirectURL)
|
||||
}
|
||||
if !cfg.SessionSecure {
|
||||
t.Error("SessionSecure = false, want true for an https base URL")
|
||||
}
|
||||
if cfg.OIDCRequiredRole != "geniusrun-user" {
|
||||
t.Errorf("OIDCRequiredRole = %q, want default", cfg.OIDCRequiredRole)
|
||||
}
|
||||
if cfg.SessionDuration != 720*time.Hour {
|
||||
t.Errorf("SessionDuration = %v, want default 720h", cfg.SessionDuration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "http://localhost:8080")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.SessionSecure {
|
||||
t.Error("SessionSecure = true, want false for an http base URL")
|
||||
}
|
||||
if cfg.OIDCRedirectURL != "http://localhost:8080/api/session/callback" {
|
||||
t.Errorf("OIDCRedirectURL = %q", cfg.OIDCRedirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingRequiredOIDCVars(t *testing.T) {
|
||||
cases := []string{
|
||||
"GENIUSRUN_PUBLIC_BASE_URL",
|
||||
"GENIUSRUN_OIDC_ISSUER_URL",
|
||||
"GENIUSRUN_OIDC_CLIENT_ID",
|
||||
"GENIUSRUN_OIDC_CLIENT_SECRET",
|
||||
}
|
||||
for _, missing := range cases {
|
||||
t.Run(missing, func(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv(missing, "")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatalf("expected error when %s is unset", missing)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_SessionSecretTooShort(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_SESSION_SECRET", "too-short")
|
||||
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected error for a session secret under 32 characters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_GarminTokenStoreRootDefaultsNextToDBPath(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GARMIN_TOKENSTORE", "")
|
||||
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
want := filepath.Join("/var/lib/geniusrun", "garmin-tokenstores")
|
||||
if cfg.GarminTokenStoreRoot != want {
|
||||
t.Errorf("GarminTokenStoreRoot = %q, want %q", cfg.GarminTokenStoreRoot, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GARMIN_TOKENSTORE", "/custom/tokenstores")
|
||||
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.GarminTokenStoreRoot != "/custom/tokenstores" {
|
||||
t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.GarminTokenStoreRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CustomRoleAndDuration(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
|
||||
t.Setenv("GENIUSRUN_SESSION_DURATION", "24h")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.OIDCRequiredRole != "admin" {
|
||||
t.Errorf("OIDCRequiredRole = %q", cfg.OIDCRequiredRole)
|
||||
}
|
||||
if cfg.SessionDuration != 24*time.Hour {
|
||||
t.Errorf("SessionDuration = %v", cfg.SessionDuration)
|
||||
}
|
||||
}
|
||||
145
backend/internal/config/envconfig.go
Normal file
145
backend/internal/config/envconfig.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Package config loads geniusrund's runtime infrastructure configuration
|
||||
// from environment variables (paths and process settings that don't belong
|
||||
// in the user-editable profile). Garmin credentials and every tunable
|
||||
// analysis-engine parameter live in the profile (internal/store.Profile)
|
||||
// instead -- see docs/superpowers/specs/2026-07-17-profile-taxonomy-analysis-engine-design.md.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EnvConfig holds geniusrund's process-level configuration.
|
||||
type EnvConfig struct {
|
||||
// BackendAddr is the HTTP listen address, e.g. ":8080".
|
||||
BackendAddr string
|
||||
// DBPath is the SQLite database file path.
|
||||
DBPath string
|
||||
|
||||
// PythonPath is the python3 interpreter used to run the embedded
|
||||
// Garmin wrapper script (internal/garmin's go:embed'd wrapper.py).
|
||||
// Defaults to "python3" resolved via PATH if unset.
|
||||
PythonPath string
|
||||
// TokenStoreRoot is the root directory under which each user's
|
||||
// Garmin session cache lives (one subdirectory per user id, e.g.
|
||||
// "<root>/3"). Read from the GENIUSRUN_TOKENSTORE_PATH env var, configured
|
||||
// independently of DBPath -- if unset, it defaults to a ".garmin"
|
||||
// directory relative to the working directory the process is started
|
||||
// from, not derived from DBPath in any way, so every deployment gets
|
||||
// per-user isolation automatically -- multi-tenant operation always
|
||||
// relies on this being a real, distinct-per-user path (see
|
||||
// api.Server.garminFor), so it can never be silently left empty.
|
||||
TokenStoreRoot string
|
||||
|
||||
// LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error").
|
||||
LogLevel string
|
||||
|
||||
// OIDC login gate (Keycloak). BackendURL is this app's own externally
|
||||
// reachable origin (e.g. "https://geniusrun.example.com") -- it derives
|
||||
// OIDCRedirectURL and whether session cookies can be marked Secure,
|
||||
// instead of requiring both to be configured separately and risking them
|
||||
// drifting out of sync.
|
||||
BackendURL string
|
||||
// FrontendURL is the origin the browser should land on after the OIDC
|
||||
// callback (both success and failure) -- e.g. "http://localhost:5173" in
|
||||
// local dev, where the frontend and backend are different origins
|
||||
// bridged by CORS (see internal/api's corsMiddleware and
|
||||
// frontend/src/api/client.ts's BASE_URL). Defaults to BackendURL when
|
||||
// unset, which is correct for the common production topology where a
|
||||
// reverse proxy unifies frontend and backend under one origin.
|
||||
// BackendURL itself must stay pointed at the backend's own origin
|
||||
// regardless -- it derives OIDCRedirectURL and post_logout_redirect_uri,
|
||||
// which must match wherever those routes are actually served.
|
||||
FrontendURL string
|
||||
OIDCIssuerURL string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
OIDCRedirectURL string
|
||||
OIDCRequiredRole string
|
||||
SessionSecret []byte
|
||||
SessionSecure bool
|
||||
}
|
||||
|
||||
// LoadEnv reads configuration from environment variables, applying defaults
|
||||
// for anything optional. Returns an error if a required variable is unset.
|
||||
func LoadEnv() (EnvConfig, error) {
|
||||
cfg := EnvConfig{
|
||||
BackendAddr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"),
|
||||
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
||||
PythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"),
|
||||
TokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"),
|
||||
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
|
||||
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
|
||||
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
|
||||
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
||||
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
|
||||
}
|
||||
|
||||
if cfg.BackendURL == "" {
|
||||
return cfg, fmt.Errorf("GENIUSRUN_BACKEND_URL is required (e.g. https://geniusrun.example.com)")
|
||||
}
|
||||
cfg.FrontendURL = strings.TrimRight(getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.BackendURL), "/")
|
||||
|
||||
if cfg.OIDCIssuerURL == "" {
|
||||
return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required")
|
||||
}
|
||||
if cfg.OIDCClientID == "" {
|
||||
return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_ID is required")
|
||||
}
|
||||
if cfg.OIDCClientSecret == "" {
|
||||
return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_SECRET is required")
|
||||
}
|
||||
cfg.OIDCRedirectURL = cfg.BackendURL + "/api/session/callback"
|
||||
|
||||
sessionSecret := os.Getenv("GENIUSRUN_SESSION_SECRET")
|
||||
if len(sessionSecret) < 32 {
|
||||
return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters")
|
||||
}
|
||||
cfg.SessionSecret = []byte(sessionSecret)
|
||||
cfg.SessionSecure = strings.HasPrefix(cfg.BackendURL, "https://")
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnvDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// EnvEntry is one environment-configuration variable as displayed on the
|
||||
// config page. Display-only: secrets are masked here, so raw values never
|
||||
// leave the process.
|
||||
type EnvEntry struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
// DisplayEnv returns the environment configuration as a display-safe
|
||||
// list, in stable order, secrets masked.
|
||||
func (c EnvConfig) DisplayEnv() []EnvEntry {
|
||||
mask := func(set bool) string {
|
||||
if set {
|
||||
return "•••• (set)"
|
||||
}
|
||||
return "(unset)"
|
||||
}
|
||||
return []EnvEntry{
|
||||
{Name: "GENIUSRUN_BACKEND_ADDR", Value: c.BackendAddr},
|
||||
{Name: "GENIUSRUN_DB_PATH", Value: c.DBPath},
|
||||
{Name: "GENIUSRUN_PYTHON_PATH", Value: c.PythonPath},
|
||||
{Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.TokenStoreRoot},
|
||||
{Name: "GENIUSRUN_LOG_LEVEL", Value: c.LogLevel},
|
||||
{Name: "GENIUSRUN_BACKEND_URL", Value: c.BackendURL},
|
||||
{Name: "GENIUSRUN_FRONTEND_URL", Value: c.FrontendURL},
|
||||
{Name: "GENIUSRUN_OIDC_ISSUER_URL", Value: c.OIDCIssuerURL},
|
||||
{Name: "GENIUSRUN_OIDC_CLIENT_ID", Value: c.OIDCClientID},
|
||||
{Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: mask(c.OIDCClientSecret != "")},
|
||||
{Name: "GENIUSRUN_OIDC_REQUIRED_ROLE", Value: c.OIDCRequiredRole},
|
||||
{Name: "GENIUSRUN_SESSION_SECRET", Value: mask(len(c.SessionSecret) > 0)},
|
||||
}
|
||||
}
|
||||
194
backend/internal/config/envconfig_test.go
Normal file
194
backend/internal/config/envconfig_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setRequiredEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("GENIUSRUN_BACKEND_URL", "https://geniusrun.example.com")
|
||||
t.Setenv("GENIUSRUN_OIDC_ISSUER_URL", "https://keycloak.example.com/realms/myrealm")
|
||||
t.Setenv("GENIUSRUN_OIDC_CLIENT_ID", "geniusrun")
|
||||
t.Setenv("GENIUSRUN_OIDC_CLIENT_SECRET", "client-secret")
|
||||
t.Setenv("GENIUSRUN_SESSION_SECRET", "a-session-secret-that-is-at-least-32-bytes-long")
|
||||
}
|
||||
|
||||
func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.OIDCRedirectURL != "https://geniusrun.example.com/api/session/callback" {
|
||||
t.Errorf("OIDCRedirectURL = %q", cfg.OIDCRedirectURL)
|
||||
}
|
||||
if !cfg.SessionSecure {
|
||||
t.Error("SessionSecure = false, want true for an https base URL")
|
||||
}
|
||||
if cfg.OIDCRequiredRole != "geniusrun-user" {
|
||||
t.Errorf("OIDCRequiredRole = %q, want default", cfg.OIDCRequiredRole)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_BACKEND_URL", "http://localhost:8080")
|
||||
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.SessionSecure {
|
||||
t.Error("SessionSecure = true, want false for an http base URL")
|
||||
}
|
||||
if cfg.OIDCRedirectURL != "http://localhost:8080/api/session/callback" {
|
||||
t.Errorf("OIDCRedirectURL = %q", cfg.OIDCRedirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingRequiredOIDCVars(t *testing.T) {
|
||||
cases := []string{
|
||||
"GENIUSRUN_BACKEND_URL",
|
||||
"GENIUSRUN_OIDC_ISSUER_URL",
|
||||
"GENIUSRUN_OIDC_CLIENT_ID",
|
||||
"GENIUSRUN_OIDC_CLIENT_SECRET",
|
||||
}
|
||||
for _, missing := range cases {
|
||||
t.Run(missing, func(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv(missing, "")
|
||||
if _, err := LoadEnv(); err == nil {
|
||||
t.Fatalf("expected error when %s is unset", missing)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_SessionSecretTooShort(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_SESSION_SECRET", "too-short")
|
||||
|
||||
if _, err := LoadEnv(); err == nil {
|
||||
t.Fatal("expected error for a session secret under 32 characters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "")
|
||||
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.TokenStoreRoot != ".garmin" {
|
||||
t.Errorf("GarminTokenStoreRoot = %q, want %q (never derived from DBPath's directory)", cfg.TokenStoreRoot, ".garmin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "/custom/tokenstores")
|
||||
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.TokenStoreRoot != "/custom/tokenstores" {
|
||||
t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.TokenStoreRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_LogLevelDefaultsToInfo(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_LOG_LEVEL", "")
|
||||
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.LogLevel != "info" {
|
||||
t.Errorf("LogLevel = %q, want default %q", cfg.LogLevel, "info")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_LogLevelExplicitOverridesDefault(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_LOG_LEVEL", "debug")
|
||||
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.LogLevel != "debug" {
|
||||
t.Errorf("LogLevel = %q, want debug", cfg.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_PYTHON_PATH", "")
|
||||
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.PythonPath != "python3" {
|
||||
t.Errorf("GarminPythonPath = %q, want default %q", cfg.PythonPath, "python3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_PYTHON_PATH", "/opt/venv/bin/python3")
|
||||
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.PythonPath != "/opt/venv/bin/python3" {
|
||||
t.Errorf("GarminPythonPath = %q, want explicit override", cfg.PythonPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_FrontendURLDefaultsToBackendURL(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_FRONTEND_URL", "")
|
||||
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.FrontendURL != cfg.BackendURL {
|
||||
t.Errorf("FrontendURL = %q, want it to default to BackendURL %q", cfg.FrontendURL, cfg.BackendURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_FrontendURLExplicitOverridesDefault(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_FRONTEND_URL", "http://localhost:5173/")
|
||||
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.FrontendURL != "http://localhost:5173" {
|
||||
t.Errorf("FrontendURL = %q, want http://localhost:5173 (trailing slash trimmed)", cfg.FrontendURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CustomRole(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
|
||||
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.OIDCRequiredRole != "admin" {
|
||||
t.Errorf("OIDCRequiredRole = %q", cfg.OIDCRequiredRole)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,36 @@
|
||||
// Package garmin wraps the mcp-garmin MCP server as a narrow Go client
|
||||
// interface, so the rest of geniusrun never deals with MCP/JSON-RPC directly.
|
||||
// Package garmin wraps a direct garminconnect subprocess (see
|
||||
// wrapper/wrapper.py) as a narrow Go client interface, so the rest of
|
||||
// geniusrun never deals with the wire protocol directly.
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
mcpclient "github.com/mark3labs/mcp-go/client"
|
||||
"github.com/mark3labs/mcp-go/client/transport"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed wrapper/wrapper.py
|
||||
var wrapperScript string
|
||||
|
||||
// maxWrapperLineBytes bounds one JSON-line response from the wrapper
|
||||
// subprocess -- well above the default 64KB bufio.Scanner limit, since
|
||||
// get_activity_details responses (per-second telemetry) can be several MB.
|
||||
const maxWrapperLineBytes = 16 * 1024 * 1024
|
||||
|
||||
// Client is the interface the rest of geniusrun depends on. The real
|
||||
// implementation drives mcp-garmin over stdio; internal/garmin/mock provides
|
||||
// a fake for tests and frontend-only development.
|
||||
// implementation drives an embedded Python wrapper subprocess over stdio;
|
||||
// internal/garmin/mock provides a fake for tests and frontend-only
|
||||
// development.
|
||||
type Client interface {
|
||||
// Authenticate triggers Garmin login using credentials the subprocess
|
||||
// was started with. Spawns the subprocess on first call.
|
||||
@@ -42,180 +55,407 @@ type Client interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Config configures how the mcp-garmin subprocess is spawned.
|
||||
type Config struct {
|
||||
PythonPath string // path to mcp-garmin's venv python executable
|
||||
ServerPath string // path to mcp-garmin's server.py
|
||||
// ClientConfig configures how the Garmin wrapper subprocess is spawned.
|
||||
type ClientConfig struct {
|
||||
// PythonPath is the python3 interpreter to run the embedded wrapper
|
||||
// script with. Empty defaults to "python3" resolved via PATH.
|
||||
PythonPath string
|
||||
GarminEmail string
|
||||
GarminPassword string
|
||||
// TokenStorePath, if set, is passed as GARMIN_TOKENSTORE so mcp-garmin
|
||||
// persists/resumes a Garmin session there instead of the default ~/.garth.
|
||||
// TokenStorePath is passed as GARMIN_TOKENSTORE so the wrapper
|
||||
// persists/resumes a Garmin session there.
|
||||
TokenStorePath string
|
||||
}
|
||||
|
||||
// mcpClient is the real Client implementation, backed by an mcp-garmin
|
||||
// subprocess spoken to over stdio MCP.
|
||||
type mcpClient struct {
|
||||
cfg Config
|
||||
|
||||
mu sync.Mutex // serializes calls; mcp-garmin holds a single shared Garmin client
|
||||
inner *mcpclient.Client
|
||||
started bool
|
||||
// wireRequest is one line sent to the wrapper subprocess's stdin.
|
||||
type wireRequest struct {
|
||||
ID int `json:"id"`
|
||||
Cmd string `json:"cmd"`
|
||||
Params any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// NewClient builds a Client. The subprocess is not spawned until the first
|
||||
// call that needs it (Authenticate, or any data call once authenticated).
|
||||
func NewClient(cfg Config) Client {
|
||||
return &mcpClient{cfg: cfg}
|
||||
// wireResponse is one line read from the wrapper subprocess's stdout.
|
||||
// ErrorType/Traceback carry the Python-side failure detail in the protocol
|
||||
// itself (see wrapper.py's dispatch), so the Go side logs one complete
|
||||
// structured record per failed call instead of correlating stderr noise.
|
||||
type wireResponse struct {
|
||||
ID int `json:"id"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
Traceback string `json:"traceback,omitempty"`
|
||||
NotFound bool `json:"not_found,omitempty"`
|
||||
}
|
||||
|
||||
func (c *mcpClient) ensureStarted(ctx context.Context) error {
|
||||
// ErrNotFound wraps any error a Client method returns when the wrapper
|
||||
// reported a definitive HTTP 404 (garminconnect's own
|
||||
// GarminConnectNotFoundError) -- e.g. GetWorkoutByID for a workout deleted
|
||||
// on Garmin's side after being linked to an activity. Callers use
|
||||
// errors.Is(err, ErrNotFound) to distinguish this from a transient failure
|
||||
// worth retrying.
|
||||
var ErrNotFound = errors.New("garmin: resource not found")
|
||||
|
||||
// callParams is the Params payload for a generic "call" request: dispatches
|
||||
// to any garminconnect.Garmin method by name.
|
||||
type callParams struct {
|
||||
Method string `json:"method"`
|
||||
Args map[string]any `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
// authResultWire is the Result payload for authenticate/complete_mfa.
|
||||
type authResultWire struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func mapAuthStatus(s string) AuthStatus {
|
||||
switch s {
|
||||
case "success":
|
||||
return AuthSuccess
|
||||
case "mfa_required":
|
||||
return AuthMFARequired
|
||||
case "failed":
|
||||
return AuthFailed
|
||||
default:
|
||||
return AuthUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// subprocessClient is the real Client implementation, backed by a wrapper
|
||||
// subprocess spoken to over newline-delimited JSON on stdio.
|
||||
type subprocessClient struct {
|
||||
cfg ClientConfig
|
||||
|
||||
mu sync.Mutex // serializes calls; the wrapper holds a single shared Garmin client
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
enc *json.Encoder
|
||||
scanner *bufio.Scanner
|
||||
scriptPath string // temp file holding the embedded wrapper.py, written once
|
||||
started bool
|
||||
nextID int
|
||||
stderrDone chan struct{} // closed once the stderr-copy goroutine has finished reading
|
||||
}
|
||||
|
||||
// ensureStarted spawns the wrapper subprocess if it isn't already running.
|
||||
// Callers must hold c.mu.
|
||||
func (c *subprocessClient) ensureStarted(ctx context.Context) error {
|
||||
if c.started {
|
||||
return nil
|
||||
}
|
||||
|
||||
env := []string{
|
||||
if c.scriptPath == "" {
|
||||
f, err := os.CreateTemp("", "geniusrun-garmin-wrapper-*.py")
|
||||
if err != nil {
|
||||
return fmt.Errorf("write wrapper script: %w", err)
|
||||
}
|
||||
if _, err := f.WriteString(wrapperScript); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("write wrapper script: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("write wrapper script: %w", err)
|
||||
}
|
||||
c.scriptPath = f.Name()
|
||||
}
|
||||
|
||||
pythonPath := c.cfg.PythonPath
|
||||
if pythonPath == "" {
|
||||
pythonPath = "python3"
|
||||
}
|
||||
|
||||
cmd := exec.Command(pythonPath, c.scriptPath)
|
||||
extraEnv := []string{
|
||||
"GARMIN_EMAIL=" + c.cfg.GarminEmail,
|
||||
"GARMIN_PASSWORD=" + c.cfg.GarminPassword,
|
||||
"GARMIN_TOKENSTORE=" + c.cfg.TokenStorePath,
|
||||
"PYTHONUNBUFFERED=1",
|
||||
}
|
||||
if c.cfg.TokenStorePath != "" {
|
||||
env = append(env, "GARMIN_TOKENSTORE="+c.cfg.TokenStorePath)
|
||||
}
|
||||
cmd.Env = append(os.Environ(), extraEnv...)
|
||||
|
||||
inner, err := mcpclient.NewStdioMCPClient(c.cfg.PythonPath, env, c.cfg.ServerPath)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("spawn mcp-garmin subprocess: %w", err)
|
||||
return fmt.Errorf("open wrapper stdin: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open wrapper stdout: %w", err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open wrapper stderr: %w", err)
|
||||
}
|
||||
|
||||
if stdio, ok := inner.GetTransport().(*transport.Stdio); ok {
|
||||
go drainStderr(stdio)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("spawn wrapper subprocess: %w", err)
|
||||
}
|
||||
// wrapper.py logs auth/rate-limit diagnostics to stderr as JSON lines
|
||||
// (see its _log helper); forwardWrapperStderr re-emits them through the
|
||||
// application logger so the backend's output stays one JSON stream. Per
|
||||
// os/exec's StderrPipe docs, it's incorrect to call Wait before all
|
||||
// reads from the pipe have completed, so close() waits on stderrDone
|
||||
// before Wait-ing.
|
||||
c.stderrDone = make(chan struct{})
|
||||
stderrDone := c.stderrDone
|
||||
go func() {
|
||||
forwardWrapperStderr(stderr)
|
||||
close(stderrDone)
|
||||
}()
|
||||
|
||||
initReq := mcp.InitializeRequest{}
|
||||
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
|
||||
initReq.Params.ClientInfo = mcp.Implementation{Name: "geniusrund", Version: "0.0.1"}
|
||||
if _, err := inner.Initialize(ctx, initReq); err != nil {
|
||||
inner.Close()
|
||||
return fmt.Errorf("mcp initialize handshake: %w", err)
|
||||
}
|
||||
|
||||
c.inner = inner
|
||||
c.cmd = cmd
|
||||
c.stdin = stdin
|
||||
c.enc = json.NewEncoder(stdin)
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
|
||||
c.scanner = scanner
|
||||
c.started = true
|
||||
c.nextID = 0
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// execute sends one request and returns its result payload, or an error
|
||||
// if the wrapper reported one. Callers must hold c.mu and have already
|
||||
// called ensureStarted. Logs exactly one type=wrapper line regardless of
|
||||
// outcome (the record's meaning is carried by its fields, no msg) --
|
||||
// cmd/params are always safe to log in full here: Garmin credentials only
|
||||
// ever reach the subprocess via env vars at spawn time (see
|
||||
// ensureStarted), never through these wire params.
|
||||
func (c *subprocessClient) execute(ctx context.Context, cmd string, params any) (result json.RawMessage, err error) {
|
||||
start := time.Now()
|
||||
var errorType, traceback string // Python-side failure detail, set from the error response
|
||||
defer func() {
|
||||
attrs := []slog.Attr{
|
||||
slog.String("type", "wrapper"),
|
||||
slog.String("package", "garmin"),
|
||||
slog.String("file", "client.go"),
|
||||
slog.String("class", "subprocessClient"),
|
||||
slog.String("method", "execute"),
|
||||
slog.String("cmd", cmd),
|
||||
slog.Any("params", params),
|
||||
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
}
|
||||
level := slog.LevelInfo
|
||||
if result != nil {
|
||||
attrs = append(attrs, slog.String("result", truncate(string(result), 500)))
|
||||
}
|
||||
if err != nil {
|
||||
level = slog.LevelError
|
||||
attrs = append(attrs, slog.String("error", err.Error()))
|
||||
if errorType != "" {
|
||||
attrs = append(attrs, slog.String("error_type", errorType))
|
||||
}
|
||||
if traceback != "" {
|
||||
attrs = append(attrs, slog.String("traceback", traceback))
|
||||
}
|
||||
}
|
||||
slog.Default().LogAttrs(ctx, level, "", attrs...)
|
||||
}()
|
||||
|
||||
c.nextID++
|
||||
id := c.nextID
|
||||
|
||||
if err = c.enc.Encode(wireRequest{ID: id, Cmd: cmd, Params: params}); err != nil {
|
||||
err = fmt.Errorf("write %s request: %w", cmd, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !c.scanner.Scan() {
|
||||
if serr := c.scanner.Err(); serr != nil {
|
||||
err = fmt.Errorf("read %s response: %w", cmd, serr)
|
||||
} else {
|
||||
err = fmt.Errorf("read %s response: subprocess closed its output", cmd)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp wireResponse
|
||||
if uerr := json.Unmarshal(c.scanner.Bytes(), &resp); uerr != nil {
|
||||
err = fmt.Errorf("parse %s response: %w", cmd, uerr)
|
||||
return nil, err
|
||||
}
|
||||
if resp.ID != id {
|
||||
err = fmt.Errorf("%s response id mismatch: got %d, want %d", cmd, resp.ID, id)
|
||||
return nil, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
errorType, traceback = resp.ErrorType, resp.Traceback
|
||||
if resp.NotFound {
|
||||
err = fmt.Errorf("%s: %s: %w", cmd, resp.Error, ErrNotFound)
|
||||
} else {
|
||||
err = fmt.Errorf("%s: %s", cmd, resp.Error)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
result = resp.Result
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *subprocessClient) Authenticate(ctx context.Context) (AuthResult, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if err := c.ensureStarted(ctx); err != nil {
|
||||
return AuthResult{}, err
|
||||
}
|
||||
raw, err := c.execute(ctx, "authenticate", nil)
|
||||
if err != nil {
|
||||
return AuthResult{}, err
|
||||
}
|
||||
var wire authResultWire
|
||||
if err := json.Unmarshal(raw, &wire); err != nil {
|
||||
return AuthResult{}, fmt.Errorf("parse authenticate result: %w", err)
|
||||
}
|
||||
return AuthResult{Status: mapAuthStatus(wire.Status), Message: wire.Message}, nil
|
||||
}
|
||||
|
||||
func (c *subprocessClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if err := c.ensureStarted(ctx); err != nil {
|
||||
return AuthResult{}, err
|
||||
}
|
||||
raw, err := c.execute(ctx, "complete_mfa", map[string]any{"code": code})
|
||||
if err != nil {
|
||||
return AuthResult{}, err
|
||||
}
|
||||
var wire authResultWire
|
||||
if err := json.Unmarshal(raw, &wire); err != nil {
|
||||
return AuthResult{}, fmt.Errorf("parse complete_mfa result: %w", err)
|
||||
}
|
||||
return AuthResult{Status: mapAuthStatus(wire.Status), Message: wire.Message}, nil
|
||||
}
|
||||
|
||||
// UpdateCredentials implements Client.
|
||||
func (c *mcpClient) UpdateCredentials(email, password string) {
|
||||
func (c *subprocessClient) UpdateCredentials(email, password string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.cfg.GarminEmail = email
|
||||
c.cfg.GarminPassword = password
|
||||
|
||||
if c.started {
|
||||
if c.inner != nil {
|
||||
c.inner.Close()
|
||||
}
|
||||
c.inner = nil
|
||||
c.started = false
|
||||
}
|
||||
c.close()
|
||||
}
|
||||
|
||||
// drainStderr forwards the subprocess's debug/log output so it isn't
|
||||
// silently dropped (mcp-garmin logs auth/rate-limit diagnostics there).
|
||||
func drainStderr(stdio *transport.Stdio) {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := stdio.Stderr().Read(buf)
|
||||
if n > 0 {
|
||||
fmt.Print(string(buf[:n]))
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *mcpClient) callTool(ctx context.Context, name string, args map[string]any) (string, error) {
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Name = name
|
||||
req.Params.Arguments = args
|
||||
|
||||
res, err := c.inner.CallTool(ctx, req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("call tool %s: %w", name, err)
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, content := range res.Content {
|
||||
if tc, ok := content.(mcp.TextContent); ok {
|
||||
out.WriteString(tc.Text)
|
||||
}
|
||||
}
|
||||
if res.IsError {
|
||||
return "", fmt.Errorf("tool %s returned an error result: %s", name, out.String())
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
func (c *mcpClient) Authenticate(ctx context.Context) (AuthResult, error) {
|
||||
// Close implements Client.
|
||||
func (c *subprocessClient) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if err := c.ensureStarted(ctx); err != nil {
|
||||
return AuthResult{}, err
|
||||
}
|
||||
msg, err := c.callTool(ctx, "authenticate", nil)
|
||||
if err != nil {
|
||||
return AuthResult{}, err
|
||||
}
|
||||
return parseAuthResult(msg), nil
|
||||
return c.close()
|
||||
}
|
||||
|
||||
func (c *mcpClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
// close terminates the subprocess, if running. Callers must hold c.mu.
|
||||
func (c *subprocessClient) close() error {
|
||||
if !c.started {
|
||||
return nil
|
||||
}
|
||||
c.started = false
|
||||
|
||||
if err := c.ensureStarted(ctx); err != nil {
|
||||
return AuthResult{}, err
|
||||
if c.stdin != nil {
|
||||
c.stdin.Close()
|
||||
}
|
||||
msg, err := c.callTool(ctx, "complete_mfa", map[string]any{"code": code})
|
||||
if err != nil {
|
||||
return AuthResult{}, err
|
||||
if c.cmd != nil && c.cmd.Process != nil {
|
||||
c.cmd.Process.Kill()
|
||||
}
|
||||
return parseAuthResult(msg), nil
|
||||
if c.stderrDone != nil {
|
||||
// Killing the process closes its end of the stderr pipe, which
|
||||
// unblocks the copy goroutine's Read with EOF; wait for it to finish
|
||||
// before Wait, per os/exec's StderrPipe doc.
|
||||
<-c.stderrDone
|
||||
}
|
||||
var err error
|
||||
if c.cmd != nil {
|
||||
err = c.cmd.Wait()
|
||||
}
|
||||
c.cmd, c.stdin, c.enc, c.scanner, c.stderrDone = nil, nil, nil, nil, nil
|
||||
return err
|
||||
}
|
||||
|
||||
func parseAuthResult(msg string) AuthResult {
|
||||
switch {
|
||||
case strings.Contains(msg, "Authenticated successfully") || strings.Contains(msg, "MFA accepted"):
|
||||
return AuthResult{Status: AuthSuccess, Message: msg}
|
||||
case strings.Contains(msg, "MFA required"):
|
||||
return AuthResult{Status: AuthMFARequired, Message: msg}
|
||||
// forwardWrapperStderr re-emits the wrapper subprocess's stderr through
|
||||
// the application logger. wrapper.py writes JSON lines ({"level","msg",
|
||||
// ...attrs} -- see its _log helper), which are decoded and re-logged at
|
||||
// the corresponding level with their attrs preserved. Anything that isn't
|
||||
// such a line (a Python startup crash before _log exists, a chatty
|
||||
// third-party library printing directly) is wrapped as a warn-level
|
||||
// type=wrapper record carrying the raw "line" rather than passed through, so the
|
||||
// backend's combined output is JSON no matter what the subprocess does.
|
||||
func forwardWrapperStderr(r io.Reader) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // tracebacks can exceed the default token size
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
var entry map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil || entry["msg"] == nil {
|
||||
slog.Warn("", "type", "wrapper", "package", "garmin", "file", "client.go", "method", "forwardWrapperStderr", "line", line)
|
||||
continue
|
||||
}
|
||||
msg, _ := entry["msg"].(string)
|
||||
levelName, _ := entry["level"].(string)
|
||||
delete(entry, "msg")
|
||||
delete(entry, "level")
|
||||
// Python-emitted lines carry no "package" (a Go-only field) and no
|
||||
// "class" (wrapper.py has none); method arrives in the entry itself
|
||||
// (wrapper.py's _log stamps the emitting function).
|
||||
attrs := make([]slog.Attr, 0, len(entry)+2)
|
||||
attrs = append(attrs, slog.String("type", "wrapper"), slog.String("file", "wrapper.py"))
|
||||
for k, v := range entry {
|
||||
attrs = append(attrs, slog.Any(k, v))
|
||||
}
|
||||
slog.Default().LogAttrs(context.Background(), wrapperLogLevel(levelName), msg, attrs...)
|
||||
}
|
||||
}
|
||||
|
||||
// wrapperLogLevel maps wrapper.py's level strings onto slog levels,
|
||||
// defaulting to info for anything unrecognized.
|
||||
func wrapperLogLevel(level string) slog.Level {
|
||||
switch level {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return AuthResult{Status: AuthFailed, Message: msg}
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func (c *mcpClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "...(truncated)"
|
||||
}
|
||||
|
||||
var _ Client = (*subprocessClient)(nil)
|
||||
|
||||
// NewClient builds a Client. The subprocess is not spawned until the first
|
||||
// call that needs it (Authenticate, or any data call once authenticated).
|
||||
func NewClient(cfg ClientConfig) Client {
|
||||
return &subprocessClient{cfg: cfg}
|
||||
}
|
||||
|
||||
func (c *subprocessClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if err := c.ensureStarted(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg, err := c.callTool(ctx, "get_activities", map[string]any{
|
||||
"start_date": startDate,
|
||||
"end_date": endDate,
|
||||
"limit": limit,
|
||||
raw, err := c.execute(ctx, "call", callParams{
|
||||
Method: "get_activities_by_date",
|
||||
Args: map[string]any{"startdate": startDate, "enddate": endDate},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rawActivities []json.RawMessage
|
||||
if err := json.Unmarshal([]byte(msg), &rawActivities); err != nil {
|
||||
return nil, fmt.Errorf("get_activities did not return a JSON array (%q): %w", truncate(msg, 200), err)
|
||||
if err := json.Unmarshal(raw, &rawActivities); err != nil {
|
||||
return nil, fmt.Errorf("get_activities_by_date did not return a JSON array (%q): %w", truncate(string(raw), 200), err)
|
||||
}
|
||||
if limit > 0 && limit < len(rawActivities) {
|
||||
rawActivities = rawActivities[:limit]
|
||||
}
|
||||
|
||||
activities := make([]Activity, 0, len(rawActivities))
|
||||
@@ -230,15 +470,16 @@ func (c *mcpClient) GetActivities(ctx context.Context, startDate, endDate string
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
func (c *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
|
||||
func (c *subprocessClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if err := c.ensureStarted(ctx); err != nil {
|
||||
return ActivitySplits{}, err
|
||||
}
|
||||
msg, err := c.callTool(ctx, "get_activity_splits", map[string]any{
|
||||
"activity_id": strconv.FormatInt(activityID, 10),
|
||||
raw, err := c.execute(ctx, "call", callParams{
|
||||
Method: "get_activity_splits",
|
||||
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
|
||||
})
|
||||
if err != nil {
|
||||
return ActivitySplits{}, err
|
||||
@@ -248,8 +489,8 @@ func (c *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (Ac
|
||||
ActivityID int64 `json:"activityId"`
|
||||
Laps []json.RawMessage `json:"lapDTOs"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(msg), &envelope); err != nil {
|
||||
return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(msg, 200), err)
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(string(raw), 200), err)
|
||||
}
|
||||
|
||||
splits := ActivitySplits{ActivityID: envelope.ActivityID, Laps: make([]Lap, 0, len(envelope.Laps))}
|
||||
@@ -264,63 +505,48 @@ func (c *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (Ac
|
||||
return splits, nil
|
||||
}
|
||||
|
||||
func (c *mcpClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
|
||||
func (c *subprocessClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if err := c.ensureStarted(ctx); err != nil {
|
||||
return ActivityDetails{}, err
|
||||
}
|
||||
msg, err := c.callTool(ctx, "get_activity_details", map[string]any{
|
||||
"activity_id": strconv.FormatInt(activityID, 10),
|
||||
raw, err := c.execute(ctx, "call", callParams{
|
||||
Method: "get_activity_details",
|
||||
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
|
||||
})
|
||||
if err != nil {
|
||||
return ActivityDetails{}, err
|
||||
}
|
||||
|
||||
var details ActivityDetails
|
||||
if err := json.Unmarshal([]byte(msg), &details); err != nil {
|
||||
return ActivityDetails{}, fmt.Errorf("get_activity_details did not return valid JSON (%q): %w", truncate(msg, 200), err)
|
||||
if err := json.Unmarshal(raw, &details); err != nil {
|
||||
return ActivityDetails{}, fmt.Errorf("get_activity_details did not return valid JSON (%q): %w", truncate(string(raw), 200), err)
|
||||
}
|
||||
details.Raw = json.RawMessage(msg)
|
||||
details.Raw = json.RawMessage(raw)
|
||||
return details, nil
|
||||
}
|
||||
|
||||
func (c *mcpClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
|
||||
func (c *subprocessClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if err := c.ensureStarted(ctx); err != nil {
|
||||
return Workout{}, err
|
||||
}
|
||||
msg, err := c.callTool(ctx, "get_workout_by_id", map[string]any{
|
||||
"workout_id": strconv.FormatInt(workoutID, 10),
|
||||
raw, err := c.execute(ctx, "call", callParams{
|
||||
Method: "get_workout_by_id",
|
||||
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
|
||||
})
|
||||
if err != nil {
|
||||
return Workout{}, err
|
||||
}
|
||||
|
||||
var workout Workout
|
||||
if err := json.Unmarshal([]byte(msg), &workout); err != nil {
|
||||
return Workout{}, fmt.Errorf("get_workout_by_id did not return valid JSON (%q): %w", truncate(msg, 200), err)
|
||||
if err := json.Unmarshal(raw, &workout); err != nil {
|
||||
return Workout{}, fmt.Errorf("get_workout_by_id did not return valid JSON (%q): %w", truncate(string(raw), 200), err)
|
||||
}
|
||||
workout.Raw = json.RawMessage(msg)
|
||||
workout.Raw = json.RawMessage(raw)
|
||||
return workout, nil
|
||||
}
|
||||
|
||||
func (c *mcpClient) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if !c.started {
|
||||
return nil
|
||||
}
|
||||
return c.inner.Close()
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "...(truncated)"
|
||||
}
|
||||
|
||||
@@ -1,29 +1,173 @@
|
||||
package garmin
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
func TestParseAuthResult(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
want AuthStatus
|
||||
}{
|
||||
{"Authenticated successfully.", AuthSuccess},
|
||||
{"MFA accepted. Authenticated successfully.", AuthSuccess},
|
||||
{"MFA required. Garmin has sent a verification code...", AuthMFARequired},
|
||||
{"Authentication failed: 401 Unauthorized (Invalid Username or Password)", AuthFailed},
|
||||
{"Authentication failed after MFA: bad code", AuthFailed},
|
||||
"geniusrun/backend/internal/log"
|
||||
)
|
||||
|
||||
// wireResponsePayload is what a fake wrapper handler returns for one
|
||||
// request; the harness fills in the response ID.
|
||||
type wireResponsePayload struct {
|
||||
result json.RawMessage
|
||||
err string
|
||||
notFound bool
|
||||
}
|
||||
|
||||
func fakeResult(v any) wireResponsePayload {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := parseAuthResult(c.msg)
|
||||
if got.Status != c.want {
|
||||
t.Errorf("parseAuthResult(%q).Status = %v, want %v", c.msg, got.Status, c.want)
|
||||
return wireResponsePayload{result: b}
|
||||
}
|
||||
|
||||
func fakeError(msg string) wireResponsePayload {
|
||||
return wireResponsePayload{err: msg}
|
||||
}
|
||||
|
||||
func fakeNotFoundError(msg string) wireResponsePayload {
|
||||
return wireResponsePayload{err: msg, notFound: true}
|
||||
}
|
||||
|
||||
// newFakeWrapperClient wires a subprocessClient to an in-process goroutine
|
||||
// that plays the Python wrapper's role, so protocol-level Go logic can be
|
||||
// tested without python3/garminconnect installed.
|
||||
func newFakeWrapperClient(t *testing.T, handle func(cmd string, params json.RawMessage) wireResponsePayload) *subprocessClient {
|
||||
t.Helper()
|
||||
|
||||
reqR, reqW := io.Pipe()
|
||||
respR, respW := io.Pipe()
|
||||
t.Cleanup(func() {
|
||||
reqW.Close()
|
||||
respW.Close()
|
||||
})
|
||||
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(reqR)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
|
||||
enc := json.NewEncoder(respW)
|
||||
for scanner.Scan() {
|
||||
var req struct {
|
||||
ID int `json:"id"`
|
||||
Cmd string `json:"cmd"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(scanner.Bytes(), &req); err != nil {
|
||||
continue
|
||||
}
|
||||
payload := handle(req.Cmd, req.Params)
|
||||
resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err, NotFound: payload.notFound}
|
||||
if err := enc.Encode(resp); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(respR)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
|
||||
|
||||
return &subprocessClient{
|
||||
started: true,
|
||||
enc: json.NewEncoder(reqW),
|
||||
scanner: scanner,
|
||||
}
|
||||
}
|
||||
|
||||
func TestMcpClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
|
||||
c := &mcpClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}}
|
||||
c.started = true // simulate an already-spawned subprocess
|
||||
func TestSubprocessClient_RoundTrip_DetectsIDMismatch(t *testing.T) {
|
||||
reqR, reqW := io.Pipe()
|
||||
respR, respW := io.Pipe()
|
||||
defer reqW.Close()
|
||||
defer respW.Close()
|
||||
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(reqR)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
|
||||
scanner.Scan() // read and discard the one request
|
||||
json.NewEncoder(respW).Encode(wireResponse{ID: 999, Result: json.RawMessage(`{}`)})
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(respR)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
|
||||
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
|
||||
|
||||
_, err := c.execute(context.Background(), "authenticate", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "id mismatch") {
|
||||
t.Fatalf("roundTrip error = %v, want an id mismatch error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_RoundTrip_SubprocessClosedIsError(t *testing.T) {
|
||||
reqR, reqW := io.Pipe()
|
||||
respR, respW := io.Pipe()
|
||||
defer reqW.Close()
|
||||
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(reqR)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
|
||||
scanner.Scan()
|
||||
respW.Close() // subprocess "exited": stdout closes with no response
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(respR)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
|
||||
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
|
||||
|
||||
_, err := c.execute(context.Background(), "authenticate", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "closed") {
|
||||
t.Fatalf("roundTrip error = %v, want a subprocess-closed error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_RoundTrip_WrapperErrorPropagates(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
return fakeError("boom")
|
||||
})
|
||||
|
||||
_, err := c.execute(context.Background(), "authenticate", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("roundTrip error = %v, want it to mention %q", err, "boom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_RoundTrip_NotFoundWrapsErrNotFound(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
return fakeNotFoundError("API Error 404")
|
||||
})
|
||||
|
||||
_, err := c.execute(context.Background(), "call", nil)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound)", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "API Error 404") {
|
||||
t.Errorf("roundTrip error = %v, want it to still contain the original message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_RoundTrip_OrdinaryErrorDoesNotWrapErrNotFound(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
return fakeError("boom")
|
||||
})
|
||||
|
||||
_, err := c.execute(context.Background(), "call", nil)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound) to be false", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
|
||||
c := &subprocessClient{cfg: ClientConfig{GarminEmail: "old@example.com", GarminPassword: "old"}}
|
||||
c.started = true // simulate an already-spawned subprocess, no real cmd/pipes
|
||||
|
||||
c.UpdateCredentials("new@example.com", "new")
|
||||
|
||||
@@ -33,7 +177,360 @@ func TestMcpClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
|
||||
if c.started {
|
||||
t.Error("started should be reset to false so the next call respawns the subprocess")
|
||||
}
|
||||
if c.inner != nil {
|
||||
t.Error("inner should be cleared so ensureStarted spawns a fresh client")
|
||||
}
|
||||
|
||||
func TestSubprocessClient_Close_NoOpWhenNotStarted(t *testing.T) {
|
||||
c := &subprocessClient{}
|
||||
if err := c.Close(); err != nil {
|
||||
t.Errorf("Close on an unstarted client = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubprocessClient_Close_WaitsForStderrCopyGoroutine exercises close()
|
||||
// against a real subprocess that writes to stderr, mirroring how
|
||||
// ensureStarted wires up the stderr-copy goroutine. Per os/exec's
|
||||
// StderrPipe docs it is incorrect to call Wait before all reads from the
|
||||
// pipe have completed; this guards against close() calling cmd.Wait()
|
||||
// before the copy goroutine has drained the pipe (which previously risked a
|
||||
// race / truncated stderr / spurious "file already closed" errors).
|
||||
func TestSubprocessClient_Close_WaitsForStderrCopyGoroutine(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "for i in 1 2 3 4 5; do echo line$i 1>&2; done; sleep 5")
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("StderrPipe: %v", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
stderrDone := make(chan struct{})
|
||||
go func() {
|
||||
io.Copy(io.Discard, stderr)
|
||||
close(stderrDone)
|
||||
}()
|
||||
|
||||
c := &subprocessClient{started: true, cmd: cmd, stderrDone: stderrDone}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.close() }()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// close() returned -- since it killed the process and waited on
|
||||
// stderrDone before Wait-ing, this proves the ordering held without
|
||||
// deadlocking.
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("close() did not return in time -- likely blocked waiting on stderrDone")
|
||||
}
|
||||
|
||||
if c.stderrDone != nil {
|
||||
t.Error("stderrDone should be reset to nil after close()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_Authenticate_Success(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
if cmd != "authenticate" {
|
||||
t.Fatalf("unexpected cmd %q", cmd)
|
||||
}
|
||||
return fakeResult(authResultWire{Status: "success", Message: "Authenticated successfully."})
|
||||
})
|
||||
|
||||
res, err := c.Authenticate(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate: %v", err)
|
||||
}
|
||||
if res.Status != AuthSuccess || res.Message != "Authenticated successfully." {
|
||||
t.Errorf("Authenticate result = %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_Authenticate_MFARequired(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
return fakeResult(authResultWire{Status: "mfa_required", Message: "MFA required. ..."})
|
||||
})
|
||||
|
||||
res, err := c.Authenticate(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate: %v", err)
|
||||
}
|
||||
if res.Status != AuthMFARequired {
|
||||
t.Errorf("Authenticate status = %v, want AuthMFARequired", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_Authenticate_WrapperErrorPropagates(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
return fakeError("subprocess exploded")
|
||||
})
|
||||
|
||||
_, err := c.Authenticate(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "subprocess exploded") {
|
||||
t.Fatalf("Authenticate error = %v, want it to mention the wrapper error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_CompleteMFA_SendsCodeAndReturnsStatus(t *testing.T) {
|
||||
var gotCode string
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
if cmd != "complete_mfa" {
|
||||
t.Fatalf("unexpected cmd %q", cmd)
|
||||
}
|
||||
var p struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
t.Fatalf("unmarshal params: %v", err)
|
||||
}
|
||||
gotCode = p.Code
|
||||
return fakeResult(authResultWire{Status: "success", Message: "MFA accepted. Authenticated successfully."})
|
||||
})
|
||||
|
||||
res, err := c.CompleteMFA(context.Background(), "123456")
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteMFA: %v", err)
|
||||
}
|
||||
if gotCode != "123456" {
|
||||
t.Errorf("code sent to wrapper = %q, want 123456", gotCode)
|
||||
}
|
||||
if res.Status != AuthSuccess {
|
||||
t.Errorf("CompleteMFA status = %v, want AuthSuccess", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_CompleteMFA_Failed(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
return fakeResult(authResultWire{Status: "failed", Message: "Authentication failed after MFA: bad code"})
|
||||
})
|
||||
|
||||
res, err := c.CompleteMFA(context.Background(), "000000")
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteMFA: %v", err)
|
||||
}
|
||||
if res.Status != AuthFailed {
|
||||
t.Errorf("CompleteMFA status = %v, want AuthFailed", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_GetActivities_UsesGarminconnectKwargNames(t *testing.T) {
|
||||
var gotMethod string
|
||||
var gotArgs map[string]any
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
var p callParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
t.Fatalf("unmarshal params: %v", err)
|
||||
}
|
||||
gotMethod, gotArgs = p.Method, p.Args
|
||||
return fakeResult([]map[string]any{
|
||||
{"activityId": float64(123), "activityType": map[string]any{"typeKey": "running"}},
|
||||
})
|
||||
})
|
||||
|
||||
activities, err := c.GetActivities(context.Background(), "2026-07-01", "2026-07-25", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivities: %v", err)
|
||||
}
|
||||
if gotMethod != "get_activities_by_date" {
|
||||
t.Errorf("method = %q, want get_activities_by_date", gotMethod)
|
||||
}
|
||||
if gotArgs["startdate"] != "2026-07-01" || gotArgs["enddate"] != "2026-07-25" {
|
||||
t.Errorf("args = %+v, want startdate/enddate matching garminconnect's real kwargs", gotArgs)
|
||||
}
|
||||
if len(activities) != 1 || activities[0].ActivityID != 123 {
|
||||
t.Fatalf("activities = %+v", activities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_GetActivities_AppliesLimitClientSide(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
raw := make([]map[string]any, 5)
|
||||
for i := range raw {
|
||||
raw[i] = map[string]any{"activityId": float64(i)}
|
||||
}
|
||||
return fakeResult(raw)
|
||||
})
|
||||
|
||||
activities, err := c.GetActivities(context.Background(), "2026-07-01", "2026-07-25", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivities: %v", err)
|
||||
}
|
||||
if len(activities) != 2 {
|
||||
t.Fatalf("len(activities) = %d, want 2", len(activities))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_GetActivitySplits_ParsesLapDTOsEnvelope(t *testing.T) {
|
||||
var gotMethod string
|
||||
var gotArgs map[string]any
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
var p callParams
|
||||
json.Unmarshal(params, &p)
|
||||
gotMethod, gotArgs = p.Method, p.Args
|
||||
return fakeResult(map[string]any{
|
||||
"activityId": float64(111),
|
||||
"lapDTOs": []map[string]any{{"lapIndex": float64(1), "distance": float64(1000)}},
|
||||
})
|
||||
})
|
||||
|
||||
splits, err := c.GetActivitySplits(context.Background(), 111)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivitySplits: %v", err)
|
||||
}
|
||||
if gotMethod != "get_activity_splits" || gotArgs["activity_id"] != "111" {
|
||||
t.Errorf("method/args = %q/%+v, want get_activity_splits with activity_id=\"111\"", gotMethod, gotArgs)
|
||||
}
|
||||
if splits.ActivityID != 111 || len(splits.Laps) != 1 || splits.Laps[0].LapIndex != 1 {
|
||||
t.Fatalf("splits = %+v", splits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_GetActivityDetails_ParsesRawTelemetry(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
var p callParams
|
||||
json.Unmarshal(params, &p)
|
||||
if p.Method != "get_activity_details" || p.Args["activity_id"] != "222" {
|
||||
t.Fatalf("unexpected call: %+v", p)
|
||||
}
|
||||
return fakeResult(map[string]any{
|
||||
"activityId": float64(222),
|
||||
"metricDescriptors": []map[string]any{{"key": "directHeartRate", "metricsIndex": float64(0)}},
|
||||
"activityDetailMetrics": []map[string]any{{"metrics": []any{float64(101)}}},
|
||||
})
|
||||
})
|
||||
|
||||
details, err := c.GetActivityDetails(context.Background(), 222)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivityDetails: %v", err)
|
||||
}
|
||||
if details.ActivityID != 222 || len(details.MetricDescriptors) != 1 {
|
||||
t.Fatalf("details = %+v", details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_GetWorkoutByID_ParsesSegments(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
var p callParams
|
||||
json.Unmarshal(params, &p)
|
||||
if p.Method != "get_workout_by_id" || p.Args["workout_id"] != "333" {
|
||||
t.Fatalf("unexpected call: %+v", p)
|
||||
}
|
||||
return fakeResult(map[string]any{
|
||||
"workoutId": float64(333),
|
||||
"workoutName": "Tempo",
|
||||
"workoutSegments": []map[string]any{{"segmentOrder": float64(1), "workoutSteps": []any{}}},
|
||||
})
|
||||
})
|
||||
|
||||
workout, err := c.GetWorkoutByID(context.Background(), 333)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkoutByID: %v", err)
|
||||
}
|
||||
if workout.WorkoutID != 333 || workout.WorkoutName != "Tempo" || len(workout.Segments) != 1 {
|
||||
t.Fatalf("workout = %+v", workout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_RoundTrip_LogsCallWithResultPreview(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
return fakeResult(authResultWire{Status: "mfa_required", Message: "MFA required."})
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(applog.NewLogger("info", &buf))
|
||||
defer slog.SetDefault(prev)
|
||||
|
||||
if _, err := c.Authenticate(context.Background()); err != nil {
|
||||
t.Fatalf("Authenticate: %v", err)
|
||||
}
|
||||
|
||||
var entry map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
|
||||
}
|
||||
if entry["type"] != "wrapper" || entry["package"] != "garmin" || entry["file"] != "client.go" || entry["class"] != "subprocessClient" || entry["method"] != "execute" {
|
||||
t.Errorf("schema fields = %v, want type=wrapper package=garmin file=client.go class=subprocessClient method=execute", entry)
|
||||
}
|
||||
if _, hasMsg := entry["msg"]; hasMsg {
|
||||
t.Errorf("expected no msg on the wrapper-call record, got %v", entry["msg"])
|
||||
}
|
||||
if entry["cmd"] != "authenticate" {
|
||||
t.Errorf("cmd = %v, want authenticate", entry["cmd"])
|
||||
}
|
||||
preview, _ := entry["result"].(string)
|
||||
if !strings.Contains(preview, "mfa_required") {
|
||||
t.Errorf("result = %q, want it to contain mfa_required", preview)
|
||||
}
|
||||
if _, hasError := entry["error"]; hasError {
|
||||
t.Errorf("expected no error field on a successful call, got %v", entry["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_RoundTrip_LogsFailedCallAtErrorLevel(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
return fakeError("boom")
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(applog.NewLogger("info", &buf))
|
||||
defer slog.SetDefault(prev)
|
||||
|
||||
if _, err := c.Authenticate(context.Background()); err == nil {
|
||||
t.Fatal("expected Authenticate to return an error")
|
||||
}
|
||||
|
||||
var entry map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
|
||||
}
|
||||
if entry["level"] != "ERROR" {
|
||||
t.Errorf("level = %v, want ERROR for a failed call", entry["level"])
|
||||
}
|
||||
if errMsg, _ := entry["error"].(string); !strings.Contains(errMsg, "boom") {
|
||||
t.Errorf("error field = %q, want it to mention \"boom\"", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardWrapperStderr_ReemitsJSONAndWrapsFreeText(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(applog.NewLogger("debug", &buf))
|
||||
defer slog.SetDefault(prev)
|
||||
|
||||
stderr := strings.NewReader(
|
||||
`{"level":"warn","msg":"startup tokenstore login failed","error":"boom"}` + "\n" +
|
||||
"Traceback (most recent call last): free text\n",
|
||||
)
|
||||
forwardWrapperStderr(stderr)
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("expected 2 log lines, got %d: %q", len(lines), buf.String())
|
||||
}
|
||||
|
||||
var first map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
|
||||
t.Fatalf("first line not JSON: %v", err)
|
||||
}
|
||||
if first["level"] != "WARN" || first["msg"] != "startup tokenstore login failed" {
|
||||
t.Errorf("first = %v, want the wrapper's level and msg preserved", first)
|
||||
}
|
||||
if first["error"] != "boom" || first["type"] != "wrapper" || first["file"] != "wrapper.py" {
|
||||
t.Errorf("first = %v, want error attr preserved and type=wrapper file=wrapper.py", first)
|
||||
}
|
||||
|
||||
var second map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
|
||||
t.Fatalf("second line not JSON: %v", err)
|
||||
}
|
||||
if _, hasMsg := second["msg"]; hasMsg {
|
||||
t.Errorf("second = %v, want no msg on a wrapped free-text record", second)
|
||||
}
|
||||
if second["type"] != "wrapper" || second["level"] != "WARN" {
|
||||
t.Errorf("second = %v, want free text wrapped as a warn type=wrapper record", second)
|
||||
}
|
||||
if line, _ := second["line"].(string); !strings.Contains(line, "Traceback") {
|
||||
t.Errorf("second line attr = %q, want the raw text preserved", line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package sync
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/classify"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
@@ -19,7 +18,7 @@ func isRunningActivityType(typeKey string) bool {
|
||||
return strings.Contains(strings.ToLower(typeKey), "run")
|
||||
}
|
||||
|
||||
func toActivityRow(a garmin.Activity) store.Activity {
|
||||
func toActivityRow(a Activity) store.Activity {
|
||||
return store.Activity{
|
||||
GarminActivityID: a.ActivityID,
|
||||
EventTypeKey: a.EventType.TypeKey,
|
||||
@@ -52,7 +51,7 @@ func nonZero(v float64) *float64 {
|
||||
// fall within that lap's time window. Lap boundaries are derived from
|
||||
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps
|
||||
// are contiguous and this sidesteps timezone parsing entirely.
|
||||
func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.WorkoutStep, profile store.Profile) []store.Lap {
|
||||
func toLapRows(laps []Lap, samples []Sample, targets []*WorkoutStep, profile store.Profile) []store.Lap {
|
||||
rows := make([]store.Lap, 0, len(laps))
|
||||
var elapsedStart float64
|
||||
for i, l := range laps {
|
||||
@@ -94,9 +93,9 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
|
||||
return rows
|
||||
}
|
||||
|
||||
// alignWorkoutTargets zips an activity's recorded laps against its
|
||||
// structured workout's flattened steps, returning one *garmin.WorkoutStep
|
||||
// per lap (nil where unavailable).
|
||||
// alignWorkoutTargets zips an activity's lap count against its structured
|
||||
// workout's flattened steps, returning one *garmin.WorkoutStep per lap (nil
|
||||
// where unavailable).
|
||||
//
|
||||
// Confirmed against a real activity (via Garmin Connect's own workout view)
|
||||
// that recording sometimes continues one lap past the end of the workout's
|
||||
@@ -110,11 +109,11 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
|
||||
// Any other mismatch (extra manual laps, auto-lap-by-distance also firing,
|
||||
// etc.) can't be trusted at all, so every entry comes back nil rather than
|
||||
// risk showing a target against the wrong lap.
|
||||
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep {
|
||||
func alignWorkoutTargets(lapCount int, workout Workout) []*WorkoutStep {
|
||||
steps := workout.FlattenSteps()
|
||||
out := make([]*garmin.WorkoutStep, len(laps))
|
||||
out := make([]*WorkoutStep, lapCount)
|
||||
|
||||
switch len(laps) - len(steps) {
|
||||
switch lapCount - len(steps) {
|
||||
case 0, 1:
|
||||
for i := range steps {
|
||||
s := steps[i]
|
||||
@@ -126,7 +125,7 @@ func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.Wo
|
||||
|
||||
// targetPaceRange returns the (low, high) m/s bounds of a workout step's
|
||||
// pace-zone target, or (nil, nil) if it doesn't target pace.
|
||||
func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
|
||||
func targetPaceRange(step WorkoutStep) (*float64, *float64) {
|
||||
if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -141,7 +140,7 @@ func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
|
||||
// heart-rate-zone target, or (nil, nil) if it doesn't target heart rate.
|
||||
// Steps that target a named zone (ZoneNumber) rather than a custom bpm
|
||||
// range are resolved via the user's Karvonen profile.
|
||||
func targetHRRange(step garmin.WorkoutStep, profile store.Profile) (*float64, *float64) {
|
||||
func targetHRRange(step WorkoutStep, profile store.Profile) (*float64, *float64) {
|
||||
if step.TargetType.TypeKey != "heart.rate.zone" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -188,7 +187,7 @@ func karvonenBounds(p store.Profile, zone int) (lowBpm, highBpm float64, ok bool
|
||||
return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true
|
||||
}
|
||||
|
||||
func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.SampleInfo {
|
||||
func samplesInWindow(samples []Sample, start, end float64) []classify.SampleInfo {
|
||||
var out []classify.SampleInfo
|
||||
for _, s := range samples {
|
||||
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
|
||||
@@ -199,7 +198,7 @@ func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.Sam
|
||||
return out
|
||||
}
|
||||
|
||||
func toSampleRows(samples []garmin.Sample) []store.Sample {
|
||||
func toSampleRows(samples []Sample) []store.Sample {
|
||||
rows := make([]store.Sample, len(samples))
|
||||
for i, s := range samples {
|
||||
rows[i] = store.Sample{
|
||||
114
backend/internal/garmin/mock.go
Normal file
114
backend/internal/garmin/mock.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// MockClient support: a fake Client for tests and frontend/dev
|
||||
// work without a live Garmin account or the wrapper subprocess.
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockClient is a fake Client returning data supplied by the test/caller.
|
||||
type MockClient struct {
|
||||
AuthResults []AuthResult // consumed in order by Authenticate/CompleteMFA calls
|
||||
Activities []Activity
|
||||
Splits map[int64]ActivitySplits
|
||||
Details map[int64]ActivityDetails
|
||||
Workouts map[int64]Workout
|
||||
// WorkoutErrByID, if set for a given workout ID, makes GetWorkoutByID
|
||||
// return that error for that ID specifically -- independent of the
|
||||
// all-calls-fail Err field below -- so a test can simulate one
|
||||
// activity's workout fetch failing while others in the same batch
|
||||
// succeed.
|
||||
WorkoutErrByID map[int64]error
|
||||
// Delay, if set, is slept (ctx-cancellable) at the start of every
|
||||
// GetActivities call -- lets a test give sync.Service's discovering
|
||||
// phase (backfillCore/incrementalSyncCore) real wall-clock duration, so
|
||||
// a concurrent goroutine can observe Progress() mid-flight instead of
|
||||
// the call returning instantly.
|
||||
Delay time.Duration
|
||||
Err error // if set, every call returns this error
|
||||
authResultCursor int
|
||||
ClosedCalled bool
|
||||
GetActivitiesCalls int
|
||||
AuthenticateCalls int
|
||||
LastEmail string
|
||||
LastPassword string
|
||||
}
|
||||
|
||||
var _ Client = (*MockClient)(nil)
|
||||
|
||||
func (c *MockClient) nextAuthResult() AuthResult {
|
||||
if c.authResultCursor >= len(c.AuthResults) {
|
||||
return AuthResult{Status: AuthSuccess, Message: "Authenticated successfully."}
|
||||
}
|
||||
r := c.AuthResults[c.authResultCursor]
|
||||
c.authResultCursor++
|
||||
return r
|
||||
}
|
||||
|
||||
func (c *MockClient) Authenticate(ctx context.Context) (AuthResult, error) {
|
||||
c.AuthenticateCalls++
|
||||
if c.Err != nil {
|
||||
return AuthResult{}, c.Err
|
||||
}
|
||||
return c.nextAuthResult(), nil
|
||||
}
|
||||
|
||||
func (c *MockClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
|
||||
if c.Err != nil {
|
||||
return AuthResult{}, c.Err
|
||||
}
|
||||
return c.nextAuthResult(), nil
|
||||
}
|
||||
|
||||
func (c *MockClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
|
||||
c.GetActivitiesCalls++
|
||||
if c.Delay > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(c.Delay):
|
||||
}
|
||||
}
|
||||
if c.Err != nil {
|
||||
return nil, c.Err
|
||||
}
|
||||
if limit > 0 && limit < len(c.Activities) {
|
||||
return c.Activities[:limit], nil
|
||||
}
|
||||
return c.Activities, nil
|
||||
}
|
||||
|
||||
func (c *MockClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
|
||||
if c.Err != nil {
|
||||
return ActivitySplits{}, c.Err
|
||||
}
|
||||
return c.Splits[activityID], nil
|
||||
}
|
||||
|
||||
func (c *MockClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
|
||||
if c.Err != nil {
|
||||
return ActivityDetails{}, c.Err
|
||||
}
|
||||
return c.Details[activityID], nil
|
||||
}
|
||||
|
||||
func (c *MockClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
|
||||
if c.Err != nil {
|
||||
return Workout{}, c.Err
|
||||
}
|
||||
if err, ok := c.WorkoutErrByID[workoutID]; ok {
|
||||
return Workout{}, err
|
||||
}
|
||||
return c.Workouts[workoutID], nil
|
||||
}
|
||||
|
||||
func (c *MockClient) UpdateCredentials(email, password string) {
|
||||
c.LastEmail = email
|
||||
c.LastPassword = password
|
||||
}
|
||||
|
||||
func (c *MockClient) Close() error {
|
||||
c.ClosedCalled = true
|
||||
return nil
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// Package mock provides a fake garmin.Client for tests and frontend/dev
|
||||
// work without a live Garmin account or the mcp-garmin subprocess.
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"geniusrun/backend/internal/garmin"
|
||||
)
|
||||
|
||||
// Client is a fake garmin.Client returning data supplied by the test/caller.
|
||||
type Client struct {
|
||||
AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls
|
||||
Activities []garmin.Activity
|
||||
Splits map[int64]garmin.ActivitySplits
|
||||
Details map[int64]garmin.ActivityDetails
|
||||
Workouts map[int64]garmin.Workout
|
||||
Err error // if set, every call returns this error
|
||||
authResultCursor int
|
||||
ClosedCalled bool
|
||||
GetActivitiesCalls int
|
||||
LastEmail string
|
||||
LastPassword string
|
||||
}
|
||||
|
||||
var _ garmin.Client = (*Client)(nil)
|
||||
|
||||
func (c *Client) nextAuthResult() garmin.AuthResult {
|
||||
if c.authResultCursor >= len(c.AuthResults) {
|
||||
return garmin.AuthResult{Status: garmin.AuthSuccess, Message: "Authenticated successfully."}
|
||||
}
|
||||
r := c.AuthResults[c.authResultCursor]
|
||||
c.authResultCursor++
|
||||
return r
|
||||
}
|
||||
|
||||
func (c *Client) Authenticate(ctx context.Context) (garmin.AuthResult, error) {
|
||||
if c.Err != nil {
|
||||
return garmin.AuthResult{}, c.Err
|
||||
}
|
||||
return c.nextAuthResult(), nil
|
||||
}
|
||||
|
||||
func (c *Client) CompleteMFA(ctx context.Context, code string) (garmin.AuthResult, error) {
|
||||
if c.Err != nil {
|
||||
return garmin.AuthResult{}, c.Err
|
||||
}
|
||||
return c.nextAuthResult(), nil
|
||||
}
|
||||
|
||||
func (c *Client) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]garmin.Activity, error) {
|
||||
c.GetActivitiesCalls++
|
||||
if c.Err != nil {
|
||||
return nil, c.Err
|
||||
}
|
||||
if limit > 0 && limit < len(c.Activities) {
|
||||
return c.Activities[:limit], nil
|
||||
}
|
||||
return c.Activities, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetActivitySplits(ctx context.Context, activityID int64) (garmin.ActivitySplits, error) {
|
||||
if c.Err != nil {
|
||||
return garmin.ActivitySplits{}, c.Err
|
||||
}
|
||||
return c.Splits[activityID], nil
|
||||
}
|
||||
|
||||
func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (garmin.ActivityDetails, error) {
|
||||
if c.Err != nil {
|
||||
return garmin.ActivityDetails{}, c.Err
|
||||
}
|
||||
return c.Details[activityID], nil
|
||||
}
|
||||
|
||||
func (c *Client) GetWorkoutByID(ctx context.Context, workoutID int64) (garmin.Workout, error) {
|
||||
if c.Err != nil {
|
||||
return garmin.Workout{}, c.Err
|
||||
}
|
||||
return c.Workouts[workoutID], nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateCredentials(email, password string) {
|
||||
c.LastEmail = email
|
||||
c.LastPassword = password
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
c.ClosedCalled = true
|
||||
return nil
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
// Package sync orchestrates fetching activities from Garmin (via
|
||||
// Package garmin orchestrates fetching activities from Garmin (via
|
||||
// internal/garmin), persisting them (via internal/store), and classifying
|
||||
// them (via internal/classify). It's the only package that depends on all
|
||||
// three, keeping garmin/store/classify decoupled from each other.
|
||||
package sync
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/classify"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
applog "geniusrun/backend/internal/log"
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
// Config tunes sync behavior. Zero values fall back to sensible defaults in
|
||||
// NewService. How far back Backfill reaches is not here -- it's
|
||||
// NewSync. How far back Backfill reaches is not here -- it's
|
||||
// Profile.BackfillHorizonDays, read fresh on every call so a user-edited
|
||||
// value takes effect on the next sync without a server restart.
|
||||
type Config struct {
|
||||
type SyncConfig struct {
|
||||
// BackfillWindowDays is the page size for each get_activities call
|
||||
// during backfill.
|
||||
BackfillWindowDays int
|
||||
@@ -38,7 +38,7 @@ type Config struct {
|
||||
MinConfidence float64
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
func (c SyncConfig) withDefaults() SyncConfig {
|
||||
if c.BackfillWindowDays == 0 {
|
||||
c.BackfillWindowDays = 90
|
||||
}
|
||||
@@ -54,49 +54,64 @@ func (c Config) withDefaults() Config {
|
||||
return c
|
||||
}
|
||||
|
||||
// Progress reports how far a currently-running (or just-finished)
|
||||
// FillPendingDetails pass has gotten, for a status banner to poll.
|
||||
// Phase values reported by Progress.Phase.
|
||||
const (
|
||||
PhaseIdle = "idle"
|
||||
PhaseDiscovering = "discovering"
|
||||
PhaseActivities = "activities"
|
||||
PhaseWorkouts = "workouts"
|
||||
)
|
||||
|
||||
// Progress reports how far a currently-running (or just-finished) FullSync
|
||||
// has gotten, for a status modal to poll. PhaseDiscovering (backfillCore/
|
||||
// incrementalSyncCore) has no meaningful Total -- discovering how many
|
||||
// activities exist IS the act of fetching them, so it's reported as an
|
||||
// indeterminate step (Done/Total both 0) rather than a fake percentage.
|
||||
// PhaseActivities/PhaseWorkouts do have a real Total, taken once from a
|
||||
// local DB count at the start of each pass (see FillPendingDetails).
|
||||
type Progress struct {
|
||||
Phase string
|
||||
Done int
|
||||
Total int
|
||||
}
|
||||
|
||||
// Service is the sync orchestrator, scoped to one user -- every store call
|
||||
// Sync is the sync orchestrator, scoped to one user -- every store call
|
||||
// it makes is for userID's data only.
|
||||
type Service struct {
|
||||
garmin garmin.Client
|
||||
type Sync struct {
|
||||
garmin Client
|
||||
db *store.DB
|
||||
userID int64
|
||||
cfg Config
|
||||
cfg SyncConfig
|
||||
now func() time.Time
|
||||
|
||||
progressMu sync.Mutex
|
||||
progress Progress
|
||||
}
|
||||
|
||||
// NewService builds a Service scoped to userID. now defaults to time.Now if
|
||||
// NewSync builds a Sync scoped to userID. now defaults to time.Now if
|
||||
// nil (tests can override it for deterministic date windows).
|
||||
func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service {
|
||||
func NewSync(g Client, db *store.DB, userID int64, cfg SyncConfig, now func() time.Time) *Sync {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Service{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now}
|
||||
return &Sync{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now, progress: Progress{Phase: PhaseIdle}}
|
||||
}
|
||||
|
||||
// Progress returns the current detail-fill progress (0/0 when idle).
|
||||
func (s *Service) Progress() Progress {
|
||||
// Progress returns the current sync progress (phase idle, 0/0 when nothing
|
||||
// is running).
|
||||
func (s *Sync) Progress() Progress {
|
||||
s.progressMu.Lock()
|
||||
defer s.progressMu.Unlock()
|
||||
return s.progress
|
||||
}
|
||||
|
||||
func (s *Service) setProgress(done, total int) {
|
||||
func (s *Sync) setProgress(phase string, done, total int) {
|
||||
s.progressMu.Lock()
|
||||
s.progress = Progress{Done: done, Total: total}
|
||||
s.progress = Progress{Phase: phase, Done: done, Total: total}
|
||||
s.progressMu.Unlock()
|
||||
}
|
||||
|
||||
// Backfill pages backward in Config.BackfillWindowDays windows until
|
||||
// backfillCore pages backward in Config.BackfillWindowDays windows until
|
||||
// Profile.BackfillHorizonDays is reached or Garmin returns an empty page.
|
||||
// The horizon is read fresh from the profile on every call (not fixed at
|
||||
// server startup), so a user-edited value takes effect on the very next
|
||||
@@ -106,28 +121,11 @@ func (s *Service) setProgress(done, total int) {
|
||||
// completed backfill, or is a fast no-op if the configured horizon is
|
||||
// already fully covered -- it does not re-walk years of already-known
|
||||
// history. Widening the horizon between calls resumes further back instead
|
||||
// of re-fetching everything.
|
||||
func (s *Service) Backfill(ctx context.Context) error {
|
||||
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindBackfill)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
total, err := s.backfillCore(ctx)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
|
||||
return err
|
||||
}
|
||||
return s.db.FinishSyncRun(ctx, s.userID, runID, total, nil)
|
||||
}
|
||||
|
||||
// backfillCore holds Backfill's actual fetch logic, without the SyncRun
|
||||
// bookkeeping, so FullSync can run it as one step of a single combined run
|
||||
// instead of its own separately-recorded one. The returned count reflects
|
||||
// whatever was fetched even when an error is also returned, matching
|
||||
// Backfill's own partial-progress-on-error behavior.
|
||||
func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
||||
// of re-fetching everything. Used by FullSync as one step of its single
|
||||
// combined SyncRun; there is no standalone entrypoint for this anymore
|
||||
// (the periodic background sync loop that used to call one is gone -- see
|
||||
// 4d2cbe4 refactor: remove automatic background incremental sync).
|
||||
func (s *Sync) backfillCore(ctx context.Context) (int, error) {
|
||||
profile, err := s.db.GetProfile(ctx, s.userID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("load profile: %w", err)
|
||||
@@ -191,26 +189,11 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// IncrementalSync fetches activities from just before the latest known
|
||||
// activity (or a short recent window if none exist yet) through today.
|
||||
func (s *Service) IncrementalSync(ctx context.Context) error {
|
||||
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindIncremental)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n, err := s.incrementalSyncCore(ctx)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
s.db.FinishSyncRun(ctx, s.userID, runID, n, &msg)
|
||||
return err
|
||||
}
|
||||
return s.db.FinishSyncRun(ctx, s.userID, runID, n, nil)
|
||||
}
|
||||
|
||||
// incrementalSyncCore holds IncrementalSync's actual fetch logic, without
|
||||
// the SyncRun bookkeeping -- see backfillCore.
|
||||
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
|
||||
// incrementalSyncCore fetches activities from just before the latest known
|
||||
// activity (or a short recent window if none exist yet) through today. Used
|
||||
// by FullSync as one step of its single combined SyncRun -- see
|
||||
// backfillCore's comment for why there's no standalone entrypoint.
|
||||
func (s *Sync) incrementalSyncCore(ctx context.Context) (int, error) {
|
||||
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
||||
if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok {
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
|
||||
@@ -221,20 +204,21 @@ func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
|
||||
return newCount, err
|
||||
}
|
||||
|
||||
// FullSync performs a complete manual "Sync now" pass -- Backfill (resumes
|
||||
// from the watermark), then IncrementalSync (catches anything new since the
|
||||
// latest known activity), then FillPendingDetails -- recorded as a single
|
||||
// SyncRun. Backfill and IncrementalSync each record their own SyncRun when
|
||||
// called on their own (used by the periodic background loop), but a manual
|
||||
// sync runs both back to back, and FillPendingDetails records no run at all;
|
||||
// showing the user only the most recently *recorded* run (IncrementalSync's)
|
||||
// would silently hide however many activities Backfill fetched. Recording
|
||||
// one combined run makes the reported count match the whole action.
|
||||
func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
||||
// FullSync performs a complete manual "Sync now" pass -- backfillCore
|
||||
// (resumes from the watermark), then incrementalSyncCore (catches anything
|
||||
// new since the latest known activity), then FillPendingDetails --
|
||||
// recorded as a single SyncRun so the reported activity count covers the
|
||||
// whole action instead of only whichever stage happened to finish last.
|
||||
func (s *Sync) FullSync(ctx context.Context, detailFillLimit int) error {
|
||||
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Reset to idle on every return path, including an early error return
|
||||
// from backfillCore/incrementalSyncCore before FillPendingDetails (which
|
||||
// otherwise owns its own idle-reset) ever runs.
|
||||
s.setProgress(PhaseDiscovering, 0, 0)
|
||||
defer s.setProgress(PhaseIdle, 0, 0)
|
||||
|
||||
backfillCount, err := s.backfillCore(ctx)
|
||||
if err != nil {
|
||||
@@ -264,7 +248,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
||||
// assignments) and rewinds the backfill watermark, so the next Backfill
|
||||
// call performs a genuinely fresh pull from Garmin instead of resuming from
|
||||
// wherever the previous one left off. Workout kinds are left untouched.
|
||||
func (s *Service) ResetAll(ctx context.Context) error {
|
||||
func (s *Sync) ResetAll(ctx context.Context) error {
|
||||
return s.db.ResetAllSyncedData(ctx, s.userID)
|
||||
}
|
||||
|
||||
@@ -280,7 +264,7 @@ func (s *Service) ResetAll(ctx context.Context) error {
|
||||
// produced a confusing, meaningless number (e.g. "2 activities" on a sync
|
||||
// that found nothing new, just because 2 already-known activities happened
|
||||
// to fall inside the queried window).
|
||||
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
|
||||
func (s *Sync) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
|
||||
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
|
||||
@@ -306,11 +290,22 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st
|
||||
return len(activities), newCount, nil
|
||||
}
|
||||
|
||||
// FillPendingDetails fetches get_activity_splits/get_activity_details for up
|
||||
// to limit activities that don't have them yet, then (re)classifies each one.
|
||||
// Calls are made sequentially with Config.InterCallDelay between them to
|
||||
// FillPendingDetails fetches activity details/splits for up to limit
|
||||
// activities missing them, then fetches workouts for up to limit activities
|
||||
// missing those (independently -- see ActivitiesMissingWorkout), then
|
||||
// (re)classifies every activity touched by the first pass. Each pass makes
|
||||
// its Garmin calls sequentially with Config.InterCallDelay between them to
|
||||
// avoid Garmin/Cloudflare rate limiting.
|
||||
func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
||||
func (s *Sync) FillPendingDetails(ctx context.Context, limit int) error {
|
||||
defer s.setProgress(PhaseIdle, 0, 0)
|
||||
|
||||
if err := s.fillPendingActivityDetails(ctx, limit); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.fillPendingWorkouts(ctx, limit)
|
||||
}
|
||||
|
||||
func (s *Sync) fillPendingActivityDetails(ctx context.Context, limit int) error {
|
||||
pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -320,8 +315,7 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
||||
return fmt.Errorf("load profile: %w", err)
|
||||
}
|
||||
|
||||
s.setProgress(0, len(pending))
|
||||
defer s.setProgress(0, 0)
|
||||
s.setProgress(PhaseActivities, 0, len(pending))
|
||||
|
||||
for i, a := range pending {
|
||||
if i > 0 {
|
||||
@@ -337,12 +331,57 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
||||
if err := s.ClassifyActivity(ctx, a.ID); err != nil {
|
||||
return fmt.Errorf("classify activity %d: %w", a.GarminActivityID, err)
|
||||
}
|
||||
s.setProgress(i+1, len(pending))
|
||||
s.setProgress(PhaseActivities, i+1, len(pending))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
|
||||
// fillPendingWorkouts fetches get_workout_by_id for up to limit activities
|
||||
// missing it. Unlike fillPendingActivityDetails, one activity's workout
|
||||
// fetch failing is non-fatal (logged, loop continues) -- workout target
|
||||
// bands are enrichment, not core activity data, and workout_raw_json
|
||||
// staying NULL means ActivitiesMissingWorkout will naturally retry it on
|
||||
// the next sync.
|
||||
func (s *Sync) fillPendingWorkouts(ctx context.Context, limit int) error {
|
||||
pending, err := s.db.ActivitiesMissingWorkout(ctx, s.userID, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profile, err := s.db.GetProfile(ctx, s.userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load profile: %w", err)
|
||||
}
|
||||
|
||||
s.setProgress(PhaseWorkouts, 0, len(pending))
|
||||
|
||||
for i, a := range pending {
|
||||
if i > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(s.cfg.InterCallDelay):
|
||||
}
|
||||
}
|
||||
if err := s.fillActivityWorkout(ctx, a, profile); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
// A definitive 404 (the workout was deleted on Garmin's side
|
||||
// after being linked to this activity) will never succeed on
|
||||
// retry -- mark it so ActivitiesMissingWorkout stops
|
||||
// surfacing it, instead of retrying forever.
|
||||
applog.App().Warn("workout not found on Garmin, marking as such (will not retry)", "garmin_activity_id", a.GarminActivityID, "error", err)
|
||||
if serr := s.db.SetActivityWorkoutNotFound(ctx, s.userID, a.ID); serr != nil {
|
||||
return fmt.Errorf("mark activity %d workout not found: %w", a.GarminActivityID, serr)
|
||||
}
|
||||
} else {
|
||||
applog.App().Warn("fill workout failed, will retry next sync", "garmin_activity_id", a.GarminActivityID, "error", err)
|
||||
}
|
||||
}
|
||||
s.setProgress(PhaseWorkouts, i+1, len(pending))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Sync) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
|
||||
splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get_activity_splits: %w", err)
|
||||
@@ -352,23 +391,14 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
|
||||
return fmt.Errorf("get_activity_details: %w", err)
|
||||
}
|
||||
|
||||
targets := make([]*garmin.WorkoutStep, len(splits.Laps))
|
||||
if a.WorkoutID != nil {
|
||||
workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID)
|
||||
if err != nil {
|
||||
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
|
||||
} else {
|
||||
targets = alignWorkoutTargets(splits.Laps, workout)
|
||||
if err := s.db.SetActivityWorkout(ctx, s.userID, a.ID, string(workout.Raw)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
samples := garmin.ExtractSamples(details)
|
||||
samples := ExtractSamples(details)
|
||||
if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil {
|
||||
return err
|
||||
}
|
||||
// No workout-target alignment here -- that's fillActivityWorkout's job,
|
||||
// run as its own later pass (see fillPendingWorkouts). targets is an
|
||||
// all-nil placeholder the same length as splits.Laps.
|
||||
targets := make([]*WorkoutStep, len(splits.Laps))
|
||||
if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -378,10 +408,39 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
|
||||
return s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)
|
||||
}
|
||||
|
||||
// fillActivityWorkout fetches a's structured workout and re-derives its
|
||||
// laps' target pace/HR bands from it. Requires a.WorkoutID to be set --
|
||||
// only ever called for activities ActivitiesMissingWorkout returned, which
|
||||
// already filters on that. Reads laps back from the DB (already written by
|
||||
// fillActivityDetails, in some earlier pass or run) rather than needing the
|
||||
// original garmin.Lap data again, since alignWorkoutTargets only needs a
|
||||
// count.
|
||||
func (s *Sync) fillActivityWorkout(ctx context.Context, a store.Activity, profile store.Profile) error {
|
||||
workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get_workout_by_id: %w", err)
|
||||
}
|
||||
laps, err := s.db.LapsForActivity(ctx, s.userID, a.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targets := alignWorkoutTargets(len(laps), workout)
|
||||
for i := range laps {
|
||||
if i < len(targets) && targets[i] != nil {
|
||||
laps[i].TargetPaceLowMps, laps[i].TargetPaceHighMps = targetPaceRange(*targets[i])
|
||||
laps[i].TargetHRLowBpm, laps[i].TargetHRHighBpm = targetHRRange(*targets[i], profile)
|
||||
}
|
||||
}
|
||||
if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, laps); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.SetActivityWorkout(ctx, s.userID, a.ID, string(workout.Raw))
|
||||
}
|
||||
|
||||
// ClassifyActivity (re)runs the rule engine for one activity against the
|
||||
// currently active workout kinds and appends a new kind_assignments row.
|
||||
// Safe to call repeatedly (e.g. after editing a workout kind's rule).
|
||||
func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error {
|
||||
func (s *Sync) ClassifyActivity(ctx context.Context, activityID int64) error {
|
||||
activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID)
|
||||
if err != nil {
|
||||
return err
|
||||
1005
backend/internal/garmin/sync_test.go
Normal file
1005
backend/internal/garmin/sync_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,8 @@ package garmin
|
||||
import "encoding/json"
|
||||
|
||||
// AuthStatus is the outcome of an authenticate()/complete_mfa() call.
|
||||
// mcp-garmin's tools return a plain human-readable string rather than a
|
||||
// structured status, so the client pattern-matches known phrases into this.
|
||||
// wrapper.py returns a structured {"status", "message"} JSON object, which
|
||||
// the client maps directly into this rather than pattern-matching strings.
|
||||
type AuthStatus int
|
||||
|
||||
const (
|
||||
|
||||
4
backend/internal/garmin/wrapper/.gitignore
vendored
Normal file
4
backend/internal/garmin/wrapper/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
18
backend/internal/garmin/wrapper/pyproject.toml
Normal file
18
backend/internal/garmin/wrapper/pyproject.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[project]
|
||||
name = "geniusrun-garmin-wrapper"
|
||||
version = "0.1.0"
|
||||
description = "Subprocess wrapper around garminconnect for geniusrund's internal/garmin package"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"garminconnect",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["."]
|
||||
0
backend/internal/garmin/wrapper/tests/__init__.py
Normal file
0
backend/internal/garmin/wrapper/tests/__init__.py
Normal file
366
backend/internal/garmin/wrapper/tests/test_wrapper.py
Normal file
366
backend/internal/garmin/wrapper/tests/test_wrapper.py
Normal file
@@ -0,0 +1,366 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import queue
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import wrapper
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_state():
|
||||
original_state = wrapper._auth_state
|
||||
original_client = wrapper._client
|
||||
original_startup_attempted = wrapper._startup_login_attempted
|
||||
for q in (wrapper._mfa_input_queue, wrapper._login_result_queue):
|
||||
while not q.empty():
|
||||
try:
|
||||
q.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
wrapper._startup_login_attempted = False
|
||||
yield
|
||||
wrapper._auth_state = original_state
|
||||
wrapper._client = original_client
|
||||
wrapper._startup_login_attempted = original_startup_attempted
|
||||
|
||||
|
||||
def test_authenticate_success():
|
||||
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin") as mock_garmin_cls:
|
||||
mock_garmin_cls.return_value = MagicMock()
|
||||
resp = wrapper.dispatch({"id": 1, "cmd": "authenticate"})
|
||||
assert resp == {"id": 1, "result": {"status": "success", "message": "Authenticated successfully."}}
|
||||
assert wrapper._auth_state == "authenticated"
|
||||
|
||||
|
||||
def test_authenticate_mfa_required():
|
||||
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin") as mock_garmin_cls:
|
||||
mock_garmin_cls.return_value = MagicMock()
|
||||
with patch.object(wrapper._login_result_queue, "get", side_effect=queue.Empty):
|
||||
resp = wrapper.dispatch({"id": 2, "cmd": "authenticate"})
|
||||
assert resp["result"]["status"] == "mfa_required"
|
||||
assert wrapper._auth_state == "mfa_pending"
|
||||
|
||||
|
||||
def test_authenticate_missing_credentials():
|
||||
with patch.dict("os.environ", {}, clear=False):
|
||||
os.environ.pop("GARMIN_EMAIL", None)
|
||||
os.environ.pop("GARMIN_PASSWORD", None)
|
||||
resp = wrapper.dispatch({"id": 3, "cmd": "authenticate"})
|
||||
assert resp["result"]["status"] == "failed"
|
||||
assert "GARMIN_EMAIL" in resp["result"]["message"]
|
||||
|
||||
|
||||
def test_complete_mfa_success():
|
||||
wrapper._auth_state = "mfa_pending"
|
||||
wrapper._login_result_queue.put(("success", None))
|
||||
resp = wrapper.dispatch({"id": 4, "cmd": "complete_mfa", "params": {"code": "123456"}})
|
||||
assert resp == {
|
||||
"id": 4,
|
||||
"result": {"status": "success", "message": "MFA accepted. Authenticated successfully."},
|
||||
}
|
||||
assert wrapper._auth_state == "authenticated"
|
||||
assert wrapper._mfa_input_queue.get_nowait() == "123456"
|
||||
|
||||
|
||||
def test_complete_mfa_not_in_progress():
|
||||
wrapper._auth_state = "unauthenticated"
|
||||
resp = wrapper.dispatch({"id": 5, "cmd": "complete_mfa", "params": {"code": "123456"}})
|
||||
assert resp["result"]["status"] == "failed"
|
||||
assert "No MFA in progress" in resp["result"]["message"]
|
||||
|
||||
|
||||
def test_call_dispatches_to_named_garminconnect_method():
|
||||
wrapper._auth_state = "authenticated"
|
||||
wrapper._client = MagicMock()
|
||||
wrapper._client.get_activities_by_date.return_value = [{"activityId": "111"}]
|
||||
|
||||
resp = wrapper.dispatch({
|
||||
"id": 6,
|
||||
"cmd": "call",
|
||||
"params": {
|
||||
"method": "get_activities_by_date",
|
||||
"args": {"startdate": "2026-07-01", "enddate": "2026-07-25"},
|
||||
},
|
||||
})
|
||||
|
||||
assert resp == {"id": 6, "result": [{"activityId": "111"}]}
|
||||
wrapper._client.get_activities_by_date.assert_called_once_with(
|
||||
startdate="2026-07-01", enddate="2026-07-25"
|
||||
)
|
||||
|
||||
|
||||
def test_call_unauthenticated_is_error():
|
||||
# Explicitly absent GARMIN_EMAIL/PASSWORD: this now indirectly triggers
|
||||
# the lazy _startup_login fallback (see _handle_call), which must
|
||||
# return immediately with nothing to resume, leaving this the same
|
||||
# "Not authenticated" error as before -- not dependent on whatever
|
||||
# happens to be in the ambient shell environment.
|
||||
wrapper._auth_state = "unauthenticated"
|
||||
with patch.dict("os.environ", {}, clear=False):
|
||||
os.environ.pop("GARMIN_EMAIL", None)
|
||||
os.environ.pop("GARMIN_PASSWORD", None)
|
||||
resp = wrapper.dispatch({
|
||||
"id": 7, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {}},
|
||||
})
|
||||
assert "error" in resp
|
||||
assert "Not authenticated" in resp["error"]
|
||||
|
||||
|
||||
def test_call_unknown_method_is_error():
|
||||
wrapper._auth_state = "authenticated"
|
||||
wrapper._client = MagicMock(spec=["get_activities_by_date"])
|
||||
resp = wrapper.dispatch({
|
||||
"id": 8, "cmd": "call", "params": {"method": "delete_everything", "args": {}},
|
||||
})
|
||||
assert resp["id"] == 8
|
||||
assert "error" in resp
|
||||
|
||||
|
||||
def test_call_propagates_garminconnect_exception_as_error():
|
||||
wrapper._auth_state = "authenticated"
|
||||
wrapper._client = MagicMock()
|
||||
wrapper._client.get_activity_splits.side_effect = Exception("not found")
|
||||
resp = wrapper.dispatch({
|
||||
"id": 9, "cmd": "call", "params": {"method": "get_activity_splits", "args": {"activity_id": "999"}},
|
||||
})
|
||||
assert resp["id"] == 9
|
||||
assert resp["error"] == "not found"
|
||||
# Failure detail travels in the protocol so the Go side can log one
|
||||
# complete structured record (no stderr correlation needed).
|
||||
assert resp["error_type"] == "Exception"
|
||||
assert "Exception: not found" in resp["traceback"]
|
||||
assert "not_found" not in resp
|
||||
|
||||
|
||||
def test_dispatch_unknown_cmd_is_error():
|
||||
resp = wrapper.dispatch({"id": 10, "cmd": "not_a_real_cmd"})
|
||||
assert resp["id"] == 10
|
||||
assert "unknown cmd" in resp["error"]
|
||||
|
||||
|
||||
def test_startup_login_success():
|
||||
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin") as mock_garmin_cls:
|
||||
mock_garmin_cls.return_value = MagicMock()
|
||||
wrapper._startup_login()
|
||||
assert wrapper._auth_state == "authenticated"
|
||||
|
||||
|
||||
def test_startup_login_missing_credentials_returns_immediately():
|
||||
with patch.dict("os.environ", {}, clear=False):
|
||||
os.environ.pop("GARMIN_EMAIL", None)
|
||||
os.environ.pop("GARMIN_PASSWORD", None)
|
||||
wrapper._auth_state = "unauthenticated"
|
||||
wrapper._startup_login()
|
||||
assert wrapper._auth_state == "unauthenticated"
|
||||
assert wrapper._client is None
|
||||
|
||||
|
||||
def test_startup_login_times_out_without_blocking():
|
||||
# _startup_login now waits on its own private, function-local queue
|
||||
# (never the shared wrapper._login_result_queue -- see the
|
||||
# no-shared-queue-leakage test below), so forcing its timeout path
|
||||
# means intercepting the queue.Queue() constructor it calls internally
|
||||
# rather than patching a module-level queue object.
|
||||
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin") as mock_garmin_cls:
|
||||
mock_garmin_cls.return_value = MagicMock()
|
||||
with patch("wrapper.queue.Queue") as mock_queue_cls:
|
||||
mock_queue_cls.return_value.get.side_effect = queue.Empty
|
||||
# Should return promptly (bounded by the 10s timeout passed to
|
||||
# queue.get, which is mocked here to raise immediately) rather
|
||||
# than blocking main()'s stdin dispatch loop from starting.
|
||||
wrapper._startup_login()
|
||||
assert wrapper._auth_state == "unauthenticated"
|
||||
|
||||
|
||||
def test_startup_login_does_not_leak_into_shared_authenticate_queue():
|
||||
"""Regression test: _startup_login used to push its background thread's
|
||||
result onto the same module-level _login_result_queue that
|
||||
_handle_authenticate/_handle_complete_mfa share for the MFA handoff. A
|
||||
startup login that was still in flight when a user's first explicit
|
||||
'authenticate' call came in could have that call accidentally dequeue
|
||||
the startup thread's stale result instead of its own fresh one.
|
||||
_startup_login now uses its own private queue, so a still-in-flight
|
||||
startup attempt can never interfere with a later authenticate() call."""
|
||||
release = threading.Event()
|
||||
|
||||
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin") as mock_garmin_cls:
|
||||
# A startup login whose underlying garminconnect call is still
|
||||
# blocked (simulating "slow to resolve") when _startup_login's
|
||||
# own bounded wait (mocked to time out immediately, so this test
|
||||
# doesn't need to sleep the real 10s) returns.
|
||||
slow_client = MagicMock()
|
||||
slow_client.login.side_effect = lambda **kwargs: release.wait(5)
|
||||
mock_garmin_cls.return_value = slow_client
|
||||
with patch("wrapper.queue.Queue") as mock_queue_cls:
|
||||
mock_queue_cls.return_value.get.side_effect = queue.Empty
|
||||
wrapper._startup_login()
|
||||
|
||||
assert wrapper._auth_state == "unauthenticated"
|
||||
# Nothing from the still-in-flight startup attempt ever touches the
|
||||
# shared queue that authenticate()/complete_mfa() rely on.
|
||||
assert wrapper._login_result_queue.empty()
|
||||
|
||||
release.set() # let the stale startup thread finish harmlessly in the background
|
||||
|
||||
# A fresh, unrelated authenticate() call must get its own result, not
|
||||
# anything left over from the startup attempt above.
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin") as mock_garmin_cls2:
|
||||
mock_garmin_cls2.return_value = MagicMock()
|
||||
resp = wrapper.dispatch({"id": 20, "cmd": "authenticate"})
|
||||
|
||||
assert resp == {"id": 20, "result": {"status": "success", "message": "Authenticated successfully."}}
|
||||
assert wrapper._auth_state == "authenticated"
|
||||
|
||||
|
||||
def test_call_triggers_startup_login_lazily_on_first_call():
|
||||
"""A data 'call' with no prior explicit authenticate on this subprocess
|
||||
-- e.g. "Sync now" reaching an already-connected user's client right
|
||||
after a backend restart -- gets exactly one lazy attempt to silently
|
||||
resume a cached tokenstore session before giving up."""
|
||||
wrapper._auth_state = "unauthenticated"
|
||||
wrapper._client = MagicMock()
|
||||
wrapper._client.get_activities_by_date.return_value = []
|
||||
|
||||
def fake_startup_login():
|
||||
wrapper._auth_state = "authenticated"
|
||||
|
||||
with patch("wrapper._startup_login", side_effect=fake_startup_login) as mock_startup:
|
||||
resp = wrapper.dispatch({
|
||||
"id": 30, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {}},
|
||||
})
|
||||
|
||||
mock_startup.assert_called_once()
|
||||
assert resp == {"id": 30, "result": []}
|
||||
|
||||
|
||||
def test_call_does_not_retry_startup_login_after_first_attempt():
|
||||
"""If the lazy startup-login attempt doesn't actually authenticate, a
|
||||
second 'call' must not try it again -- one attempt per subprocess
|
||||
lifetime, not once per call."""
|
||||
wrapper._auth_state = "unauthenticated"
|
||||
|
||||
with patch("wrapper._startup_login") as mock_startup: # no-op: leaves state unauthenticated
|
||||
wrapper.dispatch({"id": 31, "cmd": "call", "params": {"method": "x", "args": {}}})
|
||||
wrapper.dispatch({"id": 32, "cmd": "call", "params": {"method": "x", "args": {}}})
|
||||
|
||||
mock_startup.assert_called_once()
|
||||
|
||||
|
||||
def test_authenticate_prevents_later_lazy_startup_login():
|
||||
"""Once authenticate has been explicitly called (regardless of
|
||||
outcome), a later 'call' must never fall back to _startup_login -- that
|
||||
would either duplicate a login authenticate already did, or waste an
|
||||
extra hit against Garmin after a failure."""
|
||||
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin") as mock_garmin_cls:
|
||||
mock_garmin_cls.return_value = MagicMock()
|
||||
wrapper.dispatch({"id": 33, "cmd": "authenticate"})
|
||||
|
||||
wrapper._auth_state = "unauthenticated" # simulate a later, separate call finding no session
|
||||
with patch("wrapper._startup_login") as mock_startup:
|
||||
wrapper.dispatch({"id": 34, "cmd": "call", "params": {"method": "x", "args": {}}})
|
||||
|
||||
mock_startup.assert_not_called()
|
||||
|
||||
|
||||
def test_call_marks_not_found_error_specifically():
|
||||
from garminconnect import GarminConnectNotFoundError
|
||||
|
||||
wrapper._auth_state = "authenticated"
|
||||
wrapper._client = MagicMock()
|
||||
wrapper._client.get_workout_by_id.side_effect = GarminConnectNotFoundError("API Error 404")
|
||||
resp = wrapper.dispatch({
|
||||
"id": 11, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
|
||||
})
|
||||
assert resp["id"] == 11
|
||||
assert resp["error"] == "API Error 404"
|
||||
assert resp["not_found"] is True
|
||||
assert resp["error_type"] == "GarminConnectNotFoundError"
|
||||
|
||||
|
||||
def test_call_does_not_mark_other_errors_as_not_found():
|
||||
wrapper._auth_state = "authenticated"
|
||||
wrapper._client = MagicMock()
|
||||
wrapper._client.get_workout_by_id.side_effect = Exception("rate limited")
|
||||
resp = wrapper.dispatch({
|
||||
"id": 12, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
|
||||
})
|
||||
assert resp["id"] == 12
|
||||
assert resp["error"] == "rate limited"
|
||||
assert "not_found" not in resp
|
||||
|
||||
|
||||
def test_startup_login_late_success_flips_auth_state_for_later_calls():
|
||||
"""A tokenstore resume that outlives the 10s wait must still recover
|
||||
the subprocess: the background thread itself flips _auth_state on
|
||||
success, so the next `call` command passes the auth check without an
|
||||
explicit authenticate."""
|
||||
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||
release = threading.Event()
|
||||
mock_client = MagicMock()
|
||||
mock_client.login.side_effect = lambda **kwargs: release.wait(timeout=5)
|
||||
wrapper._auth_state = "unauthenticated"
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin", return_value=mock_client):
|
||||
with patch("wrapper.queue.Queue") as mock_queue_cls:
|
||||
# Simulate the 10s timeout: the reader gives up immediately,
|
||||
# while the (still-blocked) login thread keeps running.
|
||||
mock_queue_cls.return_value.get.side_effect = queue.Empty
|
||||
wrapper._startup_login()
|
||||
assert wrapper._auth_state == "unauthenticated"
|
||||
|
||||
release.set() # the slow login now completes, after the timeout
|
||||
for _ in range(100):
|
||||
if wrapper._auth_state == "authenticated":
|
||||
break
|
||||
time.sleep(0.01)
|
||||
assert wrapper._auth_state == "authenticated"
|
||||
|
||||
|
||||
def test_startup_login_late_success_never_overrides_explicit_auth_flow():
|
||||
"""If an explicit authenticate/MFA flow moved the state while the
|
||||
startup resume was still in flight, the late success must not clobber
|
||||
it -- the explicit flow owns the state once it has moved it."""
|
||||
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||
release = threading.Event()
|
||||
mock_client = MagicMock()
|
||||
mock_client.login.side_effect = lambda **kwargs: release.wait(timeout=5)
|
||||
wrapper._auth_state = "unauthenticated"
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin", return_value=mock_client):
|
||||
with patch("wrapper.queue.Queue") as mock_queue_cls:
|
||||
mock_queue_cls.return_value.get.side_effect = queue.Empty
|
||||
wrapper._startup_login()
|
||||
|
||||
wrapper._auth_state = "mfa_pending" # an explicit flow took over meanwhile
|
||||
release.set()
|
||||
time.sleep(0.2) # give the thread ample time to (wrongly) flip it
|
||||
assert wrapper._auth_state == "mfa_pending"
|
||||
|
||||
|
||||
def test_log_stamps_emitting_method(capsys):
|
||||
"""Every wrapper log line carries the emitting function as "method" --
|
||||
the Go side forwards it into the unified log schema."""
|
||||
|
||||
def some_emitter():
|
||||
wrapper._log("info", "hello", extra=1)
|
||||
|
||||
some_emitter()
|
||||
entry = json.loads(capsys.readouterr().err.strip())
|
||||
assert entry == {"level": "info", "msg": "hello", "method": "some_emitter", "extra": 1}
|
||||
312
backend/internal/garmin/wrapper/wrapper.py
Normal file
312
backend/internal/garmin/wrapper/wrapper.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited
|
||||
JSON on stdin/stdout by geniusrund's internal/garmin package. See
|
||||
docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
from garminconnect import Garmin, GarminConnectNotFoundError
|
||||
|
||||
TOKENSTORE = os.path.expanduser(os.environ.get("GARMIN_TOKENSTORE", "~/.garmin"))
|
||||
|
||||
_client = None
|
||||
_auth_state = "unauthenticated"
|
||||
_mfa_input_queue = queue.Queue()
|
||||
_login_result_queue = queue.Queue()
|
||||
# Set the first time this subprocess attempts any login, whichever path
|
||||
# gets there first (see _handle_call's lazy call and _handle_authenticate) --
|
||||
# guarantees _startup_login runs at most once per subprocess lifetime, and
|
||||
# never at all once an explicit authenticate has been attempted.
|
||||
_startup_login_attempted = False
|
||||
|
||||
|
||||
def _log(level, msg, **attrs):
|
||||
"""Emit one JSON log line on stderr. internal/garmin/client.go reads
|
||||
stderr line by line and re-emits these through the Go application
|
||||
logger, so the backend's combined output stays a single JSON stream --
|
||||
never print free text to stderr or stdout from this process (stdout is
|
||||
reserved for the request/response protocol). The emitting function is
|
||||
stamped as "method" automatically; the Go side adds the rest of the
|
||||
log schema (type=wrapper, file=wrapper.py)."""
|
||||
entry = {"level": level, "msg": msg, "method": sys._getframe(1).f_code.co_name}
|
||||
entry.update(attrs)
|
||||
print(json.dumps(entry), file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _prompt_mfa():
|
||||
_log("debug", "invoked")
|
||||
code = _mfa_input_queue.get(timeout=300)
|
||||
_log("debug", "returning MFA code", code_length=len(code))
|
||||
return code
|
||||
|
||||
|
||||
def _startup_login():
|
||||
"""Silently resume a cached tokenstore session, so a freshly
|
||||
(re)spawned subprocess can already be authenticated for a data 'call'
|
||||
that never goes through the explicit authenticate command -- e.g.
|
||||
"Sync now" reaching an already-connected user's client right after a
|
||||
backend restart cleared the in-memory cache.
|
||||
|
||||
Called lazily (see _handle_call), at most once per subprocess lifetime,
|
||||
and only if nothing has explicitly called authenticate first. Running
|
||||
it unconditionally at process start used to be actively counterproductive
|
||||
whenever the very first command actually was authenticate: on success it
|
||||
just duplicated a login _handle_authenticate was about to redo anyway
|
||||
(it always rebuilds _client from scratch), and on failure it was a
|
||||
wasted, unauthenticated hit against Garmin's servers moments before the
|
||||
real attempt -- extra load that only makes rate-limiting worse.
|
||||
|
||||
Bounded to the same 10s timeout as _handle_authenticate: the actual
|
||||
login runs on a background daemon thread, and this function waits up
|
||||
to 10s for it before returning either way, so a slow/rate-limited
|
||||
Garmin login can never block the caller indefinitely. On timeout the
|
||||
triggering call fails ("Not authenticated"), but the thread keeps
|
||||
running and, if the login eventually succeeds, flips _auth_state to
|
||||
"authenticated" itself -- only ever from "unauthenticated", never
|
||||
overriding an explicit authenticate/MFA flow that ran in the
|
||||
meantime -- so subsequent calls recover without user action. A late
|
||||
failure changes nothing (the state is already "unauthenticated").
|
||||
|
||||
Deliberately uses its own private, function-local result queue rather
|
||||
than the module-level _login_result_queue that _handle_authenticate and
|
||||
_handle_complete_mfa share -- those two are legitimately two halves of
|
||||
one explicit, MFA-capable login flow and must share a queue for the MFA
|
||||
handoff to work, but this is a plain background tokenstore resume with
|
||||
no MFA involved. Sharing the queue here would let this thread's stale
|
||||
result (arriving after the 10s timeout below) be dequeued by an
|
||||
unrelated, later authenticate/complete_mfa call instead of that call's
|
||||
own fresh result."""
|
||||
global _client, _auth_state
|
||||
|
||||
email = os.environ.get("GARMIN_EMAIL")
|
||||
password = os.environ.get("GARMIN_PASSWORD")
|
||||
if not email or not password:
|
||||
return
|
||||
|
||||
_client = Garmin(email, password)
|
||||
result_queue = queue.Queue() # private to this call, never shared with authenticate/complete_mfa
|
||||
|
||||
def _do_startup_login():
|
||||
global _auth_state
|
||||
_log("debug", "background thread starting _client.login()", tokenstore=TOKENSTORE)
|
||||
try:
|
||||
_client.login(tokenstore=TOKENSTORE)
|
||||
_log("debug", "_client.login() returned successfully")
|
||||
result_queue.put(("success", None))
|
||||
# A success arriving after the 10s timeout below would land in
|
||||
# an abandoned queue -- flip the state here too, so later calls
|
||||
# benefit from the resume. Only from "unauthenticated": a
|
||||
# concurrent explicit authenticate/MFA flow owns the state once
|
||||
# it has moved it anywhere else.
|
||||
if _auth_state == "unauthenticated":
|
||||
_auth_state = "authenticated"
|
||||
except Exception as exc:
|
||||
_log(
|
||||
"error",
|
||||
"_client.login() failed",
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
result_queue.put(("error", str(exc)))
|
||||
|
||||
threading.Thread(target=_do_startup_login, daemon=True).start()
|
||||
|
||||
try:
|
||||
status, err = result_queue.get(timeout=10)
|
||||
_log("debug", "got result within 10s timeout", status=status)
|
||||
if status == "success":
|
||||
_auth_state = "authenticated"
|
||||
else:
|
||||
_log("error", "login failed", error=err)
|
||||
_auth_state = "unauthenticated"
|
||||
except queue.Empty:
|
||||
# No assignment here: the state is already "unauthenticated", and
|
||||
# writing it again could clobber a success the background thread
|
||||
# records right around the timeout boundary (see _do_startup_login).
|
||||
_log(
|
||||
"warn",
|
||||
"hit the 10s timeout but still running in the "
|
||||
"background and will update auth state if it eventually succeeds",
|
||||
)
|
||||
|
||||
|
||||
def _handle_authenticate(_params):
|
||||
global _client, _auth_state, _startup_login_attempted
|
||||
# An explicit authenticate is happening (successful or not) -- the lazy
|
||||
# startup-login fallback in _handle_call must never fire after this, it
|
||||
# would be redundant at best and a wasted extra hit against Garmin at
|
||||
# worst.
|
||||
_startup_login_attempted = True
|
||||
|
||||
email = os.environ.get("GARMIN_EMAIL", "")
|
||||
password = os.environ.get("GARMIN_PASSWORD", "")
|
||||
if not email or not password:
|
||||
return {
|
||||
"status": "failed",
|
||||
"message": "GARMIN_EMAIL and GARMIN_PASSWORD environment variables are required.",
|
||||
}
|
||||
|
||||
def _do_authenticate_login():
|
||||
_log("debug", "background thread starting _client.login()", tokenstore=TOKENSTORE)
|
||||
try:
|
||||
_client.login(tokenstore=TOKENSTORE)
|
||||
_log("debug", "_client.login() returned successfully")
|
||||
_login_result_queue.put(("success", None))
|
||||
except Exception as exc:
|
||||
_log(
|
||||
"error",
|
||||
"_client.login() failed",
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
_login_result_queue.put(("error", str(exc)))
|
||||
|
||||
_client = Garmin(email, password)
|
||||
_client.prompt_mfa = _prompt_mfa
|
||||
threading.Thread(target=_do_authenticate_login, daemon=True).start()
|
||||
|
||||
try:
|
||||
status, err = _login_result_queue.get(timeout=10)
|
||||
_log("debug", "got result within 10s timeout", status=status)
|
||||
if status == "success":
|
||||
_auth_state = "authenticated"
|
||||
return {"status": "success", "message": "Authenticated successfully."}
|
||||
else:
|
||||
_log("error", "login failed", error=err)
|
||||
return {"status": "failed", "message": f"Authentication failed: {err}"}
|
||||
except queue.Empty:
|
||||
_log(
|
||||
"warn",
|
||||
"hit the 10s timeout with no result yet, reporting mfa_required",
|
||||
)
|
||||
_auth_state = "mfa_pending"
|
||||
return {
|
||||
"status": "mfa_required",
|
||||
"message": "MFA required. Garmin has sent a verification code to your registered email or phone.",
|
||||
}
|
||||
|
||||
|
||||
def _handle_complete_mfa(params):
|
||||
global _auth_state
|
||||
|
||||
if _auth_state != "mfa_pending":
|
||||
return {"status": "failed", "message": "No MFA in progress. Call authenticate first."}
|
||||
|
||||
code = params["code"]
|
||||
_log("debug", "received a code, pushing to mfa queue", code_length=len(code))
|
||||
_mfa_input_queue.put(code)
|
||||
|
||||
try:
|
||||
status, err = _login_result_queue.get(timeout=30)
|
||||
_log("debug", "got result", status=status, error=err)
|
||||
if status == "success":
|
||||
_auth_state = "authenticated"
|
||||
return {"status": "success", "message": "MFA accepted. Authenticated successfully."}
|
||||
return {"status": "failed", "message": f"Authentication failed after MFA: {err}"}
|
||||
except queue.Empty:
|
||||
_log(
|
||||
"error",
|
||||
"hit the 30s timeout with no result yet, reporting unauthenticated",
|
||||
)
|
||||
_auth_state = "unauthenticated"
|
||||
return {
|
||||
"status": "failed",
|
||||
"message": "Timed out waiting for authentication to complete.",
|
||||
}
|
||||
|
||||
|
||||
def _handle_call(params):
|
||||
global _startup_login_attempted
|
||||
if _auth_state != "authenticated" and not _startup_login_attempted:
|
||||
_startup_login_attempted = True
|
||||
_startup_login()
|
||||
if _auth_state != "authenticated":
|
||||
raise RuntimeError("Not authenticated. Call authenticate first.")
|
||||
method = params["method"]
|
||||
args = params.get("args") or {}
|
||||
fn = getattr(_client, method)
|
||||
return fn(**args)
|
||||
|
||||
|
||||
_HANDLERS = {
|
||||
"authenticate": _handle_authenticate,
|
||||
"complete_mfa": _handle_complete_mfa,
|
||||
"call": _handle_call,
|
||||
}
|
||||
|
||||
|
||||
def dispatch(req):
|
||||
handler = _HANDLERS.get(req.get("cmd"))
|
||||
if handler is None:
|
||||
return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"}
|
||||
try:
|
||||
result = handler(req.get("params") or {})
|
||||
return {"id": req["id"], "result": result}
|
||||
except Exception as exc:
|
||||
# The full failure detail travels IN the response -- error text,
|
||||
# exception class, and traceback -- so the Go side can log one
|
||||
# complete structured record instead of correlating stderr noise.
|
||||
resp = {
|
||||
"id": req.get("id"),
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"traceback": traceback.format_exc(),
|
||||
}
|
||||
# A 404 (e.g. get_workout_by_id for a workout deleted on Garmin's
|
||||
# side after being linked to an activity) is definitive, not a
|
||||
# transient failure worth retrying forever -- marked specifically so
|
||||
# internal/garmin/client.go can tell the two apart (see
|
||||
# docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
|
||||
if isinstance(exc, GarminConnectNotFoundError):
|
||||
resp["not_found"] = True
|
||||
return resp
|
||||
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
# A malformed request has no id to answer to -- log and keep
|
||||
# serving rather than crashing the subprocess.
|
||||
_log("error", "malformed request line", error=str(exc))
|
||||
continue
|
||||
resp = dispatch(req)
|
||||
try:
|
||||
print(json.dumps(resp), flush=True)
|
||||
except (TypeError, ValueError) as exc:
|
||||
# A non-JSON-serializable handler result must still produce a
|
||||
# protocol response, or the Go side would block on a reply.
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"id": req.get("id"),
|
||||
"error": f"unserializable result: {exc}",
|
||||
"error_type": type(exc).__name__,
|
||||
}
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as exc: # last resort: die loudly, but still in JSON
|
||||
_log(
|
||||
"error",
|
||||
"wrapper crashed",
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
raise SystemExit(1)
|
||||
87
backend/internal/log/log.go
Normal file
87
backend/internal/log/log.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Package applog provides geniusrun's structured JSON logging: a
|
||||
// log/slog-based logger writing to stdout, plus the App helper that tags
|
||||
// records with the schema fields every geniusrun log line carries --
|
||||
// "type" ("app" | "http" | "wrapper"), "package" (Go package; absent on
|
||||
// Python-emitted lines), "file" (Go/Python source file basename), "class"
|
||||
// (Go receiver type or Python class, omitted when there is none), and
|
||||
// "method" (the emitting Go/Python function). "msg" is optional:
|
||||
// NewLogger's handler drops it when empty.
|
||||
package applog
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NewLogger builds a JSON-handler *slog.Logger writing to w at the given
|
||||
// level ("debug"|"info"|"warn"|"error", case-insensitive; anything else
|
||||
// defaults to info). Empty msg values are omitted from the output --
|
||||
// records whose meaning is fully carried by type/class/method and attrs
|
||||
// (e.g. the HTTP request line) don't need one.
|
||||
func NewLogger(level string, w io.Writer) *slog.Logger {
|
||||
return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{
|
||||
Level: parseLevel(level),
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
if len(groups) == 0 && a.Key == slog.MessageKey && a.Value.String() == "" {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// App returns the default logger tagged with the schema fields for an
|
||||
// ordinary application record (type=app), deriving the emitting location
|
||||
// from the caller via the runtime: package, file, class (the receiver
|
||||
// type, omitted for free functions), method. Derivation instead of
|
||||
// hand-typed strings means the labels can never drift from the code. The
|
||||
// http and wrapper emitters (internal/api's logging middleware,
|
||||
// internal/garmin's execute/forwardWrapperStderr) tag their own type and
|
||||
// location instead.
|
||||
func App() *slog.Logger {
|
||||
pkg, file, class, method := location(1)
|
||||
logger := slog.Default().With("type", "app", "package", pkg, "file", file)
|
||||
if class != "" {
|
||||
logger = logger.With("class", class)
|
||||
}
|
||||
return logger.With("method", method)
|
||||
}
|
||||
|
||||
// location resolves the caller (skip frames above this function's caller)
|
||||
// into the log schema's package/file/class/method fields. A method's
|
||||
// runtime name looks like "geniusrun/backend/internal/api.(*Server).X";
|
||||
// a free function's like "geniusrun/backend/internal/api.X"; a closure
|
||||
// gets its parent's name plus ".funcN", kept verbatim in method.
|
||||
func location(skip int) (pkg, file, class, method string) {
|
||||
pc, path, _, ok := runtime.Caller(skip + 1)
|
||||
if !ok {
|
||||
return "unknown", "unknown", "", "unknown"
|
||||
}
|
||||
file = filepath.Base(path)
|
||||
full := runtime.FuncForPC(pc).Name()
|
||||
base := full[strings.LastIndex(full, "/")+1:]
|
||||
pkg, rest, _ := strings.Cut(base, ".")
|
||||
if strings.HasPrefix(rest, "(*") {
|
||||
if end := strings.Index(rest, ")."); end != -1 {
|
||||
class = rest[2:end]
|
||||
rest = rest[end+2:]
|
||||
}
|
||||
}
|
||||
return pkg, file, class, rest
|
||||
}
|
||||
|
||||
func parseLevel(level string) slog.Level {
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
78
backend/internal/log/log_test.go
Normal file
78
backend/internal/log/log_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package applog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewLogger_FiltersBelowConfiguredLevel(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := NewLogger("warn", &buf)
|
||||
|
||||
logger.Info("should be dropped")
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("expected no output for an Info message under a warn-level logger, got %q", buf.String())
|
||||
}
|
||||
|
||||
logger.Warn("should appear")
|
||||
if !strings.Contains(buf.String(), "should appear") {
|
||||
t.Fatalf("expected the Warn message in output, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLogger_WritesValidJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := NewLogger("info", &buf)
|
||||
logger.Info("hello", "key", "value")
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v (%q)", err, buf.String())
|
||||
}
|
||||
if decoded["msg"] != "hello" || decoded["key"] != "value" {
|
||||
t.Errorf("decoded = %+v, want msg=hello key=value", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLogger_OmitsEmptyMsg(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := NewLogger("info", &buf)
|
||||
logger.Info("", "type", "http", "status", 200)
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("output not JSON: %v (%q)", err, buf.String())
|
||||
}
|
||||
if _, present := decoded["msg"]; present {
|
||||
t.Errorf("empty msg should be omitted, got %+v", decoded)
|
||||
}
|
||||
if decoded["type"] != "http" {
|
||||
t.Errorf("attrs lost alongside dropped msg: %+v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApp_TagsMandatoryFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(NewLogger("info", &buf))
|
||||
defer slog.SetDefault(prev)
|
||||
|
||||
App().Info("did it", "extra", 1)
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("output not JSON: %v", err)
|
||||
}
|
||||
if decoded["type"] != "app" || decoded["package"] != "log" || decoded["file"] != "log_test.go" {
|
||||
t.Errorf("fields = %+v, want type=app package=log (import-path element) file=log_test.go", decoded)
|
||||
}
|
||||
if decoded["method"] != "TestApp_TagsMandatoryFields" {
|
||||
t.Errorf("method = %v, want the emitting function name", decoded["method"])
|
||||
}
|
||||
if _, hasClass := decoded["class"]; hasClass {
|
||||
t.Errorf("class should be omitted for a free function, got %v", decoded["class"])
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,13 @@ type Activity struct {
|
||||
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
|
||||
// has no WorkoutID, or was synced before this column existed.
|
||||
WorkoutRawJSON *string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
// WorkoutNotFoundAt is set when get_workout_by_id returned a definitive
|
||||
// HTTP 404 for this activity's WorkoutID -- distinct from
|
||||
// WorkoutRawJSON staying nil for "not yet fetched" (see
|
||||
// SetActivityWorkoutNotFound).
|
||||
WorkoutNotFoundAt *string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// UpsertActivity inserts a new activity for userID or updates the existing
|
||||
@@ -102,7 +107,7 @@ func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
|
||||
&a.AvgSpeedMps, &a.ElevationGainM,
|
||||
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
|
||||
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
|
||||
&a.CreatedAt, &a.UpdatedAt,
|
||||
&a.WorkoutNotFoundAt, &a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
return a, err
|
||||
}
|
||||
@@ -113,7 +118,7 @@ const activityColumns = `
|
||||
avg_speed_mps, elevation_gain_m,
|
||||
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
||||
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
|
||||
created_at, updated_at
|
||||
workout_not_found_at, created_at, updated_at
|
||||
`
|
||||
|
||||
// GetActivity fetches one activity by its internal id, scoped to userID.
|
||||
@@ -225,6 +230,21 @@ func (db *DB) SetActivityWorkout(ctx context.Context, userID, activityID int64,
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetActivityWorkoutNotFound records that get_workout_by_id returned a
|
||||
// definitive HTTP 404 for this activity's WorkoutID -- the workout was
|
||||
// deleted on Garmin's side after being linked to this activity.
|
||||
// WorkoutRawJSON is deliberately left nil (never fabricated); this is a
|
||||
// separate marker so ActivitiesMissingWorkout stops retrying it forever.
|
||||
func (db *DB) SetActivityWorkoutNotFound(ctx context.Context, userID, activityID int64) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE activities SET workout_not_found_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?`, activityID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set activity %d workout not found for user %d: %w", activityID, userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetActivitySplitsFetched records that get_activity_splits has been fetched
|
||||
// for this activity.
|
||||
func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error {
|
||||
@@ -272,3 +292,58 @@ func (db *DB) CountActivitiesMissingDetails(ctx context.Context, userID int64) (
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ActivitiesMissingWorkout returns userID's activities that have a
|
||||
// structured workout (WorkoutID set at initial upsert time, straight from
|
||||
// Garmin's activity summary) but haven't had get_workout_by_id fetched yet.
|
||||
// Gated on details_fetched_at IS NOT NULL: fillActivityWorkout resolves each
|
||||
// lap's target pace/HR band via alignWorkoutTargets, which needs the
|
||||
// activity's laps already written by fillActivityDetails -- without this
|
||||
// gate, a structured-workout activity whose details/laps haven't been
|
||||
// fetched yet would fall into a separate, independently LIMIT-bounded batch
|
||||
// than fillPendingDetails's, get its workout_raw_json set against zero laps
|
||||
// (a silent no-op alignment), and then never be retried once its laps
|
||||
// finally arrive, since workout_raw_json IS NULL is the only signal this
|
||||
// query has left. This is still independent of splits_fetched_at (unlike
|
||||
// ActivitiesMissingDetails, which requires both): splits/laps aren't needed
|
||||
// to resolve workout targets, only details_fetched_at is. It also still
|
||||
// covers the intended retry case -- an activity whose workout fetch
|
||||
// previously failed after details succeeded always has details_fetched_at
|
||||
// already set, so it's still surfaced here -- while excluding activities
|
||||
// that simply haven't been processed by fillActivityDetails at all yet.
|
||||
func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
|
||||
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
|
||||
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL
|
||||
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list activities missing workout for user %d: %w", userID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
activities := []Activity{}
|
||||
for rows.Next() {
|
||||
a, err := scanActivity(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan activity row: %w", err)
|
||||
}
|
||||
activities = append(activities, a)
|
||||
}
|
||||
return activities, rows.Err()
|
||||
}
|
||||
|
||||
// CountActivitiesMissingWorkout returns how many of userID's activities
|
||||
// still need get_workout_by_id fetched, regardless of any per-call batch
|
||||
// limit -- used to report overall remaining work, mirroring
|
||||
// CountActivitiesMissingDetails. See ActivitiesMissingWorkout for why this
|
||||
// is additionally gated on details_fetched_at IS NOT NULL.
|
||||
func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) {
|
||||
var n int
|
||||
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
|
||||
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
|
||||
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL`, userID).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count activities missing workout for user %d: %w", userID, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -76,31 +76,6 @@ func (db *DB) CurrentAssignment(ctx context.Context, userID, activityID int64) (
|
||||
return a, true, nil
|
||||
}
|
||||
|
||||
// ReviewQueue returns userID's activities whose current assignment status
|
||||
// is needs_review, newest first.
|
||||
func (db *DB) ReviewQueue(ctx context.Context, userID int64) ([]KindAssignment, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT `+kindAssignmentColumns+`
|
||||
FROM current_kind_assignment a
|
||||
JOIN activities ON activities.id = a.activity_id
|
||||
WHERE activities.user_id = ? AND a.status = ?
|
||||
ORDER BY a.created_at DESC`, userID, AssignmentStatusNeedsReview)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("review queue for user %d: %w", userID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
assignments := []KindAssignment{}
|
||||
for rows.Next() {
|
||||
a, err := scanKindAssignment(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan kind assignment row: %w", err)
|
||||
}
|
||||
assignments = append(assignments, a)
|
||||
}
|
||||
return assignments, rows.Err()
|
||||
}
|
||||
|
||||
// AllCurrentAssignments returns the latest assignment for every one of
|
||||
// userID's activities that has one, regardless of status or source -- the
|
||||
// basis for deciding which activities a global reclassify pass may touch.
|
||||
|
||||
40
backend/internal/store/config.go
Normal file
40
backend/internal/store/config.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ConfigValues returns every application-configuration row as key ->
|
||||
// value. The table holds a row for every registry key: main seeds missing
|
||||
// keys with their defaults at startup, so downstream code never needs a
|
||||
// code-side fallback.
|
||||
func (db *DB) ConfigValues(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT key, value FROM config`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config values: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
values := map[string]string{}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, fmt.Errorf("scan config row: %w", err)
|
||||
}
|
||||
values[k] = v
|
||||
}
|
||||
return values, rows.Err()
|
||||
}
|
||||
|
||||
// SetConfigValue upserts one override row. Key validation is the caller's
|
||||
// job (config.ValidateAppValue) -- the store stays a dumb K/V layer.
|
||||
func (db *DB) SetConfigValue(ctx context.Context, key, value string) error {
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
key, value); err != nil {
|
||||
return fmt.Errorf("set config %q: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
35
backend/internal/store/config_test.go
Normal file
35
backend/internal/store/config_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigValues_EmptyThenUpsertOverwrites(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
values, err := db.ConfigValues(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigValues (empty): %v", err)
|
||||
}
|
||||
if len(values) != 0 {
|
||||
t.Fatalf("expected no overrides in a fresh DB, got %v", values)
|
||||
}
|
||||
|
||||
if err := db.SetConfigValue(ctx, "session.duration", "168"); err != nil {
|
||||
t.Fatalf("SetConfigValue: %v", err)
|
||||
}
|
||||
// Same key again: upsert must overwrite, not error or duplicate.
|
||||
if err := db.SetConfigValue(ctx, "session.duration", "24"); err != nil {
|
||||
t.Fatalf("SetConfigValue (overwrite): %v", err)
|
||||
}
|
||||
|
||||
values, err = db.ConfigValues(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigValues: %v", err)
|
||||
}
|
||||
if len(values) != 1 || values["session.duration"] != "24" {
|
||||
t.Fatalf("expected {session.duration: 24}, got %v", values)
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,32 @@ package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
//go:embed schema.sql
|
||||
var schemaSQL string
|
||||
|
||||
// DB wraps a *sql.DB opened against a geniusrun SQLite database file, with
|
||||
// migrations already applied.
|
||||
// the schema already applied.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the SQLite database at path and applies
|
||||
// any migrations that haven't run yet.
|
||||
// schema.sql if it hasn't been applied yet. There is no migration history --
|
||||
// this is a pre-production app with no compatibility obligation to older
|
||||
// database files. Edit schema.sql directly to change the schema.
|
||||
func Open(path string) (*DB, error) {
|
||||
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")
|
||||
// WAL mode lets an external reader (sqlite3 CLI, DB Browser, DataGrip)
|
||||
// inspect the file concurrently without "database is locked" errors
|
||||
// while geniusrund is running -- the app's own queries are already
|
||||
// fully serialized via SetMaxOpenConns(1) below, so this doesn't change
|
||||
// in-process concurrency, only cross-process access to the same file.
|
||||
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite database: %w", err)
|
||||
}
|
||||
@@ -33,110 +38,32 @@ func Open(path string) (*DB, error) {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
db := &DB{DB: sqlDB}
|
||||
if err := db.migrate(); err != nil {
|
||||
if err := db.applySchema(); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations table: %w", err)
|
||||
// applySchema runs schema.sql once, the first time this database file is
|
||||
// opened -- detected by checking whether the users table already exists.
|
||||
func (db *DB) applySchema() error {
|
||||
var alreadyApplied int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&alreadyApplied); err != nil {
|
||||
return fmt.Errorf("check existing schema: %w", err)
|
||||
}
|
||||
if alreadyApplied > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
applied := make(map[string]bool)
|
||||
rows, err := db.Query(`SELECT filename FROM schema_migrations`)
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("query applied migrations: %w", err)
|
||||
return fmt.Errorf("begin schema tx: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("scan applied migration: %w", err)
|
||||
}
|
||||
applied[name] = true
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(schemaSQL); err != nil {
|
||||
return fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("glob migrations: %w", err)
|
||||
}
|
||||
sort.Strings(entries)
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry[len("migrations/"):]
|
||||
if applied[name] {
|
||||
continue
|
||||
}
|
||||
content, err := migrationsFS.ReadFile(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
// Migrations 0023, 0024, 0025, and 0026 rebuild tables that have
|
||||
// incoming foreign keys (kind_assignments/workout_type_paces
|
||||
// reference workout_kinds; sync_state is referenced by
|
||||
// activities/sync_runs; laps/activity_samples/kind_assignments
|
||||
// reference activities). These require temporary FK disable in
|
||||
// autocommit mode (before the transaction begins) so the DROP
|
||||
// TABLE succeeds; a mid-transaction PRAGMA is a no-op with
|
||||
// modernc.org/sqlite.
|
||||
tableRebuildMigrations := map[string]bool{
|
||||
"0023_profile_user_scoped.sql": true,
|
||||
"0024_workout_kinds_user_scoped.sql": true,
|
||||
"0025_sync_state_user_scoped.sql": true,
|
||||
"0026_activities_unique_constraint.sql": true,
|
||||
}
|
||||
needsFKToggle := tableRebuildMigrations[name]
|
||||
|
||||
if needsFKToggle {
|
||||
if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
||||
return fmt.Errorf("disable foreign keys before migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
if needsFKToggle {
|
||||
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||
}
|
||||
return fmt.Errorf("begin migration tx for %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(string(content)); err != nil {
|
||||
tx.Rollback()
|
||||
if needsFKToggle {
|
||||
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||
}
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
|
||||
tx.Rollback()
|
||||
if needsFKToggle {
|
||||
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||
}
|
||||
return fmt.Errorf("record migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
if needsFKToggle {
|
||||
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||
}
|
||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
if needsFKToggle {
|
||||
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||
return fmt.Errorf("enable foreign keys after migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) {
|
||||
t.Fatalf("userA's UpdateSyncState leaked into userB's sync_state: %+v", stateB)
|
||||
}
|
||||
|
||||
runID, err := db.StartSyncRun(ctx, userA, SyncKindBackfill)
|
||||
runID, err := db.StartSyncRun(ctx, userA, SyncKindFull)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSyncRun(a): %v", err)
|
||||
}
|
||||
@@ -161,3 +161,213 @@ func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) {
|
||||
t.Fatal("userB's FinishSyncRun call was able to mutate userA's sync run")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsolation_DeleteUserLeavesOtherUsersDataIntact confirms deleting one
|
||||
// user's account never touches another user's profile, taxonomy, or
|
||||
// activities, even though DeleteUser is a single blunt DELETE FROM users.
|
||||
func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser(a): %v", err)
|
||||
}
|
||||
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser(b): %v", err)
|
||||
}
|
||||
if _, err := db.UpsertActivity(ctx, userB, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
|
||||
t.Fatalf("UpsertActivity(b): %v", err)
|
||||
}
|
||||
|
||||
if err := db.DeleteUser(ctx, userA); err != nil {
|
||||
t.Fatalf("DeleteUser(a): %v", err)
|
||||
}
|
||||
|
||||
if _, found, err := db.GetUserBySub(ctx, "sub-b"); err != nil || !found {
|
||||
t.Fatalf("expected userB to survive userA's deletion, found=%v err=%v", found, err)
|
||||
}
|
||||
if _, err := db.GetProfile(ctx, userB); err != nil {
|
||||
t.Fatalf("GetProfile(b) after deleting a: %v", err)
|
||||
}
|
||||
userBRow, found, err := db.GetUserBySub(ctx, "sub-b")
|
||||
if err != nil || !found || userBRow.Name != "B" {
|
||||
t.Errorf("userB's account changed after deleting userA: %+v (found=%v err=%v)", userBRow, found, err)
|
||||
}
|
||||
kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
|
||||
if err != nil || len(kindsB) != 8 {
|
||||
t.Fatalf("userB's taxonomy affected by deleting userA: len=%d err=%v", len(kindsB), err)
|
||||
}
|
||||
activitiesB, err := db.ListActivities(ctx, userB, ActivityFilter{})
|
||||
if err != nil || len(activitiesB) != 1 {
|
||||
t.Fatalf("userB's activities affected by deleting userA: len=%d err=%v", len(activitiesB), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsolation_ActivitiesMissingWorkoutNeverLeaksAcrossUsers confirms
|
||||
// ActivitiesMissingWorkout/CountActivitiesMissingWorkout only ever surface
|
||||
// userID's own pending-workout activities, never another user's, even
|
||||
// though both users have activities in the exact shape (workout_id set,
|
||||
// details fetched, workout_raw_json still NULL) the query selects for.
|
||||
func TestIsolation_ActivitiesMissingWorkoutNeverLeaksAcrossUsers(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser(a): %v", err)
|
||||
}
|
||||
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser(b): %v", err)
|
||||
}
|
||||
|
||||
workoutID := int64(999)
|
||||
idA, err := db.UpsertActivity(ctx, userA, Activity{
|
||||
GarminActivityID: 1, WorkoutID: &workoutID,
|
||||
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity(a): %v", err)
|
||||
}
|
||||
if err := db.SetActivityDetails(ctx, userA, idA, "{}"); err != nil {
|
||||
t.Fatalf("SetActivityDetails(a): %v", err)
|
||||
}
|
||||
|
||||
idB, err := db.UpsertActivity(ctx, userB, Activity{
|
||||
GarminActivityID: 1, WorkoutID: &workoutID,
|
||||
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity(b): %v", err)
|
||||
}
|
||||
if err := db.SetActivityDetails(ctx, userB, idB, "{}"); err != nil {
|
||||
t.Fatalf("SetActivityDetails(b): %v", err)
|
||||
}
|
||||
|
||||
pendingA, err := db.ActivitiesMissingWorkout(ctx, userA, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ActivitiesMissingWorkout(a): %v", err)
|
||||
}
|
||||
if len(pendingA) != 1 || pendingA[0].ID != idA {
|
||||
t.Fatalf("ActivitiesMissingWorkout(a) = %+v, want only userA's own activity (id %d)", pendingA, idA)
|
||||
}
|
||||
|
||||
countA, err := db.CountActivitiesMissingWorkout(ctx, userA)
|
||||
if err != nil {
|
||||
t.Fatalf("CountActivitiesMissingWorkout(a): %v", err)
|
||||
}
|
||||
if countA != 1 {
|
||||
t.Fatalf("CountActivitiesMissingWorkout(a) = %d, want 1 (userB's identical-looking activity must not be counted)", countA)
|
||||
}
|
||||
|
||||
// Calling SetActivityWorkoutNotFound with the WRONG user's ID against
|
||||
// userA's activity (idA) must be a no-op: the WHERE id = ? AND user_id = ?
|
||||
// won't match any row, so it should neither error nor mark idA not-found.
|
||||
// (This runs before the SetActivityWorkout resolution below, while idA
|
||||
// is still pending -- once workout_raw_json is set, idA drops out of
|
||||
// ActivitiesMissingWorkout regardless of workout_not_found_at, which
|
||||
// would make the "still appears" assertion vacuous.)
|
||||
if err := db.SetActivityWorkoutNotFound(ctx, userB, idA); err != nil {
|
||||
t.Fatalf("SetActivityWorkoutNotFound(b, idA): %v", err)
|
||||
}
|
||||
pendingA, err = db.ActivitiesMissingWorkout(ctx, userA, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ActivitiesMissingWorkout(a) after userB's mismatched SetActivityWorkoutNotFound call: %v", err)
|
||||
}
|
||||
if len(pendingA) != 1 || pendingA[0].ID != idA {
|
||||
t.Fatalf("ActivitiesMissingWorkout(a) = %+v, want idA still pending -- userB's mismatched call must not mark it not-found", pendingA)
|
||||
}
|
||||
countA, err = db.CountActivitiesMissingWorkout(ctx, userA)
|
||||
if err != nil {
|
||||
t.Fatalf("CountActivitiesMissingWorkout(a) after userB's mismatched SetActivityWorkoutNotFound call: %v", err)
|
||||
}
|
||||
if countA != 1 {
|
||||
t.Fatalf("CountActivitiesMissingWorkout(a) = %d, want 1 (unaffected by userB's mismatched-user SetActivityWorkoutNotFound call)", countA)
|
||||
}
|
||||
|
||||
// Now the CORRECT user marks their own activity not-found: idA must
|
||||
// disappear from userA's own results, and userB's own idB (still
|
||||
// pending) must remain wholly unaffected.
|
||||
if err := db.SetActivityWorkoutNotFound(ctx, userA, idA); err != nil {
|
||||
t.Fatalf("SetActivityWorkoutNotFound(a, idA): %v", err)
|
||||
}
|
||||
pendingA, err = db.ActivitiesMissingWorkout(ctx, userA, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ActivitiesMissingWorkout(a) after userA marked idA not-found: %v", err)
|
||||
}
|
||||
if len(pendingA) != 0 {
|
||||
t.Fatalf("ActivitiesMissingWorkout(a) = %+v, want empty after userA marked their own idA not-found", pendingA)
|
||||
}
|
||||
countA, err = db.CountActivitiesMissingWorkout(ctx, userA)
|
||||
if err != nil {
|
||||
t.Fatalf("CountActivitiesMissingWorkout(a) after userA marked idA not-found: %v", err)
|
||||
}
|
||||
if countA != 0 {
|
||||
t.Fatalf("CountActivitiesMissingWorkout(a) = %d, want 0 after userA marked their own idA not-found", countA)
|
||||
}
|
||||
pendingB, err := db.ActivitiesMissingWorkout(ctx, userB, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ActivitiesMissingWorkout(b) after userA marked idA not-found: %v", err)
|
||||
}
|
||||
if len(pendingB) != 1 || pendingB[0].ID != idB {
|
||||
t.Fatalf("ActivitiesMissingWorkout(b) = %+v, want userB's idB still pending, unaffected by userA's SetActivityWorkoutNotFound call", pendingB)
|
||||
}
|
||||
countB, err := db.CountActivitiesMissingWorkout(ctx, userB)
|
||||
if err != nil {
|
||||
t.Fatalf("CountActivitiesMissingWorkout(b) after userA marked idA not-found: %v", err)
|
||||
}
|
||||
if countB != 1 {
|
||||
t.Fatalf("CountActivitiesMissingWorkout(b) = %d, want 1 (unaffected by userA's SetActivityWorkoutNotFound call)", countB)
|
||||
}
|
||||
|
||||
// Resolving userA's workout (now moot for idA specifically, since it was
|
||||
// already marked not-found above, but still exercises SetActivityWorkout
|
||||
// itself) must not affect userB's pending activity.
|
||||
if err := db.SetActivityWorkout(ctx, userA, idA, `{"segments":[]}`); err != nil {
|
||||
t.Fatalf("SetActivityWorkout(a): %v", err)
|
||||
}
|
||||
pendingB, err = db.ActivitiesMissingWorkout(ctx, userB, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ActivitiesMissingWorkout(b): %v", err)
|
||||
}
|
||||
if len(pendingB) != 1 || pendingB[0].ID != idB {
|
||||
t.Fatalf("ActivitiesMissingWorkout(b) = %+v, want userB's activity unaffected by userA's SetActivityWorkout", pendingB)
|
||||
}
|
||||
countB, err = db.CountActivitiesMissingWorkout(ctx, userB)
|
||||
if err != nil {
|
||||
t.Fatalf("CountActivitiesMissingWorkout(b): %v", err)
|
||||
}
|
||||
if countB != 1 {
|
||||
t.Fatalf("CountActivitiesMissingWorkout(b) = %d, want 1 (unaffected by userA's SetActivityWorkout call)", countB)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers confirms marking
|
||||
// one user's Garmin connection never sets another user's flag.
|
||||
func TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser(a): %v", err)
|
||||
}
|
||||
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser(b): %v", err)
|
||||
}
|
||||
|
||||
if err := db.MarkGarminConnected(ctx, userA); err != nil {
|
||||
t.Fatalf("MarkGarminConnected(a): %v", err)
|
||||
}
|
||||
|
||||
profileB, err := db.GetProfile(ctx, userB)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile(b): %v", err)
|
||||
}
|
||||
if profileB.GarminConnectedAt != nil {
|
||||
t.Fatal("userA's MarkGarminConnected call leaked into userB's profile")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,13 +55,13 @@ func (db *DB) ReplaceLaps(ctx context.Context, userID, activityID int64, laps []
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM laps WHERE activity_id = ?`, activityID); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM activity_laps WHERE activity_id = ?`, activityID); err != nil {
|
||||
return fmt.Errorf("delete existing laps for activity %d: %w", activityID, err)
|
||||
}
|
||||
|
||||
for _, l := range laps {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO laps (
|
||||
INSERT INTO activity_laps (
|
||||
activity_id, lap_index, avg_speed_mps,
|
||||
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
|
||||
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
|
||||
@@ -84,7 +84,7 @@ func (db *DB) LapsForActivity(ctx context.Context, userID, activityID int64) ([]
|
||||
SELECT laps.id, laps.activity_id, laps.lap_index, laps.avg_speed_mps,
|
||||
laps.intensity_type, laps.hr_drift_bpm_per_min, laps.hr_recovery_bpm_per_min,
|
||||
laps.target_pace_low_mps, laps.target_pace_high_mps, laps.target_hr_low_bpm, laps.target_hr_high_bpm, laps.raw_json
|
||||
FROM laps
|
||||
FROM activity_laps AS laps
|
||||
JOIN activities ON activities.id = laps.activity_id
|
||||
WHERE laps.activity_id = ? AND activities.user_id = ?
|
||||
ORDER BY laps.lap_index`, activityID, userID)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ClaimLegacyOwner is a one-time upgrade step, not a normal runtime
|
||||
// operation: it binds every pre-multi-tenancy row (user_id IS NULL, left
|
||||
// that way by migrations that can't take runtime parameters -- see
|
||||
// docs/superpowers/specs/2026-07-25-per-user-profile-design.md) to a single
|
||||
// new user identified by oidcSub. Safe to call on every startup: once the
|
||||
// users table is non-empty, it's a no-op, so leaving
|
||||
// GENIUSRUN_LEGACY_OWNER_OIDC_SUB set after the first successful run causes
|
||||
// no harm.
|
||||
func (db *DB) ClaimLegacyOwner(ctx context.Context, oidcSub string) error {
|
||||
var userCount int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
|
||||
return fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
if userCount > 0 {
|
||||
return nil // already bootstrapped (either claimed already, or real signups exist)
|
||||
}
|
||||
|
||||
var displayName string
|
||||
err := db.QueryRowContext(ctx, `SELECT name FROM profile WHERE user_id IS NULL LIMIT 1`).Scan(&displayName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find legacy profile: %w", err)
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin claim legacy owner tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create legacy owner user: %w", err)
|
||||
}
|
||||
userID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// table is always one of the fixed literals below, never user input.
|
||||
for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state", "sync_runs"} {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET user_id = ? WHERE user_id IS NULL`, userID); err != nil {
|
||||
return fmt.Errorf("claim legacy %s rows: %w", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClaimLegacyOwner_BindsExistingSingletonRowsToOneNewUser(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Simulate the pre-migration state: a fresh DB already has one
|
||||
// migration-seeded profile row (id=1, user_id=NULL) and 8 workout_kinds
|
||||
// rows (user_id=NULL) -- exactly what a real upgraded deployment looks
|
||||
// like right after Task 1's migrations run, before any user exists.
|
||||
if _, err := db.ExecContext(ctx, `UPDATE profile SET name = 'Kriss', garmin_email = 'kriss@example.com' WHERE user_id IS NULL`); err != nil {
|
||||
t.Fatalf("seed legacy profile: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO activities (user_id, garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json) VALUES (NULL, 1, '2026-01-01 00:00:00', 1800, 5000, '{}')`); err != nil {
|
||||
t.Fatalf("seed legacy activity: %v", err)
|
||||
}
|
||||
|
||||
if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil {
|
||||
t.Fatalf("ClaimLegacyOwner: %v", err)
|
||||
}
|
||||
|
||||
u, found, err := db.GetUserBySub(ctx, "kriss-sub")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||||
}
|
||||
if u.DisplayName != "Kriss" {
|
||||
t.Errorf("DisplayName = %q, want %q (from the legacy profile's name column)", u.DisplayName, "Kriss")
|
||||
}
|
||||
|
||||
profile, err := db.GetProfile(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile(claimed user): %v", err)
|
||||
}
|
||||
if profile.GarminEmail != "kriss@example.com" {
|
||||
t.Errorf("claimed profile GarminEmail = %q, want kriss@example.com", profile.GarminEmail)
|
||||
}
|
||||
|
||||
kinds, err := db.ListWorkoutKinds(ctx, u.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkoutKinds(claimed user): %v", err)
|
||||
}
|
||||
if len(kinds) != 8 {
|
||||
t.Fatalf("expected the 8 legacy workout_kinds rows to be claimed, got %d", len(kinds))
|
||||
}
|
||||
|
||||
activities, err := db.ListActivities(ctx, u.ID, ActivityFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListActivities(claimed user): %v", err)
|
||||
}
|
||||
if len(activities) != 1 {
|
||||
t.Fatalf("expected the 1 legacy activity to be claimed, got %d", len(activities))
|
||||
}
|
||||
|
||||
var remainingNullUserIDRows int
|
||||
for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state"} {
|
||||
var n int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` WHERE user_id IS NULL`).Scan(&n); err != nil {
|
||||
t.Fatalf("count NULL user_id in %s: %v", table, err)
|
||||
}
|
||||
remainingNullUserIDRows += n
|
||||
}
|
||||
if remainingNullUserIDRows != 0 {
|
||||
t.Errorf("expected every legacy row to be claimed, %d rows still have NULL user_id", remainingNullUserIDRows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimLegacyOwner_NoOpOnceAUserAlreadyExists(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
firstUserID, err := db.ProvisionUser(ctx, "already-here", "Someone")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
// A second call (simulating a later restart with the env var still set)
|
||||
// must not create a second user or touch anything.
|
||||
if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil {
|
||||
t.Fatalf("ClaimLegacyOwner (should be a no-op): %v", err)
|
||||
}
|
||||
|
||||
if _, found, err := db.GetUserBySub(ctx, "kriss-sub"); err != nil || found {
|
||||
t.Fatalf("expected no user created for kriss-sub, found=%v err=%v", found, err)
|
||||
}
|
||||
users, err := db.ListUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers: %v", err)
|
||||
}
|
||||
if len(users) != 1 || users[0].ID != firstUserID {
|
||||
t.Fatalf("expected exactly the 1 pre-existing user to remain, got %+v", users)
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
CREATE TABLE activities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
garmin_activity_id INTEGER NOT NULL UNIQUE,
|
||||
activity_name TEXT NOT NULL DEFAULT '',
|
||||
activity_type TEXT NOT NULL,
|
||||
start_time_utc TEXT NOT NULL,
|
||||
begin_timestamp_ms INTEGER NOT NULL,
|
||||
duration_seconds REAL NOT NULL,
|
||||
distance_meters REAL NOT NULL,
|
||||
avg_hr REAL,
|
||||
max_hr REAL,
|
||||
avg_speed_mps REAL,
|
||||
max_speed_mps REAL,
|
||||
elevation_gain_m REAL,
|
||||
elevation_loss_m REAL,
|
||||
calories REAL,
|
||||
lap_count INTEGER NOT NULL DEFAULT 0,
|
||||
aerobic_training_effect REAL,
|
||||
anaerobic_training_effect REAL,
|
||||
training_effect_label TEXT NOT NULL DEFAULT '',
|
||||
vo2max_value REAL,
|
||||
hr_time_in_zone_1 REAL,
|
||||
hr_time_in_zone_2 REAL,
|
||||
hr_time_in_zone_3 REAL,
|
||||
hr_time_in_zone_4 REAL,
|
||||
hr_time_in_zone_5 REAL,
|
||||
raw_json TEXT NOT NULL,
|
||||
details_fetched_at TEXT,
|
||||
details_raw_json TEXT,
|
||||
splits_fetched_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
|
||||
CREATE INDEX idx_activities_activity_type ON activities(activity_type);
|
||||
|
||||
CREATE TABLE laps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
lap_index INTEGER NOT NULL,
|
||||
start_time_utc TEXT NOT NULL,
|
||||
duration_seconds REAL NOT NULL,
|
||||
distance_meters REAL NOT NULL,
|
||||
avg_hr REAL,
|
||||
max_hr REAL,
|
||||
avg_speed_mps REAL,
|
||||
max_speed_mps REAL,
|
||||
elevation_gain_m REAL,
|
||||
elevation_loss_m REAL,
|
||||
intensity_type TEXT NOT NULL DEFAULT '',
|
||||
hr_drift_bpm_per_min REAL,
|
||||
hr_recovery_bpm_per_min REAL,
|
||||
raw_json TEXT NOT NULL,
|
||||
UNIQUE(activity_id, lap_index)
|
||||
);
|
||||
|
||||
CREATE TABLE activity_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
elapsed_seconds REAL NOT NULL,
|
||||
timestamp_ms INTEGER NOT NULL,
|
||||
heart_rate REAL,
|
||||
speed_mps REAL,
|
||||
distance_m REAL,
|
||||
elevation_m REAL
|
||||
);
|
||||
CREATE INDEX idx_activity_samples_activity ON activity_samples(activity_id, elapsed_seconds);
|
||||
|
||||
CREATE TABLE workout_kinds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
rule_json TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE kind_assignments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
workout_kind_id INTEGER REFERENCES workout_kinds(id),
|
||||
assignment_source TEXT NOT NULL CHECK(assignment_source IN ('rule_engine','manual')),
|
||||
status TEXT NOT NULL CHECK(status IN ('assigned','needs_review')),
|
||||
confidence REAL,
|
||||
candidate_kinds_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX idx_kind_assignments_activity ON kind_assignments(activity_id);
|
||||
CREATE INDEX idx_kind_assignments_status ON kind_assignments(status);
|
||||
|
||||
CREATE VIEW current_kind_assignment AS
|
||||
SELECT a.* FROM kind_assignments a
|
||||
JOIN (
|
||||
SELECT activity_id, MAX(id) AS max_id
|
||||
FROM kind_assignments GROUP BY activity_id
|
||||
) latest ON a.activity_id = latest.activity_id AND a.id = latest.max_id;
|
||||
|
||||
CREATE TABLE sync_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental')),
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
activities_fetched INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL CHECK(status IN ('running','success','error')),
|
||||
error_message TEXT
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Tracks how far back a full backfill has already reached. Garmin activity
|
||||
-- history is immutable once recorded, so once we've backfilled a historical
|
||||
-- window there is no need to ever re-fetch get_activities() for it again --
|
||||
-- this is the "cache" that lets repeated backfills be cheap/fast instead of
|
||||
-- re-walking years of history against Garmin's API every time.
|
||||
CREATE TABLE sync_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
earliest_synced_date TEXT,
|
||||
backfill_complete INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO sync_state (id, earliest_synced_date, backfill_complete) VALUES (1, NULL, 0);
|
||||
@@ -1,37 +0,0 @@
|
||||
-- Single-profile settings: Garmin credentials (replacing env-var-only
|
||||
-- config) and every tunable engine parameter, in one editable row.
|
||||
CREATE TABLE profile (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
garmin_email TEXT NOT NULL DEFAULT '',
|
||||
garmin_password TEXT NOT NULL DEFAULT '',
|
||||
rolling_window_days INTEGER NOT NULL DEFAULT 90,
|
||||
max_heart_rate REAL,
|
||||
resting_heart_rate REAL,
|
||||
hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
|
||||
hr_zone1_max_pct REAL NOT NULL DEFAULT 60,
|
||||
hr_zone2_min_pct REAL NOT NULL DEFAULT 60,
|
||||
hr_zone2_max_pct REAL NOT NULL DEFAULT 70,
|
||||
hr_zone3_min_pct REAL NOT NULL DEFAULT 70,
|
||||
hr_zone3_max_pct REAL NOT NULL DEFAULT 80,
|
||||
hr_zone4_min_pct REAL NOT NULL DEFAULT 80,
|
||||
hr_zone4_max_pct REAL NOT NULL DEFAULT 90,
|
||||
hr_zone5_min_pct REAL NOT NULL DEFAULT 90,
|
||||
hr_zone5_max_pct REAL NOT NULL DEFAULT 100,
|
||||
easy_warmup_minutes REAL NOT NULL DEFAULT 10,
|
||||
easy_cooldown_minutes REAL NOT NULL DEFAULT 5,
|
||||
long_warmup_minutes REAL NOT NULL DEFAULT 10,
|
||||
long_cooldown_minutes REAL NOT NULL DEFAULT 5,
|
||||
tempo_warmup_minutes REAL NOT NULL DEFAULT 15,
|
||||
tempo_cooldown_minutes REAL NOT NULL DEFAULT 10,
|
||||
threshold30_warmup_minutes REAL NOT NULL DEFAULT 15,
|
||||
threshold30_cooldown_minutes REAL NOT NULL DEFAULT 10,
|
||||
threshold60_warmup_minutes REAL NOT NULL DEFAULT 15,
|
||||
threshold60_cooldown_minutes REAL NOT NULL DEFAULT 10,
|
||||
mas_test_warmup_minutes REAL NOT NULL DEFAULT 15,
|
||||
mas_test_cooldown_minutes REAL NOT NULL DEFAULT 5,
|
||||
interval_warmup_minutes REAL NOT NULL DEFAULT 0,
|
||||
interval_cooldown_minutes REAL NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT INTO profile (id) VALUES (1);
|
||||
@@ -1,17 +0,0 @@
|
||||
-- The taxonomy is now a fixed, closed set (no more user-created "kinds").
|
||||
-- Any prior arbitrary kinds and their classification history are reset:
|
||||
-- the concept they classified against no longer exists.
|
||||
DELETE FROM kind_assignments;
|
||||
DELETE FROM workout_kinds;
|
||||
|
||||
-- Placeholder rule: distance is never negative, so this never matches.
|
||||
-- Every activity starts in needs_review for every type until real rules
|
||||
-- are tuned (a follow-up plan, not this migration).
|
||||
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active) VALUES
|
||||
('Easy Run', '', '#22c55e', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
|
||||
('Long Run', '', '#3b82f6', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
|
||||
('Threshold 30''', '', '#f59e0b', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
|
||||
('Threshold 60''', '', '#f59e0b', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
|
||||
('Tempo', '', '#eab308', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
|
||||
('Interval', '', '#ef4444', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
|
||||
('MAS Test', '', '#a855f7', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1);
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Per-workout-type target pace range and expected HR zone. Informational
|
||||
-- only: never read by the classification rule engine (see spec section 2).
|
||||
-- No history -- overwritten in place when the user updates a value; the
|
||||
-- synced activity log is the historical record.
|
||||
CREATE TABLE workout_type_paces (
|
||||
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id),
|
||||
pace_min_sec_per_km REAL,
|
||||
pace_max_sec_per_km REAL,
|
||||
expected_hr_zone INTEGER CHECK (expected_hr_zone IS NULL OR expected_hr_zone BETWEEN 1 AND 5)
|
||||
);
|
||||
|
||||
INSERT INTO workout_type_paces (workout_kind_id)
|
||||
SELECT id FROM workout_kinds;
|
||||
@@ -1,22 +0,0 @@
|
||||
-- Phase-detection warm-up/cool-down was originally one setting per workout
|
||||
-- type (14 columns: 6 types x 2, plus an unused Interval pair -- Interval
|
||||
-- detects phases from lap data directly, never from a fixed duration). That
|
||||
-- turned out to be more granularity than wanted: replaced with a single
|
||||
-- global warm-up/cool-down pair applied to every fixed-duration workout type.
|
||||
ALTER TABLE profile ADD COLUMN warmup_minutes REAL NOT NULL DEFAULT 10;
|
||||
ALTER TABLE profile ADD COLUMN cooldown_minutes REAL NOT NULL DEFAULT 5;
|
||||
|
||||
ALTER TABLE profile DROP COLUMN easy_warmup_minutes;
|
||||
ALTER TABLE profile DROP COLUMN easy_cooldown_minutes;
|
||||
ALTER TABLE profile DROP COLUMN long_warmup_minutes;
|
||||
ALTER TABLE profile DROP COLUMN long_cooldown_minutes;
|
||||
ALTER TABLE profile DROP COLUMN tempo_warmup_minutes;
|
||||
ALTER TABLE profile DROP COLUMN tempo_cooldown_minutes;
|
||||
ALTER TABLE profile DROP COLUMN threshold30_warmup_minutes;
|
||||
ALTER TABLE profile DROP COLUMN threshold30_cooldown_minutes;
|
||||
ALTER TABLE profile DROP COLUMN threshold60_warmup_minutes;
|
||||
ALTER TABLE profile DROP COLUMN threshold60_cooldown_minutes;
|
||||
ALTER TABLE profile DROP COLUMN mas_test_warmup_minutes;
|
||||
ALTER TABLE profile DROP COLUMN mas_test_cooldown_minutes;
|
||||
ALTER TABLE profile DROP COLUMN interval_warmup_minutes;
|
||||
ALTER TABLE profile DROP COLUMN interval_cooldown_minutes;
|
||||
@@ -1,12 +0,0 @@
|
||||
-- "Race" is an 8th fixed workout kind. Unlike the other 7 (seeded with a
|
||||
-- never-matching placeholder rule pending manual tuning), it gets a real
|
||||
-- rule from day one: Garmin Connect lets a user manually tag an activity's
|
||||
-- event type as "Race", and that value round-trips through get_activities()
|
||||
-- as eventType.typeKey -- a genuine, deterministic signal, not a guess.
|
||||
ALTER TABLE activities ADD COLUMN event_type_key TEXT NOT NULL DEFAULT '';
|
||||
|
||||
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active) VALUES
|
||||
('Race', '', '#dc2626', '{"match":"all","conditions":[{"metric":"is_race","op":"==","value":true}]}', 0, 1);
|
||||
|
||||
INSERT INTO workout_type_paces (workout_kind_id)
|
||||
SELECT id FROM workout_kinds WHERE name = 'Race';
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Structured Garmin workouts (created in Garmin Connect or a training plan
|
||||
-- tool) attach a per-step target pace/HR zone. An activity recorded from one
|
||||
-- carries that workout's id; when its laps line up 1:1 with the workout's
|
||||
-- flattened steps (see internal/sync's alignWorkoutTargets), the expected
|
||||
-- band is resolved and stored per lap so the Review Queue can plot expected
|
||||
-- vs actual pace/HR without a live Garmin call on every page view.
|
||||
ALTER TABLE activities ADD COLUMN workout_id INTEGER;
|
||||
|
||||
ALTER TABLE laps ADD COLUMN target_pace_low_mps REAL;
|
||||
ALTER TABLE laps ADD COLUMN target_pace_high_mps REAL;
|
||||
ALTER TABLE laps ADD COLUMN target_hr_low_bpm REAL;
|
||||
ALTER TABLE laps ADD COLUMN target_hr_high_bpm REAL;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Backfill horizon used to be a startup-time env var
|
||||
-- (GENIUSRUN_BACKFILL_HORIZON_DAYS) with no UI at all -- exactly the kind of
|
||||
-- "tunable analysis-engine parameter" this profile table exists for.
|
||||
-- Default matches the old env var's default (3 years) so existing
|
||||
-- deployments keep their current behavior until the user changes it.
|
||||
ALTER TABLE profile ADD COLUMN backfill_horizon_days INTEGER NOT NULL DEFAULT 1095;
|
||||
@@ -1,9 +0,0 @@
|
||||
-- Filters brief pace "artifacts" (e.g. GPS/motion still settling right as
|
||||
-- recording starts, before the run itself begins) out of the Review
|
||||
-- Queue's pace chart: a stretch of samples slower than
|
||||
-- min_representative_pace_sec_per_km is dropped unless it persists for at
|
||||
-- least min_representative_time_seconds, in which case it's treated as a
|
||||
-- real stop or walk break, not noise. Defaults match the values used to
|
||||
-- design this feature (12:00/km, 3 seconds).
|
||||
ALTER TABLE profile ADD COLUMN min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720;
|
||||
ALTER TABLE profile ADD COLUMN min_representative_time_seconds REAL NOT NULL DEFAULT 3;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Names the profile so a future multi-profile setup can show which one is
|
||||
-- active. Only one profile row exists today (id=1), so this is just a label.
|
||||
ALTER TABLE profile ADD COLUMN name TEXT NOT NULL DEFAULT 'Default';
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Replaces the named-zone-only expected_hr_zone with a custom %HRR range
|
||||
-- per training type (e.g. "easy runs at 70-80% heart rate reserve"),
|
||||
-- matching how pace range is already modeled. expected_hr_zone is left in
|
||||
-- place but unused going forward -- nothing reads or writes it anymore.
|
||||
ALTER TABLE workout_type_paces ADD COLUMN hr_min_pct_hrr REAL;
|
||||
ALTER TABLE workout_type_paces ADD COLUMN hr_max_pct_hrr REAL;
|
||||
@@ -1,35 +0,0 @@
|
||||
-- Phase 1 of the raw-JSON duplication cleanup: drops every Activity/Lap
|
||||
-- column that was a pure untransformed copy of a value already present in
|
||||
-- that row's own raw_json, with zero SQL/classify/functional consumer
|
||||
-- anywhere in the app (see the 2026-07 field-by-field duplication audit).
|
||||
-- Unlike this project's usual additive-only migrations, dropping these
|
||||
-- columns outright is the whole point of this one -- leaving them inert
|
||||
-- would keep the exact duplication being removed. activity_name/
|
||||
-- activity_type (Activity) and duration_seconds/avg_hr (laps) also go here
|
||||
-- even though the frontend still displays them: they're now decoded from
|
||||
-- raw_json at API-response time instead of stored separately (see
|
||||
-- internal/api's decodeActivityDisplayFields/decodeLapDisplayFields).
|
||||
DROP INDEX idx_activities_activity_type;
|
||||
|
||||
ALTER TABLE activities DROP COLUMN activity_name;
|
||||
ALTER TABLE activities DROP COLUMN activity_type;
|
||||
ALTER TABLE activities DROP COLUMN begin_timestamp_ms;
|
||||
ALTER TABLE activities DROP COLUMN max_speed_mps;
|
||||
ALTER TABLE activities DROP COLUMN elevation_loss_m;
|
||||
ALTER TABLE activities DROP COLUMN calories;
|
||||
ALTER TABLE activities DROP COLUMN lap_count;
|
||||
ALTER TABLE activities DROP COLUMN training_effect_label;
|
||||
ALTER TABLE activities DROP COLUMN hr_time_in_zone_1;
|
||||
ALTER TABLE activities DROP COLUMN hr_time_in_zone_2;
|
||||
ALTER TABLE activities DROP COLUMN hr_time_in_zone_3;
|
||||
ALTER TABLE activities DROP COLUMN hr_time_in_zone_4;
|
||||
ALTER TABLE activities DROP COLUMN hr_time_in_zone_5;
|
||||
|
||||
ALTER TABLE laps DROP COLUMN start_time_utc;
|
||||
ALTER TABLE laps DROP COLUMN duration_seconds;
|
||||
ALTER TABLE laps DROP COLUMN distance_meters;
|
||||
ALTER TABLE laps DROP COLUMN avg_hr;
|
||||
ALTER TABLE laps DROP COLUMN max_hr;
|
||||
ALTER TABLE laps DROP COLUMN max_speed_mps;
|
||||
ALTER TABLE laps DROP COLUMN elevation_gain_m;
|
||||
ALTER TABLE laps DROP COLUMN elevation_loss_m;
|
||||
@@ -1,10 +0,0 @@
|
||||
-- User-configurable chart colors: 2 "main line" colors (one per metric) and
|
||||
-- 4 "effort kind" colors (one per workout phase), used together to derive
|
||||
-- the pace/HR chart's line, under-the-line fill, and phase-background fill
|
||||
-- colors (see frontend's ExpectedVsActualChart).
|
||||
ALTER TABLE profile ADD COLUMN pace_color TEXT NOT NULL DEFAULT '#3b82f6';
|
||||
ALTER TABLE profile ADD COLUMN heart_rate_color TEXT NOT NULL DEFAULT '#ef4444';
|
||||
ALTER TABLE profile ADD COLUMN warmup_color TEXT NOT NULL DEFAULT '#c2410c';
|
||||
ALTER TABLE profile ADD COLUMN effort_color TEXT NOT NULL DEFAULT '#7c3aed';
|
||||
ALTER TABLE profile ADD COLUMN recovery_color TEXT NOT NULL DEFAULT '#15803d';
|
||||
ALTER TABLE profile ADD COLUMN cooldown_color TEXT NOT NULL DEFAULT '#fb923c';
|
||||
@@ -1,18 +0,0 @@
|
||||
-- Renames the default training-type taxonomy to a fixed display convention
|
||||
-- (drop the redundant "Run" suffix, numeral before "Threshold", "Intervals"
|
||||
-- not "Interval") and assigns explicit priorities so kinds always list in
|
||||
-- this exact order wherever they're shown (Activities filters, Progression's
|
||||
-- kind picker, Profile's Training types card) -- existing ORDER BY priority
|
||||
-- DESC, name already does the sorting, no query changes needed:
|
||||
-- Easy, Long, 60' Threshold, 30' Threshold, Tempo, Intervals, MAS Test, Race.
|
||||
--
|
||||
-- Each UPDATE matches on the original seeded name, so a kind the user has
|
||||
-- already renamed themselves (no longer matching) is left untouched.
|
||||
UPDATE workout_kinds SET name = 'Easy', priority = 80 WHERE name = 'Easy Run';
|
||||
UPDATE workout_kinds SET name = 'Long', priority = 70 WHERE name = 'Long Run';
|
||||
UPDATE workout_kinds SET name = '60'' Threshold', priority = 60 WHERE name = 'Threshold 60''';
|
||||
UPDATE workout_kinds SET name = '30'' Threshold', priority = 50 WHERE name = 'Threshold 30''';
|
||||
UPDATE workout_kinds SET priority = 40 WHERE name = 'Tempo';
|
||||
UPDATE workout_kinds SET name = 'Intervals', priority = 30 WHERE name = 'Interval';
|
||||
UPDATE workout_kinds SET priority = 20 WHERE name = 'MAS Test';
|
||||
UPDATE workout_kinds SET priority = 10 WHERE name = 'Race';
|
||||
@@ -1,5 +0,0 @@
|
||||
-- How strongly the main line color (blue/red) tints the effort-kind fill
|
||||
-- under the line, as a percentage (0-100) mixed in -- see frontend's
|
||||
-- ExpectedVsActualChart mixColor(). The phase background above the line is
|
||||
-- never tinted by the main line color regardless of this setting.
|
||||
ALTER TABLE profile ADD COLUMN main_line_tint_pct REAL NOT NULL DEFAULT 20;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- How strongly (0-100) the effort-kind color is darkened for the phase
|
||||
-- background above the line -- see frontend's ExpectedVsActualChart
|
||||
-- darken(). Never mixed with the main line color, unlike the fill below the
|
||||
-- line (see main_line_tint_pct).
|
||||
ALTER TABLE profile ADD COLUMN background_darken_pct REAL NOT NULL DEFAULT 35;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- How strongly (0-100) a chart's main line color (and everything tinted
|
||||
-- from it) is brightened when that chart actually has a structured-workout
|
||||
-- target range to show -- a color-based cue that a target is present,
|
||||
-- replacing a text label -- see frontend's ExpectedVsActualChart brighten().
|
||||
ALTER TABLE profile ADD COLUMN target_brighten_pct REAL NOT NULL DEFAULT 20;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Genuine raw JSON of the activity's structured Garmin workout (get_workout_by_id),
|
||||
-- the source used to compute each lap's TargetPaceLowMps/HighMps and
|
||||
-- TargetHRLowBpm/HighBpm (see internal/sync/mapping.go's alignWorkoutTargets).
|
||||
-- Null for activities with no WorkoutID, or synced before this column existed.
|
||||
ALTER TABLE activities ADD COLUMN workout_raw_json TEXT;
|
||||
@@ -1,19 +0,0 @@
|
||||
-- Widens sync_runs.kind's CHECK constraint to also allow 'full' (a manual
|
||||
-- "Sync now" pass recorded as one combined run instead of separate
|
||||
-- backfill/incremental rows -- see internal/sync.Service.FullSync). SQLite
|
||||
-- has no ALTER TABLE for CHECK constraints, so the table is rebuilt.
|
||||
CREATE TABLE sync_runs_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
activities_fetched INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL CHECK(status IN ('running','success','error')),
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
INSERT INTO sync_runs_new (id, kind, started_at, finished_at, activities_fetched, status, error_message)
|
||||
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message FROM sync_runs;
|
||||
|
||||
DROP TABLE sync_runs;
|
||||
ALTER TABLE sync_runs_new RENAME TO sync_runs;
|
||||
@@ -1,6 +0,0 @@
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
oidc_sub TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -1,15 +0,0 @@
|
||||
-- user_id is nullable here even though every row will eventually need one:
|
||||
-- migrations can't take runtime parameters, so the actual owner isn't known
|
||||
-- yet. A one-time Go bootstrap step (store.ClaimLegacyOwner, run at
|
||||
-- geniusrund startup) backfills every existing row to one user once given
|
||||
-- that user's OIDC subject; from then on every store method requires a
|
||||
-- non-nil userID and this column is never NULL again in practice.
|
||||
ALTER TABLE activities ADD COLUMN user_id INTEGER REFERENCES users(id);
|
||||
ALTER TABLE sync_runs ADD COLUMN user_id INTEGER REFERENCES users(id);
|
||||
|
||||
-- Used by UpsertActivity's ON CONFLICT target going forward. The original
|
||||
-- UNIQUE(garmin_activity_id) constraint (migration 0001) stays in place too
|
||||
-- -- Garmin's own activity ids are already globally unique in practice, so
|
||||
-- the stricter constraint is harmless, and SQLite can't drop a column-level
|
||||
-- constraint without a full table rebuild, which isn't worth the risk here.
|
||||
CREATE UNIQUE INDEX ux_activities_user_garmin_id ON activities(user_id, garmin_activity_id);
|
||||
@@ -1,76 +0,0 @@
|
||||
-- profile.id's CHECK(id = 1) singleton constraint (migration 0003) can't be
|
||||
-- dropped via ALTER TABLE -- SQLite has no DROP CONSTRAINT -- so this
|
||||
-- rebuilds the table via SQLite's documented rename/recreate/copy/drop
|
||||
-- pattern instead. Always create the replacement under a different name and
|
||||
-- RENAME it into place at the end (never rename the live table away first)
|
||||
-- -- verified directly against SQLite that this ordering is what keeps
|
||||
-- other tables' foreign keys intact when they exist (not the case for
|
||||
-- profile, but kept consistent with migrations 0024/0025 for the same
|
||||
-- pattern). user_id is nullable for the same not-yet-known-owner reason as
|
||||
-- migration 0022 -- see its comment.
|
||||
CREATE TABLE profile_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
name TEXT NOT NULL DEFAULT 'Default',
|
||||
garmin_email TEXT NOT NULL DEFAULT '',
|
||||
garmin_password TEXT NOT NULL DEFAULT '',
|
||||
rolling_window_days INTEGER NOT NULL DEFAULT 90,
|
||||
backfill_horizon_days INTEGER NOT NULL DEFAULT 1095,
|
||||
max_heart_rate REAL,
|
||||
resting_heart_rate REAL,
|
||||
hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
|
||||
hr_zone1_max_pct REAL NOT NULL DEFAULT 60,
|
||||
hr_zone2_min_pct REAL NOT NULL DEFAULT 60,
|
||||
hr_zone2_max_pct REAL NOT NULL DEFAULT 70,
|
||||
hr_zone3_min_pct REAL NOT NULL DEFAULT 70,
|
||||
hr_zone3_max_pct REAL NOT NULL DEFAULT 80,
|
||||
hr_zone4_min_pct REAL NOT NULL DEFAULT 80,
|
||||
hr_zone4_max_pct REAL NOT NULL DEFAULT 90,
|
||||
hr_zone5_min_pct REAL NOT NULL DEFAULT 90,
|
||||
hr_zone5_max_pct REAL NOT NULL DEFAULT 100,
|
||||
warmup_minutes REAL NOT NULL DEFAULT 10,
|
||||
cooldown_minutes REAL NOT NULL DEFAULT 5,
|
||||
min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720,
|
||||
min_representative_time_seconds REAL NOT NULL DEFAULT 3,
|
||||
pace_color TEXT NOT NULL DEFAULT '#3b82f6',
|
||||
heart_rate_color TEXT NOT NULL DEFAULT '#ef4444',
|
||||
warmup_color TEXT NOT NULL DEFAULT '#c2410c',
|
||||
effort_color TEXT NOT NULL DEFAULT '#7c3aed',
|
||||
recovery_color TEXT NOT NULL DEFAULT '#15803d',
|
||||
cooldown_color TEXT NOT NULL DEFAULT '#fb923c',
|
||||
main_line_tint_pct REAL NOT NULL DEFAULT 20,
|
||||
background_darken_pct REAL NOT NULL DEFAULT 35,
|
||||
target_brighten_pct REAL NOT NULL DEFAULT 20,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
INSERT INTO profile_new (
|
||||
id, user_id, name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days,
|
||||
max_heart_rate, resting_heart_rate,
|
||||
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
|
||||
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
|
||||
hr_zone5_min_pct, hr_zone5_max_pct,
|
||||
warmup_minutes, cooldown_minutes,
|
||||
min_representative_pace_sec_per_km, min_representative_time_seconds,
|
||||
pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color,
|
||||
main_line_tint_pct, background_darken_pct, target_brighten_pct,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, NULL, name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days,
|
||||
max_heart_rate, resting_heart_rate,
|
||||
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
|
||||
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
|
||||
hr_zone5_min_pct, hr_zone5_max_pct,
|
||||
warmup_minutes, cooldown_minutes,
|
||||
min_representative_pace_sec_per_km, min_representative_time_seconds,
|
||||
pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color,
|
||||
main_line_tint_pct, background_darken_pct, target_brighten_pct,
|
||||
created_at, updated_at
|
||||
FROM profile;
|
||||
|
||||
DROP TABLE profile;
|
||||
|
||||
ALTER TABLE profile_new RENAME TO profile;
|
||||
@@ -1,31 +0,0 @@
|
||||
-- workout_kinds.name's UNIQUE(name) constraint (migration 0001) must become
|
||||
-- per-user (UNIQUE(user_id, name)) now that every user gets their own copy
|
||||
-- of the same 8-kind taxonomy -- otherwise a second user could never be
|
||||
-- provisioned (inserting the same seeded name would collide). kind_assignments
|
||||
-- and workout_type_paces hold foreign keys into this table
|
||||
-- (workout_kind_id REFERENCES workout_kinds(id)) -- the create-new-name/
|
||||
-- copy/drop-old/rename-into-place order below (verified against a real
|
||||
-- SQLite database) leaves those foreign keys' schema text untouched
|
||||
-- throughout, so they resolve correctly again the instant the final
|
||||
-- `ALTER TABLE workout_kinds_new RENAME TO workout_kinds` completes.
|
||||
CREATE TABLE workout_kinds_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
rule_json TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
INSERT INTO workout_kinds_new (id, user_id, name, description, color, rule_json, priority, is_active, created_at, updated_at)
|
||||
SELECT id, NULL, name, description, color, rule_json, priority, is_active, created_at, updated_at
|
||||
FROM workout_kinds;
|
||||
|
||||
DROP TABLE workout_kinds;
|
||||
|
||||
ALTER TABLE workout_kinds_new RENAME TO workout_kinds;
|
||||
@@ -1,16 +0,0 @@
|
||||
-- Same CHECK(id = 1) rebuild reasoning as migration 0023's profile rebuild.
|
||||
CREATE TABLE sync_state_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
earliest_synced_date TEXT,
|
||||
backfill_complete INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
INSERT INTO sync_state_new (id, user_id, earliest_synced_date, backfill_complete)
|
||||
SELECT id, NULL, earliest_synced_date, backfill_complete
|
||||
FROM sync_state;
|
||||
|
||||
DROP TABLE sync_state;
|
||||
|
||||
ALTER TABLE sync_state_new RENAME TO sync_state;
|
||||
@@ -1,55 +0,0 @@
|
||||
-- activities.garmin_activity_id's UNIQUE constraint (migration 0001) must become
|
||||
-- per-user (UNIQUE(user_id, garmin_activity_id)) now that the per-user-profile
|
||||
-- design allows multiple users to share the same Garmin activity ID. The old
|
||||
-- constraint alone was intentionally left in place by migration 0022 as a
|
||||
-- belts-and-suspenders safeguard (Garmin IDs are globally unique in practice),
|
||||
-- but it must be removed now to allow Task 6's cross-user test to pass.
|
||||
--
|
||||
-- The FK enforcement toggle needed for this rebuild (DROP TABLE on a table
|
||||
-- with real inbound foreign keys) happens in Go code around this migration's
|
||||
-- execution (db.go's tableRebuildMigrations map), in autocommit mode before
|
||||
-- the transaction begins -- a mid-transaction PRAGMA foreign_keys statement
|
||||
-- is a documented no-op with modernc.org/sqlite, so it must not appear here.
|
||||
--
|
||||
-- event_type_key keeps the DEFAULT '' that migration 0007 originally gave
|
||||
-- it (ALTER TABLE ... ADD COLUMN event_type_key TEXT NOT NULL DEFAULT '');
|
||||
-- dropping the default here (as an earlier draft of this migration did)
|
||||
-- broke every INSERT that omits event_type_key and relies on that default,
|
||||
-- which several existing tests (e.g. TestClaimLegacyOwner_*) do.
|
||||
|
||||
CREATE TABLE activities_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
garmin_activity_id INTEGER NOT NULL,
|
||||
event_type_key TEXT NOT NULL DEFAULT '',
|
||||
workout_id INTEGER,
|
||||
start_time_utc TEXT NOT NULL,
|
||||
duration_seconds REAL NOT NULL,
|
||||
distance_meters REAL NOT NULL,
|
||||
avg_hr REAL,
|
||||
max_hr REAL,
|
||||
avg_speed_mps REAL,
|
||||
elevation_gain_m REAL,
|
||||
aerobic_training_effect REAL,
|
||||
anaerobic_training_effect REAL,
|
||||
vo2max_value REAL,
|
||||
raw_json TEXT NOT NULL,
|
||||
details_fetched_at TEXT,
|
||||
details_raw_json TEXT,
|
||||
splits_fetched_at TEXT,
|
||||
workout_raw_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, garmin_activity_id)
|
||||
);
|
||||
|
||||
INSERT INTO activities_new (id, user_id, garmin_activity_id, event_type_key, workout_id, start_time_utc, duration_seconds, distance_meters, avg_hr, max_hr, avg_speed_mps, elevation_gain_m, aerobic_training_effect, anaerobic_training_effect, vo2max_value, raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, created_at, updated_at)
|
||||
SELECT id, user_id, garmin_activity_id, event_type_key, workout_id, start_time_utc, duration_seconds, distance_meters, avg_hr, max_hr, avg_speed_mps, elevation_gain_m, aerobic_training_effect, anaerobic_training_effect, vo2max_value, raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, created_at, updated_at
|
||||
FROM activities;
|
||||
|
||||
DROP TABLE activities;
|
||||
|
||||
ALTER TABLE activities_new RENAME TO activities;
|
||||
|
||||
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
|
||||
CREATE INDEX idx_activities_user_garmin_id ON activities(user_id, garmin_activity_id);
|
||||
@@ -8,11 +8,14 @@ import (
|
||||
// Profile is the single active user's Garmin credentials plus every
|
||||
// tunable analysis-engine parameter. Always exactly one row (id=1).
|
||||
type Profile struct {
|
||||
// Name labels this profile so a future multi-profile setup can show
|
||||
// which one is active. Only one profile row exists today (id=1).
|
||||
Name string
|
||||
GarminEmail string
|
||||
GarminPassword string
|
||||
GarminEmail string
|
||||
GarminPassword string
|
||||
// GarminConnectedAt is nil until this user's first successful Garmin
|
||||
// authentication (see store.MarkGarminConnected) -- the login gate uses
|
||||
// it, not UpdateProfile, so it's deliberately excluded from
|
||||
// UpdateProfile's SET clause below and can only ever move from nil to
|
||||
// set, never reset by a normal profile save.
|
||||
GarminConnectedAt *string
|
||||
RollingWindowDays int
|
||||
// BackfillHorizonDays bounds how far back "Sync now" reaches when
|
||||
// walking backward from today; it's read fresh on every sync (not fixed
|
||||
@@ -70,7 +73,7 @@ type Profile struct {
|
||||
}
|
||||
|
||||
const profileColumns = `
|
||||
name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate,
|
||||
garmin_email, garmin_password, garmin_connected_at, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate,
|
||||
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
|
||||
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
|
||||
hr_zone5_min_pct, hr_zone5_max_pct,
|
||||
@@ -84,8 +87,8 @@ const profileColumns = `
|
||||
// GetProfile returns the profile row for userID.
|
||||
func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
|
||||
var p Profile
|
||||
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE user_id = ?`, userID).Scan(
|
||||
&p.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
|
||||
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profiles WHERE user_id = ?`, userID).Scan(
|
||||
&p.GarminEmail, &p.GarminPassword, &p.GarminConnectedAt, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
|
||||
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
|
||||
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
|
||||
&p.HRZone5MinPct, &p.HRZone5MaxPct,
|
||||
@@ -106,8 +109,8 @@ func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
|
||||
// replaces every column.
|
||||
func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE profile SET
|
||||
name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
|
||||
UPDATE profiles SET
|
||||
garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
|
||||
hr_zone1_min_pct=?, hr_zone1_max_pct=?, hr_zone2_min_pct=?, hr_zone2_max_pct=?,
|
||||
hr_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?,
|
||||
hr_zone5_min_pct=?, hr_zone5_max_pct=?,
|
||||
@@ -117,7 +120,7 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error
|
||||
main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
|
||||
updated_at=datetime('now')
|
||||
WHERE user_id = ?`,
|
||||
p.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
|
||||
p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
|
||||
p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
|
||||
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
|
||||
p.HRZone5MinPct, p.HRZone5MaxPct,
|
||||
@@ -132,3 +135,14 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkGarminConnected records the first time userID successfully
|
||||
// authenticates with Garmin. A no-op if already set, so it always reflects
|
||||
// the first connection, not the most recent one.
|
||||
func (db *DB) MarkGarminConnected(ctx context.Context, userID int64) error {
|
||||
_, err := db.ExecContext(ctx, `UPDATE profiles SET garmin_connected_at = datetime('now') WHERE user_id = ? AND garmin_connected_at IS NULL`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark garmin connected for user %d: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -26,15 +26,12 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||||
if p.WarmupMinutes != 10 || p.CooldownMinutes != 5 {
|
||||
t.Errorf("phase-minute defaults = %+v, want warmup=10, cooldown=5", p)
|
||||
}
|
||||
if p.BackfillHorizonDays != 1095 {
|
||||
t.Errorf("BackfillHorizonDays = %d, want 1095 (migration default)", p.BackfillHorizonDays)
|
||||
if p.BackfillHorizonDays != 90 {
|
||||
t.Errorf("BackfillHorizonDays = %d, want 90 (schema default)", p.BackfillHorizonDays)
|
||||
}
|
||||
if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 {
|
||||
t.Errorf("pace artifact filter defaults = %+v, want pace=720, time=3", p)
|
||||
}
|
||||
if p.Name != "Default" {
|
||||
t.Errorf("Name = %q, want %q (migration default)", p.Name, "Default")
|
||||
}
|
||||
if p.PaceColor != "#3b82f6" || p.HeartRateColor != "#ef4444" {
|
||||
t.Errorf("main line color defaults = %+v, want pace=#3b82f6, heartRate=#ef4444", p)
|
||||
}
|
||||
@@ -52,7 +49,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
maxHR, restingHR := 190.0, 50.0
|
||||
p.Name = "Kriss"
|
||||
p.PaceColor = "#111111"
|
||||
p.EffortColor = "#222222"
|
||||
p.MainLineTintPct = 45
|
||||
@@ -79,9 +75,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||||
if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 {
|
||||
t.Errorf("got = %+v, want updated email/window", got)
|
||||
}
|
||||
if got.Name != "Kriss" {
|
||||
t.Errorf("Name = %q, want %q after update", got.Name, "Kriss")
|
||||
}
|
||||
if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 {
|
||||
t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate)
|
||||
}
|
||||
@@ -107,3 +100,44 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||||
t.Errorf("TargetBrightenPct after update = %v, want 50", got.TargetBrightenPct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkGarminConnected_SetsTimestampOnceOnFirstCall(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
before, err := db.GetProfile(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile: %v", err)
|
||||
}
|
||||
if before.GarminConnectedAt != nil {
|
||||
t.Fatalf("expected a fresh profile to have nil GarminConnectedAt, got %v", *before.GarminConnectedAt)
|
||||
}
|
||||
|
||||
if err := db.MarkGarminConnected(ctx, userID); err != nil {
|
||||
t.Fatalf("MarkGarminConnected: %v", err)
|
||||
}
|
||||
afterFirst, err := db.GetProfile(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile after first mark: %v", err)
|
||||
}
|
||||
if afterFirst.GarminConnectedAt == nil {
|
||||
t.Fatal("expected GarminConnectedAt to be set after MarkGarminConnected")
|
||||
}
|
||||
firstValue := *afterFirst.GarminConnectedAt
|
||||
|
||||
// A second call must not change the recorded first-connection time.
|
||||
if err := db.MarkGarminConnected(ctx, userID); err != nil {
|
||||
t.Fatalf("MarkGarminConnected (second call): %v", err)
|
||||
}
|
||||
afterSecond, err := db.GetProfile(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile after second mark: %v", err)
|
||||
}
|
||||
if afterSecond.GarminConnectedAt == nil || *afterSecond.GarminConnectedAt != firstValue {
|
||||
t.Fatalf("GarminConnectedAt changed on second call: first=%q second=%v", firstValue, afterSecond.GarminConnectedAt)
|
||||
}
|
||||
}
|
||||
266
backend/internal/store/schema.sql
Normal file
266
backend/internal/store/schema.sql
Normal file
@@ -0,0 +1,266 @@
|
||||
-- geniusrun's complete SQLite schema, applied in full on every Open() (see
|
||||
-- db.go). There is no migration history: this file is regenerated in place
|
||||
-- whenever the schema changes, and is the single source of truth for both
|
||||
-- the app and its documentation (see docs/DATABASE.md, generated from this
|
||||
-- file's live effect via cmd/dumpschema -- regenerate it after editing this
|
||||
-- file). This is a pre-production app with no compatibility obligation to
|
||||
-- older database files; if you need to change a column, edit it directly
|
||||
-- here rather than appending an ALTER TABLE migration.
|
||||
|
||||
-- One geniusrun account per OIDC subject. Every other table below is scoped
|
||||
-- to a user_id, directly (profiles/workout_kinds/activities/sync_state/
|
||||
-- sync_runs) or transitively through a JOIN to the owning row (activity_laps/
|
||||
-- activity_samples/kind_assignments/workout_type_paces, which have no
|
||||
-- user_id column of their own since they're never queried except through a
|
||||
-- specific activity or workout kind). name is the account's single
|
||||
-- human-facing name: set at onboarding, editable from the Profile page.
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
oidc_sub TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- One row per user: Garmin credentials plus every tunable analysis-engine
|
||||
-- parameter (HR zones, phase-detection minutes, pace-artifact filtering,
|
||||
-- chart colors). store.ProvisionUser creates this (and everything else
|
||||
-- below) in one transaction when a new account signs up.
|
||||
CREATE TABLE profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
garmin_email TEXT NOT NULL DEFAULT '',
|
||||
garmin_password TEXT NOT NULL DEFAULT '',
|
||||
-- Set once, the first time this user successfully authenticates with
|
||||
-- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
|
||||
-- the login gate uses this (not the in-memory auth status, which
|
||||
-- resets on restart) to decide whether a returning user must
|
||||
-- reconnect before entering the app.
|
||||
garmin_connected_at TEXT,
|
||||
rolling_window_days INTEGER NOT NULL DEFAULT 90,
|
||||
-- BackfillHorizonDays bounds how far back "Sync now" reaches when
|
||||
-- walking backward from today; read fresh on every sync (not cached at
|
||||
-- server startup), so changing it here takes effect on the next click.
|
||||
backfill_horizon_days INTEGER NOT NULL DEFAULT 90,
|
||||
max_heart_rate REAL,
|
||||
resting_heart_rate REAL,
|
||||
hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
|
||||
hr_zone1_max_pct REAL NOT NULL DEFAULT 60,
|
||||
hr_zone2_min_pct REAL NOT NULL DEFAULT 60,
|
||||
hr_zone2_max_pct REAL NOT NULL DEFAULT 70,
|
||||
hr_zone3_min_pct REAL NOT NULL DEFAULT 70,
|
||||
hr_zone3_max_pct REAL NOT NULL DEFAULT 80,
|
||||
hr_zone4_min_pct REAL NOT NULL DEFAULT 80,
|
||||
hr_zone4_max_pct REAL NOT NULL DEFAULT 90,
|
||||
hr_zone5_min_pct REAL NOT NULL DEFAULT 90,
|
||||
hr_zone5_max_pct REAL NOT NULL DEFAULT 100,
|
||||
-- Applies uniformly to every fixed-duration workout type's phase
|
||||
-- detection (Easy, Long, Tempo, Threshold 30'/60', MAS Test). Interval
|
||||
-- workouts detect phases from lap data directly and don't use these.
|
||||
warmup_minutes REAL NOT NULL DEFAULT 10,
|
||||
cooldown_minutes REAL NOT NULL DEFAULT 5,
|
||||
-- Review Queue pace-chart artifact filter: a stretch of samples slower
|
||||
-- than min_representative_pace_sec_per_km is dropped unless it persists
|
||||
-- for at least min_representative_time_seconds, in which case it's a
|
||||
-- real stop/walk break rather than GPS/motion noise at recording start.
|
||||
min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720,
|
||||
min_representative_time_seconds REAL NOT NULL DEFAULT 3,
|
||||
-- Chart colors: pace_color/heart_rate_color are each chart's "main
|
||||
-- line" color; warmup/effort/recovery/cooldown_color are the "effort
|
||||
-- kind" colors the frontend derives fills from (see
|
||||
-- ExpectedVsActualChart).
|
||||
pace_color TEXT NOT NULL DEFAULT '#3b82f6',
|
||||
heart_rate_color TEXT NOT NULL DEFAULT '#ef4444',
|
||||
warmup_color TEXT NOT NULL DEFAULT '#c2410c',
|
||||
effort_color TEXT NOT NULL DEFAULT '#7c3aed',
|
||||
recovery_color TEXT NOT NULL DEFAULT '#15803d',
|
||||
cooldown_color TEXT NOT NULL DEFAULT '#fb923c',
|
||||
main_line_tint_pct REAL NOT NULL DEFAULT 20,
|
||||
background_darken_pct REAL NOT NULL DEFAULT 35,
|
||||
target_brighten_pct REAL NOT NULL DEFAULT 20,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
-- The fixed, closed 8-type taxonomy (Easy, Long, 60' Threshold,
|
||||
-- 30' Threshold, Tempo, Intervals, MAS Test, Race) -- one independently
|
||||
-- tunable copy per user, seeded by store.ProvisionUser. rule_json holds the
|
||||
-- recursive AND/OR condition tree evaluated by internal/classify.
|
||||
CREATE TABLE workout_kinds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
rule_json TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
-- Each workout kind's user-declared target pace range + HR range (percent
|
||||
-- of heart rate reserve). Informational only -- never read by the
|
||||
-- classification rule engine. No history: overwritten in place, since the
|
||||
-- synced activity log is the history. No user_id column of its own --
|
||||
-- ownership is checked via a JOIN to workout_kinds.
|
||||
CREATE TABLE workout_type_paces (
|
||||
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
|
||||
pace_min_sec_per_km REAL,
|
||||
pace_max_sec_per_km REAL,
|
||||
hr_min_pct_hrr REAL,
|
||||
hr_max_pct_hrr REAL
|
||||
);
|
||||
|
||||
-- One row per synced Garmin activity. garmin_activity_id is the natural
|
||||
-- idempotency key for UpsertActivity's ON CONFLICT, scoped per user so two
|
||||
-- different users' Garmin accounts can never collide even if their
|
||||
-- activity IDs coincided. raw_json/details_raw_json/workout_raw_json store
|
||||
-- the full original Garmin JSON -- fields that are a pure untransformed
|
||||
-- copy of something already in raw_json (activity_name, activity_type,
|
||||
-- etc.) are deliberately NOT modeled as their own columns; internal/api's
|
||||
-- display_fields.go decodes them fresh from raw_json at response time
|
||||
-- instead of storing a redundant copy.
|
||||
CREATE TABLE activities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
garmin_activity_id INTEGER NOT NULL,
|
||||
-- Derived at sync time from Garmin's own eventType.typeKey=="race" --
|
||||
-- a hard fact, not a rule the user tunes (see internal/classify's
|
||||
-- is_race metric and handleReclassifyAll's Race special-case).
|
||||
event_type_key TEXT NOT NULL DEFAULT '',
|
||||
-- Set when the activity was recorded from a structured Garmin workout;
|
||||
-- used to resolve each lap's expected pace/HR band (see
|
||||
-- internal/sync/mapping.go's alignWorkoutTargets).
|
||||
workout_id INTEGER,
|
||||
start_time_utc TEXT NOT NULL,
|
||||
duration_seconds REAL NOT NULL,
|
||||
distance_meters REAL NOT NULL,
|
||||
avg_hr REAL,
|
||||
max_hr REAL,
|
||||
avg_speed_mps REAL,
|
||||
elevation_gain_m REAL,
|
||||
aerobic_training_effect REAL,
|
||||
anaerobic_training_effect REAL,
|
||||
vo2max_value REAL,
|
||||
raw_json TEXT NOT NULL,
|
||||
details_fetched_at TEXT,
|
||||
details_raw_json TEXT,
|
||||
splits_fetched_at TEXT,
|
||||
-- Genuine raw get_workout_by_id() response, the source used to compute
|
||||
-- alignWorkoutTargets. Null when the activity has no workout_id.
|
||||
workout_raw_json TEXT,
|
||||
-- Set when get_workout_by_id returned a definitive HTTP 404 (the
|
||||
-- workout was deleted on Garmin's side after being linked to this
|
||||
-- activity) -- distinct from workout_raw_json staying null for "not yet
|
||||
-- fetched": this activity is excluded from ActivitiesMissingWorkout so
|
||||
-- it stops being retried forever (see
|
||||
-- docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
|
||||
-- workout_raw_json itself is never fabricated; it just stays null.
|
||||
workout_not_found_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, garmin_activity_id)
|
||||
);
|
||||
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
|
||||
|
||||
-- One row per lap/split (from get_activity_splits), plus HR drift/recovery
|
||||
-- derived from activity_samples. No user_id column -- always accessed
|
||||
-- through a specific owning activity.
|
||||
CREATE TABLE activity_laps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
lap_index INTEGER NOT NULL,
|
||||
avg_speed_mps REAL,
|
||||
intensity_type TEXT NOT NULL DEFAULT '',
|
||||
hr_drift_bpm_per_min REAL,
|
||||
hr_recovery_bpm_per_min REAL,
|
||||
-- Expected band for this lap, resolved from the activity's structured
|
||||
-- Garmin workout when its steps line up 1:1 with the recorded laps.
|
||||
-- Null when there's no structured workout or the counts don't match.
|
||||
target_pace_low_mps REAL,
|
||||
target_pace_high_mps REAL,
|
||||
target_hr_low_bpm REAL,
|
||||
target_hr_high_bpm REAL,
|
||||
raw_json TEXT NOT NULL,
|
||||
UNIQUE(activity_id, lap_index)
|
||||
);
|
||||
|
||||
-- One row per ~1-second telemetry sample (from get_activity_details). No
|
||||
-- user_id column -- always accessed through a specific owning activity.
|
||||
CREATE TABLE activity_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
elapsed_seconds REAL NOT NULL,
|
||||
timestamp_ms INTEGER NOT NULL,
|
||||
heart_rate REAL,
|
||||
speed_mps REAL,
|
||||
distance_m REAL,
|
||||
elevation_m REAL
|
||||
);
|
||||
CREATE INDEX idx_activity_samples_activity ON activity_samples(activity_id, elapsed_seconds);
|
||||
|
||||
-- Append-only classification history -- always INSERT, never UPDATE.
|
||||
-- Re-classifying after a rule edit, or a manual override, keeps full
|
||||
-- history; current_kind_assignment (below) picks the latest row per
|
||||
-- activity. assignment_source distinguishes manual overrides (locked
|
||||
-- against future global reclassifies) from rule-engine assignments. No
|
||||
-- user_id column -- always accessed through the owning activity.
|
||||
CREATE TABLE kind_assignments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
workout_kind_id INTEGER REFERENCES workout_kinds(id),
|
||||
assignment_source TEXT NOT NULL CHECK(assignment_source IN ('rule_engine','manual')),
|
||||
status TEXT NOT NULL CHECK(status IN ('assigned','needs_review')),
|
||||
confidence REAL,
|
||||
candidate_kinds_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX idx_kind_assignments_activity ON kind_assignments(activity_id);
|
||||
CREATE INDEX idx_kind_assignments_status ON kind_assignments(status);
|
||||
|
||||
CREATE VIEW current_kind_assignment AS
|
||||
SELECT a.* FROM kind_assignments a
|
||||
JOIN (
|
||||
SELECT activity_id, MAX(id) AS max_id
|
||||
FROM kind_assignments GROUP BY activity_id
|
||||
) latest ON a.activity_id = latest.activity_id AND a.id = latest.max_id;
|
||||
|
||||
-- One row per user: tracks a backfill watermark (earliest_synced_date,
|
||||
-- backfill_complete). Since Garmin history is immutable once recorded,
|
||||
-- Service.Backfill uses this to resume from where it left off instead of
|
||||
-- re-walking years of already-known history on every call.
|
||||
CREATE TABLE sync_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
earliest_synced_date TEXT,
|
||||
backfill_complete INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
-- One row per backfill/incremental/full sync attempt, for the "last sync"
|
||||
-- status the frontend polls.
|
||||
CREATE TABLE sync_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
activities_fetched INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL CHECK(status IN ('running','success','error')),
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
-- Application configuration: instance-global key/value settings, shared
|
||||
-- by every user -- deliberately the one table with no user_id, because
|
||||
-- application configuration is common to all users by definition. Every
|
||||
-- key in internal/config's app-key registry is mandatory here: missing
|
||||
-- rows are seeded with their defaults at startup (cmd/geniusrund), so
|
||||
-- there are no code-side fallbacks. Cold: read once at startup, a change
|
||||
-- applies on the next backend restart.
|
||||
CREATE TABLE config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -2,10 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -23,7 +20,7 @@ func openTestDB(t *testing.T) *DB {
|
||||
|
||||
func f(v float64) *float64 { return &v }
|
||||
|
||||
func TestMigrateIsIdempotent(t *testing.T) {
|
||||
func TestOpenIsIdempotent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "geniusrun_test.db")
|
||||
db1, err := Open(path)
|
||||
if err != nil {
|
||||
@@ -33,7 +30,7 @@ func TestMigrateIsIdempotent(t *testing.T) {
|
||||
|
||||
db2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("second Open (re-applying migrations): %v", err)
|
||||
t.Fatalf("second Open (schema already applied): %v", err)
|
||||
}
|
||||
db2.Close()
|
||||
}
|
||||
@@ -152,12 +149,12 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
||||
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
||||
}
|
||||
|
||||
queue, err := db.ReviewQueue(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewQueue: %v", err)
|
||||
pending, ok, err := db.CurrentAssignment(ctx, userID, activityID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CurrentAssignment (needs_review): ok=%v err=%v", ok, err)
|
||||
}
|
||||
if len(queue) != 1 {
|
||||
t.Fatalf("expected 1 item in review queue, got %d", len(queue))
|
||||
if pending.Status != AssignmentStatusNeedsReview {
|
||||
t.Fatalf("current status = %q, want needs_review", pending.Status)
|
||||
}
|
||||
|
||||
// Then: user manually resolves it.
|
||||
@@ -170,14 +167,6 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
||||
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
||||
}
|
||||
|
||||
queue, err = db.ReviewQueue(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewQueue after resolve: %v", err)
|
||||
}
|
||||
if len(queue) != 0 {
|
||||
t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue))
|
||||
}
|
||||
|
||||
current, ok, err := db.CurrentAssignment(ctx, userID, activityID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
||||
@@ -242,24 +231,12 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersAndOwnershipSchema_AppliesCleanly(t *testing.T) {
|
||||
func TestSchema_PerUserUniqueConstraints(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// A fresh DB has no legacy singleton rows, so every user_id column
|
||||
// should already be backfilled to nothing (fresh install, no rows at
|
||||
// all yet in these tables besides the migration-seeded workout_kinds --
|
||||
// which do have NULL user_id until a real user is provisioned).
|
||||
var nullableCount int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM workout_kinds WHERE user_id IS NULL`).Scan(&nullableCount); err != nil {
|
||||
t.Fatalf("count workout_kinds: %v", err)
|
||||
}
|
||||
if nullableCount != 8 {
|
||||
t.Fatalf("expected 8 migration-seeded workout_kinds with NULL user_id on a fresh DB, got %d", nullableCount)
|
||||
}
|
||||
|
||||
// UNIQUE(user_id, name) allows the same name across two different users.
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES ('sub-a', 'A'), ('sub-b', 'B')`); err != nil {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, name) VALUES ('sub-a', 'A'), ('sub-b', 'B')`); err != nil {
|
||||
t.Fatalf("insert users: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
@@ -270,22 +247,20 @@ func TestUsersAndOwnershipSchema_AppliesCleanly(t *testing.T) {
|
||||
}
|
||||
|
||||
// profile/sync_state UNIQUE(user_id) still rejects a genuine duplicate.
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO profile (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err != nil {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO profiles (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err != nil {
|
||||
t.Fatalf("insert profile for sub-a: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO profile (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err == nil {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO profiles (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err == nil {
|
||||
t.Fatal("expected a second profile row for the same user_id to violate UNIQUE(user_id)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignKeyEnforcementPostMigration(t *testing.T) {
|
||||
func TestForeignKeyEnforcement(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Verify that FK enforcement is correctly active after migrations complete.
|
||||
// This tests that the PRAGMA foreign_keys toggle in db.migrate() (for
|
||||
// migrations 0023/0024/0025) is properly scoped and re-enabled after each
|
||||
// table rebuild.
|
||||
// Verify FK enforcement is genuinely active: a valid reference succeeds,
|
||||
// a bogus one is rejected, not silently accepted.
|
||||
|
||||
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||
if err != nil {
|
||||
@@ -302,7 +277,7 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) {
|
||||
t.Fatalf("UpsertActivity: %v", err)
|
||||
}
|
||||
|
||||
// Get the ID of one of the 8 migration-seeded workout_kinds.
|
||||
// Get the ID of one of the 8 workout_kinds ProvisionUser seeded.
|
||||
var seedKindID int64
|
||||
if err := db.QueryRowContext(ctx, `SELECT id FROM workout_kinds LIMIT 1`).Scan(&seedKindID); err != nil {
|
||||
t.Fatalf("query seeded workout_kind: %v", err)
|
||||
@@ -341,233 +316,6 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRebuildMigrationsPreserveForeignKeyReferences proves the specific
|
||||
// property none of the other migration tests cover: every other test opens
|
||||
// a fresh DB via store.Open, which runs migrations 0001-0026 in one
|
||||
// uninterrupted pass over an empty database, so migrations 0023/0024/0025/0026's
|
||||
// table-rebuild (create-new/copy/drop-old/rename-into-place) never has any
|
||||
// real pre-existing rows to carry across. A real single-tenant deployment
|
||||
// upgrading to this schema version has months of synced activities, laps,
|
||||
// activity_samples, and kind_assignments rows referencing real activities/
|
||||
// workout_kinds rows by foreign key (per this repo's CLAUDE.md: one profile,
|
||||
// real Garmin history, a review queue with manual overrides already
|
||||
// recorded). This test manually applies migrations up through 0022, inserts
|
||||
// rows simulating that pre-existing install, then applies 0023/0024/0025/0026
|
||||
// and verifies every FK-referencing row still resolves correctly -- and that
|
||||
// FK enforcement is genuinely back on afterward -- rather than just checking
|
||||
// that migrations apply to an empty DB without erroring. This is the
|
||||
// regression guard for a real bug: migration 0026 (activities table rebuild)
|
||||
// originally issued its own mid-transaction `PRAGMA foreign_keys = OFF/ON`,
|
||||
// which SQLite documents as a no-op once a transaction is open, so
|
||||
// `DROP TABLE activities` silently cascade-deleted every laps/
|
||||
// activity_samples/kind_assignments row for every activity via
|
||||
// `ON DELETE CASCADE` -- with no error at all. It was masked because every
|
||||
// other test runs migrations back-to-back on an empty database with no
|
||||
// pre-existing child rows.
|
||||
func TestRebuildMigrationsPreserveForeignKeyReferences(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "geniusrun_rebuild_test.db")
|
||||
|
||||
// Deliberately not store.Open: that always applies every migration in
|
||||
// one uninterrupted pass with no way to stop partway through. The sqlite
|
||||
// driver itself is already registered via db.go's blank import.
|
||||
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
if _, err := sqlDB.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`); err != nil {
|
||||
t.Fatalf("create schema_migrations table: %v", err)
|
||||
}
|
||||
|
||||
rebuildMigrations := map[string]bool{
|
||||
"0023_profile_user_scoped.sql": true,
|
||||
"0024_workout_kinds_user_scoped.sql": true,
|
||||
"0025_sync_state_user_scoped.sql": true,
|
||||
"0026_activities_unique_constraint.sql": true,
|
||||
}
|
||||
|
||||
// Mirrors db.go's migrate() method: FK toggle in autocommit mode around
|
||||
// the transaction, only for the four table-rebuild migrations.
|
||||
applyMigration := func(name string) {
|
||||
t.Helper()
|
||||
content, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", name, err)
|
||||
}
|
||||
|
||||
needsFKToggle := rebuildMigrations[name]
|
||||
if needsFKToggle {
|
||||
if _, err := sqlDB.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
|
||||
t.Fatalf("disable foreign keys before migration %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := sqlDB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin migration tx for %s: %v", name, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, string(content)); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("apply migration %s: %v", name, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("record migration %s: %v", name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit migration %s: %v", name, err)
|
||||
}
|
||||
|
||||
if needsFKToggle {
|
||||
if _, err := sqlDB.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
|
||||
t.Fatalf("enable foreign keys after migration %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("glob migrations: %v", err)
|
||||
}
|
||||
sort.Strings(entries)
|
||||
|
||||
// Apply every migration up to (but not including) the three rebuilds.
|
||||
for _, entry := range entries {
|
||||
name := entry[len("migrations/"):]
|
||||
if rebuildMigrations[name] {
|
||||
continue
|
||||
}
|
||||
applyMigration(name)
|
||||
}
|
||||
|
||||
// Simulate a real pre-existing single-tenant install at this point in
|
||||
// schema history: a workout kind, a synced activity, and a
|
||||
// kind_assignment referencing both by foreign key.
|
||||
res, err := sqlDB.ExecContext(ctx, `INSERT INTO workout_kinds (name, rule_json) VALUES ('Pre-Existing Custom Kind', '{}')`)
|
||||
if err != nil {
|
||||
t.Fatalf("insert pre-existing workout_kinds row: %v", err)
|
||||
}
|
||||
kindID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("workout_kinds LastInsertId: %v", err)
|
||||
}
|
||||
|
||||
res, err = sqlDB.ExecContext(ctx, `
|
||||
INSERT INTO activities (garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json)
|
||||
VALUES (123456789, '2026-01-01 06:00:00', 1800, 5000, '{}')`)
|
||||
if err != nil {
|
||||
t.Fatalf("insert pre-existing activities row: %v", err)
|
||||
}
|
||||
activityID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("activities LastInsertId: %v", err)
|
||||
}
|
||||
|
||||
res, err = sqlDB.ExecContext(ctx, `
|
||||
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status)
|
||||
VALUES (?, ?, 'rule_engine', 'assigned')`, activityID, kindID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert pre-existing kind_assignments row: %v", err)
|
||||
}
|
||||
assignmentID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("kind_assignments LastInsertId: %v", err)
|
||||
}
|
||||
|
||||
res, err = sqlDB.ExecContext(ctx, `
|
||||
INSERT INTO laps (activity_id, lap_index, raw_json)
|
||||
VALUES (?, 0, '{}')`, activityID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert pre-existing laps row: %v", err)
|
||||
}
|
||||
lapID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("laps LastInsertId: %v", err)
|
||||
}
|
||||
|
||||
res, err = sqlDB.ExecContext(ctx, `
|
||||
INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate)
|
||||
VALUES (?, 60, 1735711260000, 150)`, activityID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert pre-existing activity_samples row: %v", err)
|
||||
}
|
||||
sampleID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("activity_samples LastInsertId: %v", err)
|
||||
}
|
||||
|
||||
// Now apply the four rebuild migrations that drop/recreate profile,
|
||||
// workout_kinds, sync_state, and (0026) activities itself.
|
||||
for _, name := range []string{
|
||||
"0023_profile_user_scoped.sql",
|
||||
"0024_workout_kinds_user_scoped.sql",
|
||||
"0025_sync_state_user_scoped.sql",
|
||||
"0026_activities_unique_constraint.sql",
|
||||
} {
|
||||
applyMigration(name)
|
||||
}
|
||||
|
||||
// The pre-existing kind_assignment row must still resolve to the same
|
||||
// workout_kind, by the same name, across the drop/recreate/rename.
|
||||
var resolvedName string
|
||||
if err := sqlDB.QueryRowContext(ctx, `
|
||||
SELECT wk.name FROM kind_assignments ka
|
||||
JOIN workout_kinds wk ON wk.id = ka.workout_kind_id
|
||||
WHERE ka.id = ?`, assignmentID).Scan(&resolvedName); err != nil {
|
||||
t.Fatalf("join kind_assignments -> workout_kinds after rebuild: %v", err)
|
||||
}
|
||||
if resolvedName != "Pre-Existing Custom Kind" {
|
||||
t.Fatalf("expected pre-existing kind_assignment to still resolve to 'Pre-Existing Custom Kind' after rebuild, got %q", resolvedName)
|
||||
}
|
||||
|
||||
// The pre-existing laps row must still exist and still reference the
|
||||
// same activity -- this is the exact regression guard for migration
|
||||
// 0026's DROP TABLE activities silently cascade-deleting laps via
|
||||
// ON DELETE CASCADE when FK enforcement wasn't actually disabled.
|
||||
var lapActivityID int64
|
||||
if err := sqlDB.QueryRowContext(ctx, `
|
||||
SELECT activity_id FROM laps WHERE id = ?`, lapID).Scan(&lapActivityID); err != nil {
|
||||
t.Fatalf("query pre-existing laps row after rebuild: %v", err)
|
||||
}
|
||||
if lapActivityID != activityID {
|
||||
t.Fatalf("expected pre-existing laps row to still reference activity %d after rebuild, got %d", activityID, lapActivityID)
|
||||
}
|
||||
|
||||
// Same guard for activity_samples.
|
||||
var sampleActivityID int64
|
||||
if err := sqlDB.QueryRowContext(ctx, `
|
||||
SELECT activity_id FROM activity_samples WHERE id = ?`, sampleID).Scan(&sampleActivityID); err != nil {
|
||||
t.Fatalf("query pre-existing activity_samples row after rebuild: %v", err)
|
||||
}
|
||||
if sampleActivityID != activityID {
|
||||
t.Fatalf("expected pre-existing activity_samples row to still reference activity %d after rebuild, got %d", activityID, sampleActivityID)
|
||||
}
|
||||
|
||||
// FK enforcement must be genuinely active again post-migration: a bogus
|
||||
// workout_kind_id, activity_id (laps), and activity_id (activity_samples)
|
||||
// must all be rejected, not silently accepted.
|
||||
if _, err := sqlDB.ExecContext(ctx, `
|
||||
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status)
|
||||
VALUES (?, 999999, 'rule_engine', 'assigned')`, activityID); err == nil {
|
||||
t.Fatal("expected FK constraint violation for nonexistent workout_kind_id after rebuild, but insert succeeded")
|
||||
}
|
||||
if _, err := sqlDB.ExecContext(ctx, `
|
||||
INSERT INTO laps (activity_id, lap_index, raw_json)
|
||||
VALUES (999999, 0, '{}')`); err == nil {
|
||||
t.Fatal("expected FK constraint violation for nonexistent activity_id in laps after rebuild, but insert succeeded")
|
||||
}
|
||||
if _, err := sqlDB.ExecContext(ctx, `
|
||||
INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate)
|
||||
VALUES (999999, 60, 1735711260000, 150)`); err == nil {
|
||||
t.Fatal("expected FK constraint violation for nonexistent activity_id in activity_samples after rebuild, but insert succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentAssignment_ScopedToOwningUser(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
@@ -600,6 +348,183 @@ func TestCurrentAssignment_ScopedToOwningUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivitiesMissingWorkout_OnlyIncludesActivitiesWithDetailsAlreadyFetched
|
||||
// is a regression test for a bug where ActivitiesMissingWorkout selected
|
||||
// activities purely on workout_id IS NOT NULL AND workout_raw_json IS NULL,
|
||||
// with no regard for whether get_activity_details had ever run for that
|
||||
// activity. Since fillPendingDetails and fillPendingWorkouts are each
|
||||
// independently LIMIT-bounded over different candidate sets, a
|
||||
// structured-workout activity could fall inside the workouts pass's window
|
||||
// while still outside the details pass's window on a large first backfill.
|
||||
// fillActivityWorkout would then call LapsForActivity on an activity with no
|
||||
// laps yet written, compute alignWorkoutTargets against zero laps (a
|
||||
// no-op), and unconditionally call SetActivityWorkout anyway -- permanently
|
||||
// marking workout_raw_json non-NULL before the activity ever had a chance
|
||||
// to get real target pace/HR bands once its laps finally arrived. The fix
|
||||
// gates the query on details_fetched_at IS NOT NULL, so an activity is only
|
||||
// eligible for a workout fetch once fillActivityDetails has actually given
|
||||
// it laps to align against.
|
||||
func TestActivitiesMissingWorkout_OnlyIncludesActivitiesWithDetailsAlreadyFetched(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
workoutID := int64(999)
|
||||
// Activity a: has a workout_id, but details/splits have never been
|
||||
// fetched (the exact shape of the bug above). Must now be EXCLUDED --
|
||||
// this is the regression assertion for the bug.
|
||||
withWorkoutNoDetails := Activity{
|
||||
GarminActivityID: 1, WorkoutID: &workoutID,
|
||||
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
|
||||
}
|
||||
idA, err := db.UpsertActivity(ctx, userID, withWorkoutNoDetails)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity (a): %v", err)
|
||||
}
|
||||
|
||||
// Activity b: has a workout_id, and its details/splits have already
|
||||
// been fetched (e.g. its workout fetch failed in some prior run after
|
||||
// details succeeded). This is the one case ActivitiesMissingWorkout must
|
||||
// still surface, since ActivitiesMissingDetails would never pick this
|
||||
// activity up again once details_fetched_at/splits_fetched_at are set.
|
||||
withWorkoutAndDetails := Activity{
|
||||
GarminActivityID: 2, WorkoutID: &workoutID,
|
||||
StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}",
|
||||
}
|
||||
idB, err := db.UpsertActivity(ctx, userID, withWorkoutAndDetails)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity (b): %v", err)
|
||||
}
|
||||
if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil {
|
||||
t.Fatalf("SetActivityDetails (b): %v", err)
|
||||
}
|
||||
if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil {
|
||||
t.Fatalf("SetActivitySplitsFetched (b): %v", err)
|
||||
}
|
||||
|
||||
// Activity c: no workout_id at all. Must be excluded.
|
||||
noWorkout := Activity{
|
||||
GarminActivityID: 3, StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}",
|
||||
}
|
||||
if _, err := db.UpsertActivity(ctx, userID, noWorkout); err != nil {
|
||||
t.Fatalf("UpsertActivity (c): %v", err)
|
||||
}
|
||||
|
||||
// Activity d: already has its workout_raw_json set. Must be excluded.
|
||||
alreadyHasWorkout := Activity{
|
||||
GarminActivityID: 4, WorkoutID: &workoutID,
|
||||
StartTimeUTC: "2026-07-04 06:00:00", RawJSON: "{}",
|
||||
}
|
||||
idD, err := db.UpsertActivity(ctx, userID, alreadyHasWorkout)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity (d): %v", err)
|
||||
}
|
||||
if err := db.SetActivityWorkout(ctx, userID, idD, `{"segments":[]}`); err != nil {
|
||||
t.Fatalf("SetActivityWorkout (d): %v", err)
|
||||
}
|
||||
|
||||
n, err := db.CountActivitiesMissingWorkout(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("CountActivitiesMissingWorkout = %d, want 1 (activity b only -- activity a must be excluded since its details were never fetched)", n)
|
||||
}
|
||||
|
||||
pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ActivitiesMissingWorkout: %v", err)
|
||||
}
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("ActivitiesMissingWorkout returned %d activities, want 1", len(pending))
|
||||
}
|
||||
if pending[0].ID != idB {
|
||||
t.Errorf("ActivitiesMissingWorkout = %+v, want only activity b (id %d)", pending, idB)
|
||||
}
|
||||
for _, a := range pending {
|
||||
if a.ID == idA {
|
||||
t.Errorf("ActivitiesMissingWorkout incorrectly included activity a (workout_id set but details never fetched) -- regression for the silent-loss bug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivitiesMissingWorkout_ExcludesConfirmedNotFound(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
workoutID := int64(999)
|
||||
// Details already fetched, workout confirmed 404 on Garmin -- must not
|
||||
// be retried, so must not appear in ActivitiesMissingWorkout/Count.
|
||||
notFound := Activity{
|
||||
GarminActivityID: 1, WorkoutID: &workoutID,
|
||||
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
|
||||
}
|
||||
idA, err := db.UpsertActivity(ctx, userID, notFound)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity (a): %v", err)
|
||||
}
|
||||
if err := db.SetActivityDetails(ctx, userID, idA, "{}"); err != nil {
|
||||
t.Fatalf("SetActivityDetails (a): %v", err)
|
||||
}
|
||||
if err := db.SetActivitySplitsFetched(ctx, userID, idA); err != nil {
|
||||
t.Fatalf("SetActivitySplitsFetched (a): %v", err)
|
||||
}
|
||||
if err := db.SetActivityWorkoutNotFound(ctx, userID, idA); err != nil {
|
||||
t.Fatalf("SetActivityWorkoutNotFound (a): %v", err)
|
||||
}
|
||||
|
||||
// A genuinely still-pending activity (details fetched, workout not yet
|
||||
// attempted) must still be included, for contrast.
|
||||
stillPending := Activity{
|
||||
GarminActivityID: 2, WorkoutID: &workoutID,
|
||||
StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}",
|
||||
}
|
||||
idB, err := db.UpsertActivity(ctx, userID, stillPending)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity (b): %v", err)
|
||||
}
|
||||
if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil {
|
||||
t.Fatalf("SetActivityDetails (b): %v", err)
|
||||
}
|
||||
if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil {
|
||||
t.Fatalf("SetActivitySplitsFetched (b): %v", err)
|
||||
}
|
||||
|
||||
n, err := db.CountActivitiesMissingWorkout(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("CountActivitiesMissingWorkout = %d, want 1 (only activity b)", n)
|
||||
}
|
||||
|
||||
pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ActivitiesMissingWorkout: %v", err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].ID != idB {
|
||||
t.Fatalf("ActivitiesMissingWorkout = %+v, want only activity b (id %d)", pending, idB)
|
||||
}
|
||||
|
||||
confirmed, _, err := db.GetActivity(ctx, userID, idA)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivity (a): %v", err)
|
||||
}
|
||||
if confirmed.WorkoutNotFoundAt == nil {
|
||||
t.Error("activity a's WorkoutNotFoundAt is nil, want it set")
|
||||
}
|
||||
if confirmed.WorkoutRawJSON != nil {
|
||||
t.Error("activity a's WorkoutRawJSON should stay nil -- not-found is recorded separately, not faked")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s string, substrs ...string) bool {
|
||||
lower := strings.ToLower(s)
|
||||
for _, substr := range substrs {
|
||||
|
||||
@@ -7,12 +7,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
SyncKindBackfill = "backfill"
|
||||
SyncKindIncremental = "incremental"
|
||||
// SyncKindFull is a manually-triggered "Sync now" pass: Backfill followed
|
||||
// by IncrementalSync followed by FillPendingDetails, recorded as one run
|
||||
// so the reported activity count covers the whole action instead of only
|
||||
// whichever stage happened to finish last.
|
||||
// SyncKindFull is a manually-triggered "Sync now" pass: backfillCore
|
||||
// followed by incrementalSyncCore followed by FillPendingDetails,
|
||||
// recorded as one run so the reported activity count covers the whole
|
||||
// action instead of only whichever stage happened to finish last.
|
||||
SyncKindFull = "full"
|
||||
|
||||
SyncStatusRunning = "running"
|
||||
@@ -20,7 +18,10 @@ const (
|
||||
SyncStatusError = "error"
|
||||
)
|
||||
|
||||
// SyncRun records one backfill or incremental sync attempt.
|
||||
// SyncRun records one sync attempt. Every run recorded today is
|
||||
// SyncKindFull (the "Sync now" action, covering backfill + incremental sync
|
||||
// + detail/workout fill in a single pass) -- "backfill"/"incremental" only
|
||||
// ever appear in historical rows predating that consolidation.
|
||||
type SyncRun struct {
|
||||
ID int64
|
||||
Kind string
|
||||
|
||||
@@ -11,30 +11,18 @@ import (
|
||||
// state) is scoped to exactly one User -- see
|
||||
// docs/superpowers/specs/2026-07-25-per-user-profile-design.md.
|
||||
type User struct {
|
||||
ID int64
|
||||
OIDCSub string
|
||||
DisplayName string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// CreateUser inserts a bare users row. Most callers want ProvisionUser
|
||||
// instead, which also seeds the profile/taxonomy/sync-state a fresh account
|
||||
// needs; CreateUser alone exists for ClaimLegacyOwner (Task 3), which
|
||||
// attaches an *existing* profile/taxonomy rather than seeding new ones.
|
||||
func (db *DB) CreateUser(ctx context.Context, oidcSub, displayName string) (int64, error) {
|
||||
res, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create user %q: %w", oidcSub, err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
ID int64
|
||||
OIDCSub string
|
||||
Name string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// GetUserBySub looks up a user by their OIDC subject -- the only lookup key
|
||||
// the session-resolution middleware (Task 11) ever uses.
|
||||
// the session-resolution middleware ever uses.
|
||||
func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) {
|
||||
var u User
|
||||
err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users WHERE oidc_sub = ?`, oidcSub).
|
||||
Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt)
|
||||
err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, name, created_at FROM users WHERE oidc_sub = ?`, oidcSub).
|
||||
Scan(&u.ID, &u.OIDCSub, &u.Name, &u.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return User{}, false, nil
|
||||
}
|
||||
@@ -44,29 +32,9 @@ func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, err
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
// ListUsers returns every provisioned user, for the background incremental
|
||||
// sync loop (Task 13) to iterate.
|
||||
func (db *DB) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := []User{}
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan user row: %w", err)
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// neverMatchRule is the same placeholder every fresh install's rule-engine
|
||||
// kinds start with (migration 0004) -- every activity lands in needs_review
|
||||
// until the user tunes real rules.
|
||||
// neverMatchRule is the placeholder every fresh install's rule-engine kinds
|
||||
// start with -- every activity lands in needs_review until the user tunes
|
||||
// real rules.
|
||||
const neverMatchRule = `{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}`
|
||||
|
||||
// defaultWorkoutKindSeeds mirrors the 8 kinds migrations 0004/0007 seed for
|
||||
@@ -88,14 +56,14 @@ var defaultWorkoutKindSeeds = []WorkoutKind{
|
||||
// default workout kinds (with their paired workout_type_paces rows), and an
|
||||
// initial sync_state row -- all in one transaction, so a partially
|
||||
// provisioned user is never observable.
|
||||
func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (int64, error) {
|
||||
func (db *DB) ProvisionUser(ctx context.Context, oidcSub, name string) (int64, error) {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin provision user tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
|
||||
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, name) VALUES (?, ?)`, oidcSub, name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create user %q: %w", oidcSub, err)
|
||||
}
|
||||
@@ -104,7 +72,7 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO profile (user_id, name) VALUES (?, ?)`, userID, displayName); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO profiles (user_id) VALUES (?)`, userID); err != nil {
|
||||
return 0, fmt.Errorf("create profile for user %d: %w", userID, err)
|
||||
}
|
||||
|
||||
@@ -131,3 +99,26 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i
|
||||
|
||||
return userID, tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteUser permanently deletes userID's account. Every row that belongs
|
||||
// to it -- profile, workout kinds (and their paces), activities (and their
|
||||
// laps/samples/kind_assignments), sync_state, and sync_runs -- cascades
|
||||
// away via the ON DELETE CASCADE foreign keys in schema.sql, so this is a
|
||||
// single statement rather than per-table deletes. Irreversible; the API
|
||||
// layer gates this behind a UI confirmation (see
|
||||
// docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
|
||||
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
|
||||
if _, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
|
||||
return fmt.Errorf("delete user %d: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserName renames userID's account -- the single human-facing name
|
||||
// (shown in the header and session info), editable from the Profile page.
|
||||
func (db *DB) UpdateUserName(ctx context.Context, userID int64, name string) error {
|
||||
if _, err := db.ExecContext(ctx, `UPDATE users SET name = ? WHERE id = ?`, name, userID); err != nil {
|
||||
return fmt.Errorf("update name for user %d: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,17 +29,14 @@ func TestProvisionUser_SeedsProfileTaxonomyAndSyncState(t *testing.T) {
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||||
}
|
||||
if u.ID != userID || u.DisplayName != "Lucie" {
|
||||
t.Fatalf("got %+v, want ID=%d DisplayName=Lucie", u, userID)
|
||||
if u.ID != userID || u.Name != "Lucie" {
|
||||
t.Fatalf("got %+v, want ID=%d Name=Lucie", u, userID)
|
||||
}
|
||||
|
||||
profile, err := db.GetProfile(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile: %v", err)
|
||||
}
|
||||
if profile.Name != "Lucie" {
|
||||
t.Errorf("profile.Name = %q, want %q", profile.Name, "Lucie")
|
||||
}
|
||||
if profile.RollingWindowDays != 90 {
|
||||
t.Errorf("profile.RollingWindowDays = %d, want 90 (default)", profile.RollingWindowDays)
|
||||
}
|
||||
@@ -89,3 +86,76 @@ func TestProvisionUser_TwoUsersGetIndependentTaxonomies(t *testing.T) {
|
||||
t.Fatal("expected each user's seeded kinds to be distinct rows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID, err := db.ProvisionUser(ctx, "delete-me", "Delete Me")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
activityID, err := db.UpsertActivity(ctx, userID, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity: %v", err)
|
||||
}
|
||||
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{Name: "Test Delete Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||
}
|
||||
if err := db.ReplaceLaps(ctx, userID, activityID, []Lap{{LapIndex: 1}}); err != nil {
|
||||
t.Fatalf("ReplaceLaps: %v", err)
|
||||
}
|
||||
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []Sample{{ElapsedSeconds: 0}}); err != nil {
|
||||
t.Fatalf("ReplaceActivitySamples: %v", err)
|
||||
}
|
||||
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
|
||||
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertKindAssignment: %v", err)
|
||||
}
|
||||
minPace := 300.0
|
||||
if err := db.UpdateWorkoutTypePace(ctx, userID, WorkoutTypePace{WorkoutKindID: kindID, PaceMinSecPerKm: &minPace}); err != nil {
|
||||
t.Fatalf("UpdateWorkoutTypePace: %v", err)
|
||||
}
|
||||
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
|
||||
t.Fatalf("UpdateSyncState: %v", err)
|
||||
}
|
||||
if _, err := db.StartSyncRun(ctx, userID, SyncKindFull); err != nil {
|
||||
t.Fatalf("StartSyncRun: %v", err)
|
||||
}
|
||||
|
||||
if err := db.DeleteUser(ctx, userID); err != nil {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
|
||||
if _, found, err := db.GetUserBySub(ctx, "delete-me"); err != nil || found {
|
||||
t.Fatalf("expected user gone after DeleteUser, found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
checks := []struct {
|
||||
query string
|
||||
arg int64
|
||||
}{
|
||||
{`SELECT COUNT(*) FROM profiles WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM workout_kinds WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID},
|
||||
{`SELECT COUNT(*) FROM activities WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM activity_laps WHERE activity_id = ?`, activityID},
|
||||
{`SELECT COUNT(*) FROM activity_samples WHERE activity_id = ?`, activityID},
|
||||
{`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID},
|
||||
{`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM sync_runs WHERE user_id = ?`, userID},
|
||||
}
|
||||
for _, c := range checks {
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, c.query, c.arg).Scan(&count); err != nil {
|
||||
t.Fatalf("count query %q: %v", c.query, err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("query %q: got %d rows, want 0 after DeleteUser", c.query, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user