Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes internal/log; the test mock moves into the garmin package as MockClient (breaking the test-only import cycle the merge created); stale test URLs and type names updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
489 lines
16 KiB
Go
489 lines
16 KiB
Go
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_preview"].(string)
|
|
if !strings.Contains(preview, "mfa_required") {
|
|
t.Errorf("result_preview = %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_LogsErrorAtWarnLevel(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"] != "WARN" {
|
|
t.Errorf("level = %v, want WARN 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)
|
|
}
|
|
}
|