Skip to content

Commit db971c8

Browse files
Terastar-PaperclipTerastar-PaperclipPaperclip-Paperclip
authored
test(qua-37): cronoclient + CLI binary E2E smoke (Track A) (#45)
* test(qua-37): add cronoclient + CLI binary E2E smoke against httptest fake cronoapi has full storyboard tests, but cronoclient and the cobra binary had zero coverage post Phase 3b. Track A of the QUA-37 release gate: - internal/cronotest: shared permissive httptest fake (login + GWT-RPC + /export) reusable across cronoclient unit tests and binary E2E. - internal/cronoclient/client_test.go: drives all five export methods (Servings/Exercises/Biometrics/Nutrition/Notes) through the wrapper and through a real login/logout round-trip; pins shape of the typed records and the CSV->[]map[string]string conversion. - cmd/clie2e_test.go: builds the binary in TestMain and execs it against the fake; covers nutrition --format json, nutrition --format markdown, servings --format json, --format xml (rejected), auth status (no creds), and prime (no network). - cronoclient.NewLoggedIn honors CRONOMETER_BASE_URL so the binary E2E can point at httptest. Production users never set it. Track B (manual real-account probe) still required before v1.1.0 -- the fake validates wire shape we *believe* we authored, not what cronometer.com actually serves today. Refs QUA-37. Co-Authored-By: Paperclip <noreply@paperclip.ing> * test(qua-37): isolate session cache in CLI E2E subprocesses The cmd/clie2e_test.go runCLI helper inherited os.Environ() directly, so each subprocess wrote to the developer's real session cache (~/.cache/crono-export/session.json on Linux, ~/Library/Caches on macOS, %LocalAppData% on Windows). That polluted dev caches with fake "alice@example.com" tokens and let a pre-existing real session mask the fake-login path the tests intend to exercise. Mirror session_test.go's redirect: per-test t.TempDir() with HOME, XDG_CACHE_HOME, and LOCALAPPDATA overridden, applied before the caller's env so a test can still override if it ever needs to. Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Terastar-Paperclip <leadgoengineer+terastar-paperclip@quantcli.dev> Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent f250b34 commit db971c8

4 files changed

Lines changed: 594 additions & 0 deletions

File tree

cmd/clie2e_test.go

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package cmd_test
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"runtime"
10+
"strings"
11+
"testing"
12+
13+
"github.com/quantcli/crono-export-cli/internal/cronotest"
14+
)
15+
16+
// binPath is the compiled crono-export binary; built once in TestMain
17+
// so individual test cases can exec it as a subprocess.
18+
var binPath string
19+
20+
func TestMain(m *testing.M) {
21+
tmpDir, err := os.MkdirTemp("", "crono-export-e2e")
22+
if err != nil {
23+
panic(err)
24+
}
25+
defer os.RemoveAll(tmpDir)
26+
27+
exe := "crono-export"
28+
if runtime.GOOS == "windows" {
29+
exe += ".exe"
30+
}
31+
binPath = filepath.Join(tmpDir, exe)
32+
33+
// Build from the repo root (parent of cmd/).
34+
build := exec.Command("go", "build", "-o", binPath, "..")
35+
build.Stdout = os.Stdout
36+
build.Stderr = os.Stderr
37+
if err := build.Run(); err != nil {
38+
panic(err)
39+
}
40+
os.Exit(m.Run())
41+
}
42+
43+
func runCLI(t *testing.T, env []string, args ...string) (stdout, stderr string, exitCode int) {
44+
t.Helper()
45+
// Redirect every cache-dir env var the binary might consult to a
46+
// per-test temp dir, mirroring session_test.go's in-process
47+
// redirect. Without this the subprocess inherits the developer's
48+
// real HOME/XDG_CACHE_HOME/LOCALAPPDATA and can write a fake
49+
// session into ~/.cache/crono-export/, clobbering the real cache
50+
// and letting a pre-existing real session mask the fake-login path.
51+
cacheDir := t.TempDir()
52+
isolation := []string{
53+
"HOME=" + cacheDir,
54+
"XDG_CACHE_HOME=" + cacheDir,
55+
"LOCALAPPDATA=" + cacheDir,
56+
}
57+
cmd := exec.Command(binPath, args...)
58+
cmd.Env = append(append(os.Environ(), isolation...), env...)
59+
var sout, serr bytes.Buffer
60+
cmd.Stdout = &sout
61+
cmd.Stderr = &serr
62+
err := cmd.Run()
63+
if err != nil {
64+
if ee, ok := err.(*exec.ExitError); ok {
65+
return sout.String(), serr.String(), ee.ExitCode()
66+
}
67+
t.Fatalf("exec %s: %v", binPath, err)
68+
}
69+
return sout.String(), serr.String(), 0
70+
}
71+
72+
// fakeEnv returns the env-var overrides that point the CLI at a
73+
// cronotest.Fake — credentials are placeholders since the fake accepts
74+
// anything that round-trips the CSRF token.
75+
func fakeEnv(f *cronotest.Fake) []string {
76+
return []string{
77+
"CRONOMETER_USERNAME=alice@example.com",
78+
"CRONOMETER_PASSWORD=p@ssw0rd",
79+
"CRONOMETER_BASE_URL=" + f.URL(),
80+
}
81+
}
82+
83+
func TestCLI_AuthStatus_NoCreds_Fails(t *testing.T) {
84+
// auth status is a local check; no network. With empty env it must
85+
// exit non-zero.
86+
_, stderr, code := runCLI(t,
87+
[]string{"CRONOMETER_USERNAME=", "CRONOMETER_PASSWORD="},
88+
"auth", "status",
89+
)
90+
if code == 0 {
91+
t.Fatalf("auth status with no creds should exit non-zero (stderr=%q)", stderr)
92+
}
93+
if !strings.Contains(stderr, "CRONOMETER_USERNAME") {
94+
t.Errorf("stderr should mention missing env var; got %q", stderr)
95+
}
96+
}
97+
98+
func TestCLI_Nutrition_JSON_E2E(t *testing.T) {
99+
f := cronotest.New()
100+
defer f.Close()
101+
f.DailySummaryCSV = "Date,Calories,Protein\n2026-05-04,1800,90\n2026-05-05,2000,110\n"
102+
103+
stdout, stderr, code := runCLI(t, fakeEnv(f),
104+
"nutrition",
105+
"--since", "2026-05-04",
106+
"--until", "2026-05-11",
107+
"--format", "json",
108+
)
109+
if code != 0 {
110+
t.Fatalf("exit=%d stderr=%q", code, stderr)
111+
}
112+
var rows []map[string]any
113+
if err := json.Unmarshal([]byte(stdout), &rows); err != nil {
114+
t.Fatalf("stdout is not valid JSON: %v\n--- stdout ---\n%s", err, stdout)
115+
}
116+
if len(rows) != 2 {
117+
t.Fatalf("got %d rows, want 2: %+v", len(rows), rows)
118+
}
119+
if rows[0]["Calories"] != 1800.0 || rows[1]["Protein"] != 110.0 {
120+
t.Errorf("rows = %+v", rows)
121+
}
122+
}
123+
124+
func TestCLI_Nutrition_Markdown_E2E(t *testing.T) {
125+
f := cronotest.New()
126+
defer f.Close()
127+
f.DailySummaryCSV = "Date,Calories,Protein\n2026-05-04,1800,90\n"
128+
129+
stdout, stderr, code := runCLI(t, fakeEnv(f),
130+
"nutrition",
131+
"--since", "2026-05-04",
132+
"--until", "2026-05-04",
133+
"--format", "markdown",
134+
)
135+
if code != 0 {
136+
t.Fatalf("exit=%d stderr=%q", code, stderr)
137+
}
138+
if !strings.Contains(stdout, "## 2026-05-04") {
139+
t.Errorf("expected date header in markdown; got %q", stdout)
140+
}
141+
if !strings.Contains(stdout, "Calories: 1800") || !strings.Contains(stdout, "Protein: 90") {
142+
t.Errorf("expected nutrient bullets in markdown; got %q", stdout)
143+
}
144+
}
145+
146+
func TestCLI_Servings_JSON_E2E(t *testing.T) {
147+
f := cronotest.New()
148+
defer f.Close()
149+
f.ServingsCSV = strings.Join([]string{
150+
`Day,Group,Food Name,Amount,Energy (kcal),Protein (g)`,
151+
`2026-05-04,Breakfast,Apple,150 g,78,0.4`,
152+
}, "\n")
153+
154+
stdout, stderr, code := runCLI(t, fakeEnv(f),
155+
"servings",
156+
"--since", "2026-05-04",
157+
"--until", "2026-05-04",
158+
"--format", "json",
159+
)
160+
if code != 0 {
161+
t.Fatalf("exit=%d stderr=%q", code, stderr)
162+
}
163+
var recs []map[string]any
164+
if err := json.Unmarshal([]byte(stdout), &recs); err != nil {
165+
t.Fatalf("stdout is not valid JSON: %v\n--- stdout ---\n%s", err, stdout)
166+
}
167+
if len(recs) != 1 {
168+
t.Fatalf("got %d records, want 1", len(recs))
169+
}
170+
if recs[0]["FoodName"] != "Apple" {
171+
t.Errorf("recs[0].FoodName = %v, want Apple", recs[0]["FoodName"])
172+
}
173+
}
174+
175+
func TestCLI_BadFormat_Exits1(t *testing.T) {
176+
f := cronotest.New()
177+
defer f.Close()
178+
179+
_, stderr, code := runCLI(t, fakeEnv(f),
180+
"nutrition",
181+
"--since", "2026-05-04",
182+
"--until", "2026-05-04",
183+
"--format", "xml",
184+
)
185+
if code == 0 {
186+
t.Fatalf("--format xml should fail; stderr=%q", stderr)
187+
}
188+
if !strings.Contains(stderr, "unknown --format") {
189+
t.Errorf("stderr should mention --format; got %q", stderr)
190+
}
191+
}
192+
193+
func TestCLI_Prime_NoNetwork(t *testing.T) {
194+
// `prime` is a local orientation dump per CONTRACT §6; it must not
195+
// require credentials or hit the network.
196+
stdout, stderr, code := runCLI(t,
197+
[]string{"CRONOMETER_USERNAME=", "CRONOMETER_PASSWORD="},
198+
"prime",
199+
)
200+
if code != 0 {
201+
t.Fatalf("prime should succeed without creds; exit=%d stderr=%q", code, stderr)
202+
}
203+
if !strings.Contains(stdout, "crono-export") {
204+
t.Errorf("prime output should mention the binary name; got %q", stdout)
205+
}
206+
}

internal/cronoclient/client.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,20 @@ type Client struct {
3535
// case — even with a cache hit we hold the password so the wrapper
3636
// can transparently re-login if the cached session turns out to be
3737
// stale.
38+
//
39+
// If CRONOMETER_BASE_URL is set, it overrides the production Cronometer
40+
// host. This is intended for the agent-runnable E2E tests in this repo;
41+
// real users never need to set it.
3842
func NewLoggedIn(ctx context.Context) (*Client, error) {
3943
user := os.Getenv("CRONOMETER_USERNAME")
4044
pass := os.Getenv("CRONOMETER_PASSWORD")
4145
if user == "" || pass == "" {
4246
return nil, fmt.Errorf("CRONOMETER_USERNAME and CRONOMETER_PASSWORD must be set")
4347
}
4448
inner := cronoapi.NewClient(nil)
49+
if base := os.Getenv("CRONOMETER_BASE_URL"); base != "" {
50+
inner.SetBaseURL(base)
51+
}
4552
c := &Client{inner: inner, user: user, pass: pass}
4653

4754
if cacheEnabled() {

0 commit comments

Comments
 (0)