|
| 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 | +} |
0 commit comments