package garmin import ( "bufio" "bytes" "context" "encoding/json" "errors" "io" "log/slog" "os/exec" "strings" "testing" "time" "geniusrun/backend/internal/log" ) // wireResponsePayload is what a fake wrapper handler returns for one // request; the harness fills in the response ID. type wireResponsePayload struct { result json.RawMessage err string notFound bool } func fakeResult(v any) wireResponsePayload { b, err := json.Marshal(v) if err != nil { panic(err) } return wireResponsePayload{result: b} } func fakeError(msg string) wireResponsePayload { return wireResponsePayload{err: msg} } func fakeNotFoundError(msg string) wireResponsePayload { return wireResponsePayload{err: msg, notFound: true} } // newFakeWrapperClient wires a subprocessClient to an in-process goroutine // that plays the Python wrapper's role, so protocol-level Go logic can be // tested without python3/garminconnect installed. func newFakeWrapperClient(t *testing.T, handle func(cmd string, params json.RawMessage) wireResponsePayload) *subprocessClient { t.Helper() reqR, reqW := io.Pipe() respR, respW := io.Pipe() t.Cleanup(func() { reqW.Close() respW.Close() }) go func() { scanner := bufio.NewScanner(reqR) scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes) enc := json.NewEncoder(respW) for scanner.Scan() { var req struct { ID int `json:"id"` Cmd string `json:"cmd"` Params json.RawMessage `json:"params"` } if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { continue } payload := handle(req.Cmd, req.Params) resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err, NotFound: payload.notFound} if err := enc.Encode(resp); err != nil { return } } }() scanner := bufio.NewScanner(respR) scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes) return &subprocessClient{ started: true, enc: json.NewEncoder(reqW), scanner: scanner, } } func TestSubprocessClient_RoundTrip_DetectsIDMismatch(t *testing.T) { reqR, reqW := io.Pipe() respR, respW := io.Pipe() defer reqW.Close() defer respW.Close() go func() { scanner := bufio.NewScanner(reqR) scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes) scanner.Scan() // read and discard the one request json.NewEncoder(respW).Encode(wireResponse{ID: 999, Result: json.RawMessage(`{}`)}) }() scanner := bufio.NewScanner(respR) scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes) c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner} _, err := c.roundTrip(context.Background(), "authenticate", nil) if err == nil || !strings.Contains(err.Error(), "id mismatch") { t.Fatalf("roundTrip error = %v, want an id mismatch error", err) } } func TestSubprocessClient_RoundTrip_SubprocessClosedIsError(t *testing.T) { reqR, reqW := io.Pipe() respR, respW := io.Pipe() defer reqW.Close() go func() { scanner := bufio.NewScanner(reqR) scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes) scanner.Scan() respW.Close() // subprocess "exited": stdout closes with no response }() scanner := bufio.NewScanner(respR) scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes) c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner} _, err := c.roundTrip(context.Background(), "authenticate", nil) if err == nil || !strings.Contains(err.Error(), "closed") { t.Fatalf("roundTrip error = %v, want a subprocess-closed error", err) } } func TestSubprocessClient_RoundTrip_WrapperErrorPropagates(t *testing.T) { c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { return fakeError("boom") }) _, err := c.roundTrip(context.Background(), "authenticate", nil) if err == nil || !strings.Contains(err.Error(), "boom") { t.Fatalf("roundTrip error = %v, want it to mention %q", err, "boom") } } func TestSubprocessClient_RoundTrip_NotFoundWrapsErrNotFound(t *testing.T) { c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { return fakeNotFoundError("API Error 404") }) _, err := c.roundTrip(context.Background(), "call", nil) if !errors.Is(err, ErrNotFound) { t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound)", err) } if !strings.Contains(err.Error(), "API Error 404") { t.Errorf("roundTrip error = %v, want it to still contain the original message", err) } } func TestSubprocessClient_RoundTrip_OrdinaryErrorDoesNotWrapErrNotFound(t *testing.T) { c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { return fakeError("boom") }) _, err := c.roundTrip(context.Background(), "call", nil) if errors.Is(err, ErrNotFound) { t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound) to be false", err) } } func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) { c := &subprocessClient{cfg: ClientConfig{GarminEmail: "old@example.com", GarminPassword: "old"}} c.started = true // simulate an already-spawned subprocess, no real cmd/pipes c.UpdateCredentials("new@example.com", "new") if c.cfg.GarminEmail != "new@example.com" || c.cfg.GarminPassword != "new" { t.Errorf("cfg after update = %+v, want new@example.com/new", c.cfg) } if c.started { t.Error("started should be reset to false so the next call respawns the subprocess") } } func TestSubprocessClient_Close_NoOpWhenNotStarted(t *testing.T) { c := &subprocessClient{} if err := c.Close(); err != nil { t.Errorf("Close on an unstarted client = %v, want nil", err) } } // TestSubprocessClient_Close_WaitsForStderrCopyGoroutine exercises close() // against a real subprocess that writes to stderr, mirroring how // ensureStarted wires up the stderr-copy goroutine. Per os/exec's // StderrPipe docs it is incorrect to call Wait before all reads from the // pipe have completed; this guards against close() calling cmd.Wait() // before the copy goroutine has drained the pipe (which previously risked a // race / truncated stderr / spurious "file already closed" errors). func TestSubprocessClient_Close_WaitsForStderrCopyGoroutine(t *testing.T) { cmd := exec.Command("sh", "-c", "for i in 1 2 3 4 5; do echo line$i 1>&2; done; sleep 5") stderr, err := cmd.StderrPipe() if err != nil { t.Fatalf("StderrPipe: %v", err) } if err := cmd.Start(); err != nil { t.Fatalf("Start: %v", err) } stderrDone := make(chan struct{}) go func() { io.Copy(io.Discard, stderr) close(stderrDone) }() c := &subprocessClient{started: true, cmd: cmd, stderrDone: stderrDone} done := make(chan error, 1) go func() { done <- c.close() }() select { case <-done: // close() returned -- since it killed the process and waited on // stderrDone before Wait-ing, this proves the ordering held without // deadlocking. case <-time.After(5 * time.Second): t.Fatal("close() did not return in time -- likely blocked waiting on stderrDone") } if c.stderrDone != nil { t.Error("stderrDone should be reset to nil after close()") } } func TestSubprocessClient_Authenticate_Success(t *testing.T) { c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { if cmd != "authenticate" { t.Fatalf("unexpected cmd %q", cmd) } return fakeResult(authResultWire{Status: "success", Message: "Authenticated successfully."}) }) res, err := c.Authenticate(context.Background()) if err != nil { t.Fatalf("Authenticate: %v", err) } if res.Status != AuthSuccess || res.Message != "Authenticated successfully." { t.Errorf("Authenticate result = %+v", res) } } func TestSubprocessClient_Authenticate_MFARequired(t *testing.T) { c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { return fakeResult(authResultWire{Status: "mfa_required", Message: "MFA required. ..."}) }) res, err := c.Authenticate(context.Background()) if err != nil { t.Fatalf("Authenticate: %v", err) } if res.Status != AuthMFARequired { t.Errorf("Authenticate status = %v, want AuthMFARequired", res.Status) } } func TestSubprocessClient_Authenticate_WrapperErrorPropagates(t *testing.T) { c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { return fakeError("subprocess exploded") }) _, err := c.Authenticate(context.Background()) if err == nil || !strings.Contains(err.Error(), "subprocess exploded") { t.Fatalf("Authenticate error = %v, want it to mention the wrapper error", err) } } func TestSubprocessClient_CompleteMFA_SendsCodeAndReturnsStatus(t *testing.T) { var gotCode string c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { if cmd != "complete_mfa" { t.Fatalf("unexpected cmd %q", cmd) } var p struct { Code string `json:"code"` } if err := json.Unmarshal(params, &p); err != nil { t.Fatalf("unmarshal params: %v", err) } gotCode = p.Code return fakeResult(authResultWire{Status: "success", Message: "MFA accepted. Authenticated successfully."}) }) res, err := c.CompleteMFA(context.Background(), "123456") if err != nil { t.Fatalf("CompleteMFA: %v", err) } if gotCode != "123456" { t.Errorf("code sent to wrapper = %q, want 123456", gotCode) } if res.Status != AuthSuccess { t.Errorf("CompleteMFA status = %v, want AuthSuccess", res.Status) } } func TestSubprocessClient_CompleteMFA_Failed(t *testing.T) { c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { return fakeResult(authResultWire{Status: "failed", Message: "Authentication failed after MFA: bad code"}) }) res, err := c.CompleteMFA(context.Background(), "000000") if err != nil { t.Fatalf("CompleteMFA: %v", err) } if res.Status != AuthFailed { t.Errorf("CompleteMFA status = %v, want AuthFailed", res.Status) } } func TestSubprocessClient_GetActivities_UsesGarminconnectKwargNames(t *testing.T) { var gotMethod string var gotArgs map[string]any c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { var p callParams if err := json.Unmarshal(params, &p); err != nil { t.Fatalf("unmarshal params: %v", err) } gotMethod, gotArgs = p.Method, p.Args return fakeResult([]map[string]any{ {"activityId": float64(123), "activityType": map[string]any{"typeKey": "running"}}, }) }) activities, err := c.GetActivities(context.Background(), "2026-07-01", "2026-07-25", 0) if err != nil { t.Fatalf("GetActivities: %v", err) } if gotMethod != "get_activities_by_date" { t.Errorf("method = %q, want get_activities_by_date", gotMethod) } if gotArgs["startdate"] != "2026-07-01" || gotArgs["enddate"] != "2026-07-25" { t.Errorf("args = %+v, want startdate/enddate matching garminconnect's real kwargs", gotArgs) } if len(activities) != 1 || activities[0].ActivityID != 123 { t.Fatalf("activities = %+v", activities) } } func TestSubprocessClient_GetActivities_AppliesLimitClientSide(t *testing.T) { c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { raw := make([]map[string]any, 5) for i := range raw { raw[i] = map[string]any{"activityId": float64(i)} } return fakeResult(raw) }) activities, err := c.GetActivities(context.Background(), "2026-07-01", "2026-07-25", 2) if err != nil { t.Fatalf("GetActivities: %v", err) } if len(activities) != 2 { t.Fatalf("len(activities) = %d, want 2", len(activities)) } } func TestSubprocessClient_GetActivitySplits_ParsesLapDTOsEnvelope(t *testing.T) { var gotMethod string var gotArgs map[string]any c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { var p callParams json.Unmarshal(params, &p) gotMethod, gotArgs = p.Method, p.Args return fakeResult(map[string]any{ "activityId": float64(111), "lapDTOs": []map[string]any{{"lapIndex": float64(1), "distance": float64(1000)}}, }) }) splits, err := c.GetActivitySplits(context.Background(), 111) if err != nil { t.Fatalf("GetActivitySplits: %v", err) } if gotMethod != "get_activity_splits" || gotArgs["activity_id"] != "111" { t.Errorf("method/args = %q/%+v, want get_activity_splits with activity_id=\"111\"", gotMethod, gotArgs) } if splits.ActivityID != 111 || len(splits.Laps) != 1 || splits.Laps[0].LapIndex != 1 { t.Fatalf("splits = %+v", splits) } } func TestSubprocessClient_GetActivityDetails_ParsesRawTelemetry(t *testing.T) { c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { var p callParams json.Unmarshal(params, &p) if p.Method != "get_activity_details" || p.Args["activity_id"] != "222" { t.Fatalf("unexpected call: %+v", p) } return fakeResult(map[string]any{ "activityId": float64(222), "metricDescriptors": []map[string]any{{"key": "directHeartRate", "metricsIndex": float64(0)}}, "activityDetailMetrics": []map[string]any{{"metrics": []any{float64(101)}}}, }) }) details, err := c.GetActivityDetails(context.Background(), 222) if err != nil { t.Fatalf("GetActivityDetails: %v", err) } if details.ActivityID != 222 || len(details.MetricDescriptors) != 1 { t.Fatalf("details = %+v", details) } } func TestSubprocessClient_GetWorkoutByID_ParsesSegments(t *testing.T) { c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { var p callParams json.Unmarshal(params, &p) if p.Method != "get_workout_by_id" || p.Args["workout_id"] != "333" { t.Fatalf("unexpected call: %+v", p) } return fakeResult(map[string]any{ "workoutId": float64(333), "workoutName": "Tempo", "workoutSegments": []map[string]any{{"segmentOrder": float64(1), "workoutSteps": []any{}}}, }) }) workout, err := c.GetWorkoutByID(context.Background(), 333) if err != nil { t.Fatalf("GetWorkoutByID: %v", err) } if workout.WorkoutID != 333 || workout.WorkoutName != "Tempo" || len(workout.Segments) != 1 { t.Fatalf("workout = %+v", workout) } } func TestSubprocessClient_RoundTrip_LogsCallWithResultPreview(t *testing.T) { c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { return fakeResult(authResultWire{Status: "mfa_required", Message: "MFA required."}) }) var buf bytes.Buffer logger := slog.New(slog.NewJSONHandler(&buf, nil)) ctx := applog.WithLogger(context.Background(), logger) if _, err := c.Authenticate(ctx); err != nil { t.Fatalf("Authenticate: %v", err) } 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["msg"] != "Garmin wrapper call" { t.Errorf("msg = %v, want \"Garmin wrapper call\"", entry["msg"]) } if entry["cmd"] != "authenticate" { t.Errorf("cmd = %v, want authenticate", entry["cmd"]) } preview, _ := entry["result"].(string) if !strings.Contains(preview, "mfa_required") { t.Errorf("result = %q, want it to contain mfa_required", preview) } if _, hasError := entry["error"]; hasError { t.Errorf("expected no error field on a successful call, got %v", entry["error"]) } } func TestSubprocessClient_RoundTrip_LogsFailedCallAtErrorLevel(t *testing.T) { c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { return fakeError("boom") }) var buf bytes.Buffer logger := slog.New(slog.NewJSONHandler(&buf, nil)) ctx := applog.WithLogger(context.Background(), logger) if _, err := c.Authenticate(ctx); err == nil { t.Fatal("expected Authenticate to return an error") } 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"] != "ERROR" { t.Errorf("level = %v, want ERROR for a failed call", entry["level"]) } if errMsg, _ := entry["error"].(string); !strings.Contains(errMsg, "boom") { t.Errorf("error field = %q, want it to mention \"boom\"", errMsg) } }