package main // Tests for `carlos economics bill` (Task 15): pure parsing/validation // first (no network), then the apiSession wire shape against an httptest // fake console, then one subprocess happy-path drive of the real binary — // restart_test.go's own three-layer shape. import ( "context" "encoding/json" "math" "net/http" "net/http/httptest" "os/exec" "strings" "testing" ) // ---- pure parsing/validation ------------------------------------------------ func TestServiceLinesSet(t *testing.T) { lines := serviceLines{} if err := lines.Set("AmazonEC2=55.00"); err != nil { t.Fatalf("Set(AmazonEC2=55.00) = %v, want nil", err) } if got := lines["AmazonEC2"]; got != 55.00 { t.Fatalf("lines[AmazonEC2] = %v, want 55.00", got) } for _, bad := range []string{"NoEquals", "X=notanumber", "X=-5", "X=NaN"} { lines := serviceLines{} if err := lines.Set(bad); err == nil { t.Errorf("Set(%q) = nil, want an error", bad) } } } func TestServiceLinesSetMultipleAccumulate(t *testing.T) { lines := serviceLines{} if err := lines.Set("AmazonEC2=55.00"); err != nil { t.Fatal(err) } if err := lines.Set("AmazonS3=12.30"); err != nil { t.Fatal(err) } if len(lines) != 2 || lines["AmazonEC2"] != 55.00 || lines["AmazonS3"] != 12.30 { t.Fatalf("lines = %+v, want both entries", lines) } } func TestValidMonth(t *testing.T) { if err := validMonth("2026-08"); err != nil { t.Errorf("validMonth(2026-08) = %v, want nil", err) } for _, bad := range []string{"2026-8", "2026-13", "garbage", ""} { if err := validMonth(bad); err == nil { t.Errorf("validMonth(%q) = nil, want an error", bad) } } } func TestValidAmount(t *testing.T) { for _, good := range []float64{0, 55, 123.45} { if err := validAmount(good); err != nil { t.Errorf("validAmount(%v) = %v, want nil", good, err) } } for _, bad := range []float64{math.NaN(), math.Inf(1), math.Inf(-1), -5} { if err := validAmount(bad); err == nil { t.Errorf("validAmount(%v) = nil, want an error", bad) } } } // ---- apiSession-level: wire shape against a fake console ------------------- // TestAPISessionEconomicsBillWireShape pins what handleAPIEconomicsBill // requires: a bearer POST to /api/economics/bill with month/total_usd/ // services in the body, decoding the console's own echoed bill back. func TestAPISessionEconomicsBillWireShape(t *testing.T) { var gotPath, gotMethod, gotAuth string var gotBody struct { Month string `json:"month"` TotalUSD float64 `json:"total_usd"` Services map[string]float64 `json:"services"` } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") json.NewDecoder(r.Body).Decode(&gotBody) json.NewEncoder(w).Encode(map[string]any{ "month": "2026-08", "total_usd": 123.45, "services": map[string]float64{"AmazonEC2": 55.0}, "source": "manual", }) })) defer ts.Close() res, err := newTestSession(ts.URL).economicsBill(context.Background(), "2026-08", 123.45, map[string]float64{"AmazonEC2": 55.0}) if err != nil { t.Fatalf("economicsBill: %v", err) } if gotMethod != http.MethodPost { t.Fatalf("method = %s, want POST", gotMethod) } if gotPath != "/api/economics/bill" { t.Fatalf("path = %s", gotPath) } if gotAuth != "Bearer cli-token" { t.Fatalf("auth = %q", gotAuth) } if gotBody.Month != "2026-08" || gotBody.TotalUSD != 123.45 || gotBody.Services["AmazonEC2"] != 55.0 { t.Fatalf("request body = %+v", gotBody) } if res.Month != "2026-08" || res.TotalUSD != 123.45 || res.Services["AmazonEC2"] != 55.0 || res.Source != "manual" { t.Fatalf("result = %+v, want the console's echoed bill", res) } } // TestAPISessionEconomicsBillNotFoundNamesOperatorBit: handleAPIEconomicsBill // answers a non-operator's token with the same bare 404 an unknown route // gets — notFoundErr's own "-account and -app" text does not fit this // door at all, so this must be its own message. func TestAPISessionEconomicsBillNotFoundNamesOperatorBit(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer ts.Close() _, err := newTestSession(ts.URL).economicsBill(context.Background(), "2026-08", 1, nil) if err == nil { t.Fatal("a 404 must error") } if !strings.Contains(err.Error(), "operator") { t.Fatalf("error %q does not name the operator-bit cause", err) } if strings.Contains(err.Error(), "-account") { t.Fatalf("error %q reuses notFoundErr's --account/--app text, which does not apply to this door", err) } } // TestAPISessionEconomicsBillServerErrorSurfacesVerbatim: a real diagnosis // (a bad month, a CAS conflict) reaches the terminal in the console's own // words. func TestAPISessionEconomicsBillServerErrorSurfacesVerbatim(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(map[string]string{"error": "someone else saved a bill for this month — try again"}) })) defer ts.Close() _, err := newTestSession(ts.URL).economicsBill(context.Background(), "2026-08", 1, nil) if err == nil || !strings.Contains(err.Error(), "try again") { t.Fatalf("economicsBill 409 = %v, want the console's own text", err) } } // ---- subprocess: bad arguments --------------------------------------------- // TestEconomicsVerbRefusals drives the real binary for the ways the verb // stops before it ever dials anything: no sub-verb, an unknown sub-verb, a // missing/malformed -month, a missing -total, and direct bucket mode. Every // run goes through carlosTestEnv with a scratch cmd.Dir — the repo rule — // so a logged-in laptop's real credential and console are never what this // test exercises. func TestEconomicsVerbRefusals(t *testing.T) { bin := buildCarlos(t) scratch := t.TempDir() run := func(env []string, args ...string) (string, error) { t.Helper() cmd := exec.Command(bin, args...) cmd.Env = carlosTestEnv(t, env...) cmd.Dir = scratch out, err := cmd.CombinedOutput() return string(out), err } out, err := run(nil, "economics") if err == nil { t.Fatalf("economics with no sub-verb exited 0:\n%s", out) } if !strings.Contains(out, "usage: carlos economics bill") { t.Fatalf("economics with no sub-verb = %q, want the usage string", out) } out, err = run(nil, "economics", "scenario") if err == nil { t.Fatalf("economics with an unknown sub-verb exited 0:\n%s", out) } if !strings.Contains(out, "usage: carlos economics bill") { t.Fatalf("economics with an unknown sub-verb = %q, want the usage string", out) } out, err = run(nil, "economics", "bill", "-total", "1") if err == nil { t.Fatalf("bill with no --month exited 0:\n%s", out) } if !strings.Contains(out, "usage: carlos economics bill") { t.Fatalf("bill with no --month = %q, want the usage string", out) } out, err = run(nil, "economics", "bill", "-month", "2026-08") if err == nil { t.Fatalf("bill with no -total exited 0:\n%s", out) } if !strings.Contains(out, "usage: carlos economics bill") { t.Fatalf("bill with no -total = %q, want the usage string", out) } out, err = run(nil, "economics", "bill", "-month", "2026-8", "-total", "1") if err == nil { t.Fatalf("bill with a malformed --month exited 0:\n%s", out) } if !strings.Contains(out, "YYYY-MM") { t.Fatalf("bill with a malformed --month = %q, want the month-shape complaint", out) } out, err = run(nil, "economics", "bill", "-month", "2026-08", "-total", "-5") if err == nil { t.Fatalf("bill with a negative -total exited 0:\n%s", out) } if !strings.Contains(out, "negative") { t.Fatalf("bill with a negative -total = %q, want the negative-amount complaint", out) } out, err = run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "economics", "bill", "-month", "2026-08", "-total", "1") if err == nil { t.Fatalf("bill in direct bucket mode exited 0:\n%s", out) } if !strings.Contains(out, "carlos economics bill works through a console") || !strings.Contains(out, "carlos auth login") { t.Fatalf("direct-mode refusal = %q, want economics bill's own message and the login fix", out) } } // ---- subprocess: happy path ------------------------------------------------- // TestEconomicsBillCLIHappyPath drives `carlos economics bill` end to end // against a fake console (credentials written straight to the path // carlosTestEnv designates, the same shortcut fleets_cli_test.go's // TestRunFleetsCreateHitsRightPathWithBody takes to avoid a real login // round trip) and checks the printed output names the service line and the // total. func TestEconomicsBillCLIHappyPath(t *testing.T) { bin := buildCarlos(t) scratch := t.TempDir() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/economics/bill" || r.Method != http.MethodPost { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "month": "2026-08", "total_usd": 123.45, "services": map[string]float64{"AmazonEC2": 55.0}, "source": "manual", }) })) defer ts.Close() env := carlosTestEnv(t) writeTestCredential(t, envSliceValue(env, "CARLOS_CREDENTIALS"), ts.URL) cmd := exec.Command(bin, "economics", "bill", "-month", "2026-08", "-total", "123.45", "-line", "AmazonEC2=55.00") cmd.Env = env cmd.Dir = scratch out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("economics bill: %v\n%s", err, out) } got := string(out) if !strings.Contains(got, "AmazonEC2") || !strings.Contains(got, "55.00") { t.Fatalf("stdout = %q, want the AmazonEC2 service line", got) } if !strings.Contains(got, "123.45") { t.Fatalf("stdout = %q, want the total", got) } } // envSliceValue returns the value of the last occurrence of key=... in env, // or "" if absent — used only to recover the CARLOS_CREDENTIALS path // carlosTestEnv picked, so a credential can be written to the exact spot the // subprocess will look, without adding any env var of this test's own // invention. Distinct from scopedkeys_e2e_test.go's envValue (same package), // which pulls VAR=value out of a verb's STDOUT, not a []string env slice. func envSliceValue(env []string, key string) string { prefix := key + "=" for i := len(env) - 1; i >= 0; i-- { if strings.HasPrefix(env[i], prefix) { return strings.TrimPrefix(env[i], prefix) } } return "" }