2026-07-17 18:33:06 +02:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
2026-07-19 16:31:24 +02:00
|
|
|
"fmt"
|
2026-07-26 14:30:39 +02:00
|
|
|
"log/slog"
|
2026-07-17 18:33:06 +02:00
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
2026-07-19 16:31:24 +02:00
|
|
|
"net/url"
|
2026-07-26 10:19:00 +02:00
|
|
|
"os"
|
2026-07-17 18:33:06 +02:00
|
|
|
"path/filepath"
|
|
|
|
|
"strconv"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-07-24 21:36:00 +02:00
|
|
|
"geniusrun/backend/internal/auth"
|
|
|
|
|
authmock "geniusrun/backend/internal/auth/mock"
|
2026-07-25 18:10:09 +02:00
|
|
|
"geniusrun/backend/internal/garmin"
|
2026-08-04 16:04:18 +02:00
|
|
|
"geniusrun/backend/internal/log"
|
2026-07-24 21:08:07 +02:00
|
|
|
"geniusrun/backend/internal/store"
|
2026-07-17 18:33:06 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func newCtx() context.Context { return context.Background() }
|
|
|
|
|
|
2026-07-24 21:36:00 +02:00
|
|
|
var testSessionConfig = SessionConfig{
|
2026-08-04 18:17:24 +02:00
|
|
|
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
|
|
|
|
|
Duration: time.Hour,
|
|
|
|
|
SetupTimeout: 15 * time.Minute,
|
|
|
|
|
Secure: false,
|
|
|
|
|
BackendURL: "https://geniusrun.example.com",
|
|
|
|
|
FrontendURL: "https://app.geniusrun.example.com",
|
2026-07-24 21:36:00 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:28:02 +02:00
|
|
|
func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
|
2026-07-17 18:33:06 +02:00
|
|
|
t.Helper()
|
2026-07-24 21:08:07 +02:00
|
|
|
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
2026-07-17 18:33:06 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("store.Open: %v", err)
|
|
|
|
|
}
|
|
|
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
|
|
2026-07-25 18:28:02 +02:00
|
|
|
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
|
|
|
|
|
if err != nil {
|
2026-07-25 18:10:09 +02:00
|
|
|
t.Fatalf("ProvisionUser: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 16:04:18 +02:00
|
|
|
m := &garmin.MockClient{}
|
|
|
|
|
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
|
|
|
|
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
2026-07-25 18:28:02 +02:00
|
|
|
return s, db, userID
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
|
|
|
|
t.Helper()
|
|
|
|
|
var reader *bytes.Reader
|
|
|
|
|
if body != nil {
|
|
|
|
|
b, err := json.Marshal(body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("marshal request body: %v", err)
|
|
|
|
|
}
|
|
|
|
|
reader = bytes.NewReader(b)
|
|
|
|
|
} else {
|
|
|
|
|
reader = bytes.NewReader(nil)
|
|
|
|
|
}
|
|
|
|
|
req := httptest.NewRequest(method, path, reader)
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
2026-08-04 17:44:37 +02:00
|
|
|
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
2026-07-24 21:36:00 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("mint test session cookie: %v", err)
|
|
|
|
|
}
|
|
|
|
|
req.AddCookie(cookie)
|
2026-07-17 18:33:06 +02:00
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
|
return rec
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHealth(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, _, _ := newTestServer(t)
|
2026-07-17 18:33:06 +02:00
|
|
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 10:52:09 +02:00
|
|
|
func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, _, _ := newTestServer(t)
|
2026-07-17 19:09:55 +02:00
|
|
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d", rec.Code)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-17 19:09:55 +02:00
|
|
|
var kinds []workoutKindResponse
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &kinds); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-19 10:52:09 +02:00
|
|
|
if len(kinds) != 8 {
|
|
|
|
|
t.Fatalf("expected 8 seeded kinds, got %d", len(kinds))
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-17 19:09:55 +02:00
|
|
|
for _, k := range kinds {
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.HRMinPctHRR != nil || k.HRMaxPctHRR != nil {
|
2026-07-17 19:09:55 +02:00
|
|
|
t.Errorf("expected freshly-seeded kind %q to have nil pace fields, got %+v", k.Name, k)
|
|
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-17 19:09:55 +02:00
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
|
2026-07-17 19:09:55 +02:00
|
|
|
func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, _, _ := newTestServer(t)
|
2026-07-17 19:09:55 +02:00
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
|
|
|
|
var kinds []workoutKindResponse
|
2026-07-17 18:33:06 +02:00
|
|
|
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
2026-07-17 19:09:55 +02:00
|
|
|
target := kinds[0]
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
minPace, maxPace, hrMin, hrMax := 330.0, 420.0, 70.0, 80.0
|
2026-07-17 19:09:55 +02:00
|
|
|
body := map[string]any{
|
|
|
|
|
"name": target.Name,
|
|
|
|
|
"rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`),
|
|
|
|
|
"pace_min_sec_per_km": minPace,
|
|
|
|
|
"pace_max_sec_per_km": maxPace,
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
"hr_min_pct_hrr": hrMin,
|
|
|
|
|
"hr_max_pct_hrr": hrMax,
|
2026-07-17 19:09:55 +02:00
|
|
|
}
|
|
|
|
|
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(target.ID), body)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("update status = %d, body = %s", rec.Code, rec.Body.String())
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-17 19:09:55 +02:00
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/"+itoa(target.ID), nil)
|
|
|
|
|
var updated workoutKindResponse
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &updated)
|
|
|
|
|
if updated.PaceMinSecPerKm == nil || *updated.PaceMinSecPerKm != 330 {
|
|
|
|
|
t.Errorf("PaceMinSecPerKm = %v, want 330", updated.PaceMinSecPerKm)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
if updated.HRMinPctHRR == nil || *updated.HRMinPctHRR != 70 {
|
|
|
|
|
t.Errorf("HRMinPctHRR = %v, want 70", updated.HRMinPctHRR)
|
|
|
|
|
}
|
|
|
|
|
if updated.HRMaxPctHRR == nil || *updated.HRMaxPctHRR != 80 {
|
|
|
|
|
t.Errorf("HRMaxPctHRR = %v, want 80", updated.HRMaxPctHRR)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 19:09:55 +02:00
|
|
|
func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, _, _ := newTestServer(t)
|
2026-07-17 19:09:55 +02:00
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
|
|
|
|
var kinds []workoutKindResponse
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
|
|
|
|
|
|
|
|
|
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{
|
|
|
|
|
"name": kinds[0].Name,
|
2026-07-17 18:33:06 +02:00
|
|
|
"rule": json.RawMessage(`{"match":"xor","conditions":[]}`),
|
|
|
|
|
})
|
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, _, _ := newTestServer(t)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
|
|
|
|
var kinds []workoutKindResponse
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
|
|
|
|
|
|
|
|
|
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{
|
|
|
|
|
"name": kinds[0].Name,
|
|
|
|
|
"rule": json.RawMessage(`{"match":"all","conditions":[]}`),
|
|
|
|
|
"hr_min_pct_hrr": 80,
|
|
|
|
|
"hr_max_pct_hrr": 70,
|
|
|
|
|
})
|
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, _, _ := newTestServer(t)
|
2026-07-17 19:09:55 +02:00
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
|
|
|
|
var kinds []workoutKindResponse
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
|
|
|
|
|
|
|
|
|
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
"name": kinds[0].Name,
|
|
|
|
|
"rule": json.RawMessage(`{"match":"all","conditions":[]}`),
|
|
|
|
|
"pace_min_sec_per_km": 420,
|
|
|
|
|
"pace_max_sec_per_km": 330,
|
2026-07-17 19:09:55 +02:00
|
|
|
})
|
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 00:04:03 +02:00
|
|
|
func TestActivitiesAssign(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, db, userID := newTestServer(t)
|
2026-07-17 18:33:06 +02:00
|
|
|
ctx := newCtx()
|
|
|
|
|
|
2026-07-25 18:28:02 +02:00
|
|
|
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
2026-07-17 18:33:06 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("UpsertActivity: %v", err)
|
|
|
|
|
}
|
2026-07-25 18:28:02 +02:00
|
|
|
kindID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
2026-07-17 18:33:06 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
|
|
|
|
}
|
2026-07-25 18:28:02 +02:00
|
|
|
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
2026-07-17 18:33:06 +02:00
|
|
|
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
|
|
|
|
CandidateKindsJSON: "[]",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("InsertKindAssignment: %v", err)
|
|
|
|
|
}
|
2026-07-19 11:55:13 +02:00
|
|
|
targetLow, targetHigh := 3.0, 3.5
|
2026-07-25 18:28:02 +02:00
|
|
|
if err := db.ReplaceLaps(ctx, userID, activityID, []store.Lap{
|
2026-07-19 11:55:13 +02:00
|
|
|
{LapIndex: 1, TargetPaceLowMps: &targetLow, TargetPaceHighMps: &targetHigh},
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("ReplaceLaps: %v", err)
|
|
|
|
|
}
|
2026-07-19 15:25:48 +02:00
|
|
|
hr := 150.0
|
2026-07-25 18:28:02 +02:00
|
|
|
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []store.Sample{
|
2026-07-19 15:25:48 +02:00
|
|
|
{ElapsedSeconds: 0, HeartRate: &hr},
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("ReplaceActivitySamples: %v", err)
|
|
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
|
|
|
|
|
router := s.Router()
|
2026-08-04 00:04:03 +02:00
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
2026-07-17 18:33:06 +02:00
|
|
|
if rec.Code != http.StatusOK {
|
2026-08-04 00:04:03 +02:00
|
|
|
t.Fatalf("activities list status = %d", rec.Code)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-19 16:31:24 +02:00
|
|
|
var page struct {
|
|
|
|
|
Items []map[string]any `json:"items"`
|
|
|
|
|
NextCursor *string `json:"next_cursor"`
|
|
|
|
|
Total int `json:"total"`
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-19 16:31:24 +02:00
|
|
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
|
|
|
|
if len(page.Items) != 1 || page.Total != 1 {
|
2026-08-04 00:04:03 +02:00
|
|
|
t.Fatalf("expected 1 item in activities list (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String())
|
2026-07-19 16:31:24 +02:00
|
|
|
}
|
|
|
|
|
laps, _ := page.Items[0]["laps"].([]any)
|
2026-07-19 11:55:13 +02:00
|
|
|
if len(laps) != 1 {
|
2026-08-04 00:04:03 +02:00
|
|
|
t.Fatalf("expected 1 lap in activities list item, got %d: %s", len(laps), rec.Body.String())
|
2026-07-19 11:55:13 +02:00
|
|
|
}
|
2026-07-19 16:31:24 +02:00
|
|
|
samples, _ := page.Items[0]["samples"].([]any)
|
2026-07-19 15:25:48 +02:00
|
|
|
if len(samples) != 1 {
|
2026-08-04 00:04:03 +02:00
|
|
|
t.Fatalf("expected 1 sample in activities list item, got %d: %s", len(samples), rec.Body.String())
|
2026-07-19 15:25:48 +02:00
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
|
2026-08-04 00:04:03 +02:00
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindID})
|
2026-07-17 18:33:06 +02:00
|
|
|
if rec.Code != http.StatusOK {
|
2026-08-04 00:04:03 +02:00
|
|
|
t.Fatalf("assign status = %d, body = %s", rec.Code, rec.Body.String())
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
// The activity stays listed after resolving -- the Activities page shows
|
|
|
|
|
// every activity, locked or not, not just ones still needing review.
|
2026-08-04 00:04:03 +02:00
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
2026-07-19 16:31:24 +02:00
|
|
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
if len(page.Items) != 1 || page.Total != 1 {
|
2026-08-04 00:04:03 +02:00
|
|
|
t.Fatalf("expected activity to remain listed after assign, got %d items, total=%d", len(page.Items), page.Total)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
}
|
|
|
|
|
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
|
2026-08-04 00:04:03 +02:00
|
|
|
t.Fatalf("expected AssignmentSource=manual after assign, got %v", page.Items[0]["AssignmentSource"])
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
}
|
|
|
|
|
if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
|
2026-08-04 00:04:03 +02:00
|
|
|
t.Fatalf("expected WorkoutKindID=%d after assign, got %v", kindID, got)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unlocking reverts the source to rule_engine but keeps the same kind,
|
|
|
|
|
// so a later reclassify pass is free to change it again.
|
2026-08-04 00:04:03 +02:00
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unlock status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
2026-08-04 00:04:03 +02:00
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
|
|
|
|
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
|
|
|
|
|
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
|
|
|
|
|
}
|
|
|
|
|
if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
|
|
|
|
|
t.Fatalf("expected WorkoutKindID=%d to survive unlock, got %v", kindID, got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unlocking an already-unlocked activity is rejected.
|
2026-08-04 00:04:03 +02:00
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Manually unassigning clears the kind back to Unclassified and locks
|
|
|
|
|
// that decision (source=manual), same as resolving to a specific kind.
|
2026-08-04 00:04:03 +02:00
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unassign", nil)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unassign status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
2026-08-04 00:04:03 +02:00
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
|
|
|
|
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
|
|
|
|
|
t.Fatalf("expected AssignmentSource=manual after unassign, got %v", page.Items[0]["AssignmentSource"])
|
|
|
|
|
}
|
|
|
|
|
if got := page.Items[0]["WorkoutKindID"]; got != nil {
|
|
|
|
|
t.Fatalf("expected WorkoutKindID=nil after unassign, got %v", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// It also shows up under the Unclassified filter now.
|
2026-08-04 00:04:03 +02:00
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/activities/?unclassified=true", nil)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
|
|
|
|
if len(page.Items) != 1 || page.Total != 1 {
|
|
|
|
|
t.Fatalf("expected unassigned activity to show up as unclassified, got %d items, total=%d", len(page.Items), page.Total)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unassigning locks it, so unlocking works again (reverting to
|
|
|
|
|
// rule_engine sourcing with no kind, i.e. needs_review).
|
2026-08-04 00:04:03 +02:00
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unlock after unassign status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
2026-08-04 00:04:03 +02:00
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
|
|
|
|
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
|
|
|
|
|
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
|
2026-07-19 16:31:24 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 00:04:03 +02:00
|
|
|
func TestActivities_PaginatesByCursor(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, db, userID := newTestServer(t)
|
2026-07-19 16:31:24 +02:00
|
|
|
ctx := newCtx()
|
|
|
|
|
|
|
|
|
|
// 5 activities, newest first once sorted: 2026-07-05 .. 2026-07-01.
|
|
|
|
|
for i := 1; i <= 5; i++ {
|
2026-07-25 18:28:02 +02:00
|
|
|
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
GarminActivityID: int64(i),
|
2026-07-25 18:10:09 +02:00
|
|
|
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}",
|
2026-07-19 16:31:24 +02:00
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("UpsertActivity: %v", err)
|
|
|
|
|
}
|
2026-07-25 18:28:02 +02:00
|
|
|
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
2026-07-19 16:31:24 +02:00
|
|
|
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
|
|
|
|
CandidateKindsJSON: "[]",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("InsertKindAssignment: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
type page struct {
|
|
|
|
|
Items []map[string]any `json:"items"`
|
|
|
|
|
NextCursor *string `json:"next_cursor"`
|
|
|
|
|
Total int `json:"total"`
|
|
|
|
|
}
|
|
|
|
|
getPage := func(query string) page {
|
|
|
|
|
t.Helper()
|
2026-08-04 00:04:03 +02:00
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil)
|
2026-07-19 16:31:24 +02:00
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var p page
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return p
|
|
|
|
|
}
|
|
|
|
|
startTime := func(item map[string]any) string {
|
|
|
|
|
return item["activity"].(map[string]any)["StartTimeUTC"].(string)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
first := getPage("?limit=2")
|
|
|
|
|
if len(first.Items) != 2 || first.Total != 5 {
|
|
|
|
|
t.Fatalf("first page: expected 2 items (total=5), got %d items, total=%d", len(first.Items), first.Total)
|
|
|
|
|
}
|
|
|
|
|
if startTime(first.Items[0]) != "2026-07-05 06:00:00" || startTime(first.Items[1]) != "2026-07-04 06:00:00" {
|
|
|
|
|
t.Fatalf("first page not newest-first: %v", first.Items)
|
|
|
|
|
}
|
|
|
|
|
if first.NextCursor == nil {
|
|
|
|
|
t.Fatal("expected a next_cursor on the first page")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
second := getPage("?limit=2&before=" + url.QueryEscape(*first.NextCursor))
|
|
|
|
|
if len(second.Items) != 2 {
|
|
|
|
|
t.Fatalf("second page: expected 2 items, got %d", len(second.Items))
|
|
|
|
|
}
|
|
|
|
|
if startTime(second.Items[0]) != "2026-07-03 06:00:00" || startTime(second.Items[1]) != "2026-07-02 06:00:00" {
|
|
|
|
|
t.Fatalf("second page not continuing newest-first: %v", second.Items)
|
|
|
|
|
}
|
|
|
|
|
if second.NextCursor == nil {
|
|
|
|
|
t.Fatal("expected a next_cursor on the second page")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
third := getPage("?limit=2&before=" + url.QueryEscape(*second.NextCursor))
|
|
|
|
|
if len(third.Items) != 1 {
|
|
|
|
|
t.Fatalf("third page: expected 1 remaining item, got %d", len(third.Items))
|
|
|
|
|
}
|
|
|
|
|
if startTime(third.Items[0]) != "2026-07-01 06:00:00" {
|
|
|
|
|
t.Fatalf("third page wrong item: %v", third.Items)
|
|
|
|
|
}
|
|
|
|
|
if third.NextCursor != nil {
|
|
|
|
|
t.Fatalf("expected no next_cursor on the last page, got %v", *third.NextCursor)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 00:04:03 +02:00
|
|
|
func TestActivities_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, db, userID := newTestServer(t)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
ctx := newCtx()
|
|
|
|
|
|
2026-07-25 18:28:02 +02:00
|
|
|
kinds, err := db.ListWorkoutKinds(ctx, userID, true)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
if err != nil || len(kinds) < 2 {
|
|
|
|
|
t.Fatalf("ListWorkoutKinds: %v (len=%d)", err, len(kinds))
|
|
|
|
|
}
|
|
|
|
|
kindA, kindB := kinds[0], kinds[1]
|
|
|
|
|
|
|
|
|
|
// 2 activities assigned to kindA, 1 to kindB, 1 unclassified.
|
|
|
|
|
makeActivity := func(n int64, kindID *int64) {
|
2026-07-25 18:28:02 +02:00
|
|
|
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
GarminActivityID: n,
|
2026-07-25 18:10:09 +02:00
|
|
|
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", n), RawJSON: "{}",
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("UpsertActivity: %v", err)
|
|
|
|
|
}
|
|
|
|
|
status := store.AssignmentStatusAssigned
|
|
|
|
|
if kindID == nil {
|
|
|
|
|
status = store.AssignmentStatusNeedsReview
|
|
|
|
|
}
|
2026-07-25 18:28:02 +02:00
|
|
|
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
ActivityID: activityID, WorkoutKindID: kindID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
|
|
|
|
Status: status, CandidateKindsJSON: "[]",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("InsertKindAssignment: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
makeActivity(1, &kindA.ID)
|
|
|
|
|
makeActivity(2, &kindA.ID)
|
|
|
|
|
makeActivity(3, &kindB.ID)
|
|
|
|
|
makeActivity(4, nil)
|
|
|
|
|
|
|
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
type page struct {
|
|
|
|
|
Items []map[string]any `json:"items"`
|
|
|
|
|
NextCursor *string `json:"next_cursor"`
|
|
|
|
|
Total int `json:"total"`
|
|
|
|
|
}
|
|
|
|
|
getPage := func(query string) page {
|
|
|
|
|
t.Helper()
|
2026-08-04 00:04:03 +02:00
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var p page
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return p
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Filtering by kind_id, with a small limit, still paginates -- it doesn't
|
|
|
|
|
// have to load the whole matching backlog to serve one page.
|
|
|
|
|
kindAPage := getPage(fmt.Sprintf("?kind_id=%d&limit=1", kindA.ID))
|
|
|
|
|
if kindAPage.Total != 2 {
|
|
|
|
|
t.Fatalf("kindA total = %d, want 2", kindAPage.Total)
|
|
|
|
|
}
|
|
|
|
|
if len(kindAPage.Items) != 1 {
|
|
|
|
|
t.Fatalf("kindA page: expected 1 item (limit=1), got %d", len(kindAPage.Items))
|
|
|
|
|
}
|
|
|
|
|
if kindAPage.NextCursor == nil {
|
|
|
|
|
t.Fatal("expected a next_cursor on kindA's first (limited) page")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
kindBPage := getPage(fmt.Sprintf("?kind_id=%d", kindB.ID))
|
|
|
|
|
if kindBPage.Total != 1 || len(kindBPage.Items) != 1 {
|
|
|
|
|
t.Fatalf("kindB page: total=%d items=%d, want 1/1", kindBPage.Total, len(kindBPage.Items))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
unclassifiedPage := getPage("?unclassified=true")
|
|
|
|
|
if unclassifiedPage.Total != 1 || len(unclassifiedPage.Items) != 1 {
|
|
|
|
|
t.Fatalf("unclassified page: total=%d items=%d, want 1/1", unclassifiedPage.Total, len(unclassifiedPage.Items))
|
|
|
|
|
}
|
|
|
|
|
if unclassifiedPage.Items[0]["WorkoutKindID"] != nil {
|
|
|
|
|
t.Fatalf("expected nil WorkoutKindID in unclassified filter, got %v", unclassifiedPage.Items[0]["WorkoutKindID"])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
all := getPage("")
|
|
|
|
|
if all.Total != 4 {
|
|
|
|
|
t.Fatalf("unfiltered total = %d, want 4", all.Total)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 00:04:03 +02:00
|
|
|
func TestAssignActivity_RejectsRaceKind(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, db, userID := newTestServer(t)
|
2026-07-19 11:11:50 +02:00
|
|
|
ctx := newCtx()
|
|
|
|
|
|
2026-07-25 18:28:02 +02:00
|
|
|
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
2026-07-19 11:11:50 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("UpsertActivity: %v", err)
|
|
|
|
|
}
|
2026-07-25 18:28:02 +02:00
|
|
|
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
|
2026-07-19 11:11:50 +02:00
|
|
|
if err != nil || !ok {
|
|
|
|
|
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 00:04:03 +02:00
|
|
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": raceKind.ID})
|
2026-07-19 11:11:50 +02:00
|
|
|
if rec.Code != http.StatusBadRequest {
|
2026-08-04 00:04:03 +02:00
|
|
|
t.Fatalf("assign to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
2026-07-19 11:11:50 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, db, userID := newTestServer(t)
|
2026-07-19 11:11:50 +02:00
|
|
|
ctx := newCtx()
|
|
|
|
|
|
2026-07-25 18:28:02 +02:00
|
|
|
easyID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Easy", RuleJSON: `{"match":"all","conditions":[{"metric":"distance_meters","op":">=","value":0}]}`, IsActive: true})
|
2026-07-19 11:11:50 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
|
|
|
|
}
|
2026-07-25 18:28:02 +02:00
|
|
|
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
|
2026-07-19 11:11:50 +02:00
|
|
|
if err != nil || !ok {
|
|
|
|
|
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:28:02 +02:00
|
|
|
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: "{}"})
|
2026-07-19 11:11:50 +02:00
|
|
|
|
2026-07-25 18:28:02 +02:00
|
|
|
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
2026-07-19 11:11:50 +02:00
|
|
|
ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
|
|
|
|
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
|
|
|
|
}
|
2026-07-25 18:28:02 +02:00
|
|
|
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
2026-07-19 11:11:50 +02:00
|
|
|
ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual,
|
|
|
|
|
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
|
|
|
|
}
|
2026-07-25 18:28:02 +02:00
|
|
|
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
2026-07-19 11:11:50 +02:00
|
|
|
ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
|
|
|
|
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("InsertKindAssignment (race): %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/reclassify", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("reclassify status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var body struct {
|
|
|
|
|
Reclassified int `json:"reclassified"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if body.Reclassified != 1 {
|
|
|
|
|
t.Fatalf("reclassified = %d, want 1 (only the non-locked rule-engine activity)", body.Reclassified)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:28:02 +02:00
|
|
|
manualAssignment, ok, err := db.CurrentAssignment(ctx, userID, manualActivity)
|
2026-07-19 11:11:50 +02:00
|
|
|
if err != nil || !ok {
|
|
|
|
|
t.Fatalf("CurrentAssignment(manual): ok=%v err=%v", ok, err)
|
|
|
|
|
}
|
|
|
|
|
if manualAssignment.AssignmentSource != store.AssignmentSourceManual {
|
|
|
|
|
t.Errorf("manual assignment was overwritten: %+v", manualAssignment)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:28:02 +02:00
|
|
|
raceAssignment, ok, err := db.CurrentAssignment(ctx, userID, raceActivity)
|
2026-07-19 11:11:50 +02:00
|
|
|
if err != nil || !ok {
|
|
|
|
|
t.Fatalf("CurrentAssignment(race): ok=%v err=%v", ok, err)
|
|
|
|
|
}
|
|
|
|
|
if raceAssignment.WorkoutKindID == nil || *raceAssignment.WorkoutKindID != raceKind.ID {
|
|
|
|
|
t.Errorf("race assignment changed: %+v", raceAssignment)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 12:41:38 +02:00
|
|
|
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, db, userID := newTestServer(t)
|
2026-07-19 12:41:38 +02:00
|
|
|
ctx := newCtx()
|
|
|
|
|
|
2026-07-25 18:28:02 +02:00
|
|
|
if _, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
|
2026-07-19 12:41:38 +02:00
|
|
|
t.Fatalf("UpsertActivity: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
router := s.Router()
|
2026-08-04 16:04:18 +02:00
|
|
|
rec := doJSON(t, router, http.MethodPost, "/api/garmin/sync/reset", nil)
|
2026-07-19 12:41:38 +02:00
|
|
|
if rec.Code != http.StatusAccepted {
|
|
|
|
|
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
|
|
|
for {
|
2026-07-25 18:28:02 +02:00
|
|
|
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
2026-07-19 12:41:38 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("ListActivities: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(activities) == 0 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if time.Now().After(deadline) {
|
|
|
|
|
t.Fatalf("timed out waiting for reset to delete activities, still have %d", len(activities))
|
|
|
|
|
}
|
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// "Full backfill" no longer exists as an endpoint -- superseded by reset.
|
2026-08-04 16:04:18 +02:00
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/garmin/sync/backfill", nil)
|
2026-07-19 12:41:38 +02:00
|
|
|
if rec.Code != http.StatusNotFound {
|
2026-08-04 16:04:18 +02:00
|
|
|
t.Errorf("/api/garmin/sync/backfill status = %d, want 404 (removed)", rec.Code)
|
2026-07-19 12:41:38 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:27:34 +02:00
|
|
|
func TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending(t *testing.T) {
|
|
|
|
|
s, db, userID := newTestServer(t)
|
|
|
|
|
ctx := newCtx()
|
|
|
|
|
|
|
|
|
|
workoutID := int64(555)
|
2026-07-27 09:01:02 +02:00
|
|
|
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
|
2026-07-27 08:27:34 +02:00
|
|
|
GarminActivityID: 1, WorkoutID: &workoutID,
|
|
|
|
|
StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}",
|
2026-07-27 09:01:02 +02:00
|
|
|
})
|
|
|
|
|
if err != nil {
|
2026-07-27 08:27:34 +02:00
|
|
|
t.Fatalf("UpsertActivity: %v", err)
|
|
|
|
|
}
|
2026-07-27 09:01:02 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-07-27 08:27:34 +02:00
|
|
|
|
2026-08-04 16:04:18 +02:00
|
|
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/garmin/sync/status", nil)
|
2026-07-27 08:27:34 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:33:06 +02:00
|
|
|
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, db, userID := newTestServer(t)
|
2026-07-17 18:33:06 +02:00
|
|
|
ctx := newCtx()
|
|
|
|
|
|
2026-07-25 18:28:02 +02:00
|
|
|
kindID, _ := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true})
|
2026-07-17 18:33:06 +02:00
|
|
|
|
|
|
|
|
speed := 3.0
|
2026-07-25 18:28:02 +02:00
|
|
|
a1, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
|
|
|
|
a2, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
2026-07-17 18:33:06 +02:00
|
|
|
|
|
|
|
|
for _, id := range []int64{a2, a1} { // insert out of order on purpose
|
2026-07-25 18:28:02 +02:00
|
|
|
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/progression/"+itoa(kindID)+"?metric=pace", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var points []progressionPoint
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &points); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(points) != 2 {
|
|
|
|
|
t.Fatalf("expected 2 points, got %d", len(points))
|
|
|
|
|
}
|
|
|
|
|
if points[0].Date > points[1].Date {
|
|
|
|
|
t.Errorf("points not sorted ascending by date: %+v", points)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
func TestMetricValue_EfficiencyFactor(t *testing.T) {
|
|
|
|
|
speed, hr := 3.0, 150.0
|
|
|
|
|
v, ok := metricValue("efficiency_factor", store.Activity{AvgSpeedMps: &speed, AvgHR: &hr})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected efficiency_factor to be computable")
|
|
|
|
|
}
|
|
|
|
|
if v != 20 {
|
|
|
|
|
t.Errorf("efficiency_factor = %v, want 20 (3.0/150*1000)", v)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, ok := metricValue("efficiency_factor", store.Activity{AvgSpeedMps: &speed}); ok {
|
|
|
|
|
t.Error("expected efficiency_factor to be unavailable without AvgHR")
|
|
|
|
|
}
|
|
|
|
|
zero := 0.0
|
|
|
|
|
if _, ok := metricValue("efficiency_factor", store.Activity{AvgSpeedMps: &speed, AvgHR: &zero}); ok {
|
|
|
|
|
t.Error("expected efficiency_factor to be unavailable with AvgHR=0")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:33:06 +02:00
|
|
|
func itoa(v int64) string {
|
|
|
|
|
return strconv.FormatInt(v, 10)
|
|
|
|
|
}
|
2026-07-17 19:03:29 +02:00
|
|
|
|
|
|
|
|
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
2026-08-04 16:11:23 +02:00
|
|
|
s, db, _ := newTestServer(t)
|
2026-07-17 19:03:29 +02:00
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("get status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var got store.Profile
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got.RollingWindowDays != 90 {
|
|
|
|
|
t.Fatalf("RollingWindowDays = %d, want 90", got.RollingWindowDays)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got.GarminEmail = "runner@example.com"
|
|
|
|
|
got.GarminPassword = "hunter2"
|
|
|
|
|
got.RollingWindowDays = 120
|
2026-08-04 16:11:23 +02:00
|
|
|
// 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"})
|
2026-07-17 19:03:29 +02:00
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
2026-08-04 16:11:23 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-07-17 19:03:29 +02:00
|
|
|
|
|
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
|
|
|
|
var updated store.Profile
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &updated)
|
|
|
|
|
if updated.GarminEmail != "runner@example.com" || updated.RollingWindowDays != 120 {
|
|
|
|
|
t.Fatalf("updated = %+v, want new email/window", updated)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, _, _ := newTestServer(t)
|
2026-07-17 19:03:29 +02:00
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
|
|
|
|
var p store.Profile
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &p)
|
|
|
|
|
|
|
|
|
|
maxHR, restingHR := 100.0, 150.0 // resting > max: invalid
|
|
|
|
|
p.MaxHeartRate = &maxHR
|
|
|
|
|
p.RestingHeartRate = &restingHR
|
|
|
|
|
|
|
|
|
|
rec = doJSON(t, router, http.MethodPut, "/api/profile", p)
|
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-24 21:36:00 +02:00
|
|
|
|
2026-07-26 12:23:39 +02:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 16:04:18 +02:00
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // garmin.MockClient defaults to AuthSuccess
|
2026-07-26 12:23:39 +02:00
|
|
|
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)
|
2026-08-04 16:04:18 +02:00
|
|
|
client, err := s.clientFor(context.Background(), userID)
|
2026-07-26 12:23:39 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("garminFor: %v", err)
|
|
|
|
|
}
|
2026-08-04 16:04:18 +02:00
|
|
|
mockClient, ok := client.(*garmin.MockClient)
|
2026-07-26 12:23:39 +02:00
|
|
|
if !ok {
|
2026-08-04 16:04:18 +02:00
|
|
|
t.Fatalf("expected *garmin.MockClient, got %T", client)
|
2026-07-26 12:23:39 +02:00
|
|
|
}
|
|
|
|
|
mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
|
|
|
|
|
|
|
|
|
|
router := s.Router()
|
2026-08-04 16:04:18 +02:00
|
|
|
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
|
2026-07-26 12:23:39 +02:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 10:19:00 +02:00
|
|
|
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.
|
2026-08-04 16:04:18 +02:00
|
|
|
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
|
2026-07-26 10:19:00 +02:00
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
2026-08-04 16:04:18 +02:00
|
|
|
client, err := s.clientFor(context.Background(), userID)
|
2026-07-26 10:19:00 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("garminFor: %v", err)
|
|
|
|
|
}
|
2026-08-04 16:04:18 +02:00
|
|
|
mockClient, ok := client.(*garmin.MockClient)
|
2026-07-26 10:19:00 +02:00
|
|
|
if !ok {
|
2026-08-04 16:04:18 +02:00
|
|
|
t.Fatalf("expected *garmin.MockClient, got %T", client)
|
2026-07-26 10:19:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 16:04:18 +02:00
|
|
|
m := &garmin.MockClient{}
|
|
|
|
|
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
|
|
|
|
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
2026-07-26 10:19:00 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 21:36:00 +02:00
|
|
|
func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
|
|
|
|
|
t.Helper()
|
2026-07-25 18:28:02 +02:00
|
|
|
s, db, _ := newTestServer(t)
|
2026-07-24 21:36:00 +02:00
|
|
|
s.Auth = verifier
|
|
|
|
|
return s, db
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHealth_NoSessionRequired(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, _, _ := newTestServer(t)
|
2026-07-24 21:36:00 +02:00
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
s.Router().ServeHTTP(rec, req)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestProtectedRoute_RejectsMissingSession(t *testing.T) {
|
2026-07-25 18:28:02 +02:00
|
|
|
s, _, _ := newTestServer(t)
|
2026-07-24 21:36:00 +02:00
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/profile/", nil)
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
s.Router().ServeHTTP(rec, req)
|
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
|
|
|
t.Fatalf("status = %d, want 401", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSessionLogin_RedirectsToAuthURLAndSetsTxnCookie(t *testing.T) {
|
|
|
|
|
s, _ := newTestServerWithAuth(t, &authmock.Verifier{
|
|
|
|
|
AuthURL: "https://keycloak.example.com/auth?client_id=geniusrun",
|
|
|
|
|
Txn: auth.TxnState{State: "s1", CodeVerifier: "v1"},
|
|
|
|
|
})
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/session/login", nil)
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
s.Router().ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusFound {
|
|
|
|
|
t.Fatalf("status = %d, want 302", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
if got := rec.Header().Get("Location"); got != "https://keycloak.example.com/auth?client_id=geniusrun" {
|
|
|
|
|
t.Fatalf("Location = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if rec.Result().Cookies()[0].Name != auth.TxnCookieName {
|
|
|
|
|
t.Fatalf("expected a %s cookie to be set", auth.TxnCookieName)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSessionCallback_AuthorizedSetsSessionCookieAndRedirectsHome(t *testing.T) {
|
|
|
|
|
verifier := &authmock.Verifier{
|
|
|
|
|
CallbackResult: auth.LoginResult{
|
|
|
|
|
Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"},
|
|
|
|
|
Authorized: true,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
|
2026-07-25 22:52:15 +02:00
|
|
|
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/" {
|
2026-07-24 21:36:00 +02:00
|
|
|
t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
|
|
|
|
|
}
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
claims, err := auth.ParseSessionCookie(sessionCookie, testSessionConfig.Secret)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("parse session cookie: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if claims.Name != "Alice" {
|
|
|
|
|
t.Fatalf("claims.Name = %q, want Alice", claims.Name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSessionCallback_UnauthorizedRedirectsWithoutSessionCookie(t *testing.T) {
|
|
|
|
|
verifier := &authmock.Verifier{
|
|
|
|
|
CallbackResult: auth.LoginResult{Claims: auth.Claims{Sub: "u1"}, Authorized: false},
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
|
2026-07-25 22:52:15 +02:00
|
|
|
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/?auth_error=forbidden" {
|
2026-07-24 21:36:00 +02:00
|
|
|
t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
|
|
|
|
|
}
|
|
|
|
|
for _, c := range rec.Result().Cookies() {
|
|
|
|
|
if c.Name == auth.SessionCookieName {
|
|
|
|
|
t.Fatal("session cookie must not be set when Authorized is false")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSessionCallback_MissingTxnCookieRedirectsFailed(t *testing.T) {
|
|
|
|
|
s, _ := newTestServerWithAuth(t, &authmock.Verifier{})
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil)
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
s.Router().ServeHTTP(rec, req)
|
2026-07-25 22:52:15 +02:00
|
|
|
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/?auth_error=failed" {
|
2026-07-24 21:36:00 +02:00
|
|
|
t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSessionMe_ReturnsAuthenticatedUser(t *testing.T) {
|
|
|
|
|
rec := doJSON(t, mustServerRouter(t), http.MethodGet, "/api/session/me", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var got sessionMeResponse
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got.Name != "Test User" || got.Email != "test@example.com" {
|
|
|
|
|
t.Fatalf("got %+v, want the doJSON test-cookie identity", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func mustServerRouter(t *testing.T) http.Handler {
|
|
|
|
|
t.Helper()
|
2026-07-25 18:28:02 +02:00
|
|
|
s, _, _ := newTestServer(t)
|
2026-07-24 21:36:00 +02:00
|
|
|
return s.Router()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSessionLogout_ClearsSessionCookieAndRedirectsToEndSession(t *testing.T) {
|
|
|
|
|
verifier := &authmock.Verifier{EndSessionResult: "https://keycloak.example.com/logout?post_logout_redirect_uri=%2F"}
|
|
|
|
|
s, _ := newTestServerWithAuth(t, verifier)
|
|
|
|
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/session/logout", nil)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusFound || rec.Header().Get("Location") != verifier.EndSessionResult {
|
|
|
|
|
t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
|
|
|
|
|
}
|
|
|
|
|
var cleared *http.Cookie
|
|
|
|
|
for _, c := range rec.Result().Cookies() {
|
|
|
|
|
if c.Name == auth.SessionCookieName {
|
|
|
|
|
cleared = c
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if cleared == nil || cleared.MaxAge >= 0 {
|
|
|
|
|
t.Fatalf("expected session cookie to be cleared, got %+v", cleared)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-25 23:37:22 +02:00
|
|
|
|
|
|
|
|
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+"/")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-26 11:55:13 +02:00
|
|
|
|
|
|
|
|
// 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(
|
2026-08-04 17:44:37 +02:00
|
|
|
auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"},
|
|
|
|
|
"raw-id-token-jwt", // travels in the cookie apart from Claims
|
2026-07-26 11:55:13 +02:00
|
|
|
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{
|
2026-08-04 17:44:37 +02:00
|
|
|
Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"},
|
2026-07-26 11:55:13 +02:00
|
|
|
Authorized: true,
|
2026-08-04 17:44:37 +02:00
|
|
|
IDToken: "raw-id-token-jwt",
|
2026-07-26 11:55:13 +02:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
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")
|
|
|
|
|
}
|
2026-08-04 17:44:37 +02:00
|
|
|
idToken, err := auth.IDTokenFromSessionCookie(sessionCookie, testSessionConfig.Secret)
|
2026-07-26 11:55:13 +02:00
|
|
|
if err != nil {
|
2026-08-04 17:44:37 +02:00
|
|
|
t.Fatalf("read id token from session cookie: %v", err)
|
2026-07-26 11:55:13 +02:00
|
|
|
}
|
2026-08-04 17:44:37 +02:00
|
|
|
if idToken != "raw-id-token-jwt" {
|
|
|
|
|
t.Errorf("cookie id token = %q, want %q", idToken, "raw-id-token-jwt")
|
2026-07-26 11:55:13 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-26 14:30:39 +02:00
|
|
|
|
|
|
|
|
func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
|
|
|
|
|
s, _, _ := newTestServer(t)
|
|
|
|
|
var buf bytes.Buffer
|
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
|
|
|
prev := slog.Default()
|
|
|
|
|
slog.SetDefault(applog.NewLogger("info", &buf))
|
|
|
|
|
defer slog.SetDefault(prev)
|
2026-07-26 14:30:39 +02:00
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
}
|
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
|
|
|
if entry["type"] != "http" || entry["class"] != "api" || entry["method"] != "loggingMiddleware" {
|
|
|
|
|
t.Errorf("schema fields = %v, want type=http class=api method=loggingMiddleware", entry)
|
2026-07-26 14:30:39 +02:00
|
|
|
}
|
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
|
|
|
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"])
|
2026-07-26 14:30:39 +02:00
|
|
|
}
|
|
|
|
|
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
|
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
|
|
|
prev := slog.Default()
|
|
|
|
|
slog.SetDefault(applog.NewLogger("info", &buf))
|
|
|
|
|
defer slog.SetDefault(prev)
|
2026-07-26 14:30:39 +02:00
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/profile", nil)
|
2026-08-04 17:44:37 +02:00
|
|
|
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
2026-07-26 14:30:39 +02:00
|
|
|
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"])
|
|
|
|
|
}
|
|
|
|
|
}
|