Files
geniusrun/backend/internal/api/api_test.go
Christophe Vila 6effb79097 refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.

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

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["class"] != "api" || entry["method"] != "loggingMiddleware" {
t.Errorf("schema fields = %v, want type=http class=api 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"])
}
}