From 63c0190a05b31ac708d34a8f264123b9cb6421c1 Mon Sep 17 00:00:00 2001 From: Terastar-Paperclip <279373186+Terastar-Paperclip@users.noreply.github.com> Date: Sun, 10 May 2026 20:28:33 -0400 Subject: [PATCH 1/3] feat(client): WITHINGS_API_BASE override for compat-test stub server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §4 compat bundle's data-path subtests (JSONIsArray, CSVHasHeader, DefaultIsMarkdown) invoke the binary against its real API. Withings' production API requires a live OAuth token, which a clean CI runner does not have. Letting WITHINGS_API_BASE redirect requests to a stub origin lets the compat fixture stand up an httptest.Server that returns the empty Withings envelope, so the data-path subtests can run hermetically. Unset (the default) keeps the existing https://wbsapi.withings.net behavior. Co-Authored-By: Paperclip --- internal/client/client.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/client/client.go b/internal/client/client.go index d00157b..1740371 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "os" "strings" "github.com/quantcli/withings-export-cli/internal/auth" @@ -18,8 +19,16 @@ type Client struct { baseURL string } +// New returns a client pointed at Withings' production API. Setting +// WITHINGS_API_BASE redirects requests to that origin instead — used by +// the compat-test fixture to point the binary at a stub server so the +// §4 data-path subtests can run without live OAuth. func New() *Client { - return &Client{http: &http.Client{}, baseURL: BaseURL} + base := BaseURL + if v := strings.TrimSpace(os.Getenv("WITHINGS_API_BASE")); v != "" { + base = strings.TrimRight(v, "/") + } + return &Client{http: &http.Client{}, baseURL: base} } // Call posts form-encoded params to a Withings API path and decodes the `body` field From 523320ec40669718d19e1a44cdcfb18c17ac21dd Mon Sep 17 00:00:00 2001 From: Terastar-Paperclip <279373186+Terastar-Paperclip@users.noreply.github.com> Date: Sun, 10 May 2026 20:28:42 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat(compat):=20wire=20=C2=A74=20conformanc?= =?UTF-8?q?e=20test=20against=20compat/formats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a build-tag-gated compat_contract_test.go that imports github.com/quantcli/common/compat and …/compat/formats and runs formats.RunContract against the withings-export binary. The test declares withings' five data subcommands (activity, intraday, measurements, sleep, workouts) via compat.Runner.Subcommands so any single-subcommand regression surfaces as a named subtest failure. Because withings' data path requires a usable OAuth token and a reachable Withings API origin, the test fixture stands up an httptest.Server that answers every POST with `{"status":0,"body":{}}` and seeds a never-expired synthetic token at $HOME/.config/withings-export/auth.json. The binary picks up the stub origin via WITHINGS_API_BASE and the token via the HOME-rooted config path. With this fixture in place the bundle's three data-path subtests (JSONIsArray, CSVHasHeader, DefaultIsMarkdown) run with empty-result shapes (`[]` / one-line CSV header / empty markdown) without any network call leaving the runner. go.mod pins github.com/quantcli/common/compat to the v0.0.0-20260511001927-62c4c6634ff5 pseudo-version (post-QUA-21, includes Runner.SupportedFormats; withings declares the full §4 surface so SupportedFormats is left nil). Co-Authored-By: Paperclip --- compat_contract_test.go | 114 ++++++++++++++++++++++++++++++++++++++++ go.mod | 5 +- go.sum | 2 + 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 compat_contract_test.go diff --git a/compat_contract_test.go b/compat_contract_test.go new file mode 100644 index 0000000..8a73ccd --- /dev/null +++ b/compat_contract_test.go @@ -0,0 +1,114 @@ +//go:build compat + +// Compat-test entry point for withings-export-cli. +// +// This file is only compiled under the `compat` build tag, so it does +// not affect the default `go test ./...` run. CI invokes it as +// `go test -tags=compat ./...` after building the export binary and +// exposing its path through WITHINGS_EXPORT_BIN. +// +// The actual assertions live in github.com/quantcli/common/compat. +// Drift between this CLI and CONTRACT.md surfaces as a failure here. +package main_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/quantcli/common/compat" + "github.com/quantcli/common/compat/formats" +) + +func TestContractFormats(t *testing.T) { + bin := os.Getenv("WITHINGS_EXPORT_BIN") + if bin == "" { + t.Skip("WITHINGS_EXPORT_BIN not set; skipping compat suite") + } + + // withings's data path requires a usable OAuth token plus a + // reachable Withings API origin, neither of which a clean CI + // runner has. We stand up a stub that returns the empty Withings + // envelope to every POST and write a never-expired token into a + // scratch HOME so auth.GetToken returns without touching the + // network. The binary picks up the stub origin via + // WITHINGS_API_BASE (added in internal/client) and the token via + // the existing HOME-rooted config path. + stub := newWithingsStub() + t.Cleanup(stub.Close) + home := writeFakeAuthToken(t) + + formats.RunContract(t, compat.Runner{ + Binary: bin, + // withings is cobra-based: --format lives on each + // data-producing subcommand. The compat suite dispatches per + // subcommand under a `subcommand=NAME/...` subtree so any + // regression surfaces as a named subtest failure rather than + // masking the rest. + Subcommands: []string{ + "activity", + "intraday", + "measurements", + "sleep", + "workouts", + }, + Env: []string{ + "HOME=" + home, + "PATH=/usr/bin:/bin", + "TZ=UTC", + "WITHINGS_API_BASE=" + stub.URL, + }, + }) +} + +// newWithingsStub returns an httptest.Server that answers every POST +// with the Withings success envelope and an empty body. The Withings +// shape is `{"status":0,"body":{…}}`; an empty body satisfies each +// subcommand's response struct (slice fields stay nil, `more` stays +// false, the pagination loop exits on the first call), so every codec +// renders the empty-result form: `--format json` → `[]`, `--format csv` +// → a header line, default and `--format markdown` → byte-identical +// (empty) stdout. The stub does not validate the Authorization header — +// the fake token below is just a placeholder so the auth layer reaches +// the HTTP call rather than short-circuiting at `not logged in`. +func newWithingsStub() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":0,"body":{}}`)) + })) +} + +// writeFakeAuthToken seeds a synthetic, never-expired token store at +// HOME/.config/withings-export/auth.json so auth.GetToken returns a +// usable string and the binary proceeds to the HTTP path. Returns the +// HOME directory to set in Runner.Env. +func writeFakeAuthToken(t *testing.T) string { + t.Helper() + home := t.TempDir() + dir := filepath.Join(home, ".config", "withings-export") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir auth dir: %v", err) + } + token := map[string]any{ + "access_token": "compat-fake-access-token", + "refresh_token": "compat-fake-refresh-token", + // Far enough in the future that auth.GetToken takes the + // not-expired branch on every invocation. + "expires_at": time.Now().AddDate(10, 0, 0).Format(time.RFC3339), + "user_id": "0", + "client_id": "compat-fake-client-id", + "client_secret": "compat-fake-client-secret", + } + body, err := json.MarshalIndent(token, "", " ") + if err != nil { + t.Fatalf("marshal fake token: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "auth.json"), body, 0o600); err != nil { + t.Fatalf("write auth.json: %v", err) + } + return home +} diff --git a/go.mod b/go.mod index 83f4797..e991155 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/quantcli/withings-export-cli go 1.25.5 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/quantcli/common/compat v0.0.0-20260511001927-62c4c6634ff5 + github.com/spf13/cobra v1.10.2 +) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index a6ee3e0..ed5f5d2 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/quantcli/common/compat v0.0.0-20260511001927-62c4c6634ff5 h1:NoWh40ITAC6W7YyIp2eM6RyRKLHzXcs8TXUjwoYrlUY= +github.com/quantcli/common/compat v0.0.0-20260511001927-62c4c6634ff5/go.mod h1:VBC/zEphSZgCZS1rhWsR3A8EWYSbTkP/MwqWHL7266s= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= From 1dcf2d299401b129a00a6968804bab989de365d3 Mon Sep 17 00:00:00 2001 From: Terastar-Paperclip <279373186+Terastar-Paperclip@users.noreply.github.com> Date: Sun, 10 May 2026 20:28:48 -0400 Subject: [PATCH 3/3] ci: add baseline + compat-conformance workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withings-export-cli did not have a CI workflow for `go vet` / `go test` or for the compat-test bundle from quantcli/common. Adding the same two-job shape used by crono-export-cli (PR quantcli/crono-export-cli#31): a `go` job that runs `go vet` and `go test` on every PR, and a `compat` job that builds the binary, points WITHINGS_EXPORT_BIN at it, and runs `go test -tags=compat -run TestContractFormats ./...` against the common-module §4 bundle. Co-Authored-By: Paperclip --- .github/workflows/ci.yml | 47 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6b63eb1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + go: + name: go vet + test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: go vet + run: go vet ./... + - name: go test + run: go test ./... + + compat: + name: compat (CONTRACT machine-attestation) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: resolve compat dependency + # The compat module lives at github.com/quantcli/common/compat + # under a subpath. `go mod tidy` materializes go.sum for it on + # first run; subsequent runs are cache-served. + run: go mod tidy + - name: build withings-export + run: go build -o /tmp/withings-export . + - name: run compat suite + env: + # Path the compat-tagged test reads from os.Getenv. + WITHINGS_EXPORT_BIN: /tmp/withings-export + run: go test -tags=compat -run TestContractFormats ./...