Skip to content

Commit fe72d4e

Browse files
Terastar-PaperclipPaperclip-Paperclip
andcommitted
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>
1 parent 8613433 commit fe72d4e

4 files changed

Lines changed: 583 additions & 0 deletions

File tree

cmd/clie2e_test.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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+
cmd := exec.Command(binPath, args...)
46+
cmd.Env = append(os.Environ(), env...)
47+
var sout, serr bytes.Buffer
48+
cmd.Stdout = &sout
49+
cmd.Stderr = &serr
50+
err := cmd.Run()
51+
if err != nil {
52+
if ee, ok := err.(*exec.ExitError); ok {
53+
return sout.String(), serr.String(), ee.ExitCode()
54+
}
55+
t.Fatalf("exec %s: %v", binPath, err)
56+
}
57+
return sout.String(), serr.String(), 0
58+
}
59+
60+
// fakeEnv returns the env-var overrides that point the CLI at a
61+
// cronotest.Fake — credentials are placeholders since the fake accepts
62+
// anything that round-trips the CSRF token.
63+
func fakeEnv(f *cronotest.Fake) []string {
64+
return []string{
65+
"CRONOMETER_USERNAME=alice@example.com",
66+
"CRONOMETER_PASSWORD=p@ssw0rd",
67+
"CRONOMETER_BASE_URL=" + f.URL(),
68+
}
69+
}
70+
71+
func TestCLI_AuthStatus_NoCreds_Fails(t *testing.T) {
72+
// auth status is a local check; no network. With empty env it must
73+
// exit non-zero.
74+
_, stderr, code := runCLI(t,
75+
[]string{"CRONOMETER_USERNAME=", "CRONOMETER_PASSWORD="},
76+
"auth", "status",
77+
)
78+
if code == 0 {
79+
t.Fatalf("auth status with no creds should exit non-zero (stderr=%q)", stderr)
80+
}
81+
if !strings.Contains(stderr, "CRONOMETER_USERNAME") {
82+
t.Errorf("stderr should mention missing env var; got %q", stderr)
83+
}
84+
}
85+
86+
func TestCLI_Nutrition_JSON_E2E(t *testing.T) {
87+
f := cronotest.New()
88+
defer f.Close()
89+
f.DailySummaryCSV = "Date,Calories,Protein\n2026-05-04,1800,90\n2026-05-05,2000,110\n"
90+
91+
stdout, stderr, code := runCLI(t, fakeEnv(f),
92+
"nutrition",
93+
"--since", "2026-05-04",
94+
"--until", "2026-05-11",
95+
"--format", "json",
96+
)
97+
if code != 0 {
98+
t.Fatalf("exit=%d stderr=%q", code, stderr)
99+
}
100+
var rows []map[string]string
101+
if err := json.Unmarshal([]byte(stdout), &rows); err != nil {
102+
t.Fatalf("stdout is not valid JSON: %v\n--- stdout ---\n%s", err, stdout)
103+
}
104+
if len(rows) != 2 {
105+
t.Fatalf("got %d rows, want 2: %+v", len(rows), rows)
106+
}
107+
if rows[0]["Calories"] != "1800" || rows[1]["Protein"] != "110" {
108+
t.Errorf("rows = %+v", rows)
109+
}
110+
}
111+
112+
func TestCLI_Nutrition_Markdown_E2E(t *testing.T) {
113+
f := cronotest.New()
114+
defer f.Close()
115+
f.DailySummaryCSV = "Date,Calories,Protein\n2026-05-04,1800,90\n"
116+
117+
stdout, stderr, code := runCLI(t, fakeEnv(f),
118+
"nutrition",
119+
"--since", "2026-05-04",
120+
"--until", "2026-05-04",
121+
"--format", "markdown",
122+
)
123+
if code != 0 {
124+
t.Fatalf("exit=%d stderr=%q", code, stderr)
125+
}
126+
if !strings.Contains(stdout, "## 2026-05-04") {
127+
t.Errorf("expected date header in markdown; got %q", stdout)
128+
}
129+
if !strings.Contains(stdout, "Calories: 1800") || !strings.Contains(stdout, "Protein: 90") {
130+
t.Errorf("expected nutrient bullets in markdown; got %q", stdout)
131+
}
132+
}
133+
134+
func TestCLI_Servings_JSON_E2E(t *testing.T) {
135+
f := cronotest.New()
136+
defer f.Close()
137+
f.ServingsCSV = strings.Join([]string{
138+
`Day,Group,Food Name,Amount,Energy (kcal),Protein (g)`,
139+
`2026-05-04,Breakfast,Apple,150 g,78,0.4`,
140+
}, "\n")
141+
142+
stdout, stderr, code := runCLI(t, fakeEnv(f),
143+
"servings",
144+
"--since", "2026-05-04",
145+
"--until", "2026-05-04",
146+
"--format", "json",
147+
)
148+
if code != 0 {
149+
t.Fatalf("exit=%d stderr=%q", code, stderr)
150+
}
151+
var recs []map[string]any
152+
if err := json.Unmarshal([]byte(stdout), &recs); err != nil {
153+
t.Fatalf("stdout is not valid JSON: %v\n--- stdout ---\n%s", err, stdout)
154+
}
155+
if len(recs) != 1 {
156+
t.Fatalf("got %d records, want 1", len(recs))
157+
}
158+
if recs[0]["FoodName"] != "Apple" {
159+
t.Errorf("recs[0].FoodName = %v, want Apple", recs[0]["FoodName"])
160+
}
161+
}
162+
163+
func TestCLI_BadFormat_Exits1(t *testing.T) {
164+
f := cronotest.New()
165+
defer f.Close()
166+
167+
_, stderr, code := runCLI(t, fakeEnv(f),
168+
"nutrition",
169+
"--since", "2026-05-04",
170+
"--until", "2026-05-04",
171+
"--format", "xml",
172+
)
173+
if code == 0 {
174+
t.Fatalf("--format xml should fail; stderr=%q", stderr)
175+
}
176+
if !strings.Contains(stderr, "unknown --format") {
177+
t.Errorf("stderr should mention --format; got %q", stderr)
178+
}
179+
}
180+
181+
func TestCLI_Prime_NoNetwork(t *testing.T) {
182+
// `prime` is a local orientation dump per CONTRACT §6; it must not
183+
// require credentials or hit the network.
184+
stdout, stderr, code := runCLI(t,
185+
[]string{"CRONOMETER_USERNAME=", "CRONOMETER_PASSWORD="},
186+
"prime",
187+
)
188+
if code != 0 {
189+
t.Fatalf("prime should succeed without creds; exit=%d stderr=%q", code, stderr)
190+
}
191+
if !strings.Contains(stdout, "crono-export") {
192+
t.Errorf("prime output should mention the binary name; got %q", stdout)
193+
}
194+
}

internal/cronoclient/client.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,20 @@ type Client struct {
1919

2020
// NewLoggedIn creates a client and logs in using CRONOMETER_USERNAME and
2121
// CRONOMETER_PASSWORD from the environment.
22+
//
23+
// If CRONOMETER_BASE_URL is set, it overrides the production Cronometer
24+
// host. This is intended for the agent-runnable E2E tests in this repo;
25+
// real users never need to set it.
2226
func NewLoggedIn(ctx context.Context) (*Client, error) {
2327
user := os.Getenv("CRONOMETER_USERNAME")
2428
pass := os.Getenv("CRONOMETER_PASSWORD")
2529
if user == "" || pass == "" {
2630
return nil, fmt.Errorf("CRONOMETER_USERNAME and CRONOMETER_PASSWORD must be set")
2731
}
2832
inner := cronoapi.NewClient(nil)
33+
if base := os.Getenv("CRONOMETER_BASE_URL"); base != "" {
34+
inner.SetBaseURL(base)
35+
}
2936
if err := inner.Login(ctx, user, pass); err != nil {
3037
return nil, fmt.Errorf("login failed: %w", err)
3138
}

0 commit comments

Comments
 (0)