Compare commits

..

123 Commits

Author SHA1 Message Date
5b238b3f54 docs: align historical plans/specs with renamed identifiers (roundTrip -> execute, log schema)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 20:24:05 +02:00
5ebb0f756d feat(frontend): pathname-addressable views, deep links survive login, config page layout
Every view (/activities, /analysis, /plan, /profile, /config) is now
driven by the URL -- one pushState/popstate navigate() replaces the
tab/profile/config state flags, so all views deep-link, survive reload,
and honor Back/Forward. A logged-out visit to any path is stashed by
LoginGate and restored (replaceState) on the first render after the
OIDC round-trip, which otherwise always lands on '/'. The config page
gets Profile-style card spacing and a wider card so environment values
fit on one line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 20:23:05 +02:00
49ebd289a4 chore: untrack Garmin token store, ignore token/db-sidecar/IDE files
backend/.garmin (live Garmin OAuth tokens) was accidentally swept into
1ba7724 by a broad git add; stop tracking it and ignore it, along with
SQLite -shm/-wal sidecars and the rest of .idea.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 20:21:03 +02:00
10cdc4176a refactor(log): split location into package/file/class/method, auto-derived
Per the log schema: 'package' is the Go package (absent on
Python-emitted lines), 'file' the Go/Python source basename, 'class' the
Go receiver or Python class (omitted when there is none -- free
functions no longer masquerade under a package-as-class), 'method' the
emitting function. applog.App() now takes no arguments and derives all
of it from runtime.Caller, so labels can never drift from the code; the
manual http/wrapper emitters and forwarded wrapper.py lines (file=
wrapper.py, no package) carry the same fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:59:48 +02:00
6effb79097 refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
78c494affb fix(garmin): startup tokenstore resume recovers after its 10s timeout
The background login thread now flips _auth_state to authenticated
itself when a slow tokenstore resume succeeds after the 10s wait --
previously its result landed in an abandoned private queue and the
docstring's promised late recovery never happened, leaving every
subsequent call failing until an explicit authenticate. Guarded to only
ever transition from unauthenticated, so an explicit authenticate/MFA
flow that took over meanwhile is never clobbered; the timeout branch no
longer re-writes the state (a redundant write that could race a success
at the boundary). Structured-log messages reworked alongside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 18:17:24 +02:00
0e6cf00dba refactor(config): mandatory app-config rows seeded in DB; rename to session.setup_timeout
All registry keys must exist as rows in the config table: main seeds
missing keys with their defaults at startup, LoadApp fails fast on a
missing key, and the code-side fallback const/helper for the onboarding
setup timeout is gone -- the value rides in SessionConfig.SetupTimeout.
The key is renamed session.idle_timeout -> session.setup_timeout, and
the /config page's 'overridden' now means 'differs from the default'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 18:17:24 +02:00
8c9285f33c fix: IDEAS.md quickfixes — idle-timeout app config, id_token out of Claims, FormEvent import
session.idle_timeout (minutes, default 15) joins the app-config registry
and drives the onboarding Garmin session eviction, distinct from
session.duration (the login cookie lifetime in hours). The raw Keycloak
ID token no longer rides in auth.Claims through every request context:
it's minted into the session cookie separately and read back only by the
logout handler via IDTokenFromSessionCookie. OnboardingWizard uses the
type-imported FormEvent<HTMLFormElement> instead of the React.FormEvent
namespace alias.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:44:37 +02:00
dcbb1d8bb0 feat(garmin): all-JSON wrapper protocol and log stream
Error responses from wrapper.py now carry error_type and traceback in
the protocol itself, logged as attrs on the Go side's per-call record.
wrapper.py's free-text stderr debug prints become JSON log lines
({level,msg,...attrs}); client.go parses that stream and re-emits it
through the application logger (level-mapped, source=garmin-wrapper),
wrapping any non-JSON line instead of passing it through raw. The
wrapper also survives malformed request lines and unserializable
results with JSON responses instead of crashing with a raw traceback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:42:25 +02:00
431315bac3 refactor(log): replace stdlib log with the application JSON logger
Every log.Printf/Fatalf becomes a structured slog call through
internal/log: request handlers use the request-scoped logger
(applog.FromContext, carrying request_id), startup failures exit via a
fatal() helper that still emits JSON, and the seedsample/dumpschema CLIs
bootstrap the same JSON logger. Stale client_test expectations aligned
with the refactored wrapper-call logging (message casing, result attr,
ERROR level).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:33:26 +02:00
042579a396 refactor(store): single account name on users; rename profile/laps tables
users.display_name becomes users.name and is now the only human-facing
name -- profiles.name is dropped (it duplicated the account name; the
Profile page's Name field now edits users.name through PUT /api/profile,
which carries Name alongside the profiles columns). The profile table
becomes profiles and laps becomes activity_laps, homogeneous with
activity_samples. Onboarding pre-fills the display name from the OIDC
claim's name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:11:23 +02:00
e2b2bf9611 refactor: merge internal/sync into internal/garmin, regroup api files and routes
Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes
garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes
internal/log; the test mock moves into the garmin package as MockClient
(breaking the test-only import cycle the merge created); stale test
URLs and type names updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:04:18 +02:00
19c9aecdeb docs(skill): update renamed env vars in geniusrun-dev skill
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:46:10 +02:00
bee3d7e493 feat(frontend): /config page + icon-only header (profile/settings/logout)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:20:02 +02:00
d45489714d feat(api): GET/PUT /api/config serving app + env configuration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:16:26 +02:00
e6cb21c65a feat(config): app-config registry, env-var renames, wire session.duration from DB
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:11:56 +02:00
f6712497ba feat(store): config table for instance-global app-config overrides
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:06:22 +02:00
8508e89ad7 chore: onboarding/MFA-modal WIP, dev-workflow guidance, ideas updates
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:04:03 +02:00
27af77418e feat(store): default backfill horizon to 90 days
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:04:03 +02:00
a2f24273ce feat(frontend): error banners persist until manually dismissed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:04:03 +02:00
a394bbb770 refactor(api): merge review-queue into /api/activities, rename resolve to assign
Removes the unused GET /api/activities list/detail endpoints, moves the
review-queue list and resolve/unlock/unassign actions under
/api/activities, renames resolve to assign end-to-end, and drops the
now-unused store.ReviewQueue helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:04:03 +02:00
eb24dcd89d docs: spec for application vs environment configuration
Design for the 'configuration' backlog idea: instance-global key/value
config table (overrides only, cold reload), env-var renames, /api/config
GET/PUT, and the /config page with header icon buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 23:47:57 +02:00
c40f6634f2 test(store): cover SetActivityWorkoutNotFound adversarially for user isolation
SetActivityWorkoutNotFound is a per-user-scoped store method but never got
the adversarial cross-user coverage CLAUDE.md's testing conventions require
for such features. Extend the existing isolation test with a mismatched-user
call (must no-op) and a correct-user call (must exclude the activity from
that user's own ActivitiesMissingWorkout/count without touching the other
user's pending activity).
2026-07-27 19:16:43 +02:00
6f6d1ea5e0 fix(sync): stop retrying a workout Garmin confirms is gone
fillPendingWorkouts previously treated every get_workout_by_id
failure identically -- log it, leave workout_raw_json null, retry
next sync -- which is correct for a transient failure but means a
definitive 404 (the workout deleted on Garmin's side after being
linked to an activity) got silently retried forever, with the only
symptom being an unexplained, permanent "1 more workout pending"
nudge. Now checks errors.Is(err, garmin.ErrNotFound) and, only for
that case, calls SetActivityWorkoutNotFound instead of retrying.
2026-07-27 19:03:09 +02:00
7d371219b7 feat(store): add workout_not_found_at, exclude it from missing-workout queries
A confirmed-404 workout must stop being retried forever, but
workout_raw_json should never be fabricated -- it stays null exactly
as it does for "not yet fetched." workout_not_found_at is the
separate marker ActivitiesMissingWorkout/CountActivitiesMissingWorkout
now check for, mirroring the existing pattern of the other _fetched_at
columns. UpsertActivity's ON CONFLICT clause already never touches
these columns, so the marker persists across every later re-sync.
2026-07-27 18:58:21 +02:00
c6e2c5c00e feat(garmin): expose ErrNotFound for a wrapper-reported 404
roundTrip now wraps the returned error with the new ErrNotFound
sentinel whenever the wrapper's response set not_found (Task 1),
letting any Client method's caller distinguish a definitive 404 from
a transient failure via errors.Is, regardless of which garminconnect
method was called.
2026-07-27 18:53:59 +02:00
1d878e0f46 feat(garmin): mark a wrapper 404 with not_found in the error response
garminconnect's own GarminConnectNotFoundError already exists
specifically for this (its docstring: "so callers can now catch a
missing resource specifically, e.g. deleting an already-deleted
workout"), raised by connectapi() for any real HTTP 404. dispatch()
now surfaces that distinction as an extra not_found: true field
alongside the existing error string, so internal/garmin/client.go
(next commit) can tell a definitive 404 apart from a transient
failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 18:50:43 +02:00
b5cd47c219 docs: add implementation plan for permanently-missing-workout handling
4 tasks: mark a wrapper-level 404 as not_found, expose it as
garmin.ErrNotFound in the Go client, add a workout_not_found_at
column + store query updates, then have fillPendingWorkouts stop
retrying a confirmed-404 workout instead of retrying forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 18:45:11 +02:00
67f4ec27ab docs: add design spec for permanently-missing-workout handling
A workout deleted on Garmin's side after being linked to an activity
404s forever on get_workout_by_id, and today's fillPendingWorkouts
retries it every sync indefinitely with no user-visible feedback --
just a perpetual "1 more workout pending" nudge. Distinguishes a
definitive 404 (via garminconnect's own GarminConnectNotFoundError)
from a transient failure, and stops retrying the former.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 18:00:38 +02:00
fe344867c0 fix(frontend): show a banner when LoginGate can't reach the backend
LoginGate's initial session check swallowed every failure into the
same "unauthenticated" branch, so a genuinely unreachable backend
looked identical to a normal logged-out state -- the one screen in
the app where "backend not here" showed no feedback at all, since
every banner-migrated component only renders after authentication.

api/client.ts's request() now throws a dedicated NetworkError (a
distinct type, not just a distinguishable message) for a fetch()
failure specifically, so LoginGate.tsx's catch can tell that apart
from a real 401 via instanceof and show a banner before falling
through to "unauthenticated" either way.

Verified with a real headless-browser run against the dev server
with no backend: the banner renders correctly and coexists with the
existing ?auth_error= banner without conflict.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 11:00:00 +02:00
0e3295b746 fix(auth): stop flipLastChar's tamper tests from flaking
Corrupting the true last character of a JWT's base64url-encoded
HMAC-SHA256 signature is unreliable: that character encodes only 4
real bits plus 2 unused padding bits, and Go's encoding/base64
ignores those padding bits by default -- about 1 in 4 replacement
characters decode to byte-identical signature bytes, so the
"tampered" cookie still verifies and the test spuriously passes.
Corrupting the second-to-last character instead is deterministic,
since HMAC-SHA256's fixed 32-byte digest length means that position
is always fully significant.

Confirmed via 20 repeated runs (previously ~1/3 failure rate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 10:59:49 +02:00
f9b1d454bc fix(frontend): dedupe identical banners in banner.ts
Multiple independent requests that fail with the same message (e.g.
Activities.tsx's mount effect firing loadFirstPage/listWorkoutKinds/getProfile
separately) each call showError() independently, previously stacking
2-3 textually-identical banners for a single root cause (backend down,
brief network blip). show() now skips adding a banner if one with the
same severity+message is already visible, and skips scheduling a
redundant auto-dismiss timer for it -- the original banner's timer
keeps running untouched.
2026-07-27 10:34:57 +02:00
83a59d0fdd chore(frontend): remove the now-dead .error CSS class
Every inline error paragraph that used it has been migrated to the
banner system across the preceding tasks.
2026-07-27 10:20:14 +02:00
3cf5d4b747 refactor(frontend): split OnboardingWizard's validation vs real failures
Field-validation messages ("please fill this in") stay local and
inline. Real failures (Garmin login rejected, MFA rejected, complete()
failing) now call showError. The complete()-failure Retry button's
visibility switches from the error text's presence to a new
completeFailed boolean, since the error text itself now lives only in
the (transient) banner.
2026-07-27 10:11:18 +02:00
273731442f refactor(frontend): migrate LoginGate.tsx's auth_error to the banner system 2026-07-27 10:07:00 +02:00
7e960e3ce1 refactor(frontend): migrate TrainingTypesCard.tsx to the banner system 2026-07-27 10:01:56 +02:00
687dc2bca2 refactor(frontend): migrate Analysis.tsx to the banner system 2026-07-27 09:59:32 +02:00
668fb9efb8 refactor(frontend): migrate Activities.tsx to the banner system
Every load/classify/unlock failure now calls showError instead of
setting local error state; the inline error paragraph is removed.
2026-07-27 09:56:33 +02:00
940295510e refactor(frontend): migrate Profile.tsx to the banner system
Load/save/delete failures now call showError instead of setting local
error state. The profile-not-loaded-yet fallback gains a
profileLoadFailed boolean so it can still distinguish "loading" from
"failed to load" without the error text itself, which now lives only
in the (transient) banner.
2026-07-27 09:53:28 +02:00
a5fffff737 refactor(frontend): SyncModal reports its result via a banner, auto-closes
The modal is now purely the progress bar/spinner. The instant a poll
reports the sync finished, it builds one banner message (the
success/error line plus any pending-activities/pending-workouts
nudge as extra lines) and closes itself -- no more manual Close click
to dismiss a result the user already saw. The Close button stays as a
manual escape hatch for a still-running sync.
2026-07-27 09:47:53 +02:00
d48bbfd43e refactor(frontend): migrate GarminConnection.tsx to the banner system
Removes the local error state and its inline paragraph; every failure
(connect, MFA, sync trigger, reset-all) now calls showError directly.
2026-07-27 09:44:53 +02:00
a147f5cffa fix(frontend): show a friendly message when the backend is unreachable
request()'s fetch() call throwing (network down, connection refused,
offline) previously propagated the raw browser error text (e.g.
"TypeError: Failed to fetch"). Centralizing the friendly message here,
rather than in each call site, means every existing
catch((e) => showError(String(e))) pattern gets it for free.
2026-07-27 09:41:34 +02:00
aef58fd529 feat(frontend): add shared banner system (error/warning/notice/success)
A plain module-level singleton store (banner.ts) plus a single renderer
(BannerStack, mounted once above LoginGate in main.tsx) -- no React
Context, since this codebase has none today and a plain exported
function is callable from anywhere, including api/client.ts (a plain
module, not a component) in a later task. Nothing calls it yet; that's
each of this plan's remaining tasks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:37:24 +02:00
3bf11ae6bf docs: add implementation plan for modern error displays
11 tasks: the shared banner store + renderer, a friendlier
network-unreachable message in client.ts, then migrating every
existing inline error site (GarminConnection, SyncModal, Profile,
Activities, Analysis, TrainingTypesCard, LoginGate, OnboardingWizard)
to it, finishing with dead-CSS cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:31:57 +02:00
b2c705ce29 docs: add design spec for modern error displays
Replaces every page/component's ad-hoc inline error paragraph with a
shared, module-level banner store (red/orange/blue/green, stacking,
auto-dismiss + manual close) -- from docs/IDEAS.md's backlog item.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:22:47 +02:00
6e01037832 fix(frontend): stop SyncModal polling once sync has finished
It polled /api/sync/status every 1.5s indefinitely even after
in_progress became false and the final result was already shown,
only stopping on unmount (clicking Close). Skip scheduling the next
poll once a fetched status reports the sync is no longer running.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:38 +02:00
f7e7b68078 test(sync): cover FullSync's phase transitions end-to-end
TestFillPendingDetails_ReportsPhaseAwareProgressThroughActivitiesAndWorkoutsPhases
only ever called FillPendingDetails directly, never exercising
FullSync's own setProgress(PhaseDiscovering, ...) / deferred
setProgress(PhaseIdle, ...) wiring around backfillCore/
incrementalSyncCore -- what handleSyncRun actually runs in production
and what SyncModal's "Discovering activities..." spinner depends on.

Add a mock.Client.Delay field (slept, ctx-cancellable, at the start of
GetActivities) so a test can give the discovering phase real
wall-clock duration, then add
TestFullSync_ReportsPhaseTransitionsThroughDiscoveringToIdle, which
runs FullSync in a goroutine and polls Progress() to confirm it
observes PhaseDiscovering mid-flight and PhaseIdle after completion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:30 +02:00
3a7f305221 docs(store): update stale SyncRun doc comment
Every run recorded today is SyncKindFull (the single-pass "Sync now"
action) -- "backfill"/"incremental" only ever appear in historical
rows predating that consolidation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:17 +02:00
0a7aa3a5e0 test(store): adversarially cover ActivitiesMissingWorkout isolation
Follow this file's existing pattern (two real provisioned users,
identical-shaped rows, assert on empty-result/unaffected-by rather
than just two independently-created rows not colliding) for the two
methods touched by the details_fetched_at gating fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:10 +02:00
1046e9f7a0 fix(store): gate ActivitiesMissingWorkout on details already fetched
fillPendingDetails and fillPendingWorkouts are each independently
LIMIT-bounded over different candidate sets, so on a large first
backfill a structured-workout activity could fall inside the workouts
pass's window while still outside the details pass's window.
fillActivityWorkout would then align workout targets against zero laps
(details/laps never written yet) and unconditionally mark
workout_raw_json non-NULL, permanently losing that activity's target
pace/HR bands once its real laps arrived later -- silently, with no
error.

Add details_fetched_at IS NOT NULL to ActivitiesMissingWorkout's and
CountActivitiesMissingWorkout's WHERE clause: an activity is only
eligible for a workout fetch once its laps actually exist to align
against. This still covers the intended retry case (an activity whose
workout fetch previously failed always has details_fetched_at already
set) while excluding never-yet-processed activities.

Rename/rewrite the store test to assert the corrected exclusion
(count=1, not 2) as an explicit regression test, and fix the
TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending API fixture,
which exercised the same buggy shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:02 +02:00
101ef639ab feat(frontend): replace inline sync progress with SyncModal
GarminConnection.tsx no longer polls sync status itself or renders any
inline "syncing.../last sync..." text -- SyncModal (previous commit) is
now the only place sync progress and results are shown. Auth-status
polling is simplified to a fixed interval now that it no longer needs
to speed up while a sync is running.
2026-07-27 08:36:31 +02:00
1fb9271aaa feat(frontend): add SyncModal component
Blocking overlay polling /api/sync/status, rendering by progress.Phase
(indeterminate spinner while discovering, a real progress bar for the
activities/workouts phases), then the final sync result (success count,
or the error message that was already fetched but never rendered
before) plus the existing pending-details/pending-workouts nudge. Never
auto-closes -- not yet wired into GarminConnection.tsx (next commit).
2026-07-27 08:31:01 +02:00
39aa121420 feat(api): reshape /api/sync/status for phase-aware progress
Replaces detail_fill_progress ({Done, Total}) with progress ({Phase,
Done, Total}), and adds workouts_pending (mirroring
activities_pending_details) from the new CountActivitiesMissingWorkout.
Frontend types updated to match -- GarminConnection.tsx is intentionally
left broken by this commit alone; it's fixed in the next commit that
adds SyncModal.
2026-07-27 08:27:34 +02:00
35d9933d1b feat(sync): split FillPendingDetails into activities/workouts phases
Progress becomes phase-aware ({Phase, Done, Total} instead of a flat
{Done, Total}), with FullSync reporting an indeterminate "discovering"
phase during backfill/incremental sync (there's no meaningful total
before those calls have already happened), then FillPendingDetails
reporting a real Done/Total for its activities pass, then its new,
independent workouts pass.

alignWorkoutTargets now takes a lap count instead of a []garmin.Lap
slice, since it never used lap content, only length -- this lets the
new workouts pass call it against laps read back from the DB rather
than needing the original Garmin lap data again.

Splitting the passes also fixes a latent bug: an activity whose
details were fetched successfully but whose workout fetch failed in
that same run previously had no way to ever retry the workout fetch,
since ActivitiesMissingDetails stops returning it once
details_fetched_at/splits_fetched_at are set. ActivitiesMissingWorkout
queries workout_id/workout_raw_json independently, so it keeps
surfacing that activity until its workout is actually fetched.
2026-07-27 06:55:23 +02:00
32fdfc1c72 feat(store): add ActivitiesMissingWorkout/CountActivitiesMissingWorkout
Mirrors ActivitiesMissingDetails/CountActivitiesMissingDetails, but
queries workout_id IS NOT NULL AND workout_raw_json IS NULL --
independent of whether details/splits were ever fetched, since
workout_id is known at initial upsert time. This also fixes a latent
gap: an activity whose details were fetched successfully but whose
workout fetch failed in the same run previously had no way to ever be
retried, since ActivitiesMissingDetails stops returning it the moment
details_fetched_at/splits_fetched_at are set.
2026-07-27 06:49:44 +02:00
7204a48c1c refactor(sync): remove dead Backfill/IncrementalSync exported wrappers
Both were only reachable via the periodic background sync loop removed
in 4d2cbe4 -- nothing in production calls them anymore, only tests did.
FullSync already calls backfillCore/incrementalSyncCore directly.
Rewrite the affected tests to call the *Core functions (still exported
within the package) instead, dropping the now-redundant standalone
SyncRun-recording assertion covered by TestFullSync_RecordsOneCombinedSyncRun.
2026-07-27 06:44:39 +02:00
9d65c50068 chore: ignore .worktrees/ directory
Local worktree scratch space for isolated feature branches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 06:37:25 +02:00
aac9359b02 docs: add implementation plan for improved synchronization UX
Six tasks: remove dead Backfill/IncrementalSync wrappers, add
ActivitiesMissingWorkout/CountActivitiesMissingWorkout, split
FillPendingDetails into phase-aware activities/workouts passes,
reshape GET /api/sync/status, add SyncModal, wire it into
GarminConnection.tsx.
2026-07-26 23:05:43 +02:00
a828055c30 docs: fold dead Backfill/IncrementalSync wrapper cleanup into sync spec
Discovered while verifying the sync design with the user: these two
exported methods are dead in production (only FullSync's *Core calls
are actually used), kept alive only by tests, and their doc comments
still reference the background sync loop removed in 4d2cbe4.
2026-07-26 22:45:59 +02:00
7f5618ce49 docs: add design spec for improved synchronization UX
Phase-aware sync progress (discovering/activities/workouts), a new
/api/sync/status shape, and a blocking SyncModal replacing the Profile
page's inline sync text -- implementing the "improve synchronization"
idea from docs/IDEAS.md.
2026-07-26 22:09:49 +02:00
d3b26d7d41 fix(frontend): fix off-by-one date shown in the backfill horizon picker
daysAgoToISODate built the date at local midnight but formatted it with
toISOString(), which converts to UTC first -- in any timezone ahead of
UTC (e.g. Europe/Paris), local midnight is still the previous day in
UTC, so the picker displayed a date one day earlier than the one that
was actually selected/stored. Format from the Date's local
year/month/day components instead of going through UTC.
2026-07-26 21:52:38 +02:00
0211dffa1e fix(config): stop deriving GarminTokenStoreRoot's default from DBPath
There's no reason the Garmin token-store root and the SQLite database
file need to live near each other -- they're independent env vars
(GARMIN_TOKENSTORE, GENIUSRUN_DB_PATH) and should stay independently
configurable, including in their defaults. Revert the "next to DBPath"
default introduced in 77f9588 back to a plain ".garmin" relative to
the working directory, so pointing GENIUSRUN_DB_PATH elsewhere never
silently drags the token-store default along with it.
2026-07-26 21:45:39 +02:00
12cbfdbbee docs(ideas): refine backlog notes on sync UX and error banners
Merge the old "improve activities/workouts download" and "better UX
when backend is not available" bullets into one clearer "modern error
displays" idea (ephemeral, severity-colored banners reused across
login/logout/sync/backend-down states), and flesh out the sync-modal
idea with the activities/workouts split and progress-bar prerequisite.
2026-07-26 21:26:24 +02:00
92284d3b96 polish(frontend): tighten the onboarding wizard's UX
- Drop "Logout" everywhere in the flow: nothing is persisted to the
  database until Garmin actually connects, so closing the tab is
  already a clean escape hatch -- no separate control needed.
- Display-name step: replace the "Next" button with a small checkmark
  icon button next to the input (Enter still submits).
- Garmin credentials step: drop "Previous" (changing the display name
  just means quitting and relaunching, since nothing is persisted yet)
  and rename "Next" to "Login".
- MFA step: drop "Previous" for the same reason.
- Once Garmin reports success (MFA or not), finish setup immediately
  instead of waiting on a "Continue to geniusrun" click.
2026-07-26 21:26:14 +02:00
45f2921971 fix(garmin): make wrapper.py's startup tokenstore login lazy
_startup_login() used to run unconditionally at process boot, before
main()'s stdin dispatch loop started. Whenever the very first real
command turned out to be an explicit authenticate, this was actively
counterproductive: on success it duplicated a login authenticate was
about to redo anyway, and on failure it was a wasted, unauthenticated
hit against Garmin's servers moments before the real attempt --
exactly the kind of extra load that worsens rate-limiting risk. It
also never handled MFA, so it couldn't stand in for authenticate
regardless.

Make it lazy instead: only _handle_call falls back to it, at most once
per subprocess lifetime, and only if authenticate was never explicitly
attempted first. This is what it was actually for -- silently resuming
a cached tokenstore session 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 client cache).
2026-07-26 21:25:53 +02:00
77f9588c2d refactor(config): rename default Garmin token-store dir to .garmin
Shorter, more conventional dotdir name than garmin-tokenstores. Restore
the "next to DBPath" join that got dropped when the default was
inlined via getEnvDefault -- an explicit GARMIN_TOKENSTORE still wins
verbatim, but the implicit default must still resolve relative to the
DB file's directory, not the process's CWD.

internal/garmin/client.go's GARMIN_TOKENSTORE env var is now always
set (TokenStorePath can no longer be empty), so the conditional that
only appended it when non-empty is dead code.
2026-07-26 21:25:39 +02:00
a12fe3e810 fix(api): stop promoting the ephemeral Garmin client after setup completes
handleSetupComplete reused the onboarding client object as-is in the
permanent per-user cache, but its garmin.Config.TokenStorePath was fixed
at construction to the ephemeral setup/{hash} directory and never
corrected after that directory was renamed to the permanent {userID}
path. The next respawn of that same client (e.g. any Profile-page save,
which unconditionally calls UpdateCredentials) wrote a fresh token file
back under setup/{hash}, forcing a real re-login/MFA on the next Garmin
connect even though a valid session already existed under {userID}.

Close the ephemeral client instead and let the next garminFor call build
a fresh one against the correct, already-renamed directory.
2026-07-26 21:23:24 +02:00
4d2cbe4883 refactor: remove automatic background incremental sync
Garmin's rate-limiting means every unattended sync attempt risks a
ban; syncing should only ever happen when explicitly triggered via
"Sync now" (POST /api/sync/run), never on an unattended timer.

Removes the periodic background loop (main.go's runIncrementalSyncLoop,
api.Server.RunIncrementalSyncForAllUsers), its GENIUSRUN_INCREMENTAL_SYNC_EVERY
config, and store.DB.ListUsers (which existed solely to feed it). The
manual "Sync now" flow (Backfill/IncrementalSync/FillPendingDetails via
FullSync) is untouched.
2026-07-26 18:01:41 +02:00
7346e2eadd docs: add implementation plan for structured JSON logging 2026-07-26 14:36:04 +02:00
c9e089a2b2 feat(garmin): log every wrapper subprocess call as structured JSON 2026-07-26 14:33:20 +02:00
1d4b1cccfd feat(api): add structured JSON access log with request-id correlation 2026-07-26 14:30:39 +02:00
379e7cc990 feat(applog): add JSON logger and context helpers 2026-07-26 14:28:22 +02:00
501d951e5b docs: add design spec for structured JSON logging 2026-07-26 14:22:28 +02:00
2fbe762290 docs: add implementation plan for deferred-commit onboarding wizard 2026-07-26 14:12:44 +02:00
84a7417781 feat(onboarding): replace CreateProfile/ConnectGarmin with a single deferred-commit wizard 2026-07-26 13:32:32 +02:00
7d68af5b3c feat(api): defer account creation until Garmin actually connects 2026-07-26 13:30:51 +02:00
a1eaa42d42 docs: add design spec for deferred-commit onboarding wizard 2026-07-26 13:17:59 +02:00
35eff03509 docs: add implementation plan for improved first-connection flow 2026-07-26 12:29:26 +02:00
a622af1bfa Merge branch 'worktree-improve-first-connection' 2026-07-26 12:28:45 +02:00
7f4f6d3493 docs: remove improve-first-connection-page from IDEAS backlog (implemented) 2026-07-26 12:26:33 +02:00
049ed16b69 feat(onboarding): add mandatory ConnectGarmin gate to first login 2026-07-26 12:25:09 +02:00
002858c1cb feat(api): persist and expose garmin_connected on successful auth 2026-07-26 12:23:39 +02:00
6a3f0201af feat(store): persist garmin_connected_at, set once on first successful auth 2026-07-26 12:21:42 +02:00
50d65b40c2 docs: add design spec for improved first-connection flow 2026-07-26 12:09:35 +02:00
d1d2d612a5 added ideas 2026-07-26 12:00:10 +02:00
4c939be6d6 added go work files 2026-07-26 12:00:00 +02:00
f7bf7e9c79 removed unwanted ide files 2026-07-26 11:57:33 +02:00
8353cd148b feat(auth): pass id_token_hint on Keycloak logout
Carries the raw ID token in the session cookie so logout can hand it back
to Keycloak as id_token_hint, letting it skip its own logout-confirmation
prompt -- otherwise a user could cancel out of it and land back in the app
with a Keycloak SSO session but no geniusrun profile (e.g. right after
deleting their account).
2026-07-26 11:55:13 +02:00
d7202eb9bb docs: add implementation plan for profile deletion 2026-07-26 10:23:32 +02:00
ce5057a309 Merge branch 'worktree-profile-deletion' 2026-07-26 10:22:52 +02:00
efbe6a6760 docs: remove profile deletion from IDEAS backlog (implemented) 2026-07-26 10:21:31 +02:00
e2533bd1a8 feat(profile): add Danger zone account deletion UI 2026-07-26 10:20:56 +02:00
ab8cdea214 feat(api): add DELETE /api/profile with Garmin client/tokenstore teardown 2026-07-26 10:19:00 +02:00
e8c340a00e feat(store): add DeleteUser with cascading account deletion 2026-07-26 10:17:21 +02:00
c5faaf5a17 docs: add design spec for profile deletion 2026-07-26 10:06:04 +02:00
65a2dc90d0 refactored component names 2026-07-26 10:00:24 +02:00
3b2b5d0735 refined ideas + start.sh rely on .env 2026-07-26 09:59:56 +02:00
d308201803 fix(api): logout's post_logout_redirect_uri uses FrontendURL, not BackendURL
Same class of bug as the OIDC callback fix: handleSessionLogout redirected
Keycloak's end-session flow back to BackendURL+"/", which 404s in a
split-origin deployment (the backend serves no "/" route). Flagged as a
known-deferred question in the callback-redirect design spec; fixing it
now that it's been hit in practice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 23:37:22 +02:00
099e746119 refactor(config): rename GENIUSRUN_PUBLIC_BASE_URL to GENIUSRUN_BACKEND_URL
Now that GENIUSRUN_FRONTEND_URL exists as a separate config value, keeping
the backend's own origin named "PublicBaseURL" invited exactly the kind of
mixup that caused the OIDC callback 404 in the first place. Renamed
consistently: env var, Config.BackendURL, api.SessionConfig.BackendURL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 23:00:35 +02:00
db2e8e487d fix(api): redirect the OIDC callback to FrontendURL, not a relative path
handleSessionCallback's redirects (success and all 4 failure branches)
were relative paths, which resolve against the backend's own origin --
broken in this project's own supported split-origin local dev setup,
since the Go backend serves no "/" route at all. Now uses the new
config.Config.FrontendURL (defaults to PublicBaseURL, so no change for
single-origin production deployments).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:52:15 +02:00
f93aa331d9 feat(config): add GENIUSRUN_FRONTEND_URL, defaulting to PublicBaseURL
Lets the OIDC callback redirect to the frontend's real origin instead of
a relative path resolved against the backend's own origin -- needed for
this project's own supported split-origin local dev setup (frontend on
Vite, backend on geniusrund, bridged by CORS).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:50:46 +02:00
d73c841ab7 docs: add implementation plan for the OIDC callback redirect fix
Two small tasks: add config.Config.FrontendURL (Task 1), then thread it
through api.SessionConfig and handleSessionCallback's five redirects
(Task 2).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:49:02 +02:00
fe4978dbea docs: add design spec for fixing OIDC callback redirect origin
handleSessionCallback's relative redirects resolve against the backend's
own origin, which 404s in this project's own supported split-origin local
dev setup (frontend on Vite, backend on geniusrund, bridged by CORS). Adds
GENIUSRUN_FRONTEND_URL, defaulting to PublicBaseURL for the common
single-origin production case, as the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:42:50 +02:00
85577c9d26 added ideas 2026-07-25 21:59:56 +02:00
487df1f9b9 fix(garmin): give startup login its own result queue, not the shared one
_startup_login's background thread and _handle_authenticate's shared the
module-level _login_result_queue with no correlation. Since _startup_login
can now time out at 10s while its thread keeps running (from the previous
fix in this wave), a slow-cold-starting subprocess's first explicit
"Connect to Garmin" call could accidentally dequeue the startup thread's
stale result instead of its own fresh one, orphaning the loser's result to
corrupt a later authenticate/complete_mfa call.

_handle_authenticate and _handle_complete_mfa still correctly share
_login_result_queue -- they're two halves of one explicit, MFA-capable
login flow. _startup_login is a background tokenstore resume with no MFA
involved, so it now uses its own private, function-local queue.Queue()
instead, making cross-contamination structurally impossible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:52:48 +02:00
e422f86925 fix: bound startup-login timeout, refresh stale SKILL.md and doc comments
_startup_login() ran garminconnect's login synchronously with no timeout
before main() ever started reading stdin, so a slow/rate-limited Garmin
login could wedge a user's whole subprocess before it became responsive.
Give it the same background-thread + bounded-10s-timeout shape
_handle_authenticate already uses, with tests for both the fast-success
and timeout paths.

Also refreshes .claude/skills/geniusrun-dev/SKILL.md (still describing
the retired mcp-garmin MCP architecture) and three stale doc comments
(garmin.AuthStatus, config.GarminTokenStoreRoot, mock package doc) left
over from the direct-wrapper migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:43:52 +02:00
136567d964 docs: remove stale cmd/mcpspike references 2026-07-25 21:31:24 +02:00
2fad83fe9a chore: drop the mcp-go dependency and remove mcpspike throwaway spike
Nothing in the backend speaks MCP anymore -- internal/garmin talks to its
embedded Python wrapper over plain JSON-lines instead. The cmd/mcpspike
directory was a temporary spike for validating the mcp-go client, which is
no longer needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:27:19 +02:00
f122f28206 docs: fix stale mcp-garmin references in CLAUDE.md's overview
Updates the Project overview section to correctly describe the direct
Python wrapper instead of the retired MCP-based approach, eliminating
the contradiction with the Garmin integration section below.
2026-07-25 21:23:24 +02:00
1b9088bd03 chore: wire up the new garmin.Config shape, refresh start.sh and CLAUDE.md
Removes the last references to garmin.Config.ServerPath and the
MCP_GARMIN_* env vars now that the wrapper script is embedded in the
binary; updates CLAUDE.md's mcp-garmin section to describe the direct
wrapper instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:18:50 +02:00
284ec3142d feat(config): drop MCP_GARMIN_SERVER, default GARMIN_WRAPPER_PYTHON
The wrapper script is embedded in the binary now (internal/garmin), so
there's no script path left to configure. The interpreter path becomes
optional, defaulting to python3 on PATH, matching how other optional
plumbing (e.g. GENIUSRUN_OIDC_REQUIRED_ROLE) is already handled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:13:26 +02:00
8c94c94c3f feat(garmin): implement data-fetch methods via generic call dispatch
GetActivities/GetActivitySplits/GetActivityDetails/GetWorkoutByID now send
{"cmd":"call","params":{"method":...,"args":...}} instead of named MCP
tools. subprocessClient fully implements Client.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:02:49 +02:00
9cbcf62aed feat(garmin): implement Authenticate/CompleteMFA on subprocessClient
Structured {status, message} responses replace the old string
pattern-matching (parseAuthResult) -- both sides of the protocol are now
owned by this repo, so there's no need to guess at phrasing anymore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:54:54 +02:00
df197f45fd fix(garmin): wait for stderr-copy goroutine before cmd.Wait()
ensureStarted's stderr-copy goroutine could still be mid-Read when close()
called cmd.Wait(), which os/exec's StderrPipe docs call out as incorrect
and can truncate/garble trailing stderr diagnostics or surface a spurious
"file already closed" error. close() now waits on a stderrDone channel,
closed by the copy goroutine once it hits EOF (unblocked by killing the
process), before calling Wait.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:50:39 +02:00
6c161f2206 feat(garmin): replace mcp-go transport with a JSON-lines subprocess client
subprocessClient spawns the embedded pyscript/wrapper.py over os/exec and
speaks newline-delimited JSON instead of MCP. Auth/data methods land in
follow-up commits; this is the transport + lifecycle plumbing only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:41:44 +02:00
7985000440 feat(garmin): add direct garminconnect wrapper script
Replaces mcp-garmin's server.py: a JSON-lines subprocess protocol
(authenticate/complete_mfa/call) around garminconnect directly, no MCP.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:35:13 +02:00
de48c44858 docs: add implementation plan for the direct Garmin wrapper
Task-by-task TDD plan for docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md:
Python wrapper + tests, Go transport scaffolding, auth methods, data-fetch
methods, config changes, wiring/docs, and final dependency cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:30:05 +02:00
0936d98161 docs: embed the garmin wrapper script instead of configuring its path
Since wrapper.py now lives inside this repo, its location is no longer a
deploy-time concern -- go:embed it into the binary and drop the
GARMIN_WRAPPER_SCRIPT env var entirely. Only the Python interpreter choice
remains configurable, and now optionally so (defaults to "python3").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:14:36 +02:00
961a7d8aca docs: add design spec for dropping MCP in favor of a direct Garmin wrapper
Replaces internal/garmin's MCP-based mcp-garmin integration with a custom
newline-delimited-JSON subprocess protocol around python-garminconnect
directly, folded into this repo. MCP's dynamic tool-discovery value is
unused here (fixed call sites, no LLM choosing tools), and both projects
are owned by the same person, so the extra protocol layer and two SDK
dependencies (mcp-go, mcp[cli]) were pure overhead -- especially given
plans to containerize the backend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:04:18 +02:00
0ff6793854 store: enable WAL journal mode
Lets an external reader (sqlite3 CLI, DB Browser, DataGrip) inspect the
database file concurrently without "database is locked" errors while
geniusrund is running. Doesn't change in-process concurrency -- queries
are already fully serialized via SetMaxOpenConns(1).

auth: fix flaky tampered-cookie tests

Both tests corrupted a signed cookie by blindly overwriting its last
character with "x", which is occasionally a no-op if that character (part
of the token's signature, so effectively randomized by the embedded
timestamp) already happened to be "x" -- silently passing without having
tampered with anything. Confirmed via 15 repeated runs (3 spurious passes)
before the fix and 30 clean runs after. flipLastChar now guarantees the
byte actually changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 19:33:15 +02:00
644d87f1dc store: collapse migrations into a single schema.sql, drop legacy-owner path
Pre-production app, no need to preserve incremental migration history:
replace the 26 migration files with one current-state schema.sql (the
schema.sql comments are now the living documentation), simplify db.go to
apply it once instead of tracking/rebuilding through schema_migrations,
and make user_id NOT NULL everywhere now that there's no staged migration
to accommodate a nullable backfill window.

This removes the reason ClaimLegacyOwner/GENIUSRUN_LEGACY_OWNER_OIDC_SUB
existed (binding a pre-existing singleton-schema database to one account
across a staged migration), so that whole path is gone too -- the
existing dev database was wiped and reseeded fresh under the new schema.

Add cmd/dumpschema, which regenerates docs/DATABASE.md straight from the
live schema (via store.Open + sqlite_master introspection) so the
database documentation can never drift out of sync with reality.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 19:24:19 +02:00
0f2101bce5 docs: add IDEAS.md for informal backlog, link from CLAUDE.md
A lightweight, low-friction place to jot future development ideas before
they're formalized into a spec/plan -- distinct from
docs/superpowers/specs/ and docs/superpowers/plans/, which are for once an
idea is ready to be built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 19:07:48 +02:00
15c2b6a2b6 docs: update CLAUDE.md for per-user profile isolation
Reflects the just-merged per-user-profile work: every table is now
user_id-scoped, OIDC accounts get their own isolated dataset via
resolveUser/requireProvisionedUser + POST /api/setup, Garmin sessions are
namespaced per user, and the legacy-owner upgrade bootstrap. Supersedes the
old "single shared profile" framing throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 19:05:09 +02:00
153 changed files with 21555 additions and 4296 deletions

View File

@@ -1,13 +1,13 @@
--- ---
name: geniusrun-dev 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 # geniusrun-dev
## Project overview ## 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. **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/ backend/
cmd/geniusrund/ main server entrypoint cmd/geniusrund/ main server entrypoint
cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds
cmd/mcpspike/ throwaway MCP-client spike, safe to delete 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/ MCP client wrapper (auth, get_activities, get_activity_splits, get_activity_details)
internal/garmin/mock/ fake Client for tests internal/garmin/mock/ fake Client for tests
internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery) internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery)
internal/store/ SQLite layer + embedded migrations 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. - **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. - **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. - **`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 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). - **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`).
- **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. - **`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`). - **`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_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()`** (added to mcp-garmin, wraps `garminconnect`'s existing `get_activity_splits`) is the one that returns actual lap/split summaries (`lapDTOs`). - **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
## Data model conventions ## Data model conventions
@@ -75,7 +74,7 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
## Dev workflow ## 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`). - 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. - 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. - `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/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/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`. - `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
View File

@@ -20,6 +20,15 @@ frontend/dist/
backend/mcpspike backend/mcpspike
backend/cmd/mcpspike/mcpspike backend/cmd/mcpspike/mcpspike
.superpowers/ .superpowers/
.worktrees/
# Claude Code session-local runtime state # Claude Code session-local runtime state
.claude/*.lock .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/

View File

@@ -4,23 +4,24 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project overview ## 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 ## Commands
Backend (from `backend/`): 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 ./...` - Build/vet: `go build ./...` && `go vet ./...`
- Format: `gofmt -l .` must report nothing before committing. - Format: `gofmt -l .` must report nothing before committing.
- All tests: `go test ./...` - All tests: `go test ./...`
- Single package: `go test ./internal/store/...` - Single package: `go test ./internal/store/...`
- Single test: `go test ./internal/store/... -run TestProfile -v` - 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`. - 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/`): 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`. - 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) - Lint: `npm run lint` (oxlint)
- No frontend test suite exists yet. - 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 ## Repo layout
@@ -40,16 +45,17 @@ Required env vars (`config.Load()` fails fast if any are unset, same pattern as
backend/ backend/
cmd/geniusrund/ main server entrypoint cmd/geniusrund/ main server entrypoint
cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds
cmd/mcpspike/ throwaway MCP-client spike, safe to delete cmd/dumpschema/ regenerates docs/DATABASE.md from the live schema -- run after editing schema.sql
internal/garmin/ MCP client wrapper (auth, get_activities, get_activity_splits, get_activity_details, get_workout_by_id) 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/garmin/mock/ fake Client for tests
internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery) internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery)
internal/store/ SQLite layer + embedded migrations internal/store/ SQLite layer; schema.sql is the whole schema (no migration history), users.go owns account provisioning
internal/sync/ orchestrates fetch -> store -> classify internal/sync/ orchestrates fetch -> store -> classify; one Service instance per user
internal/api/ HTTP handlers (chi router) 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) internal/config/ env var config loading (process-level only, not user-tunable params)
frontend/ frontend/
src/pages/ ReviewQueue ("Activities" tab), Dashboard ("Progression" tab), Plan (stub), Profile (reached via the profile-name button, not a tab) 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/charts/ Recharts wrappers (ExpectedVsActualChart, ProgressionChart)
src/components/ ColorField, PaceField, NullableNumberField, RawDataModal, TrainingTypesCard src/components/ ColorField, PaceField, NullableNumberField, RawDataModal, TrainingTypesCard
src/api/client.ts thin typed fetch client 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). - 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`. - 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. - **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. - **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. - **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. - 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. - **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. - **`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 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). - **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`).
- **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. - **`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`). - **`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_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_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. - **`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. - 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 ## 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. - `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. - **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.
- `garmin_activity_id` is the natural idempotency key for `UpsertActivity` (`ON CONFLICT ... DO UPDATE`), safe to re-run on every sync pass. - `(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` (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. - `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`. The frontend's `GarminConnection` banner polls this and shows "syncing: N/M activities" live. - 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. `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. - 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`. - 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. - 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 ## Dev workflow
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess. - `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 ## Testing conventions
- Table-driven Go tests throughout; test cases are inline, no separate fixture files. - 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/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/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`. - `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`.
- 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. - **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
View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View File

@@ -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>

View 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)
}
}

View File

@@ -5,8 +5,9 @@ package main
import ( import (
"context" "context"
"log" "log/slog"
"net/http" "net/http"
"os"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
@@ -15,86 +16,104 @@ import (
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
"geniusrun/backend/internal/config" "geniusrun/backend/internal/config"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
applog "geniusrun/backend/internal/log"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func main() { // fatal logs through the application JSON logger and exits -- the
cfg, err := config.Load() // structured replacement for log.Fatalf, so even startup failures come
if err != nil { // out as JSON lines.
log.Fatalf("config: %v", err) 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 { 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() defer db.Close()
if cfg.LegacyOwnerOIDCSub != "" { values, err := db.ConfigValues(context.Background())
if err := db.ClaimLegacyOwner(context.Background(), cfg.LegacyOwnerOIDCSub); err != nil { if err != nil {
log.Fatalf("claim legacy owner: %v", err) 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{ authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
IssuerURL: cfg.OIDCIssuerURL, IssuerURL: envCfg.OIDCIssuerURL,
ClientID: cfg.OIDCClientID, ClientID: envCfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret, ClientSecret: envCfg.OIDCClientSecret,
RedirectURL: cfg.OIDCRedirectURL, RedirectURL: envCfg.OIDCRedirectURL,
RequiredRole: cfg.OIDCRequiredRole, RequiredRole: envCfg.OIDCRequiredRole,
}) })
if err != nil { if err != nil {
log.Fatalf("oidc: %v", err) fatal("oidc verifier setup", err)
} }
server := api.NewServer(db, garmin.NewClient, garmin.Config{ server := api.NewServer(
PythonPath: cfg.GarminPythonPath, db,
ServerPath: cfg.GarminServerPath, garmin.NewClient,
TokenStorePath: cfg.GarminTokenStoreRoot, garmin.ClientConfig{
}, appsync.Config{ PythonPath: envCfg.PythonPath,
MinConfidence: cfg.MinConfidence, TokenStorePath: envCfg.TokenStoreRoot,
}, authVerifier, api.SessionConfig{ },
Secret: cfg.SessionSecret, garmin.SyncConfig{},
Duration: cfg.SessionDuration, authVerifier,
Secure: cfg.SessionSecure, api.SessionConfig{
PublicBaseURL: cfg.PublicBaseURL, 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) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
go runIncrementalSyncLoop(ctx, server, cfg.IncrementalSyncEvery) httpServer := &http.Server{Addr: envCfg.BackendAddr, Handler: server.Router()}
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
go func() { 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 { if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err) fatal("http server", err)
} }
}() }()
<-ctx.Done() <-ctx.Done()
log.Println("shutting down...") applog.App().Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil { if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.Printf("http server shutdown: %v", err) applog.App().Error("http server shutdown", "error", 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)
}
} }
} }

View File

@@ -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)"
}

View File

@@ -9,23 +9,25 @@ import (
"context" "context"
"flag" "flag"
"fmt" "fmt"
"log" "log/slog"
"os"
"time" "time"
"geniusrun/backend/internal/classify" "geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin/mock" "geniusrun/backend/internal/garmin"
applog "geniusrun/backend/internal/log"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func main() { func main() {
slog.SetDefault(applog.NewLogger("info", os.Stdout))
dbPath := flag.String("db", "geniusrun_sample.db", "path to the SQLite database to seed") dbPath := flag.String("db", "geniusrun_sample.db", "path to the SQLite database to seed")
flag.Parse() flag.Parse()
ctx := context.Background() ctx := context.Background()
db, err := store.Open(*dbPath) db, err := store.Open(*dbPath)
if err != nil { if err != nil {
log.Fatalf("open db: %v", err) fatal("open db", err)
} }
defer db.Close() defer db.Close()
@@ -73,8 +75,8 @@ func main() {
IsActive: true, IsActive: true,
})) }))
m := &mock.Client{} m := &garmin.MockClient{}
svc := appsync.NewService(m, db, userID, appsync.Config{MinConfidence: 0.6}, nil) svc := garmin.NewSync(m, db, userID, garmin.SyncConfig{MinConfidence: 0.6}, nil)
today := time.Now() today := time.Now()
activityIDs := []int64{} activityIDs := []int64{}
@@ -234,10 +236,17 @@ func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, userID, activ
func must(err error) { func must(err error) {
if err != nil { 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 { func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string) int64 {
kinds, err := db.ListWorkoutKinds(ctx, userID, false) kinds, err := db.ListWorkoutKinds(ctx, userID, false)
must(err) must(err)
@@ -246,6 +255,7 @@ func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string
return k.ID 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 return 0
} }

View File

@@ -6,7 +6,6 @@ require (
github.com/coreos/go-oidc/v3 v3.20.0 github.com/coreos/go-oidc/v3 v3.20.0
github.com/go-chi/chi/v5 v5.3.1 github.com/go-chi/chi/v5 v5.3.1
github.com/golang-jwt/jwt/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 golang.org/x/oauth2 v0.36.0
modernc.org/sqlite v1.53.0 modernc.org/sqlite v1.53.0
) )
@@ -14,16 +13,11 @@ require (
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // 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/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/sys v0.44.0 // indirect
golang.org/x/text v0.14.0 // indirect
modernc.org/libc v1.73.4 // indirect modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

View File

@@ -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 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= 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 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= 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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= 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.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 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= 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 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= 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= modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=

View File

@@ -1,7 +1,9 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -9,109 +11,248 @@ import (
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
) )
// activityListItem is one row of the activities list, enriched with its // defaultActivitiesPageSize matches the frontend's initial/incremental
// current classification so the frontend can show what kind it's assigned // page size for its infinite-scroll list.
// to and whether that assignment is locked (see Locked's doc comment). const defaultActivitiesPageSize = 10
type activityListItem struct {
activityResponse type activityItem struct {
WorkoutKindID *int64 `json:"workout_kind_id"` store.KindAssignment
WorkoutKindName *string `json:"workout_kind_name"` Activity activityResponse `json:"activity"`
AssignmentSource *string `json:"assignment_source"` Laps []lapResponse `json:"laps"`
AssignmentStatus *string `json:"assignment_status"` Samples []store.Sample `json:"samples"`
// 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"`
} }
// 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) { func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context()) userID := userIDFromContext(r.Context())
q := r.URL.Query() limit := defaultActivitiesPageSize
filter := store.ActivityFilter{ if v := r.URL.Query().Get("limit"); v != "" {
FromDate: q.Get("from"), if n, err := strconv.Atoi(v); err == nil && n > 0 {
ToDate: q.Get("to"), limit = n
} }
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
} }
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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false) type withActivity struct {
if err != nil { assignment store.KindAssignment
writeError(w, http.StatusInternalServerError, err.Error()) activity store.Activity
return
} }
kindNames := make(map[int64]string, len(kinds)) all := make([]withActivity, 0, len(queue))
for _, k := range kinds { for _, a := range queue {
kindNames[k.ID] = k.Name if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) {
continue
} }
if unclassifiedOnly && a.WorkoutKindID != nil {
resp := make([]activityListItem, 0, len(activities)) continue
for _, a := range activities {
item := activityListItem{activityResponse: toActivityResponse(a)}
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), userID, a.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
} }
if ok { activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
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")
}
resp = append(resp, item)
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
activity, ok, err := s.DB.GetActivity(r.Context(), userID, id)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
if !ok { if !ok {
writeError(w, http.StatusNotFound, "activity not found") continue
return }
all = append(all, withActivity{assignment: a, activity: activity})
} }
laps, err := s.DB.LapsForActivity(r.Context(), userID, id) // 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
// Per-second telemetry, not just per-lap averages, so the chart can
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id) // 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
items = append(items, activityItem{
resp := map[string]any{"activity": toActivityResponse(activity), "laps": toLapResponses(laps)} KindAssignment: wa.assignment,
if hasAssignment { Activity: toActivityResponse(wa.activity),
resp["assignment"] = assignment Laps: toLapResponses(laps),
Samples: samples,
})
} }
writeJSON(w, http.StatusOK, resp)
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) handleAssignActivity(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": "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 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"})
} }

View File

@@ -5,9 +5,11 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"testing" "testing"
@@ -16,9 +18,8 @@ import (
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock" authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock" "geniusrun/backend/internal/log"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func newCtx() context.Context { return context.Background() } func newCtx() context.Context { return context.Background() }
@@ -26,8 +27,10 @@ func newCtx() context.Context { return context.Background() }
var testSessionConfig = SessionConfig{ var testSessionConfig = SessionConfig{
Secret: []byte("test-session-secret-at-least-32-bytes-long"), Secret: []byte("test-session-secret-at-least-32-bytes-long"),
Duration: time.Hour, Duration: time.Hour,
SetupTimeout: 15 * time.Minute,
Secure: false, Secure: false,
PublicBaseURL: "https://geniusrun.example.com", BackendURL: "https://geniusrun.example.com",
FrontendURL: "https://app.geniusrun.example.com",
} }
func newTestServer(t *testing.T) (*Server, *store.DB, int64) { 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) t.Fatalf("ProvisionUser: %v", err)
} }
m := &mock.Client{} m := &garmin.MockClient{}
garminFactory := func(garmin.Config) garmin.Client { return m } garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
return s, db, userID 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 := httptest.NewRequest(method, path, reader)
req.Header.Set("Content-Type", "application/json") 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 { if err != nil {
t.Fatalf("mint test session cookie: %v", err) 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) s, db, userID := newTestServer(t)
ctx := newCtx() ctx := newCtx()
@@ -225,9 +228,9 @@ func TestReviewQueueResolve(t *testing.T) {
} }
router := s.Router() 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 { if rec.Code != http.StatusOK {
t.Fatalf("review queue status = %d", rec.Code) t.Fatalf("activities list status = %d", rec.Code)
} }
var page struct { var page struct {
Items []map[string]any `json:"items"` Items []map[string]any `json:"items"`
@@ -236,43 +239,43 @@ func TestReviewQueueResolve(t *testing.T) {
} }
json.Unmarshal(rec.Body.Bytes(), &page) json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || page.Total != 1 { 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) laps, _ := page.Items[0]["laps"].([]any)
if len(laps) != 1 { 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) samples, _ := page.Items[0]["samples"].([]any)
if len(samples) != 1 { 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 { 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 // The activity stays listed after resolving -- the Activities page shows
// every activity, locked or not, not just ones still needing review. // 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) json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || page.Total != 1 { 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 { 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) { 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, // Unlocking reverts the source to rule_engine but keeps the same kind,
// so a later reclassify pass is free to change it again. // 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 { if rec.Code != http.StatusOK {
t.Fatalf("unlock status = %d, body = %s", rec.Code, rec.Body.String()) 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) json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine { if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"]) 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. // 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 { if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code) t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code)
} }
// Manually unassigning clears the kind back to Unclassified and locks // Manually unassigning clears the kind back to Unclassified and locks
// that decision (source=manual), same as resolving to a specific kind. // 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 { if rec.Code != http.StatusOK {
t.Fatalf("unassign status = %d, body = %s", rec.Code, rec.Body.String()) 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) json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual { if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
t.Fatalf("expected AssignmentSource=manual after unassign, got %v", page.Items[0]["AssignmentSource"]) 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. // 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) json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || page.Total != 1 { 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) 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 // Unassigning locks it, so unlocking works again (reverting to
// rule_engine sourcing with no kind, i.e. needs_review). // 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 { if rec.Code != http.StatusOK {
t.Fatalf("unlock after unassign status = %d, body = %s", rec.Code, rec.Body.String()) 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) json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine { if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"]) 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) s, db, userID := newTestServer(t)
ctx := newCtx() ctx := newCtx()
@@ -352,7 +355,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
} }
getPage := func(query string) page { getPage := func(query string) page {
t.Helper() 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 { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) 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) s, db, userID := newTestServer(t)
ctx := newCtx() ctx := newCtx()
@@ -444,7 +447,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
} }
getPage := func(query string) page { getPage := func(query string) page {
t.Helper() 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 { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) 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) s, db, userID := newTestServer(t)
ctx := newCtx() ctx := newCtx()
@@ -500,9 +503,9 @@ func TestResolveReview_RejectsRaceKind(t *testing.T) {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) 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 { 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) { func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
s, db, userID := newTestServer(t) s, db, userID := newTestServer(t)
ctx := newCtx() ctx := newCtx()
@@ -633,7 +585,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
} }
router := s.Router() 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 { if rec.Code != http.StatusAccepted {
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String()) 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. // "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 { 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) { func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
s, _, _ := newTestServer(t) s, db, _ := newTestServer(t)
router := s.Router() router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil) 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.GarminEmail = "runner@example.com"
got.GarminPassword = "hunter2" got.GarminPassword = "hunter2"
got.RollingWindowDays = 120 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 { if rec.Code != http.StatusOK {
t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String()) 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) rec = doJSON(t, router, http.MethodGet, "/api/profile", nil)
var updated store.Profile 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) { func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
t.Helper() t.Helper()
s, db, _ := newTestServer(t) s, db, _ := newTestServer(t)
@@ -828,7 +967,7 @@ func TestSessionCallback_AuthorizedSetsSessionCookieAndRedirectsHome(t *testing.
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req) 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")) t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
} }
var sessionCookie *http.Cookie var sessionCookie *http.Cookie
@@ -864,7 +1003,7 @@ func TestSessionCallback_UnauthorizedRedirectsWithoutSessionCookie(t *testing.T)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req) 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")) t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
} }
for _, c := range rec.Result().Cookies() { 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) req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req) 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")) 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) 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"])
}
}

View File

@@ -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})
}

View 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)
}

View 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)
}
}

View 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)
}

View File

@@ -10,9 +10,7 @@ import (
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock" authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
// doJSONAs is doJSON but for an explicit session Sub, for tests that need // 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 := httptest.NewRequest(method, path, reader)
req.Header.Set("Content-Type", "application/json") 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 { if err != nil {
t.Fatalf("mint test session cookie: %v", err) 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 return rec
} }
func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) { func TestIsolation_AssignCannotTargetOtherUsersActivity(t *testing.T) {
s, db, _ := newTestServer(t) // provisions "test-user" (userA) s, db, _ := newTestServer(t) // provisions "test-user" (userA)
userB, err := db.ProvisionUser(newCtx(), "user-b", "B") userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
if err != nil { if err != nil {
@@ -58,21 +56,26 @@ func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("UpsertActivity: %v", err) t.Fatalf("UpsertActivity: %v", err)
} }
_ = userB
router := s.Router() kindsB, err := db.ListWorkoutKinds(newCtx(), userB, false)
if err != nil || len(kindsB) == 0 {
// userA (the default doJSON identity) can see it. t.Fatalf("ListWorkoutKinds(b): len=%d err=%v", len(kindsB), err)
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+itoa(activityID), nil) }
if rec.Code != http.StatusOK { kindB := kindsB[0]
t.Fatalf("userA GetActivity status = %d, body = %s", rec.Code, rec.Body.String()) 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 // userB, given userA's real activity id (and a kind userB legitimately
// user's data, and not a 500 that would leak existence either way. // owns), must not be able to write an assignment onto it.
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/activities/"+itoa(activityID), nil) 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.StatusNotFound { if rec.Code == http.StatusOK {
t.Fatalf("userB GetActivity(userA's id) status = %d, want 404, body = %s", rec.Code, rec.Body.String()) 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) s, db, _ := newTestServer(t) // provisions "test-user" (userA)
userB, err := db.ProvisionUser(newCtx(), "user-b", "B") userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
if err != nil { if err != nil {
@@ -125,7 +128,7 @@ func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
t.Fatalf("InsertKindAssignment(b): %v", err) 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 { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) 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) json.Unmarshal(rec.Body.Bytes(), &page)
if page.Total != 0 || len(page.Items) != 0 { 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) t.Fatalf("store.Open: %v", err)
} }
defer db.Close() defer db.Close()
m := &mock.Client{} m := &garmin.MockClient{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) 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 rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String()) 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())
}
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"strings"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
) )
@@ -35,6 +36,14 @@ func validateProfile(p store.Profile) error {
return nil 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) { func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context()) userID := userIDFromContext(r.Context())
p, err := s.DB.GetProfile(r.Context(), userID) 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()) writeError(w, http.StatusInternalServerError, err.Error())
return 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) { func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context()) userID := userIDFromContext(r.Context())
var p store.Profile var p profilePayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil { if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body") writeError(w, http.StatusBadRequest, "invalid request body")
return 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()) writeError(w, http.StatusBadRequest, err.Error())
return 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()) writeError(w, http.StatusInternalServerError, err.Error())
return 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -73,5 +91,31 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return 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)
} }

View File

@@ -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"})
}

View File

@@ -4,20 +4,25 @@ package api
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" applog "geniusrun/backend/internal/log"
"log/slog"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"sync" "sync"
"time"
"github.com/go-chi/chi/v5"
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store" "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 // Server wires the HTTP handlers to the app's dependencies. garmin.Client
@@ -26,124 +31,58 @@ import (
type Server struct { type Server struct {
DB *store.DB DB *store.DB
Auth auth.Verifier Auth auth.Verifier
Session SessionConfig SessionConfig SessionConfig
// GarminFactory builds a real (or fake, in tests) garmin.Client from a // GarminFactory builds a real (or fake, in tests) garmin.Client from a
// fully-resolved per-user Config. Production wiring passes // fully-resolved per-user Config. Production wiring passes
// garmin.NewClient; tests inject a factory returning a shared // garmin.NewClient; tests inject a factory returning a shared
// *mock.Client (see newTestServer in api_test.go). // *mock.Client (see newTestServer in api_test.go).
GarminFactory func(garmin.Config) garmin.Client GarminFactory func(garmin.ClientConfig) garmin.Client
// GarminBase holds the plumbing shared by every user's garmin.Config
// (subprocess paths + the token-store root directory); only // 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 // GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
// garminFor. // garminFor.
GarminBase garmin.Config ClientConfig garmin.ClientConfig
SyncConfig appsync.Config 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 mu sync.Mutex
userGarmin map[int64]garmin.Client userClient map[int64]garmin.Client
userSync map[int64]*appsync.Service userSync map[int64]*garmin.Sync
userAuthStatus map[int64]garmin.AuthStatus userAuthStatus map[int64]garmin.AuthStatus
userAuthMessage map[int64]string userAuthMessage map[int64]string
userSyncRunning map[int64]bool userSyncRunning map[int64]bool
setupSession map[string]*setupSession
} }
// NewServer builds a Server. // 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{ return &Server{
DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig, DB: db,
Auth: authVerifier, Session: session, GarminFactory: garminFactory,
userGarmin: map[int64]garmin.Client{}, ClientConfig: garminBase,
userSync: map[int64]*appsync.Service{}, SyncConfig: syncConfig,
Auth: authVerifier,
SessionConfig: sessionConfig,
userClient: map[int64]garmin.Client{},
userSync: map[int64]*garmin.Sync{},
userAuthStatus: map[int64]garmin.AuthStatus{}, userAuthStatus: map[int64]garmin.AuthStatus{},
userAuthMessage: map[int64]string{}, userAuthMessage: map[int64]string{},
userSyncRunning: map[int64]bool{}, userSyncRunning: map[int64]bool{},
} setupSession: map[string]*setupSession{},
}
// 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)
}
} }
} }
// Router builds the HTTP routes. // Router builds the HTTP routes.
func (s *Server) Router() http.Handler { func (s *Server) Router() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(loggingMiddleware)
r.Use(corsMiddleware) r.Use(corsMiddleware)
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
r.Get("/health", s.handleHealth) r.Get("/health", s.handleHealth)
@@ -154,12 +93,19 @@ func (s *Server) Router() http.Handler {
r.Get("/session/callback", s.handleSessionCallback) r.Get("/session/callback", s.handleSessionCallback)
r.Group(func(r chi.Router) { 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.Use(s.resolveUser)
r.Get("/session/me", s.handleSessionMe) r.Get("/session/me", s.handleSessionMe)
r.Post("/session/logout", s.handleSessionLogout) 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.Group(func(r chi.Router) {
r.Use(requireProvisionedUser) r.Use(requireProvisionedUser)
@@ -167,24 +113,30 @@ func (s *Server) Router() http.Handler {
r.Route("/profile", func(r chi.Router) { r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile) r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile) r.Put("/", s.handleUpdateProfile)
r.Delete("/", s.handleDeleteProfile)
}) })
r.Route("/garmin", func(r chi.Router) {
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
r.Post("/login", s.handleAuthLogin) r.Post("/login", s.handleGarminAuthLogin)
r.Post("/mfa", s.handleAuthMFA) r.Post("/mfa", s.handleGarminAuthMFA)
r.Get("/status", s.handleAuthStatus) r.Get("/status", s.handleGarminAuthStatus)
}) })
r.Route("/sync", func(r chi.Router) { r.Route("/sync", func(r chi.Router) {
r.Post("/run", s.handleSyncRun) r.Post("/run", s.handleGarminSyncRun)
r.Post("/reset", s.handleSyncReset) r.Post("/reset", s.handleGarminSyncReset)
r.Get("/runs", s.handleSyncRuns) r.Get("/runs", s.handleGarminSyncRuns)
r.Get("/status", s.handleSyncStatus) r.Get("/status", s.handleGarminSyncStatus)
})
}) })
r.Route("/activities", func(r chi.Router) { r.Route("/activities", func(r chi.Router) {
r.Get("/", s.handleListActivities) 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) { r.Route("/workout-kinds", func(r chi.Router) {
@@ -195,12 +147,8 @@ func (s *Server) Router() http.Handler {
r.Post("/reclassify", s.handleReclassifyAll) r.Post("/reclassify", s.handleReclassifyAll)
r.Route("/review-queue", func(r chi.Router) { r.Get("/config", s.handleGetConfig)
r.Get("/", s.handleReviewQueue) r.Put("/config", s.handlePutConfig)
r.Post("/{activityID}/resolve", s.handleResolveReview)
r.Post("/{activityID}/unlock", s.handleUnlockReview)
r.Post("/{activityID}/unassign", s.handleUnassignReview)
})
r.Get("/progression/{kindID}", s.handleProgression) r.Get("/progression/{kindID}", s.handleProgression)
}) })
@@ -209,6 +157,37 @@ func (s *Server) Router() http.Handler {
return r 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 // corsMiddleware allows the frontend dev server (a different port) to call
// this API. Reflecting any origin back is safe even with credentials // this API. Reflecting any origin back is safe even with credentials
// enabled: this remains a single-operator app whose real access control is // 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) { // clientFor returns userID's garmin.Client, building and caching it (from
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) // 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) { // removeUserClient drops userID's cached garmin.Client/sync.Service (if
w.Header().Set("Content-Type", "application/json") // any) and every other per-user in-memory entry for userID, terminating the
w.WriteHeader(status) // client's subprocess and best-effort removing its on-disk token-store
if err := json.NewEncoder(w).Encode(v); err != nil { // directory. Called when a user's account has just been deleted from the
log.Printf("api: encode response: %v", err) // 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) { // syncFor returns userID's sync.Service, building and caching it on first use.
writeJSON(w, status, map[string]string{"error": msg}) 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 // 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() s.mu.Unlock()
}() }()
if err := fn(context.Background()); err != nil { 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 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})
}

View File

@@ -1,10 +1,11 @@
package api package api
import ( import (
"log"
"net/http" "net/http"
"time" "time"
applog "geniusrun/backend/internal/log"
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
) )
@@ -14,13 +15,27 @@ import (
type SessionConfig struct { type SessionConfig struct {
Secret []byte Secret []byte
Duration time.Duration Duration time.Duration
// 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 Secure bool
// PublicBaseURL is this app's own externally reachable origin (e.g. // BackendURL is this app's own externally reachable origin (e.g.
// "https://geniusrun.example.com", no trailing slash), used to build an // "https://geniusrun.example.com", no trailing slash) -- derives
// absolute post_logout_redirect_uri for the identity provider -- some // OIDCRedirectURL (config.Config), the only thing that must stay pointed
// providers, including Keycloak, require this to be an absolute URL // at the backend itself, since that's where /api/session/callback is
// matching one registered on the client, not a bare relative path. // actually served.
PublicBaseURL string 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 { type sessionMeResponse struct {
@@ -28,6 +43,7 @@ type sessionMeResponse struct {
Email string `json:"email"` Email string `json:"email"`
HasProfile bool `json:"has_profile"` HasProfile bool `json:"has_profile"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
GarminConnected bool `json:"garmin_connected"`
} }
func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) { 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()) writeError(w, http.StatusBadGateway, err.Error())
return 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return 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) { func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
txnCookie, err := r.Cookie(auth.TxnCookieName) txnCookie, err := r.Cookie(auth.TxnCookieName)
if err != nil { if err != nil {
log.Printf("session callback: missing txn cookie: %v", err) applog.App().Warn("missing txn cookie", "error", err)
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound) http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return 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 { if err != nil {
log.Printf("session callback: failed to parse txn cookie: %v", err) applog.App().Warn("failed to parse txn cookie", "error", err)
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound) http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return return
} }
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query()) result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
if err != nil { if err != nil {
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", err) applog.App().Error("callback failed (state mismatch, code exchange, or ID-token verification)", "error", err)
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound) http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return return
} }
if !result.Authorized { 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 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
http.SetCookie(w, sessionCookie) 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) { func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure)) claims, _ := auth.ClaimsFromContext(r.Context())
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.PublicBaseURL+"/"), http.StatusFound) 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) { 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} resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
if u, found := userFromContext(r.Context()); found { if u, found := userFromContext(r.Context()); found {
resp.HasProfile = true 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) writeJSON(w, http.StatusOK, resp)
} }

View File

@@ -3,11 +3,120 @@ package api
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"os"
"path/filepath"
"strconv"
"time"
"geniusrun/backend/internal/auth" "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()) claims, ok := auth.ClaimsFromContext(r.Context())
if !ok { if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated") writeError(w, http.StatusUnauthorized, "not authenticated")
@@ -30,10 +139,145 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
return 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) userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return 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}) 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))
}
}

View File

@@ -4,82 +4,314 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
authmock "geniusrun/backend/internal/auth/mock" authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) { // newUnprovisionedServer builds a Server whose "test-user" OIDC subject
// This test specifically needs an *unprovisioned* session, unlike every // (the identity doJSON's cookie always mints) has no users/profile row yet
// other test in this package -- build the server without the // -- every test in this file needs that starting state, unlike
// newTestServer helper's automatic ProvisionUser call. // 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")) db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil { if err != nil {
t.Fatalf("store.Open: %v", err) t.Fatalf("store.Open: %v", err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
m := &mock.Client{} m := &garmin.MockClient{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) 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() router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil) rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
var me sessionMeResponse "garmin_email": "runner@example.com", "garmin_password": "hunter2",
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"})
if rec.Code != http.StatusOK { 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) if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
unmarshalBody(t, rec, &me) t.Fatalf("expected no user created yet, found=%v err=%v", found, err)
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)
} }
} }
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")) db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil { if err != nil {
t.Fatalf("store.Open: %v", err) t.Fatalf("store.Open: %v", err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
m := &mock.Client{} m := &garmin.MockClient{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) 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 { if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code) t.Fatalf("status = %d, want 400", rec.Code)
} }
} }
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) { func TestSetupGarminLogin_RejectsWhenAlreadyProvisioned(t *testing.T) {
s, _, _ := newTestServer(t) s, _, _ := newTestServer(t) // pre-provisioned "test-user"
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"}) 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 { if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) 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) { func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
t.Helper() t.Helper()
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err) 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)
}
}

View File

@@ -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)
}

View File

@@ -15,7 +15,7 @@ const resolvedUserContextKey userContextKey = iota
// session's OIDC subject. // session's OIDC subject.
type resolvedUser struct { type resolvedUser struct {
ID int64 ID int64
DisplayName string Name string
} }
// resolveUser runs after auth.RequireSession on every request and looks up // 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() ctx := r.Context()
if found { 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)) next.ServeHTTP(w, r.WithContext(ctx))
}) })

View File

@@ -9,9 +9,7 @@ import (
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock" authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) { func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
@@ -41,8 +39,8 @@ func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) {
t.Fatalf("store.Open: %v", err) t.Fatalf("store.Open: %v", err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
m := &mock.Client{} m := &garmin.MockClient{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
var gotOK bool var gotOK bool
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -28,7 +28,7 @@ func TestRequireSession_NoCookie(t *testing.T) {
} }
func TestRequireSession_ValidCookie(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 { if err != nil {
t.Fatalf("mint: %v", err) t.Fatalf("mint: %v", err)
} }
@@ -45,7 +45,7 @@ func TestRequireSession_ValidCookie(t *testing.T) {
} }
func TestRequireSession_ExpiredCookie(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 { if err != nil {
t.Fatalf("mint: %v", err) t.Fatalf("mint: %v", err)
} }
@@ -59,11 +59,11 @@ func TestRequireSession_ExpiredCookie(t *testing.T) {
} }
func TestRequireSession_TamperedCookie(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 { if err != nil {
t.Fatalf("mint: %v", err) 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 := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(cookie) req.AddCookie(cookie)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()

View File

@@ -18,6 +18,7 @@ type Verifier struct {
CallbackResult auth.LoginResult CallbackResult auth.LoginResult
CallbackErr error CallbackErr error
EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged
LastIDTokenHint string // records the idTokenHint passed to the last EndSessionURL call
} }
var _ auth.Verifier = (*Verifier)(nil) var _ auth.Verifier = (*Verifier)(nil)
@@ -33,7 +34,8 @@ func (v *Verifier) HandleCallback(ctx context.Context, txn auth.TxnState, query
return v.CallbackResult, nil 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 != "" { if v.EndSessionResult != "" {
return v.EndSessionResult return v.EndSessionResult
} }

View File

@@ -6,6 +6,7 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"net/url" "net/url"
"slices"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@@ -24,13 +25,21 @@ type Verifier interface {
HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error) HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error)
// EndSessionURL builds the identity provider's logout URL, redirecting // EndSessionURL builds the identity provider's logout URL, redirecting
// back to postLogoutRedirectURL once Keycloak's own session is cleared. // 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. // LoginResult is what a completed callback exchange resolves to.
type LoginResult struct { type LoginResult struct {
Claims Claims Claims Claims
Authorized bool 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 // OIDCConfig configures NewOIDCVerifier. RequiredRole is checked against the
@@ -55,12 +64,7 @@ type idTokenClaims struct {
} }
func (c idTokenClaims) hasRole(required string) bool { func (c idTokenClaims) hasRole(required string) bool {
for _, r := range c.RealmAccess.Roles { return slices.Contains(c.RealmAccess.Roles, required)
if r == required {
return true
}
}
return false
} }
type oidcVerifier struct { type oidcVerifier struct {
@@ -137,10 +141,11 @@ func (v *oidcVerifier) HandleCallback(ctx context.Context, txn TxnState, query u
return LoginResult{ return LoginResult{
Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email}, Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email},
Authorized: claims.hasRole(v.requiredRole), Authorized: claims.hasRole(v.requiredRole),
IDToken: rawIDToken,
}, nil }, nil
} }
func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string { func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL, idTokenHint string) string {
var discovery struct { var discovery struct {
EndSessionEndpoint string `json:"end_session_endpoint"` EndSessionEndpoint string `json:"end_session_endpoint"`
} }
@@ -154,6 +159,9 @@ func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string {
q := u.Query() q := u.Query()
q.Set("client_id", v.oauth2Config.ClientID) q.Set("client_id", v.oauth2Config.ClientID)
q.Set("post_logout_redirect_uri", postLogoutRedirectURL) q.Set("post_logout_redirect_uri", postLogoutRedirectURL)
if idTokenHint != "" {
q.Set("id_token_hint", idTokenHint)
}
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
return u.String() return u.String()
} }

View File

@@ -1,10 +1,83 @@
package auth package auth
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing" "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) { func TestIDTokenClaims_HasRole(t *testing.T) {
cases := []struct { cases := []struct {
name string name string

View File

@@ -1,9 +1,17 @@
// Package auth implements geniusrun's own login gate: an OIDC Authorization // Package auth implements geniusrun's own login gate: an OIDC Authorization
// Code flow against an existing Keycloak realm (see oidc.go), backed by a // Code flow against an existing Keycloak realm (see oidc.go), backed by a
// signed session cookie geniusrun mints itself (this file) and a chi // signed session cookie geniusrun mints itself (this file) and a chi
// middleware that checks it (middleware.go). Keycloak's own tokens are never // middleware that checks it (middleware.go). Keycloak's own access/refresh
// stored or refreshed -- once HandleCallback verifies the ID token and role, // tokens are never stored or refreshed -- once HandleCallback verifies the
// only this package's own cookie matters for subsequent requests. // 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 package auth
import ( import (
@@ -26,7 +34,11 @@ const (
) )
// Claims identifies the authenticated user, carried in the signed session // 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 { type Claims struct {
Sub string Sub string
Name string Name string
@@ -37,6 +49,7 @@ type sessionClaims struct {
Sub string `json:"sub"` Sub string `json:"sub"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
IDToken string `json:"id_token,omitempty"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@@ -55,12 +68,15 @@ type txnClaims struct {
// MintSessionCookie signs claims into a JWT valid for duration and wraps it // 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. // 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() now := time.Now()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{ token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{
Sub: claims.Sub, Sub: claims.Sub,
Name: claims.Name, Name: claims.Name,
Email: claims.Email, Email: claims.Email,
IDToken: idToken,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(duration)), 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 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. // MintTxnCookie signs an OIDC login transaction into a short-lived cookie.
func MintTxnCookie(txn TxnState, secret []byte, secure bool) (*http.Cookie, error) { func MintTxnCookie(txn TxnState, secret []byte, secure bool) (*http.Cookie, error) {
now := time.Now() now := time.Now()

View File

@@ -9,7 +9,7 @@ const testSecret = "test-secret-at-least-32-bytes-long!"
func TestMintAndParseSessionCookie(t *testing.T) { func TestMintAndParseSessionCookie(t *testing.T) {
claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"} 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 { if err != nil {
t.Fatalf("mint: %v", err) t.Fatalf("mint: %v", err)
} }
@@ -24,10 +24,20 @@ func TestMintAndParseSessionCookie(t *testing.T) {
if got != claims { if got != claims {
t.Fatalf("got %+v, want %+v", 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) { 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 { if err != nil {
t.Fatalf("mint: %v", err) t.Fatalf("mint: %v", err)
} }
@@ -37,18 +47,46 @@ func TestParseSessionCookie_Expired(t *testing.T) {
} }
func TestParseSessionCookie_Tampered(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 { if err != nil {
t.Fatalf("mint: %v", err) 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 { if _, err := ParseSessionCookie(cookie, []byte(testSecret)); err == nil {
t.Fatal("expected error for tampered cookie") 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) { 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 { if err != nil {
t.Fatalf("mint: %v", err) t.Fatalf("mint: %v", err)
} }

View 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
}

View 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)
}
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View 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)},
}
}

View 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)
}
}

View File

@@ -1,23 +1,36 @@
// Package garmin wraps the mcp-garmin MCP server as a narrow Go client // Package garmin wraps a direct garminconnect subprocess (see
// interface, so the rest of geniusrun never deals with MCP/JSON-RPC directly. // wrapper/wrapper.py) as a narrow Go client interface, so the rest of
// geniusrun never deals with the wire protocol directly.
package garmin package garmin
import ( import (
"bufio"
"context" "context"
_ "embed"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"log/slog"
"os"
"os/exec"
"strconv" "strconv"
"strings"
"sync" "sync"
"time"
mcpclient "github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
) )
//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 // Client is the interface the rest of geniusrun depends on. The real
// implementation drives mcp-garmin over stdio; internal/garmin/mock provides // implementation drives an embedded Python wrapper subprocess over stdio;
// a fake for tests and frontend-only development. // internal/garmin/mock provides a fake for tests and frontend-only
// development.
type Client interface { type Client interface {
// Authenticate triggers Garmin login using credentials the subprocess // Authenticate triggers Garmin login using credentials the subprocess
// was started with. Spawns the subprocess on first call. // was started with. Spawns the subprocess on first call.
@@ -42,180 +55,407 @@ type Client interface {
Close() error Close() error
} }
// Config configures how the mcp-garmin subprocess is spawned. // ClientConfig configures how the Garmin wrapper subprocess is spawned.
type Config struct { type ClientConfig struct {
PythonPath string // path to mcp-garmin's venv python executable // PythonPath is the python3 interpreter to run the embedded wrapper
ServerPath string // path to mcp-garmin's server.py // script with. Empty defaults to "python3" resolved via PATH.
PythonPath string
GarminEmail string GarminEmail string
GarminPassword string GarminPassword string
// TokenStorePath, if set, is passed as GARMIN_TOKENSTORE so mcp-garmin // TokenStorePath is passed as GARMIN_TOKENSTORE so the wrapper
// persists/resumes a Garmin session there instead of the default ~/.garth. // persists/resumes a Garmin session there.
TokenStorePath string TokenStorePath string
} }
// mcpClient is the real Client implementation, backed by an mcp-garmin // wireRequest is one line sent to the wrapper subprocess's stdin.
// subprocess spoken to over stdio MCP. type wireRequest struct {
type mcpClient struct { ID int `json:"id"`
cfg Config Cmd string `json:"cmd"`
Params any `json:"params,omitempty"`
}
mu sync.Mutex // serializes calls; mcp-garmin holds a single shared Garmin client // wireResponse is one line read from the wrapper subprocess's stdout.
inner *mcpclient.Client // 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"`
}
// 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 started bool
nextID int
stderrDone chan struct{} // closed once the stderr-copy goroutine has finished reading
} }
// NewClient builds a Client. The subprocess is not spawned until the first // ensureStarted spawns the wrapper subprocess if it isn't already running.
// call that needs it (Authenticate, or any data call once authenticated). // Callers must hold c.mu.
func NewClient(cfg Config) Client { func (c *subprocessClient) ensureStarted(ctx context.Context) error {
return &mcpClient{cfg: cfg}
}
func (c *mcpClient) ensureStarted(ctx context.Context) error {
if c.started { if c.started {
return nil 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_EMAIL=" + c.cfg.GarminEmail,
"GARMIN_PASSWORD=" + c.cfg.GarminPassword, "GARMIN_PASSWORD=" + c.cfg.GarminPassword,
"GARMIN_TOKENSTORE=" + c.cfg.TokenStorePath,
"PYTHONUNBUFFERED=1", "PYTHONUNBUFFERED=1",
} }
if c.cfg.TokenStorePath != "" { cmd.Env = append(os.Environ(), extraEnv...)
env = append(env, "GARMIN_TOKENSTORE="+c.cfg.TokenStorePath)
}
inner, err := mcpclient.NewStdioMCPClient(c.cfg.PythonPath, env, c.cfg.ServerPath) stdin, err := cmd.StdinPipe()
if err != nil { 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 { if err := cmd.Start(); err != nil {
go drainStderr(stdio) 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{} c.cmd = cmd
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION c.stdin = stdin
initReq.Params.ClientInfo = mcp.Implementation{Name: "geniusrund", Version: "0.0.1"} c.enc = json.NewEncoder(stdin)
if _, err := inner.Initialize(ctx, initReq); err != nil { scanner := bufio.NewScanner(stdout)
inner.Close() scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
return fmt.Errorf("mcp initialize handshake: %w", err) c.scanner = scanner
}
c.inner = inner
c.started = true c.started = true
c.nextID = 0
return nil 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. // UpdateCredentials implements Client.
func (c *mcpClient) UpdateCredentials(email, password string) { func (c *subprocessClient) UpdateCredentials(email, password string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.cfg.GarminEmail = email c.cfg.GarminEmail = email
c.cfg.GarminPassword = password c.cfg.GarminPassword = password
c.close()
}
if c.started { // Close implements Client.
if c.inner != nil { func (c *subprocessClient) Close() error {
c.inner.Close() c.mu.Lock()
defer c.mu.Unlock()
return c.close()
}
// close terminates the subprocess, if running. Callers must hold c.mu.
func (c *subprocessClient) close() error {
if !c.started {
return nil
} }
c.inner = nil
c.started = false c.started = false
if c.stdin != nil {
c.stdin.Close()
}
if c.cmd != nil && c.cmd.Process != nil {
c.cmd.Process.Kill()
}
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
}
// 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...)
} }
} }
// drainStderr forwards the subprocess's debug/log output so it isn't // wrapperLogLevel maps wrapper.py's level strings onto slog levels,
// silently dropped (mcp-garmin logs auth/rate-limit diagnostics there). // defaulting to info for anything unrecognized.
func drainStderr(stdio *transport.Stdio) { func wrapperLogLevel(level string) slog.Level {
buf := make([]byte, 4096) switch level {
for { case "debug":
n, err := stdio.Stderr().Read(buf) return slog.LevelDebug
if n > 0 { case "warn", "warning":
fmt.Print(string(buf[:n])) return slog.LevelWarn
} case "error":
if err != nil { return slog.LevelError
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) {
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
}
func (c *mcpClient) 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
}
msg, err := c.callTool(ctx, "complete_mfa", map[string]any{"code": code})
if err != nil {
return AuthResult{}, err
}
return parseAuthResult(msg), nil
}
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}
default: 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() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil { if err := c.ensureStarted(ctx); err != nil {
return nil, err return nil, err
} }
msg, err := c.callTool(ctx, "get_activities", map[string]any{ raw, err := c.execute(ctx, "call", callParams{
"start_date": startDate, Method: "get_activities_by_date",
"end_date": endDate, Args: map[string]any{"startdate": startDate, "enddate": endDate},
"limit": limit,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
var rawActivities []json.RawMessage var rawActivities []json.RawMessage
if err := json.Unmarshal([]byte(msg), &rawActivities); err != nil { if err := json.Unmarshal(raw, &rawActivities); err != nil {
return nil, fmt.Errorf("get_activities did not return a JSON array (%q): %w", truncate(msg, 200), err) 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)) activities := make([]Activity, 0, len(rawActivities))
@@ -230,15 +470,16 @@ func (c *mcpClient) GetActivities(ctx context.Context, startDate, endDate string
return activities, nil 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() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil { if err := c.ensureStarted(ctx); err != nil {
return ActivitySplits{}, err return ActivitySplits{}, err
} }
msg, err := c.callTool(ctx, "get_activity_splits", map[string]any{ raw, err := c.execute(ctx, "call", callParams{
"activity_id": strconv.FormatInt(activityID, 10), Method: "get_activity_splits",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
}) })
if err != nil { if err != nil {
return ActivitySplits{}, err return ActivitySplits{}, err
@@ -248,8 +489,8 @@ func (c *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (Ac
ActivityID int64 `json:"activityId"` ActivityID int64 `json:"activityId"`
Laps []json.RawMessage `json:"lapDTOs"` Laps []json.RawMessage `json:"lapDTOs"`
} }
if err := json.Unmarshal([]byte(msg), &envelope); err != nil { if err := json.Unmarshal(raw, &envelope); err != nil {
return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(msg, 200), err) 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))} 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 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() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil { if err := c.ensureStarted(ctx); err != nil {
return ActivityDetails{}, err return ActivityDetails{}, err
} }
msg, err := c.callTool(ctx, "get_activity_details", map[string]any{ raw, err := c.execute(ctx, "call", callParams{
"activity_id": strconv.FormatInt(activityID, 10), Method: "get_activity_details",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
}) })
if err != nil { if err != nil {
return ActivityDetails{}, err return ActivityDetails{}, err
} }
var details ActivityDetails var details ActivityDetails
if err := json.Unmarshal([]byte(msg), &details); err != nil { if err := json.Unmarshal(raw, &details); err != nil {
return ActivityDetails{}, fmt.Errorf("get_activity_details did not return valid JSON (%q): %w", truncate(msg, 200), err) 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 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() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil { if err := c.ensureStarted(ctx); err != nil {
return Workout{}, err return Workout{}, err
} }
msg, err := c.callTool(ctx, "get_workout_by_id", map[string]any{ raw, err := c.execute(ctx, "call", callParams{
"workout_id": strconv.FormatInt(workoutID, 10), Method: "get_workout_by_id",
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
}) })
if err != nil { if err != nil {
return Workout{}, err return Workout{}, err
} }
var workout Workout var workout Workout
if err := json.Unmarshal([]byte(msg), &workout); err != nil { if err := json.Unmarshal(raw, &workout); err != nil {
return Workout{}, fmt.Errorf("get_workout_by_id did not return valid JSON (%q): %w", truncate(msg, 200), err) 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 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)"
}

View File

@@ -1,29 +1,173 @@
package garmin package garmin
import "testing" import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"os/exec"
"strings"
"testing"
"time"
func TestParseAuthResult(t *testing.T) { "geniusrun/backend/internal/log"
cases := []struct { )
msg string
want AuthStatus // wireResponsePayload is what a fake wrapper handler returns for one
}{ // request; the harness fills in the response ID.
{"Authenticated successfully.", AuthSuccess}, type wireResponsePayload struct {
{"MFA accepted. Authenticated successfully.", AuthSuccess}, result json.RawMessage
{"MFA required. Garmin has sent a verification code...", AuthMFARequired}, err string
{"Authentication failed: 401 Unauthorized (Invalid Username or Password)", AuthFailed}, notFound bool
{"Authentication failed after MFA: bad code", AuthFailed}, }
func fakeResult(v any) wireResponsePayload {
b, err := json.Marshal(v)
if err != nil {
panic(err)
} }
for _, c := range cases { return wireResponsePayload{result: b}
got := parseAuthResult(c.msg) }
if got.Status != c.want {
t.Errorf("parseAuthResult(%q).Status = %v, want %v", c.msg, got.Status, c.want) 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) { func TestSubprocessClient_RoundTrip_DetectsIDMismatch(t *testing.T) {
c := &mcpClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}} reqR, reqW := io.Pipe()
c.started = true // simulate an already-spawned subprocess 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") c.UpdateCredentials("new@example.com", "new")
@@ -33,7 +177,360 @@ func TestMcpClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
if c.started { if c.started {
t.Error("started should be reset to false so the next call respawns the subprocess") 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)
} }
} }

View File

@@ -1,4 +1,4 @@
package sync package garmin
import ( import (
"encoding/json" "encoding/json"
@@ -6,7 +6,6 @@ import (
"time" "time"
"geniusrun/backend/internal/classify" "geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
) )
@@ -19,7 +18,7 @@ func isRunningActivityType(typeKey string) bool {
return strings.Contains(strings.ToLower(typeKey), "run") return strings.Contains(strings.ToLower(typeKey), "run")
} }
func toActivityRow(a garmin.Activity) store.Activity { func toActivityRow(a Activity) store.Activity {
return store.Activity{ return store.Activity{
GarminActivityID: a.ActivityID, GarminActivityID: a.ActivityID,
EventTypeKey: a.EventType.TypeKey, 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 // fall within that lap's time window. Lap boundaries are derived from
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps // cumulative elapsed duration rather than parsing StartTimeGMT, since laps
// are contiguous and this sidesteps timezone parsing entirely. // 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)) rows := make([]store.Lap, 0, len(laps))
var elapsedStart float64 var elapsedStart float64
for i, l := range laps { for i, l := range laps {
@@ -94,9 +93,9 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
return rows return rows
} }
// alignWorkoutTargets zips an activity's recorded laps against its // alignWorkoutTargets zips an activity's lap count against its structured
// structured workout's flattened steps, returning one *garmin.WorkoutStep // workout's flattened steps, returning one *garmin.WorkoutStep per lap (nil
// per lap (nil where unavailable). // where unavailable).
// //
// Confirmed against a real activity (via Garmin Connect's own workout view) // 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 // 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, // 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 // etc.) can't be trusted at all, so every entry comes back nil rather than
// risk showing a target against the wrong lap. // 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() 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: case 0, 1:
for i := range steps { for i := range steps {
s := steps[i] 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 // 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. // 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 { if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil {
return nil, 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. // 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 // Steps that target a named zone (ZoneNumber) rather than a custom bpm
// range are resolved via the user's Karvonen profile. // 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" { if step.TargetType.TypeKey != "heart.rate.zone" {
return nil, nil 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 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 var out []classify.SampleInfo
for _, s := range samples { for _, s := range samples {
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end { if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
@@ -199,7 +198,7 @@ func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.Sam
return out return out
} }
func toSampleRows(samples []garmin.Sample) []store.Sample { func toSampleRows(samples []Sample) []store.Sample {
rows := make([]store.Sample, len(samples)) rows := make([]store.Sample, len(samples))
for i, s := range samples { for i, s := range samples {
rows[i] = store.Sample{ rows[i] = store.Sample{

View 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
}

View File

@@ -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
}

View File

@@ -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 // internal/garmin), persisting them (via internal/store), and classifying
// them (via internal/classify). It's the only package that depends on all // them (via internal/classify). It's the only package that depends on all
// three, keeping garmin/store/classify decoupled from each other. // three, keeping garmin/store/classify decoupled from each other.
package sync package garmin
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log"
"sync" "sync"
"time" "time"
"geniusrun/backend/internal/classify" "geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin" applog "geniusrun/backend/internal/log"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
) )
// Config tunes sync behavior. Zero values fall back to sensible defaults in // 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 // Profile.BackfillHorizonDays, read fresh on every call so a user-edited
// value takes effect on the next sync without a server restart. // 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 // BackfillWindowDays is the page size for each get_activities call
// during backfill. // during backfill.
BackfillWindowDays int BackfillWindowDays int
@@ -38,7 +38,7 @@ type Config struct {
MinConfidence float64 MinConfidence float64
} }
func (c Config) withDefaults() Config { func (c SyncConfig) withDefaults() SyncConfig {
if c.BackfillWindowDays == 0 { if c.BackfillWindowDays == 0 {
c.BackfillWindowDays = 90 c.BackfillWindowDays = 90
} }
@@ -54,49 +54,64 @@ func (c Config) withDefaults() Config {
return c return c
} }
// Progress reports how far a currently-running (or just-finished) // Phase values reported by Progress.Phase.
// FillPendingDetails pass has gotten, for a status banner to poll. 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 { type Progress struct {
Phase string
Done int Done int
Total 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. // it makes is for userID's data only.
type Service struct { type Sync struct {
garmin garmin.Client garmin Client
db *store.DB db *store.DB
userID int64 userID int64
cfg Config cfg SyncConfig
now func() time.Time now func() time.Time
progressMu sync.Mutex progressMu sync.Mutex
progress Progress 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). // 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 { if now == nil {
now = time.Now 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). // Progress returns the current sync progress (phase idle, 0/0 when nothing
func (s *Service) Progress() Progress { // is running).
func (s *Sync) Progress() Progress {
s.progressMu.Lock() s.progressMu.Lock()
defer s.progressMu.Unlock() defer s.progressMu.Unlock()
return s.progress return s.progress
} }
func (s *Service) setProgress(done, total int) { func (s *Sync) setProgress(phase string, done, total int) {
s.progressMu.Lock() s.progressMu.Lock()
s.progress = Progress{Done: done, Total: total} s.progress = Progress{Phase: phase, Done: done, Total: total}
s.progressMu.Unlock() 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. // Profile.BackfillHorizonDays is reached or Garmin returns an empty page.
// The horizon is read fresh from the profile on every call (not fixed at // 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 // 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 // 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 // already fully covered -- it does not re-walk years of already-known
// history. Widening the horizon between calls resumes further back instead // history. Widening the horizon between calls resumes further back instead
// of re-fetching everything. // of re-fetching everything. Used by FullSync as one step of its single
func (s *Service) Backfill(ctx context.Context) error { // combined SyncRun; there is no standalone entrypoint for this anymore
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindBackfill) // (the periodic background sync loop that used to call one is gone -- see
if err != nil { // 4d2cbe4 refactor: remove automatic background incremental sync).
return err func (s *Sync) backfillCore(ctx context.Context) (int, error) {
}
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) {
profile, err := s.db.GetProfile(ctx, s.userID) profile, err := s.db.GetProfile(ctx, s.userID)
if err != nil { if err != nil {
return 0, fmt.Errorf("load profile: %w", err) return 0, fmt.Errorf("load profile: %w", err)
@@ -191,26 +189,11 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
return total, nil return total, nil
} }
// IncrementalSync fetches activities from just before the latest known // incrementalSyncCore fetches activities from just before the latest known
// activity (or a short recent window if none exist yet) through today. // activity (or a short recent window if none exist yet) through today. Used
func (s *Service) IncrementalSync(ctx context.Context) error { // by FullSync as one step of its single combined SyncRun -- see
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindIncremental) // backfillCore's comment for why there's no standalone entrypoint.
if err != nil { func (s *Sync) incrementalSyncCore(ctx context.Context) (int, error) {
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) {
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays) start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok { 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 { 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 return newCount, err
} }
// FullSync performs a complete manual "Sync now" pass -- Backfill (resumes // FullSync performs a complete manual "Sync now" pass -- backfillCore
// from the watermark), then IncrementalSync (catches anything new since the // (resumes from the watermark), then incrementalSyncCore (catches anything
// latest known activity), then FillPendingDetails -- recorded as a single // new since the latest known activity), then FillPendingDetails --
// SyncRun. Backfill and IncrementalSync each record their own SyncRun when // recorded as a single SyncRun so the reported activity count covers the
// called on their own (used by the periodic background loop), but a manual // whole action instead of only whichever stage happened to finish last.
// sync runs both back to back, and FillPendingDetails records no run at all; func (s *Sync) FullSync(ctx context.Context, detailFillLimit int) error {
// 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 {
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull) runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull)
if err != nil { if err != nil {
return err 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) backfillCount, err := s.backfillCore(ctx)
if err != nil { 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 // assignments) and rewinds the backfill watermark, so the next Backfill
// call performs a genuinely fresh pull from Garmin instead of resuming from // call performs a genuinely fresh pull from Garmin instead of resuming from
// wherever the previous one left off. Workout kinds are left untouched. // 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) 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 // produced a confusing, meaningless number (e.g. "2 activities" on a sync
// that found nothing new, just because 2 already-known activities happened // that found nothing new, just because 2 already-known activities happened
// to fall inside the queried window). // 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) activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err) 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 return len(activities), newCount, nil
} }
// FillPendingDetails fetches get_activity_splits/get_activity_details for up // FillPendingDetails fetches activity details/splits for up to limit
// to limit activities that don't have them yet, then (re)classifies each one. // activities missing them, then fetches workouts for up to limit activities
// Calls are made sequentially with Config.InterCallDelay between them to // 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. // 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) pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit)
if err != nil { if err != nil {
return err return err
@@ -320,8 +315,7 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
return fmt.Errorf("load profile: %w", err) return fmt.Errorf("load profile: %w", err)
} }
s.setProgress(0, len(pending)) s.setProgress(PhaseActivities, 0, len(pending))
defer s.setProgress(0, 0)
for i, a := range pending { for i, a := range pending {
if i > 0 { 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 { if err := s.ClassifyActivity(ctx, a.ID); err != nil {
return fmt.Errorf("classify activity %d: %w", a.GarminActivityID, err) 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 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) splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID)
if err != nil { if err != nil {
return fmt.Errorf("get_activity_splits: %w", err) 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) return fmt.Errorf("get_activity_details: %w", err)
} }
targets := make([]*garmin.WorkoutStep, len(splits.Laps)) samples := ExtractSamples(details)
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)
if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil { if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil {
return err 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 { if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
return err 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) 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 // ClassifyActivity (re)runs the rule engine for one activity against the
// currently active workout kinds and appends a new kind_assignments row. // currently active workout kinds and appends a new kind_assignments row.
// Safe to call repeatedly (e.g. after editing a workout kind's rule). // 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) activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID)
if err != nil { if err != nil {
return err return err

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,8 @@ package garmin
import "encoding/json" import "encoding/json"
// AuthStatus is the outcome of an authenticate()/complete_mfa() call. // AuthStatus is the outcome of an authenticate()/complete_mfa() call.
// mcp-garmin's tools return a plain human-readable string rather than a // wrapper.py returns a structured {"status", "message"} JSON object, which
// structured status, so the client pattern-matches known phrases into this. // the client maps directly into this rather than pattern-matching strings.
type AuthStatus int type AuthStatus int
const ( const (

View File

@@ -0,0 +1,4 @@
.venv/
__pycache__/
.pytest_cache/
*.pyc

View 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 = ["."]

View 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}

View 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)

View 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
}
}

View 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"])
}
}

View File

@@ -45,6 +45,11 @@ type Activity struct {
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity // internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
// has no WorkoutID, or was synced before this column existed. // has no WorkoutID, or was synced before this column existed.
WorkoutRawJSON *string WorkoutRawJSON *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 CreatedAt string
UpdatedAt string UpdatedAt string
} }
@@ -102,7 +107,7 @@ func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
&a.AvgSpeedMps, &a.ElevationGainM, &a.AvgSpeedMps, &a.ElevationGainM,
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue, &a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON, &a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
&a.CreatedAt, &a.UpdatedAt, &a.WorkoutNotFoundAt, &a.CreatedAt, &a.UpdatedAt,
) )
return a, err return a, err
} }
@@ -113,7 +118,7 @@ const activityColumns = `
avg_speed_mps, elevation_gain_m, avg_speed_mps, elevation_gain_m,
aerobic_training_effect, anaerobic_training_effect, vo2max_value, aerobic_training_effect, anaerobic_training_effect, vo2max_value,
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, 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. // 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 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 // SetActivitySplitsFetched records that get_activity_splits has been fetched
// for this activity. // for this activity.
func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error { 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 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
}

View File

@@ -76,31 +76,6 @@ func (db *DB) CurrentAssignment(ctx context.Context, userID, activityID int64) (
return a, true, nil 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 // AllCurrentAssignments returns the latest assignment for every one of
// userID's activities that has one, regardless of status or source -- the // userID's activities that has one, regardless of status or source -- the
// basis for deciding which activities a global reclassify pass may touch. // basis for deciding which activities a global reclassify pass may touch.

View 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
}

View 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)
}
}

View File

@@ -4,27 +4,32 @@ package store
import ( import (
"database/sql" "database/sql"
"embed" _ "embed"
"fmt" "fmt"
"io/fs"
"sort"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
//go:embed migrations/*.sql //go:embed schema.sql
var migrationsFS embed.FS var schemaSQL string
// DB wraps a *sql.DB opened against a geniusrun SQLite database file, with // DB wraps a *sql.DB opened against a geniusrun SQLite database file, with
// migrations already applied. // the schema already applied.
type DB struct { type DB struct {
*sql.DB *sql.DB
} }
// Open opens (creating if needed) the SQLite database at path and applies // 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) { 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 { if err != nil {
return nil, fmt.Errorf("open sqlite database: %w", err) return nil, fmt.Errorf("open sqlite database: %w", err)
} }
@@ -33,110 +38,32 @@ func Open(path string) (*DB, error) {
sqlDB.SetMaxOpenConns(1) sqlDB.SetMaxOpenConns(1)
db := &DB{DB: sqlDB} db := &DB{DB: sqlDB}
if err := db.migrate(); err != nil { if err := db.applySchema(); err != nil {
sqlDB.Close() sqlDB.Close()
return nil, err return nil, err
} }
return db, nil return db, nil
} }
func (db *DB) migrate() error { // applySchema runs schema.sql once, the first time this database file is
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( // opened -- detected by checking whether the users table already exists.
filename TEXT PRIMARY KEY, func (db *DB) applySchema() error {
applied_at TEXT NOT NULL DEFAULT (datetime('now')) var alreadyApplied int
)`); err != nil { if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&alreadyApplied); err != nil {
return fmt.Errorf("create schema_migrations table: %w", err) return fmt.Errorf("check existing schema: %w", err)
}
applied := make(map[string]bool)
rows, err := db.Query(`SELECT filename FROM schema_migrations`)
if err != nil {
return fmt.Errorf("query applied migrations: %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
}
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)
} }
if alreadyApplied > 0 {
return nil
} }
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
if needsFKToggle { return fmt.Errorf("begin schema tx: %w", err)
db.Exec(`PRAGMA foreign_keys = ON`)
}
return fmt.Errorf("begin migration tx for %s: %w", name, err)
} }
defer tx.Rollback()
if _, err := tx.Exec(string(content)); err != nil { if _, err := tx.Exec(schemaSQL); err != nil {
tx.Rollback() return fmt.Errorf("apply schema: %w", err)
if needsFKToggle {
db.Exec(`PRAGMA foreign_keys = ON`)
} }
return fmt.Errorf("apply migration %s: %w", name, err) return tx.Commit()
}
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
} }

View File

@@ -137,7 +137,7 @@ func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) {
t.Fatalf("userA's UpdateSyncState leaked into userB's sync_state: %+v", stateB) 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 { if err != nil {
t.Fatalf("StartSyncRun(a): %v", err) 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") 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")
}
}

View File

@@ -55,13 +55,13 @@ func (db *DB) ReplaceLaps(ctx context.Context, userID, activityID int64, laps []
} }
defer tx.Rollback() 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) return fmt.Errorf("delete existing laps for activity %d: %w", activityID, err)
} }
for _, l := range laps { for _, l := range laps {
_, err := tx.ExecContext(ctx, ` _, err := tx.ExecContext(ctx, `
INSERT INTO laps ( INSERT INTO activity_laps (
activity_id, lap_index, avg_speed_mps, activity_id, lap_index, avg_speed_mps,
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min, 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 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, 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.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 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 JOIN activities ON activities.id = laps.activity_id
WHERE laps.activity_id = ? AND activities.user_id = ? WHERE laps.activity_id = ? AND activities.user_id = ?
ORDER BY laps.lap_index`, activityID, userID) ORDER BY laps.lap_index`, activityID, userID)

View File

@@ -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()
}

View File

@@ -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)
}
}

View File

@@ -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
);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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'))
);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -8,11 +8,14 @@ import (
// Profile is the single active user's Garmin credentials plus every // Profile is the single active user's Garmin credentials plus every
// tunable analysis-engine parameter. Always exactly one row (id=1). // tunable analysis-engine parameter. Always exactly one row (id=1).
type Profile struct { 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 GarminEmail string
GarminPassword 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 RollingWindowDays int
// BackfillHorizonDays bounds how far back "Sync now" reaches when // BackfillHorizonDays bounds how far back "Sync now" reaches when
// walking backward from today; it's read fresh on every sync (not fixed // walking backward from today; it's read fresh on every sync (not fixed
@@ -70,7 +73,7 @@ type Profile struct {
} }
const profileColumns = ` 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_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_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
hr_zone5_min_pct, hr_zone5_max_pct, hr_zone5_min_pct, hr_zone5_max_pct,
@@ -84,8 +87,8 @@ const profileColumns = `
// GetProfile returns the profile row for userID. // GetProfile returns the profile row for userID.
func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) { func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
var p Profile var p Profile
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE user_id = ?`, userID).Scan( err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profiles WHERE user_id = ?`, userID).Scan(
&p.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate, &p.GarminEmail, &p.GarminPassword, &p.GarminConnectedAt, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct, &p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct, &p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
&p.HRZone5MinPct, &p.HRZone5MaxPct, &p.HRZone5MinPct, &p.HRZone5MaxPct,
@@ -106,8 +109,8 @@ func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
// replaces every column. // replaces every column.
func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error { func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error {
_, err := db.ExecContext(ctx, ` _, err := db.ExecContext(ctx, `
UPDATE profile SET UPDATE profiles SET
name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?, 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_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_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?,
hr_zone5_min_pct=?, hr_zone5_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=?, main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
updated_at=datetime('now') updated_at=datetime('now')
WHERE user_id = ?`, 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.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct, p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
p.HRZone5MinPct, p.HRZone5MaxPct, p.HRZone5MinPct, p.HRZone5MaxPct,
@@ -132,3 +135,14 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error
} }
return nil 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
}

View File

@@ -26,15 +26,12 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if p.WarmupMinutes != 10 || p.CooldownMinutes != 5 { if p.WarmupMinutes != 10 || p.CooldownMinutes != 5 {
t.Errorf("phase-minute defaults = %+v, want warmup=10, cooldown=5", p) t.Errorf("phase-minute defaults = %+v, want warmup=10, cooldown=5", p)
} }
if p.BackfillHorizonDays != 1095 { if p.BackfillHorizonDays != 90 {
t.Errorf("BackfillHorizonDays = %d, want 1095 (migration default)", p.BackfillHorizonDays) t.Errorf("BackfillHorizonDays = %d, want 90 (schema default)", p.BackfillHorizonDays)
} }
if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 { if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 {
t.Errorf("pace artifact filter defaults = %+v, want pace=720, time=3", p) 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" { if p.PaceColor != "#3b82f6" || p.HeartRateColor != "#ef4444" {
t.Errorf("main line color defaults = %+v, want pace=#3b82f6, heartRate=#ef4444", p) 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 maxHR, restingHR := 190.0, 50.0
p.Name = "Kriss"
p.PaceColor = "#111111" p.PaceColor = "#111111"
p.EffortColor = "#222222" p.EffortColor = "#222222"
p.MainLineTintPct = 45 p.MainLineTintPct = 45
@@ -79,9 +75,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 { if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 {
t.Errorf("got = %+v, want updated email/window", got) 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 { if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 {
t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate) 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) 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)
}
}

View 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'))
);

View File

@@ -2,10 +2,7 @@ package store
import ( import (
"context" "context"
"database/sql"
"io/fs"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"testing" "testing"
) )
@@ -23,7 +20,7 @@ func openTestDB(t *testing.T) *DB {
func f(v float64) *float64 { return &v } 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") path := filepath.Join(t.TempDir(), "geniusrun_test.db")
db1, err := Open(path) db1, err := Open(path)
if err != nil { if err != nil {
@@ -33,7 +30,7 @@ func TestMigrateIsIdempotent(t *testing.T) {
db2, err := Open(path) db2, err := Open(path)
if err != nil { if err != nil {
t.Fatalf("second Open (re-applying migrations): %v", err) t.Fatalf("second Open (schema already applied): %v", err)
} }
db2.Close() db2.Close()
} }
@@ -152,12 +149,12 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
t.Fatalf("InsertKindAssignment (rule engine): %v", err) t.Fatalf("InsertKindAssignment (rule engine): %v", err)
} }
queue, err := db.ReviewQueue(ctx, userID) pending, ok, err := db.CurrentAssignment(ctx, userID, activityID)
if err != nil { if err != nil || !ok {
t.Fatalf("ReviewQueue: %v", err) t.Fatalf("CurrentAssignment (needs_review): ok=%v err=%v", ok, err)
} }
if len(queue) != 1 { if pending.Status != AssignmentStatusNeedsReview {
t.Fatalf("expected 1 item in review queue, got %d", len(queue)) t.Fatalf("current status = %q, want needs_review", pending.Status)
} }
// Then: user manually resolves it. // Then: user manually resolves it.
@@ -170,14 +167,6 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
t.Fatalf("InsertKindAssignment (manual): %v", err) 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) current, ok, err := db.CurrentAssignment(ctx, userID, activityID)
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err) 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) db := openTestDB(t)
ctx := context.Background() 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. // 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) t.Fatalf("insert users: %v", err)
} }
if _, err := db.ExecContext(ctx, ` 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. // 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) 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)") 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) db := openTestDB(t)
ctx := context.Background() ctx := context.Background()
// Verify that FK enforcement is correctly active after migrations complete. // Verify FK enforcement is genuinely active: a valid reference succeeds,
// This tests that the PRAGMA foreign_keys toggle in db.migrate() (for // a bogus one is rejected, not silently accepted.
// migrations 0023/0024/0025) is properly scoped and re-enabled after each
// table rebuild.
userID, err := db.ProvisionUser(ctx, "test-sub", "Test") userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil { if err != nil {
@@ -302,7 +277,7 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) {
t.Fatalf("UpsertActivity: %v", err) 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 var seedKindID int64
if err := db.QueryRowContext(ctx, `SELECT id FROM workout_kinds LIMIT 1`).Scan(&seedKindID); err != nil { if err := db.QueryRowContext(ctx, `SELECT id FROM workout_kinds LIMIT 1`).Scan(&seedKindID); err != nil {
t.Fatalf("query seeded workout_kind: %v", err) 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) { func TestCurrentAssignment_ScopedToOwningUser(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() 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 { func contains(s string, substrs ...string) bool {
lower := strings.ToLower(s) lower := strings.ToLower(s)
for _, substr := range substrs { for _, substr := range substrs {

View File

@@ -7,12 +7,10 @@ import (
) )
const ( const (
SyncKindBackfill = "backfill" // SyncKindFull is a manually-triggered "Sync now" pass: backfillCore
SyncKindIncremental = "incremental" // followed by incrementalSyncCore followed by FillPendingDetails,
// SyncKindFull is a manually-triggered "Sync now" pass: Backfill followed // recorded as one run so the reported activity count covers the whole
// by IncrementalSync followed by FillPendingDetails, recorded as one run // action instead of only whichever stage happened to finish last.
// so the reported activity count covers the whole action instead of only
// whichever stage happened to finish last.
SyncKindFull = "full" SyncKindFull = "full"
SyncStatusRunning = "running" SyncStatusRunning = "running"
@@ -20,7 +18,10 @@ const (
SyncStatusError = "error" 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 { type SyncRun struct {
ID int64 ID int64
Kind string Kind string

View File

@@ -13,28 +13,16 @@ import (
type User struct { type User struct {
ID int64 ID int64
OIDCSub string OIDCSub string
DisplayName string Name string
CreatedAt 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()
}
// GetUserBySub looks up a user by their OIDC subject -- the only lookup key // 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) { func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) {
var u User var u User
err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users WHERE oidc_sub = ?`, oidcSub). err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, name, created_at FROM users WHERE oidc_sub = ?`, oidcSub).
Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt) Scan(&u.ID, &u.OIDCSub, &u.Name, &u.CreatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return User{}, false, nil return User{}, false, nil
} }
@@ -44,29 +32,9 @@ func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, err
return u, true, nil return u, true, nil
} }
// ListUsers returns every provisioned user, for the background incremental // neverMatchRule is the placeholder every fresh install's rule-engine kinds
// sync loop (Task 13) to iterate. // start with -- every activity lands in needs_review until the user tunes
func (db *DB) ListUsers(ctx context.Context) ([]User, error) { // real rules.
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.
const neverMatchRule = `{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}` const neverMatchRule = `{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}`
// defaultWorkoutKindSeeds mirrors the 8 kinds migrations 0004/0007 seed for // 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 // default workout kinds (with their paired workout_type_paces rows), and an
// initial sync_state row -- all in one transaction, so a partially // initial sync_state row -- all in one transaction, so a partially
// provisioned user is never observable. // 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) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return 0, fmt.Errorf("begin provision user tx: %w", err) return 0, fmt.Errorf("begin provision user tx: %w", err)
} }
defer tx.Rollback() 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 { if err != nil {
return 0, fmt.Errorf("create user %q: %w", oidcSub, err) 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 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) 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() 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
}

View File

@@ -29,17 +29,14 @@ func TestProvisionUser_SeedsProfileTaxonomyAndSyncState(t *testing.T) {
if err != nil || !found { if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err) t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
} }
if u.ID != userID || u.DisplayName != "Lucie" { if u.ID != userID || u.Name != "Lucie" {
t.Fatalf("got %+v, want ID=%d DisplayName=Lucie", u, userID) t.Fatalf("got %+v, want ID=%d Name=Lucie", u, userID)
} }
profile, err := db.GetProfile(ctx, userID) profile, err := db.GetProfile(ctx, userID)
if err != nil { if err != nil {
t.Fatalf("GetProfile: %v", err) t.Fatalf("GetProfile: %v", err)
} }
if profile.Name != "Lucie" {
t.Errorf("profile.Name = %q, want %q", profile.Name, "Lucie")
}
if profile.RollingWindowDays != 90 { if profile.RollingWindowDays != 90 {
t.Errorf("profile.RollingWindowDays = %d, want 90 (default)", profile.RollingWindowDays) 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") 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