feat(garmin): replace mcp-go transport with a JSON-lines subprocess client
subprocessClient spawns the embedded pyscript/wrapper.py over os/exec and speaks newline-delimited JSON instead of MCP. Auth/data methods land in follow-up commits; this is the transport + lifecycle plumbing only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,29 +1,135 @@
|
||||
package garmin
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseAuthResult(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
want AuthStatus
|
||||
}{
|
||||
{"Authenticated successfully.", AuthSuccess},
|
||||
{"MFA accepted. Authenticated successfully.", AuthSuccess},
|
||||
{"MFA required. Garmin has sent a verification code...", AuthMFARequired},
|
||||
{"Authentication failed: 401 Unauthorized (Invalid Username or Password)", AuthFailed},
|
||||
{"Authentication failed after MFA: bad code", AuthFailed},
|
||||
// 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
|
||||
}
|
||||
|
||||
func fakeResult(v any) wireResponsePayload {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := parseAuthResult(c.msg)
|
||||
if got.Status != c.want {
|
||||
t.Errorf("parseAuthResult(%q).Status = %v, want %v", c.msg, got.Status, c.want)
|
||||
return wireResponsePayload{result: b}
|
||||
}
|
||||
|
||||
func fakeError(msg string) wireResponsePayload {
|
||||
return wireResponsePayload{err: msg}
|
||||
}
|
||||
|
||||
// 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}
|
||||
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 TestMcpClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
|
||||
c := &mcpClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}}
|
||||
c.started = true // simulate an already-spawned subprocess
|
||||
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("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("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("authenticate", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("roundTrip error = %v, want it to mention %q", err, "boom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
|
||||
c := &subprocessClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}}
|
||||
c.started = true // simulate an already-spawned subprocess, no real cmd/pipes
|
||||
|
||||
c.UpdateCredentials("new@example.com", "new")
|
||||
|
||||
@@ -33,7 +139,11 @@ func TestMcpClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
|
||||
if c.started {
|
||||
t.Error("started should be reset to false so the next call respawns the subprocess")
|
||||
}
|
||||
if c.inner != nil {
|
||||
t.Error("inner should be cleared so ensureStarted spawns a fresh client")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user