Files
geniusrun/backend/internal/api/api_test.go
Christophe Vila 10cdc4176a refactor(log): split location into package/file/class/method, auto-derived
Per the log schema: 'package' is the Go package (absent on
Python-emitted lines), 'file' the Go/Python source basename, 'class' the
Go receiver or Python class (omitted when there is none -- free
functions no longer masquerade under a package-as-class), 'method' the
emitting function. applog.App() now takes no arguments and derives all
of it from runtime.Caller, so labels can never drift from the code; the
manual http/wrapper emitters and forwarded wrapper.py lines (file=
wrapper.py, no package) carry the same fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:59:48 +02:00

1207 lines
44 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/log"
"geniusrun/backend/internal/store"
)
func newCtx() context.Context { return context.Background() }
var testSessionConfig = SessionConfig{
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
Duration: time.Hour,
SetupTimeout: 15 * time.Minute,
Secure: false,
BackendURL: "https://geniusrun.example.com",
FrontendURL: "https://app.geniusrun.example.com",
}
func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
m := &garmin.MockClient{}
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
return s, db, userID
}
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")
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
if err != nil {
t.Fatalf("mint test session cookie: %v", err)
}
req.AddCookie(cookie)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func TestHealth(t *testing.T) {
s, _, _ := newTestServer(t)
rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
s, _, _ := newTestServer(t)
rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var kinds []workoutKindResponse
if err := json.Unmarshal(rec.Body.Bytes(), &kinds); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(kinds) != 8 {
t.Fatalf("expected 8 seeded kinds, got %d", len(kinds))
}
for _, k := range kinds {
if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.HRMinPctHRR != nil || k.HRMaxPctHRR != nil {
t.Errorf("expected freshly-seeded kind %q to have nil pace fields, got %+v", k.Name, k)
}
}
}
func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
s, _, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
var kinds []workoutKindResponse
json.Unmarshal(rec.Body.Bytes(), &kinds)
target := kinds[0]
minPace, maxPace, hrMin, hrMax := 330.0, 420.0, 70.0, 80.0
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,
"hr_min_pct_hrr": hrMin,
"hr_max_pct_hrr": hrMax,
}
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())
}
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)
}
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)
}
}
func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
s, _, _ := newTestServer(t)
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":"xor","conditions":[]}`),
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
}
}
func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) {
s, _, _ := newTestServer(t)
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) {
s, _, _ := newTestServer(t)
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":[]}`),
"pace_min_sec_per_km": 420,
"pace_max_sec_per_km": 330,
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
}
}
func TestActivitiesAssign(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment: %v", err)
}
targetLow, targetHigh := 3.0, 3.5
if err := db.ReplaceLaps(ctx, userID, activityID, []store.Lap{
{LapIndex: 1, TargetPaceLowMps: &targetLow, TargetPaceHighMps: &targetHigh},
}); err != nil {
t.Fatalf("ReplaceLaps: %v", err)
}
hr := 150.0
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []store.Sample{
{ElapsedSeconds: 0, HeartRate: &hr},
}); err != nil {
t.Fatalf("ReplaceActivitySamples: %v", err)
}
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/activities/", nil)
if rec.Code != http.StatusOK {
t.Fatalf("activities list status = %d", rec.Code)
}
var page struct {
Items []map[string]any `json:"items"`
NextCursor *string `json:"next_cursor"`
Total int `json:"total"`
}
json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || page.Total != 1 {
t.Fatalf("expected 1 item in activities list (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String())
}
laps, _ := page.Items[0]["laps"].([]any)
if len(laps) != 1 {
t.Fatalf("expected 1 lap in activities list item, got %d: %s", len(laps), rec.Body.String())
}
samples, _ := page.Items[0]["samples"].([]any)
if len(samples) != 1 {
t.Fatalf("expected 1 sample in activities list item, got %d: %s", len(samples), rec.Body.String())
}
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindID})
if rec.Code != http.StatusOK {
t.Fatalf("assign status = %d, body = %s", rec.Code, rec.Body.String())
}
// The activity stays listed after resolving -- the Activities page shows
// every activity, locked or not, not just ones still needing review.
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || page.Total != 1 {
t.Fatalf("expected activity to remain listed after assign, got %d items, total=%d", len(page.Items), page.Total)
}
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
t.Fatalf("expected AssignmentSource=manual after assign, got %v", page.Items[0]["AssignmentSource"])
}
if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
t.Fatalf("expected WorkoutKindID=%d after assign, got %v", kindID, got)
}
// Unlocking reverts the source to rule_engine but keeps the same kind,
// so a later reclassify pass is free to change it again.
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
if rec.Code != http.StatusOK {
t.Fatalf("unlock status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
}
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.
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code)
}
// Manually unassigning clears the kind back to Unclassified and locks
// that decision (source=manual), same as resolving to a specific kind.
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unassign", nil)
if rec.Code != http.StatusOK {
t.Fatalf("unassign status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
t.Fatalf("expected AssignmentSource=manual after unassign, got %v", page.Items[0]["AssignmentSource"])
}
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.
rec = doJSON(t, router, http.MethodGet, "/api/activities/?unclassified=true", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || page.Total != 1 {
t.Fatalf("expected unassigned activity to show up as unclassified, got %d items, total=%d", len(page.Items), page.Total)
}
// Unassigning locks it, so unlocking works again (reverting to
// rule_engine sourcing with no kind, i.e. needs_review).
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
if rec.Code != http.StatusOK {
t.Fatalf("unlock after unassign status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
}
}
func TestActivities_PaginatesByCursor(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
// 5 activities, newest first once sorted: 2026-07-05 .. 2026-07-01.
for i := 1; i <= 5; i++ {
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
GarminActivityID: int64(i),
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
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()
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
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)
}
}
func TestActivities_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
kinds, err := db.ListWorkoutKinds(ctx, userID, true)
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) {
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
GarminActivityID: n,
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", n), RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
status := store.AssignmentStatusAssigned
if kindID == nil {
status = store.AssignmentStatusNeedsReview
}
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
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()
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
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)
}
}
func TestAssignActivity_RejectsRaceKind(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
if err != nil || !ok {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
rec := doJSON(t, s.Router(), http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": raceKind.ID})
if rec.Code != http.StatusBadRequest {
t.Fatalf("assign to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String())
}
}
func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
easyID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Easy", RuleJSON: `{"match":"all","conditions":[{"metric":"distance_meters","op":">=","value":0}]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
if err != nil || !ok {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
manualActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
raceActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine,
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
}
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual,
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment (manual): %v", err)
}
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
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)
}
manualAssignment, ok, err := db.CurrentAssignment(ctx, userID, manualActivity)
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)
}
raceAssignment, ok, err := db.CurrentAssignment(ctx, userID, raceActivity)
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)
}
}
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
if _, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/garmin/sync/reset", nil)
if rec.Code != http.StatusAccepted {
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String())
}
deadline := time.Now().Add(2 * time.Second)
for {
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
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.
rec = doJSON(t, router, http.MethodPost, "/api/garmin/sync/backfill", nil)
if rec.Code != http.StatusNotFound {
t.Errorf("/api/garmin/sync/backfill status = %d, want 404 (removed)", rec.Code)
}
}
func TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
workoutID := int64(555)
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
// ActivitiesMissingWorkout only surfaces activities whose details have
// already been fetched (see store.ActivitiesMissingWorkout) -- an
// activity is only eligible for a workout fetch once fillActivityDetails
// has given it laps to align target pace/HR bands against.
if err := db.SetActivityDetails(ctx, userID, activityID, "{}"); err != nil {
t.Fatalf("SetActivityDetails: %v", err)
}
rec := doJSON(t, s.Router(), http.MethodGet, "/api/garmin/sync/status", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp struct {
InProgress bool `json:"in_progress"`
Progress struct {
Phase string `json:"Phase"`
Done int `json:"Done"`
Total int `json:"Total"`
} `json:"progress"`
ActivitiesPendingDetails int `json:"activities_pending_details"`
WorkoutsPending int `json:"workouts_pending"`
}
unmarshalBody(t, rec, &resp)
if resp.InProgress {
t.Error("in_progress = true, want false (nothing running)")
}
if resp.Progress.Phase != "idle" {
t.Errorf("progress.Phase = %q, want %q", resp.Progress.Phase, "idle")
}
if resp.ActivitiesPendingDetails != 1 {
t.Errorf("activities_pending_details = %d, want 1", resp.ActivitiesPendingDetails)
}
if resp.WorkoutsPending != 1 {
t.Errorf("workouts_pending = %d, want 1", resp.WorkoutsPending)
}
}
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
kindID, _ := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true})
speed := 3.0
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: "{}"})
for _, id := range []int64{a2, a1} { // insert out of order on purpose
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
}
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)
}
}
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")
}
}
func itoa(v int64) string {
return strconv.FormatInt(v, 10)
}
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
s, db, _ := newTestServer(t)
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
// Name rides along in the profile payload but lives on the users row.
rec = doJSON(t, router, http.MethodPut, "/api/profile", struct {
store.Profile
Name string
}{got, "Renamed Runner"})
if rec.Code != http.StatusOK {
t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String())
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found || u.Name != "Renamed Runner" {
t.Fatalf("users.name after profile PUT = %q (found=%v err=%v), want Renamed Runner", u.Name, found, err)
}
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil)
var updated store.Profile
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) {
s, _, _ := newTestServer(t)
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())
}
}
func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
s, _, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var before sessionMeResponse
unmarshalBody(t, rec, &before)
if before.GarminConnected {
t.Fatal("expected a fresh account to report garmin_connected=false")
}
rec = doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // garmin.MockClient defaults to AuthSuccess
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var after sessionMeResponse
unmarshalBody(t, rec, &after)
if !after.GarminConnected {
t.Fatal("expected garmin_connected=true after a successful auth")
}
}
func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) {
s, _, userID := newTestServer(t)
client, err := s.clientFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*garmin.MockClient)
if !ok {
t.Fatalf("expected *garmin.MockClient, got %T", client)
}
mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var me sessionMeResponse
unmarshalBody(t, rec, &me)
if me.GarminConnected {
t.Fatal("expected garmin_connected=false after mfa_required")
}
}
func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
s, db, userID := newTestServer(t)
router := s.Router()
// Force the per-user Garmin client to be built and cached.
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
client, err := s.clientFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*garmin.MockClient)
if !ok {
t.Fatalf("expected *garmin.MockClient, got %T", client)
}
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || found {
t.Fatalf("expected user gone after delete, found=%v err=%v", found, err)
}
if !mockClient.ClosedCalled {
t.Error("expected the cached garmin client to be Close()d on profile deletion")
}
}
func TestDeleteProfile_RejectsWhileSyncInProgress(t *testing.T) {
s, db, userID := newTestServer(t)
s.mu.Lock()
s.userSyncRunning[userID] = true
s.mu.Unlock()
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || !found {
t.Fatalf("expected user to survive a rejected delete, found=%v err=%v", found, err)
}
}
func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
tokenStoreRoot := t.TempDir()
userTokenDir := filepath.Join(tokenStoreRoot, strconv.FormatInt(userID, 10))
if err := os.MkdirAll(userTokenDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(userTokenDir, "session.json"), []byte("{}"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
m := &garmin.MockClient{}
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(userTokenDir); !os.IsNotExist(err) {
t.Fatalf("expected token store dir %q to be removed, stat err = %v", userTokenDir, err)
}
}
func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
t.Helper()
s, db, _ := newTestServer(t)
s.Auth = verifier
return s, db
}
func TestHealth_NoSessionRequired(t *testing.T) {
s, _, _ := newTestServer(t)
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) {
s, _, _ := newTestServer(t)
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)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/" {
t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
}
var sessionCookie *http.Cookie
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)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/?auth_error=forbidden" {
t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
}
for _, c := range rec.Result().Cookies() {
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)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/?auth_error=failed" {
t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
}
}
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()
s, _, _ := newTestServer(t)
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)
}
}
func TestSessionLogout_PostLogoutRedirectUsesFrontendURL(t *testing.T) {
verifier := &authmock.Verifier{} // EndSessionResult unset: echoes back postLogoutRedirectURL unchanged
s, _ := newTestServerWithAuth(t, verifier)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/session/logout", nil)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != testSessionConfig.FrontendURL+"/" {
t.Fatalf("status = %d, Location = %q, want post_logout_redirect_uri = %q", rec.Code, rec.Header().Get("Location"), testSessionConfig.FrontendURL+"/")
}
}
// TestSessionLogout_PassesIDTokenHintFromSessionCookie confirms the raw ID
// token carried in the session cookie (minted at callback time) is handed
// back to EndSessionURL on logout, so Keycloak can skip its own
// logout-confirmation prompt instead of leaving the user a chance to cancel
// out of it after their geniusrun account is already deleted.
func TestSessionLogout_PassesIDTokenHintFromSessionCookie(t *testing.T) {
verifier := &authmock.Verifier{EndSessionResult: "https://keycloak.example.com/logout"}
s, _ := newTestServerWithAuth(t, verifier)
cookie, err := auth.MintSessionCookie(
auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"},
"raw-id-token-jwt", // travels in the cookie apart from Claims
testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure,
)
if err != nil {
t.Fatalf("mint session cookie: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/session/logout", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
}
if verifier.LastIDTokenHint != "raw-id-token-jwt" {
t.Errorf("LastIDTokenHint = %q, want %q", verifier.LastIDTokenHint, "raw-id-token-jwt")
}
}
// TestSessionCallback_MintsSessionCookieCarryingIDToken confirms the raw ID
// token from a completed OIDC callback ends up in the session cookie (not
// just Sub/Name/Email), since that's the only place logout can later read
// it back from to build id_token_hint.
func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) {
verifier := &authmock.Verifier{
CallbackResult: auth.LoginResult{
Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"},
Authorized: true,
IDToken: "raw-id-token-jwt",
},
}
s, _ := newTestServerWithAuth(t, verifier)
txnCookie, err := auth.MintTxnCookie(auth.TxnState{State: "s1", CodeVerifier: "v1"}, testSessionConfig.Secret, false)
if err != nil {
t.Fatalf("mint txn cookie: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil)
req.AddCookie(txnCookie)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
var sessionCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.SessionCookieName {
sessionCookie = c
}
}
if sessionCookie == nil {
t.Fatal("expected a session cookie to be set")
}
idToken, err := auth.IDTokenFromSessionCookie(sessionCookie, testSessionConfig.Secret)
if err != nil {
t.Fatalf("read id token from session cookie: %v", err)
}
if idToken != "raw-id-token-jwt" {
t.Errorf("cookie id token = %q, want %q", idToken, "raw-id-token-jwt")
}
}
func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
s, _, _ := newTestServer(t)
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(applog.NewLogger("info", &buf))
defer slog.SetDefault(prev)
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["type"] != "http" || entry["package"] != "api" || entry["file"] != "server.go" || entry["method"] != "loggingMiddleware" {
t.Errorf("schema fields = %v, want type=http package=api file=server.go method=loggingMiddleware", entry)
}
if _, hasMsg := entry["msg"]; hasMsg {
t.Errorf("expected no msg on the http record, got %v", entry["msg"])
}
if entry["http_method"] != "GET" || entry["path"] != "/api/health" {
t.Errorf("http_method/path = %v/%v, want GET//api/health", entry["http_method"], entry["path"])
}
if entry["status"] != float64(http.StatusOK) {
t.Errorf("status = %v, want 200", entry["status"])
}
if _, ok := entry["duration_ms"]; !ok {
t.Error("expected a duration_ms field")
}
}
func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) {
s, db, _ := newTestServer(t)
db.Close() // force a downstream DB call to fail with a 500
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(applog.NewLogger("info", &buf))
defer slog.SetDefault(prev)
req := httptest.NewRequest(http.MethodGet, "/api/profile", nil)
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
if err != nil {
t.Fatalf("mint session cookie: %v", err)
}
req.AddCookie(cookie)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected the request itself to 500 after closing the DB, got %d", rec.Code)
}
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["level"] != "WARN" {
t.Errorf("level = %v, want WARN for a 5xx response", entry["level"])
}
}