carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// Tests for `carlos economics bill` (Task 15): pure parsing/validation
4// first (no network), then the apiSession wire shape against an httptest
5// fake console, then one subprocess happy-path drive of the real binary —
6// restart_test.go's own three-layer shape.
7
8import (
9 "context"
10 "encoding/json"
11 "math"
12 "net/http"
13 "net/http/httptest"
14 "os/exec"
15 "strings"
16 "testing"
17)
18
19// ---- pure parsing/validation ------------------------------------------------
20
21func TestServiceLinesSet(t *testing.T) {
22 lines := serviceLines{}
23 if err := lines.Set("AmazonEC2=55.00"); err != nil {
24 t.Fatalf("Set(AmazonEC2=55.00) = %v, want nil", err)
25 }
26 if got := lines["AmazonEC2"]; got != 55.00 {
27 t.Fatalf("lines[AmazonEC2] = %v, want 55.00", got)
28 }
29
30 for _, bad := range []string{"NoEquals", "X=notanumber", "X=-5", "X=NaN"} {
31 lines := serviceLines{}
32 if err := lines.Set(bad); err == nil {
33 t.Errorf("Set(%q) = nil, want an error", bad)
34 }
35 }
36}
37
38func TestServiceLinesSetMultipleAccumulate(t *testing.T) {
39 lines := serviceLines{}
40 if err := lines.Set("AmazonEC2=55.00"); err != nil {
41 t.Fatal(err)
42 }
43 if err := lines.Set("AmazonS3=12.30"); err != nil {
44 t.Fatal(err)
45 }
46 if len(lines) != 2 || lines["AmazonEC2"] != 55.00 || lines["AmazonS3"] != 12.30 {
47 t.Fatalf("lines = %+v, want both entries", lines)
48 }
49}
50
51func TestValidMonth(t *testing.T) {
52 if err := validMonth("2026-08"); err != nil {
53 t.Errorf("validMonth(2026-08) = %v, want nil", err)
54 }
55 for _, bad := range []string{"2026-8", "2026-13", "garbage", ""} {
56 if err := validMonth(bad); err == nil {
57 t.Errorf("validMonth(%q) = nil, want an error", bad)
58 }
59 }
60}
61
62func TestValidAmount(t *testing.T) {
63 for _, good := range []float64{0, 55, 123.45} {
64 if err := validAmount(good); err != nil {
65 t.Errorf("validAmount(%v) = %v, want nil", good, err)
66 }
67 }
68 for _, bad := range []float64{math.NaN(), math.Inf(1), math.Inf(-1), -5} {
69 if err := validAmount(bad); err == nil {
70 t.Errorf("validAmount(%v) = nil, want an error", bad)
71 }
72 }
73}
74
75// ---- apiSession-level: wire shape against a fake console -------------------
76
77// TestAPISessionEconomicsBillWireShape pins what handleAPIEconomicsBill
78// requires: a bearer POST to /api/economics/bill with month/total_usd/
79// services in the body, decoding the console's own echoed bill back.
80func TestAPISessionEconomicsBillWireShape(t *testing.T) {
81 var gotPath, gotMethod, gotAuth string
82 var gotBody struct {
83 Month string `json:"month"`
84 TotalUSD float64 `json:"total_usd"`
85 Services map[string]float64 `json:"services"`
86 }
87 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88 gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
89 json.NewDecoder(r.Body).Decode(&gotBody)
90 json.NewEncoder(w).Encode(map[string]any{
91 "month": "2026-08", "total_usd": 123.45,
92 "services": map[string]float64{"AmazonEC2": 55.0}, "source": "manual",
93 })
94 }))
95 defer ts.Close()
96
97 res, err := newTestSession(ts.URL).economicsBill(context.Background(), "2026-08", 123.45, map[string]float64{"AmazonEC2": 55.0})
98 if err != nil {
99 t.Fatalf("economicsBill: %v", err)
100 }
101 if gotMethod != http.MethodPost {
102 t.Fatalf("method = %s, want POST", gotMethod)
103 }
104 if gotPath != "/api/economics/bill" {
105 t.Fatalf("path = %s", gotPath)
106 }
107 if gotAuth != "Bearer cli-token" {
108 t.Fatalf("auth = %q", gotAuth)
109 }
110 if gotBody.Month != "2026-08" || gotBody.TotalUSD != 123.45 || gotBody.Services["AmazonEC2"] != 55.0 {
111 t.Fatalf("request body = %+v", gotBody)
112 }
113 if res.Month != "2026-08" || res.TotalUSD != 123.45 || res.Services["AmazonEC2"] != 55.0 || res.Source != "manual" {
114 t.Fatalf("result = %+v, want the console's echoed bill", res)
115 }
116}
117
118// TestAPISessionEconomicsBillNotFoundNamesOperatorBit: handleAPIEconomicsBill
119// answers a non-operator's token with the same bare 404 an unknown route
120// gets — notFoundErr's own "-account and -app" text does not fit this
121// door at all, so this must be its own message.
122func TestAPISessionEconomicsBillNotFoundNamesOperatorBit(t *testing.T) {
123 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
124 http.NotFound(w, r)
125 }))
126 defer ts.Close()
127
128 _, err := newTestSession(ts.URL).economicsBill(context.Background(), "2026-08", 1, nil)
129 if err == nil {
130 t.Fatal("a 404 must error")
131 }
132 if !strings.Contains(err.Error(), "operator") {
133 t.Fatalf("error %q does not name the operator-bit cause", err)
134 }
135 if strings.Contains(err.Error(), "-account") {
136 t.Fatalf("error %q reuses notFoundErr's --account/--app text, which does not apply to this door", err)
137 }
138}
139
140// TestAPISessionEconomicsBillServerErrorSurfacesVerbatim: a real diagnosis
141// (a bad month, a CAS conflict) reaches the terminal in the console's own
142// words.
143func TestAPISessionEconomicsBillServerErrorSurfacesVerbatim(t *testing.T) {
144 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
145 w.WriteHeader(http.StatusConflict)
146 json.NewEncoder(w).Encode(map[string]string{"error": "someone else saved a bill for this month — try again"})
147 }))
148 defer ts.Close()
149
150 _, err := newTestSession(ts.URL).economicsBill(context.Background(), "2026-08", 1, nil)
151 if err == nil || !strings.Contains(err.Error(), "try again") {
152 t.Fatalf("economicsBill 409 = %v, want the console's own text", err)
153 }
154}
155
156// ---- subprocess: bad arguments ---------------------------------------------
157
158// TestEconomicsVerbRefusals drives the real binary for the ways the verb
159// stops before it ever dials anything: no sub-verb, an unknown sub-verb, a
160// missing/malformed -month, a missing -total, and direct bucket mode. Every
161// run goes through carlosTestEnv with a scratch cmd.Dir — the repo rule —
162// so a logged-in laptop's real credential and console are never what this
163// test exercises.
164func TestEconomicsVerbRefusals(t *testing.T) {
165 bin := buildCarlos(t)
166 scratch := t.TempDir()
167 run := func(env []string, args ...string) (string, error) {
168 t.Helper()
169 cmd := exec.Command(bin, args...)
170 cmd.Env = carlosTestEnv(t, env...)
171 cmd.Dir = scratch
172 out, err := cmd.CombinedOutput()
173 return string(out), err
174 }
175
176 out, err := run(nil, "economics")
177 if err == nil {
178 t.Fatalf("economics with no sub-verb exited 0:\n%s", out)
179 }
180 if !strings.Contains(out, "usage: carlos economics bill") {
181 t.Fatalf("economics with no sub-verb = %q, want the usage string", out)
182 }
183
184 out, err = run(nil, "economics", "scenario")
185 if err == nil {
186 t.Fatalf("economics with an unknown sub-verb exited 0:\n%s", out)
187 }
188 if !strings.Contains(out, "usage: carlos economics bill") {
189 t.Fatalf("economics with an unknown sub-verb = %q, want the usage string", out)
190 }
191
192 out, err = run(nil, "economics", "bill", "-total", "1")
193 if err == nil {
194 t.Fatalf("bill with no --month exited 0:\n%s", out)
195 }
196 if !strings.Contains(out, "usage: carlos economics bill") {
197 t.Fatalf("bill with no --month = %q, want the usage string", out)
198 }
199
200 out, err = run(nil, "economics", "bill", "-month", "2026-08")
201 if err == nil {
202 t.Fatalf("bill with no -total exited 0:\n%s", out)
203 }
204 if !strings.Contains(out, "usage: carlos economics bill") {
205 t.Fatalf("bill with no -total = %q, want the usage string", out)
206 }
207
208 out, err = run(nil, "economics", "bill", "-month", "2026-8", "-total", "1")
209 if err == nil {
210 t.Fatalf("bill with a malformed --month exited 0:\n%s", out)
211 }
212 if !strings.Contains(out, "YYYY-MM") {
213 t.Fatalf("bill with a malformed --month = %q, want the month-shape complaint", out)
214 }
215
216 out, err = run(nil, "economics", "bill", "-month", "2026-08", "-total", "-5")
217 if err == nil {
218 t.Fatalf("bill with a negative -total exited 0:\n%s", out)
219 }
220 if !strings.Contains(out, "negative") {
221 t.Fatalf("bill with a negative -total = %q, want the negative-amount complaint", out)
222 }
223
224 out, err = run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "economics", "bill", "-month", "2026-08", "-total", "1")
225 if err == nil {
226 t.Fatalf("bill in direct bucket mode exited 0:\n%s", out)
227 }
228 if !strings.Contains(out, "carlos economics bill works through a console") ||
229 !strings.Contains(out, "carlos auth login") {
230 t.Fatalf("direct-mode refusal = %q, want economics bill's own message and the login fix", out)
231 }
232}
233
234// ---- subprocess: happy path -------------------------------------------------
235
236// TestEconomicsBillCLIHappyPath drives `carlos economics bill` end to end
237// against a fake console (credentials written straight to the path
238// carlosTestEnv designates, the same shortcut fleets_cli_test.go's
239// TestRunFleetsCreateHitsRightPathWithBody takes to avoid a real login
240// round trip) and checks the printed output names the service line and the
241// total.
242func TestEconomicsBillCLIHappyPath(t *testing.T) {
243 bin := buildCarlos(t)
244 scratch := t.TempDir()
245
246 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
247 if r.URL.Path != "/api/economics/bill" || r.Method != http.MethodPost {
248 http.NotFound(w, r)
249 return
250 }
251 w.Header().Set("Content-Type", "application/json")
252 json.NewEncoder(w).Encode(map[string]any{
253 "month": "2026-08", "total_usd": 123.45,
254 "services": map[string]float64{"AmazonEC2": 55.0}, "source": "manual",
255 })
256 }))
257 defer ts.Close()
258
259 env := carlosTestEnv(t)
260 writeTestCredential(t, envSliceValue(env, "CARLOS_CREDENTIALS"), ts.URL)
261
262 cmd := exec.Command(bin, "economics", "bill", "-month", "2026-08", "-total", "123.45", "-line", "AmazonEC2=55.00")
263 cmd.Env = env
264 cmd.Dir = scratch
265 out, err := cmd.CombinedOutput()
266 if err != nil {
267 t.Fatalf("economics bill: %v\n%s", err, out)
268 }
269 got := string(out)
270 if !strings.Contains(got, "AmazonEC2") || !strings.Contains(got, "55.00") {
271 t.Fatalf("stdout = %q, want the AmazonEC2 service line", got)
272 }
273 if !strings.Contains(got, "123.45") {
274 t.Fatalf("stdout = %q, want the total", got)
275 }
276}
277
278// envSliceValue returns the value of the last occurrence of key=... in env,
279// or "" if absent — used only to recover the CARLOS_CREDENTIALS path
280// carlosTestEnv picked, so a credential can be written to the exact spot the
281// subprocess will look, without adding any env var of this test's own
282// invention. Distinct from scopedkeys_e2e_test.go's envValue (same package),
283// which pulls VAR=value out of a verb's STDOUT, not a []string env slice.
284func envSliceValue(env []string, key string) string {
285 prefix := key + "="
286 for i := len(env) - 1; i >= 0; i-- {
287 if strings.HasPrefix(env[i], prefix) {
288 return strings.TrimPrefix(env[i], prefix)
289 }
290 }
291 return ""
292}
293