Server no longer holds one fixed Garmin/Sync pair -- garminFor/syncFor build and cache one instance per user, keyed off their own profile's Garmin credentials and a per-user token-store subdirectory. The background incremental sync loop now iterates every provisioned user each tick instead of syncing one global account.
924 lines
34 KiB
Go
924 lines
34 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"geniusrun/backend/internal/auth"
|
|
authmock "geniusrun/backend/internal/auth/mock"
|
|
"geniusrun/backend/internal/garmin"
|
|
"geniusrun/backend/internal/garmin/mock"
|
|
"geniusrun/backend/internal/store"
|
|
appsync "geniusrun/backend/internal/sync"
|
|
)
|
|
|
|
func newCtx() context.Context { return context.Background() }
|
|
|
|
var testSessionConfig = SessionConfig{
|
|
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
|
|
Duration: time.Hour,
|
|
Secure: false,
|
|
PublicBaseURL: "https://geniusrun.example.com",
|
|
}
|
|
|
|
func newTestServer(t *testing.T) (*Server, *store.DB) {
|
|
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() })
|
|
|
|
if _, err := db.ProvisionUser(context.Background(), "test-user", "Test User"); err != nil {
|
|
t.Fatalf("ProvisionUser: %v", err)
|
|
}
|
|
|
|
m := &mock.Client{}
|
|
garminFactory := func(garmin.Config) garmin.Client { return m }
|
|
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
|
return s, db
|
|
}
|
|
|
|
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 TestReviewQueueResolve(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
ctx := newCtx()
|
|
|
|
activityID, err := db.UpsertActivity(ctx, 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, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
|
}
|
|
if _, err := db.InsertKindAssignment(ctx, 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, activityID, []store.Lap{
|
|
{LapIndex: 1, TargetPaceLowMps: &targetLow, TargetPaceHighMps: &targetHigh},
|
|
}); err != nil {
|
|
t.Fatalf("ReplaceLaps: %v", err)
|
|
}
|
|
hr := 150.0
|
|
if err := db.ReplaceActivitySamples(ctx, activityID, []store.Sample{
|
|
{ElapsedSeconds: 0, HeartRate: &hr},
|
|
}); err != nil {
|
|
t.Fatalf("ReplaceActivitySamples: %v", err)
|
|
}
|
|
|
|
router := s.Router()
|
|
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("review queue 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 review queue (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 review queue 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 review queue item, got %d: %s", len(samples), rec.Body.String())
|
|
}
|
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("resolve 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/review-queue/", nil)
|
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
|
if len(page.Items) != 1 || page.Total != 1 {
|
|
t.Fatalf("expected activity to remain listed after resolve, got %d items, total=%d", len(page.Items), page.Total)
|
|
}
|
|
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
|
|
t.Fatalf("expected AssignmentSource=manual after resolve, got %v", page.Items[0]["AssignmentSource"])
|
|
}
|
|
if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
|
|
t.Fatalf("expected WorkoutKindID=%d after resolve, 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/review-queue/"+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/review-queue/", 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/review-queue/"+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/review-queue/"+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/review-queue/", 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/review-queue/?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/review-queue/"+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/review-queue/", 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 TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
|
s, db := 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, 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, 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/review-queue/"+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 TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
ctx := newCtx()
|
|
|
|
kinds, err := db.ListWorkoutKinds(ctx, 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, 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, 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/review-queue/"+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 TestResolveReview_RejectsRaceKind(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
ctx := newCtx()
|
|
|
|
activityID, err := db.UpsertActivity(ctx, 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, "Race")
|
|
if err != nil || !ok {
|
|
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
|
}
|
|
|
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": raceKind.ID})
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("resolve to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
ctx := newCtx()
|
|
|
|
easyID, err := db.CreateWorkoutKind(ctx, 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, "Race")
|
|
if err != nil || !ok {
|
|
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
|
}
|
|
|
|
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
|
|
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
|
|
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
|
|
|
|
if _, err := db.InsertKindAssignment(ctx, 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, 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, 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, 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, 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 TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
ctx := newCtx()
|
|
|
|
easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
|
}
|
|
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
|
|
if err != nil || !ok {
|
|
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
|
}
|
|
|
|
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
|
|
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
|
|
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
|
|
|
|
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
|
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
|
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
|
|
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var items []activityListItem
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
locked := map[int64]bool{}
|
|
kindName := map[int64]string{}
|
|
for _, it := range items {
|
|
locked[it.ID] = it.Locked
|
|
if it.WorkoutKindName != nil {
|
|
kindName[it.ID] = *it.WorkoutKindName
|
|
}
|
|
}
|
|
if locked[ruleEngineActivity] {
|
|
t.Errorf("rule-engine activity should not be locked")
|
|
}
|
|
if !locked[manualActivity] {
|
|
t.Errorf("manual activity should be locked")
|
|
}
|
|
if !locked[raceActivity] {
|
|
t.Errorf("race activity should be locked")
|
|
}
|
|
if kindName[raceActivity] != "Race" {
|
|
t.Errorf("race activity workout_kind_name = %q, want %q", kindName[raceActivity], "Race")
|
|
}
|
|
}
|
|
|
|
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
ctx := newCtx()
|
|
|
|
if _, err := db.UpsertActivity(ctx, 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/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, 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/sync/backfill", nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("/api/sync/backfill status = %d, want 404 (removed)", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
ctx := newCtx()
|
|
|
|
kindID, _ := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true})
|
|
|
|
speed := 3.0
|
|
a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
|
a2, _ := db.UpsertActivity(ctx, 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, 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, _ := 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
|
|
rec = doJSON(t, router, http.MethodPut, "/api/profile", got)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
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 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") != "/" {
|
|
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") != "/?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") != "/?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)
|
|
}
|
|
}
|