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>
1207 lines
44 KiB
Go
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"])
|
|
}
|
|
}
|