| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/economics" |
| 12 | "github.com/carlosframework/platform/internal/metrics" |
| 13 | "github.com/carlosframework/platform/internal/registry" |
| 14 | "github.com/carlosframework/platform/internal/usage" |
| 15 | ) |
| 16 | |
| 17 | // TestEconomicsOperatorOnly is TestFleetOperatorOnly's shape (server_test.go) |
| 18 | // against /fleet/economics: signed-out and non-operator both get the exact |
| 19 | // 404 an unrouted path gets, an operator gets 200 with the page's content, |
| 20 | // and revoking sends it straight back to that same 404 — the gate this page |
| 21 | // promises is byte-identical to every other operator-only route, never a |
| 22 | // separate "you can't see this" page that would itself confirm the route |
| 23 | // exists. |
| 24 | func TestEconomicsOperatorOnly(t *testing.T) { |
| 25 | fx := newConsoleFixture(t) |
| 26 | |
| 27 | out := newBrowser(t) |
| 28 | statusGhost, _, bodyGhost := get(t, out, fx.ts.URL+"/this-route-does-not-exist") |
| 29 | if statusGhost != http.StatusNotFound { |
| 30 | t.Fatalf("sanity: a missing route = %d, want 404", statusGhost) |
| 31 | } |
| 32 | if status, _, body := get(t, out, fx.ts.URL+"/fleet/economics"); status != http.StatusNotFound || body != bodyGhost { |
| 33 | t.Fatalf("signed-out /fleet/economics = %d %q, want the same 404 as a missing route (%d %q)", status, body, statusGhost, bodyGhost) |
| 34 | } |
| 35 | |
| 36 | // Signed in and owns kass, but is not an operator: still the same 404. |
| 37 | owner := signInPaul(t, fx) |
| 38 | if status, _, body := get(t, owner, fx.ts.URL+"/fleet/economics"); status != http.StatusNotFound || body != bodyGhost { |
| 39 | t.Fatalf("non-operator /fleet/economics = %d %q, want the same 404 as a missing route", status, body) |
| 40 | } |
| 41 | |
| 42 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | status, _, body := get(t, owner, fx.ts.URL+"/fleet/economics") |
| 46 | if status != 200 || !strings.Contains(body, "id=\"econ-data\"") { |
| 47 | t.Errorf("operator economics view: %d %s", status, body) |
| 48 | } |
| 49 | |
| 50 | // Revoked: back to the exact same 404. |
| 51 | if err := fx.st.SetOperator("paul@keymail.dev", false); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | if status, _, body := get(t, owner, fx.ts.URL+"/fleet/economics"); status != http.StatusNotFound || body != bodyGhost { |
| 55 | t.Fatalf("revoked operator /fleet/economics = %d %q, want the same 404", status, body) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // seedEconomicsGolden reuses TestLoadEconomicsGolden's seeding |
| 60 | // (economicsview_test.go): one box ("box1"), one route, an hour of cpu/mem |
| 61 | // samples, and matching edge-traffic for the same route's account — enough |
| 62 | // for LoadEconomics to price a real account and a real box under the |
| 63 | // default scenario. |
| 64 | func seedEconomicsGolden(t *testing.T, fx *consoleFixture) { |
| 65 | t.Helper() |
| 66 | reg, err := registry.Open(fx.regPath) |
| 67 | if err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance", |
| 71 | Status: "ready", App: fixtureApp, Version: "v1"}); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | |
| 75 | date := usage.DayOf(time.Now()) |
| 76 | roll := metrics.DayRollup{V: 1, Box: "box1", Date: date, Hours: map[string]metrics.HourAgg{ |
| 77 | "09": {Units: map[string]map[string]metrics.Agg{ |
| 78 | "carlos-app@a.example.com.service": { |
| 79 | "cpu": {Avg: 0.5, N: 60}, |
| 80 | "mem_bytes": {Avg: 1e9, N: 60}, |
| 81 | }, |
| 82 | }}, |
| 83 | }} |
| 84 | putStoreJSON(t, fx.rel, metrics.DayKey("box1", date), roll) |
| 85 | |
| 86 | putStoreJSON(t, fx.rel, usage.EdgeDayKey("box1", date), usage.EdgeDay{ |
| 87 | V: 1, Box: "box1", Date: date, |
| 88 | Hours: map[string]map[string]usage.EdgeRow{"09": { |
| 89 | "a.example.com": {Account: fixtureAcct, App: fixtureApp, Requests: 4000, BytesOut: 2e9}, |
| 90 | }}, |
| 91 | }) |
| 92 | } |
| 93 | |
| 94 | // TestEconomicsPageContent proves the page renders real numbers from |
| 95 | // LoadEconomics — not just chrome: the coverage label, the box's own label, |
| 96 | // an account name, and the embedded payload every id="econ-data" the |
| 97 | // content test above already looked for by string is the SAME script tag |
| 98 | // this test confirms carries actual JSON. |
| 99 | func TestEconomicsPageContent(t *testing.T) { |
| 100 | fx := newConsoleFixture(t) |
| 101 | seedEconomicsGolden(t, fx) |
| 102 | |
| 103 | c := signInPaul(t, fx) |
| 104 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | |
| 108 | status, _, body := get(t, c, fx.ts.URL+"/fleet/economics") |
| 109 | if status != 200 { |
| 110 | t.Fatalf("/fleet/economics = %d, want 200:\n%s", status, body) |
| 111 | } |
| 112 | for _, want := range []string{"days metered", "box1", fixtureAcct, `id="econ-data"`} { |
| 113 | if !strings.Contains(body, want) { |
| 114 | t.Fatalf("/fleet/economics missing %q in:\n%s", want, body) |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // TestEconomicsPageMTDShowsProjection is the spec's "month-to-date with |
| 120 | // projection" promise (F2, unit-economics final review; corrected post-F2: |
| 121 | // the projection's target denominator is the full current month's length, |
| 122 | // EconomicsView.DaysInMonth — NOT PeriodDays, which on "mtd" is only the |
| 123 | // number of days elapsed so far and would make "projected" collapse to |
| 124 | // plain MTD at full coverage): on "mtd" with at least one covered day, each |
| 125 | // of the three dollar tiles (revenue, cost, profit — never margin, a ratio |
| 126 | // doesn't project) gains a second line naming the word "projected" and |
| 127 | // carrying economics.Project's own figure. Rendered directly through |
| 128 | // templates.ExecuteTemplate against a hand-built EconomicsView (the |
| 129 | // ladder_test.go pattern) rather than a real LoadEconomics sweep, so |
| 130 | // CoveredDays/PeriodDays are exact, known numbers instead of whatever |
| 131 | // today's real calendar date happens to produce. DaysInMonth is still |
| 132 | // derived from the real current date, the same formula buildEconomicsView |
| 133 | // uses, so the expected figure never drifts from what the page itself would |
| 134 | // compute on any given day. |
| 135 | func TestEconomicsPageMTDShowsProjection(t *testing.T) { |
| 136 | now := time.Now().UTC() |
| 137 | daysInMonth := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day() |
| 138 | |
| 139 | view := &EconomicsView{ |
| 140 | Period: "mtd", |
| 141 | CoveredDays: 10, |
| 142 | PeriodDays: 31, |
| 143 | DaysInMonth: daysInMonth, |
| 144 | Accounts: []economics.AccountEconomics{{Account: fixtureAcct, CostUSD: 100, RevenueUSD: 200}}, |
| 145 | } |
| 146 | // No overhead/box-idle line items on the scenario, so the tiles' totals |
| 147 | // are exactly the one account's own numbers: revenue 200, cost 100, |
| 148 | // profit 100 — easy to hand-check economics.Project's daysInMonth/10 |
| 149 | // scale-up against. |
| 150 | p := page{Title: "Fleet economics", CSRF: "tok", Economics: view} |
| 151 | var buf bytes.Buffer |
| 152 | if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil { |
| 153 | t.Fatalf("render economics: %v", err) |
| 154 | } |
| 155 | html := buf.String() |
| 156 | |
| 157 | wantRevenue := usd(economics.Project(200, 10, daysInMonth)) |
| 158 | wantCost := usd(economics.Project(100, 10, daysInMonth)) |
| 159 | wantProfit := usd(economics.Project(100, 10, daysInMonth)) |
| 160 | for _, want := range []string{ |
| 161 | "projected " + wantRevenue + " for the month", |
| 162 | "projected " + wantCost + " for the month", |
| 163 | "projected " + wantProfit + " for the month", |
| 164 | } { |
| 165 | if !strings.Contains(html, want) { |
| 166 | t.Fatalf("mtd page missing %q in:\n%s", want, html) |
| 167 | } |
| 168 | } |
| 169 | if !strings.Contains(html, "projected") { |
| 170 | t.Fatal("mtd page must contain the word \"projected\" (honesty rule)") |
| 171 | } |
| 172 | |
| 173 | // A non-mtd period must show none of it: the projected line only ever |
| 174 | // exists on "mtd" (economics.html's $showProjection guard). |
| 175 | view7d := &EconomicsView{ |
| 176 | Period: "7d", |
| 177 | CoveredDays: 7, |
| 178 | PeriodDays: 7, |
| 179 | Accounts: []economics.AccountEconomics{{Account: fixtureAcct, CostUSD: 100, RevenueUSD: 200}}, |
| 180 | } |
| 181 | p7d := page{Title: "Fleet economics", CSRF: "tok", Economics: view7d} |
| 182 | var buf7d bytes.Buffer |
| 183 | if err := templates.ExecuteTemplate(&buf7d, "economics", p7d); err != nil { |
| 184 | t.Fatalf("render economics (7d): %v", err) |
| 185 | } |
| 186 | if strings.Contains(buf7d.String(), "projected") { |
| 187 | t.Fatalf("?period=7d page must not contain \"projected\":\n%s", buf7d.String()) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // usd mirrors server.go's "usd" template func (same rounding, same sign |
| 192 | // handling) so this test's expected strings are computed the exact way the |
| 193 | // page itself renders them, without reaching into the unexported FuncMap |
| 194 | // closure directly. |
| 195 | func usd(v float64) string { |
| 196 | if v < 0 { |
| 197 | return fmt.Sprintf("-$%.2f", -v) |
| 198 | } |
| 199 | return fmt.Sprintf("$%.2f", v) |
| 200 | } |
| 201 | |
| 202 | // TestEconomicsPageHonestUnmetered: a box with a metrics day rollup but no |
| 203 | // usage/edge day ever written must render "unmetered" for that box's egress |
| 204 | // — never a blank or a fabricated zero. See BoxEconomics.EgressMetered and |
| 205 | // usageSweep.EgressMetered's doc comments (economicspass.go): a box simply |
| 206 | // absent from the edge-boxes listing is unmetered, not "confirmed no |
| 207 | // egress". |
| 208 | func TestEconomicsPageHonestUnmetered(t *testing.T) { |
| 209 | fx := newConsoleFixture(t) |
| 210 | reg, err := registry.Open(fx.regPath) |
| 211 | if err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance", |
| 215 | Status: "ready", App: fixtureApp, Version: "v1"}); err != nil { |
| 216 | t.Fatal(err) |
| 217 | } |
| 218 | |
| 219 | date := usage.DayOf(time.Now()) |
| 220 | roll := metrics.DayRollup{V: 1, Box: "darkbox", Date: date, Hours: map[string]metrics.HourAgg{ |
| 221 | "09": {Units: map[string]map[string]metrics.Agg{ |
| 222 | "carlos-app@a.example.com.service": { |
| 223 | "cpu": {Avg: 0.5, N: 60}, |
| 224 | "mem_bytes": {Avg: 1e9, N: 60}, |
| 225 | }, |
| 226 | }}, |
| 227 | }} |
| 228 | putStoreJSON(t, fx.rel, metrics.DayKey("darkbox", date), roll) |
| 229 | // Deliberately no usage.EdgeDayKey("darkbox", date) object: darkbox has |
| 230 | // metrics but has never written an edge day. |
| 231 | |
| 232 | c := signInPaul(t, fx) |
| 233 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 234 | t.Fatal(err) |
| 235 | } |
| 236 | status, _, body := get(t, c, fx.ts.URL+"/fleet/economics") |
| 237 | if status != 200 { |
| 238 | t.Fatalf("/fleet/economics = %d, want 200:\n%s", status, body) |
| 239 | } |
| 240 | if !strings.Contains(body, "darkbox") { |
| 241 | t.Fatalf("/fleet/economics missing box label %q in:\n%s", "darkbox", body) |
| 242 | } |
| 243 | if !strings.Contains(body, "unmetered") { |
| 244 | t.Fatalf("/fleet/economics missing %q for a box with no usage/edge day:\n%s", "unmetered", body) |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | // economics.DefaultScenario is used implicitly by every test above (no |
| 249 | // active.json seeded) — sanity-checked once here so a future change to it |
| 250 | // that breaks Validate fails loudly at the source, not as a mystery 500 on |
| 251 | // this page. |
| 252 | func TestEconomicsDefaultScenarioValid(t *testing.T) { |
| 253 | sc := economics.DefaultScenario() |
| 254 | if err := sc.Validate(); err != nil { |
| 255 | t.Fatalf("DefaultScenario: %v", err) |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | // TestEconomicsPageBillServicesGuardShape is a structural stand-in for a |
| 260 | // browser test (none exists in this suite — no headless DOM): it greps the |
| 261 | // SERVED page for the exact guard shapes the fix-round review demanded, so |
| 262 | // a future edit that reintroduces either bug fails a test instead of only |
| 263 | // a manual re-review. |
| 264 | // |
| 265 | // Finding 1 (economics.html, fix round 1): the original one-line ternary |
| 266 | // `(boxes.length > 0 && checked.length < boxes.length) ? checked : []` |
| 267 | // wrote [] whenever no bill checkboxes existed on screen — clobbering the |
| 268 | // base scenario's saved bill_services from an unrelated keystroke in the |
| 269 | // manual-bill-entry form sharing #econ-live. The fix wraps the whole |
| 270 | // bill_services branch in `if (boxes.length > 0)`, leaving sc.bill_services |
| 271 | // untouched when there are no checkboxes at all. |
| 272 | // |
| 273 | // Finding 2: an all-unchecked selection is not a representable |
| 274 | // reconciliation state (the empty list already means "count everything"). |
| 275 | // The fix keeps a lastNonEmptyBillServices variable and an inline |
| 276 | // #bill-services-zero-note the script shows exactly when every checkbox is |
| 277 | // unchecked. |
| 278 | func TestEconomicsPageBillServicesGuardShape(t *testing.T) { |
| 279 | fx := newConsoleFixture(t) |
| 280 | seedEconomicsGolden(t, fx) |
| 281 | c := signInPaul(t, fx) |
| 282 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 283 | t.Fatal(err) |
| 284 | } |
| 285 | status, _, body := get(t, c, fx.ts.URL+"/fleet/economics") |
| 286 | if status != 200 { |
| 287 | t.Fatalf("/fleet/economics = %d, want 200:\n%s", status, body) |
| 288 | } |
| 289 | |
| 290 | old := "(boxes.length > 0 && checked.length < boxes.length) ? checked : []" |
| 291 | if strings.Contains(body, old) { |
| 292 | t.Fatalf("/fleet/economics still contains the pre-fix ungated ternary %q — Finding 1 regressed", old) |
| 293 | } |
| 294 | for _, want := range []string{ |
| 295 | // Finding 1: the bill_services branch is gated on boxes existing, |
| 296 | // so a page with no bill on screen never touches sc.bill_services. |
| 297 | `if (boxes.length > 0) {`, |
| 298 | // Finding 2: the last-non-empty-selection fallback and its visible |
| 299 | // note both exist, wired to the all-unchecked branch. |
| 300 | "lastNonEmptyBillServices", |
| 301 | `id="bill-services-zero-note"`, |
| 302 | "sc.bill_services = lastNonEmptyBillServices !== null ? lastNonEmptyBillServices : sc.bill_services;", |
| 303 | "setBillServicesZeroNote(true)", |
| 304 | "setBillServicesZeroNote(false)", |
| 305 | } { |
| 306 | if !strings.Contains(body, want) { |
| 307 | t.Fatalf("/fleet/economics missing guard-shape string %q", want) |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | // The header comment claiming a "Project" transliteration was dropped |
| 312 | // (the script renders no projections) — check the doc-comment line |
| 313 | // itself, not the word in isolation (which could legitimately appear |
| 314 | // elsewhere, e.g. an English sentence using "project" as a verb). |
| 315 | if strings.Contains(body, "AllocateBox/PriceAccount/Project") { |
| 316 | t.Fatalf("/fleet/economics still claims a Project transliteration in its header comment") |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | // TestEconomicsAccountsOperatorOnly is TestEconomicsOperatorOnly's shape, |
| 321 | // cloned against /fleet/economics/accounts (Task 14): signed-out and |
| 322 | // non-operator both get the exact 404 an unrouted path gets, an operator |
| 323 | // gets 200 with the page's content, and revoking sends it straight back to |
| 324 | // that same 404. |
| 325 | func TestEconomicsAccountsOperatorOnly(t *testing.T) { |
| 326 | fx := newConsoleFixture(t) |
| 327 | |
| 328 | out := newBrowser(t) |
| 329 | statusGhost, _, bodyGhost := get(t, out, fx.ts.URL+"/this-route-does-not-exist") |
| 330 | if statusGhost != http.StatusNotFound { |
| 331 | t.Fatalf("sanity: a missing route = %d, want 404", statusGhost) |
| 332 | } |
| 333 | if status, _, body := get(t, out, fx.ts.URL+"/fleet/economics/accounts"); status != http.StatusNotFound || body != bodyGhost { |
| 334 | t.Fatalf("signed-out /fleet/economics/accounts = %d %q, want the same 404 as a missing route (%d %q)", status, body, statusGhost, bodyGhost) |
| 335 | } |
| 336 | |
| 337 | // Signed in and owns kass, but is not an operator: still the same 404. |
| 338 | owner := signInPaul(t, fx) |
| 339 | if status, _, body := get(t, owner, fx.ts.URL+"/fleet/economics/accounts"); status != http.StatusNotFound || body != bodyGhost { |
| 340 | t.Fatalf("non-operator /fleet/economics/accounts = %d %q, want the same 404 as a missing route", status, body) |
| 341 | } |
| 342 | |
| 343 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 344 | t.Fatal(err) |
| 345 | } |
| 346 | status, _, body := get(t, owner, fx.ts.URL+"/fleet/economics/accounts") |
| 347 | if status != 200 || !strings.Contains(body, "id=\"econ-data\"") { |
| 348 | t.Errorf("operator economics accounts view: %d %s", status, body) |
| 349 | } |
| 350 | |
| 351 | // Revoked: back to the exact same 404. |
| 352 | if err := fx.st.SetOperator("paul@keymail.dev", false); err != nil { |
| 353 | t.Fatal(err) |
| 354 | } |
| 355 | if status, _, body := get(t, owner, fx.ts.URL+"/fleet/economics/accounts"); status != http.StatusNotFound || body != bodyGhost { |
| 356 | t.Fatalf("revoked operator /fleet/economics/accounts = %d %q, want the same 404", status, body) |
| 357 | } |
| 358 | } |
| 359 | |
| 360 | // TestEconomicsAccountsPageContent proves the page renders one real |
| 361 | // account row from LoadEconomics, joined with its own meters: seeds the |
| 362 | // same golden fixture as TestEconomicsPageContent (one box, one route, 2GB |
| 363 | // egress over 4000 requests for fixtureAcct — DefaultScenario's free tier |
| 364 | // fully covers that egress, so PriceAccount marks the row "partial", per |
| 365 | // PriceAccount's own clipped rule: any one meter benefiting from the free |
| 366 | // tier is enough, independent of the others). Asserts the account name, its |
| 367 | // egress-GB meter cell (2.00, exactly the seeded 2e9 bytes), and the |
| 368 | // free-tier chip all render. |
| 369 | func TestEconomicsAccountsPageContent(t *testing.T) { |
| 370 | fx := newConsoleFixture(t) |
| 371 | seedEconomicsGolden(t, fx) |
| 372 | |
| 373 | // Task 5 (tier-aware revenue): an untiered account is pool-tier by |
| 374 | // default and renders FreeTierPos "pooled", not a priced position — |
| 375 | // this test wants the fixture priced normally to assert a real |
| 376 | // "partial" chip, so it needs the fixture account tiered "hosted". |
| 377 | // Same sanctioned pattern as economicsview_test.go's |
| 378 | // TestLoadEconomicsGolden (economics-v2 plan, Task 5); no production |
| 379 | // behavior change. |
| 380 | if err := fx.st.SetAccountTier(OpsAccountID, "hosted"); err != nil { |
| 381 | t.Fatal(err) |
| 382 | } |
| 383 | |
| 384 | c := signInPaul(t, fx) |
| 385 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 386 | t.Fatal(err) |
| 387 | } |
| 388 | |
| 389 | status, _, body := get(t, c, fx.ts.URL+"/fleet/economics/accounts") |
| 390 | if status != 200 { |
| 391 | t.Fatalf("/fleet/economics/accounts = %d, want 200:\n%s", status, body) |
| 392 | } |
| 393 | for _, want := range []string{ |
| 394 | fixtureAcct, |
| 395 | ">2.00<", // the egress-GB meter cell (2e9 bytes seeded) |
| 396 | `class="chip chip-freetier-partial"`, // the free-tier chip, this fixture's position |
| 397 | `id="econ-data"`, |
| 398 | } { |
| 399 | if !strings.Contains(body, want) { |
| 400 | t.Fatalf("/fleet/economics/accounts missing %q in:\n%s", want, body) |
| 401 | } |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | // TestEconomicsAccountsPageRendersPooledChip is the fix-round regression for |
| 406 | // IMPORTANT 2: "pooled" (Task 5's fourth FreeTierPos word) must render as a |
| 407 | // first-class chip on the accounts page, both on the row itself and as a |
| 408 | // countable header filter chip — the fixture account here is deliberately |
| 409 | // left UNTIERED (the default "" tier, pool semantics), so its row lands in |
| 410 | // "pooled" rather than a priced position, same as a fresh console with no |
| 411 | // tiers placed yet. |
| 412 | func TestEconomicsAccountsPageRendersPooledChip(t *testing.T) { |
| 413 | fx := newConsoleFixture(t) |
| 414 | seedEconomicsGolden(t, fx) |
| 415 | |
| 416 | c := signInPaul(t, fx) |
| 417 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 418 | t.Fatal(err) |
| 419 | } |
| 420 | |
| 421 | status, _, body := get(t, c, fx.ts.URL+"/fleet/economics/accounts") |
| 422 | if status != 200 { |
| 423 | t.Fatalf("/fleet/economics/accounts = %d, want 200:\n%s", status, body) |
| 424 | } |
| 425 | for _, want := range []string{ |
| 426 | `class="chip chip-freetier-pooled"`, // the row's own free-tier chip |
| 427 | ">pooled 1<", // the header filter chip, count 1 |
| 428 | } { |
| 429 | if !strings.Contains(body, want) { |
| 430 | t.Fatalf("/fleet/economics/accounts missing %q in:\n%s", want, body) |
| 431 | } |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | // TestEconomicsAccountsPageThreadsPeriod is the fix-round regression for |
| 436 | // the silent-wrong-data bug: every list control on this page (search form, |
| 437 | // pos-filter chips, period nav) must carry the SAME period the rows on |
| 438 | // screen were priced under. Seeds the golden fixture inside LAST calendar |
| 439 | // month's window specifically (economicsPeriodBounds("last", now)) — |
| 440 | // same shape as seedEconomicsGolden, just dated a month back — so |
| 441 | // ?period=last&pos=partial has a genuine matching row (this fixture's |
| 442 | // egress is fully free-tier-covered under DefaultScenario, same reasoning |
| 443 | // as TestEconomicsAccountsPageContent) and renders a real "partial" chip, |
| 444 | // rather than asserting only on chrome that happens to render with zero |
| 445 | // matches. |
| 446 | func TestEconomicsAccountsPageThreadsPeriod(t *testing.T) { |
| 447 | fx := newConsoleFixture(t) |
| 448 | |
| 449 | reg, err := registry.Open(fx.regPath) |
| 450 | if err != nil { |
| 451 | t.Fatal(err) |
| 452 | } |
| 453 | if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance", |
| 454 | Status: "ready", App: fixtureApp, Version: "v1"}); err != nil { |
| 455 | t.Fatal(err) |
| 456 | } |
| 457 | |
| 458 | // A date safely inside last calendar month by the wall clock — the same |
| 459 | // "relative to real time" rule TestLoadEconomicsGolden documents. |
| 460 | lastMonth := time.Now().UTC().AddDate(0, -1, 0) |
| 461 | date := usage.DayOf(lastMonth) |
| 462 | roll := metrics.DayRollup{V: 1, Box: "box1", Date: date, Hours: map[string]metrics.HourAgg{ |
| 463 | "09": {Units: map[string]map[string]metrics.Agg{ |
| 464 | "carlos-app@a.example.com.service": { |
| 465 | "cpu": {Avg: 0.5, N: 60}, |
| 466 | "mem_bytes": {Avg: 1e9, N: 60}, |
| 467 | }, |
| 468 | }}, |
| 469 | }} |
| 470 | putStoreJSON(t, fx.rel, metrics.DayKey("box1", date), roll) |
| 471 | putStoreJSON(t, fx.rel, usage.EdgeDayKey("box1", date), usage.EdgeDay{ |
| 472 | V: 1, Box: "box1", Date: date, |
| 473 | Hours: map[string]map[string]usage.EdgeRow{"09": { |
| 474 | "a.example.com": {Account: fixtureAcct, App: fixtureApp, Requests: 4000, BytesOut: 2e9}, |
| 475 | }}, |
| 476 | }) |
| 477 | |
| 478 | c := signInPaul(t, fx) |
| 479 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 480 | t.Fatal(err) |
| 481 | } |
| 482 | |
| 483 | status, _, body := get(t, c, fx.ts.URL+"/fleet/economics/accounts?period=last&pos=partial&cursor=abc123") |
| 484 | if status != 200 { |
| 485 | t.Fatalf("/fleet/economics/accounts?period=last = %d, want 200:\n%s", status, body) |
| 486 | } |
| 487 | |
| 488 | // 1. The search form carries period forward as a hidden field. |
| 489 | if !strings.Contains(body, `<input type="hidden" name="period" value="last">`) { |
| 490 | t.Fatalf("missing hidden period field in the search form:\n%s", body) |
| 491 | } |
| 492 | // 2. A pos-filter chip's href carries period=last — this fixture's |
| 493 | // "partial" row makes that chip's presence deterministic rather than |
| 494 | // incidental. html/template escapes "&" to "&" inside an href |
| 495 | // attribute value. |
| 496 | if !strings.Contains(body, `href="?period=last&pos=partial"`) { |
| 497 | t.Fatalf("missing the partial-position chip href carrying period=last:\n%s", body) |
| 498 | } |
| 499 | // 3. The period nav preserves pos= (the operator's filter survives a |
| 500 | // period switch) but never a cursor. The "Month to date" link |
| 501 | // switches TO the default period, so period= itself is omitted (the |
| 502 | // same "omit when mtd" rule as everywhere else on this page), |
| 503 | // leaving exactly "?pos=partial" — no period=, no cursor=. |
| 504 | if !strings.Contains(body, `href="?pos=partial"`) { |
| 505 | t.Fatalf("missing the Month-to-date period link preserving pos=partial with no period/cursor:\n%s", body) |
| 506 | } |
| 507 | } |
| 508 | |
| 509 | // TestEconomicsAccountsPageShowsAccountName (account-names batch): a row for |
| 510 | // an account with a known console name renders the NAME as primary text |
| 511 | // with the sqid muted+small beside it (class="small", the existing |
| 512 | // secondary-text treatment; there is no separate ".muted" CSS class, only |
| 513 | // the ".small" rule, which |
| 514 | // already carries var(--muted)). A usage-only ghost row — real traffic under |
| 515 | // a sqid with no accounts-table row at all, same shape as |
| 516 | // TestEconomicsAccountsPageTierFormOnlyForConsoleRows — renders the bare |
| 517 | // sqid alone, exactly as before this batch. Checked on BOTH pages: the |
| 518 | // dashboard's own accounts table (economics.html, which has no econAccountRow |
| 519 | // and must look Name up from EconomicsView.Names) and the searchable |
| 520 | // accounts page (economics_accounts.html, whose econAccountRow now carries |
| 521 | // its own Name field). |
| 522 | func TestEconomicsAccountsPageShowsAccountName(t *testing.T) { |
| 523 | fx := newConsoleFixture(t) |
| 524 | |
| 525 | const ghostAcct = "ghostacct" |
| 526 | date := usage.DayOf(time.Now()) |
| 527 | putStoreJSON(t, fx.rel, usage.EdgeDayKey("boxghost", date), usage.EdgeDay{ |
| 528 | V: 1, Box: "boxghost", Date: date, |
| 529 | Hours: map[string]map[string]usage.EdgeRow{"09": { |
| 530 | "ghost.example.com": {Account: ghostAcct, App: "ghostapp", Requests: 100, BytesOut: 1e6}, |
| 531 | }}, |
| 532 | }) |
| 533 | |
| 534 | c := signInPaul(t, fx) |
| 535 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 536 | t.Fatal(err) |
| 537 | } |
| 538 | |
| 539 | for _, path := range []string{"/fleet/economics", "/fleet/economics/accounts"} { |
| 540 | status, _, body := get(t, c, fx.ts.URL+path) |
| 541 | if status != 200 { |
| 542 | t.Fatalf("%s = %d, want 200:\n%s", path, status, body) |
| 543 | } |
| 544 | |
| 545 | fixtureRowStart := strings.Index(body, fmt.Sprintf(`data-account="%s"`, fixtureAcct)) |
| 546 | if fixtureRowStart < 0 { |
| 547 | t.Fatalf("%s missing a row for %q in:\n%s", path, fixtureAcct, body) |
| 548 | } |
| 549 | fixtureRowEnd := strings.Index(body[fixtureRowStart:], "</tr>") |
| 550 | if fixtureRowEnd < 0 { |
| 551 | t.Fatalf("%s unterminated row for %q in:\n%s", path, fixtureAcct, body) |
| 552 | } |
| 553 | fixtureRow := body[fixtureRowStart : fixtureRowStart+fixtureRowEnd] |
| 554 | if !strings.Contains(fixtureRow, "ops <span class=\"small\">"+fixtureAcct+"</span>") { |
| 555 | t.Fatalf("%s: known-name row missing NAME %q as primary text with the sqid muted+small beside it:\n%s", path, "ops", fixtureRow) |
| 556 | } |
| 557 | |
| 558 | ghostRowStart := strings.Index(body, fmt.Sprintf(`data-account="%s"`, ghostAcct)) |
| 559 | if ghostRowStart < 0 { |
| 560 | t.Fatalf("%s missing a row for %q in:\n%s", path, ghostAcct, body) |
| 561 | } |
| 562 | ghostRowEnd := strings.Index(body[ghostRowStart:], "</tr>") |
| 563 | if ghostRowEnd < 0 { |
| 564 | t.Fatalf("%s unterminated row for %q in:\n%s", path, ghostAcct, body) |
| 565 | } |
| 566 | ghostRow := body[ghostRowStart : ghostRowStart+ghostRowEnd] |
| 567 | if !strings.Contains(ghostRow, ">"+ghostAcct+"<") { |
| 568 | t.Fatalf("%s: ghost row missing the bare sqid %q:\n%s", path, ghostAcct, ghostRow) |
| 569 | } |
| 570 | if strings.Contains(ghostRow, `<span class="small">`) { |
| 571 | t.Fatalf("%s: ghost row (no console name) must not render a muted-sqid span:\n%s", path, ghostRow) |
| 572 | } |
| 573 | } |
| 574 | } |
| 575 | |
| 576 | // ---- Task 6: pool tile, v2 knobs, tier chips ------------------------------- |
| 577 | |
| 578 | // TestEconomicsPagePoolTile proves the dashboard renders id="tile-pool" with |
| 579 | // the "POOL $X · draw $Y · headroom $Z" figures straight from |
| 580 | // EconomicsView.Pool — templates.ExecuteTemplate against a hand-built view |
| 581 | // (TestEconomicsPageMTDShowsProjection's pattern), so the expected dollar |
| 582 | // strings are exact, known numbers rather than whatever a real sweep |
| 583 | // produces. |
| 584 | func TestEconomicsPagePoolTile(t *testing.T) { |
| 585 | view := &EconomicsView{ |
| 586 | Period: "mtd", |
| 587 | Pool: PoolLedger{PoolUSD: 123.45, DrawUSD: 50, HeadroomUSD: 73.45, PayingRevenueUSD: 1000}, |
| 588 | } |
| 589 | p := page{Title: "Fleet economics", CSRF: "tok", Economics: view} |
| 590 | var buf bytes.Buffer |
| 591 | if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil { |
| 592 | t.Fatalf("render economics: %v", err) |
| 593 | } |
| 594 | html := buf.String() |
| 595 | for _, want := range []string{ |
| 596 | `id="tile-pool"`, |
| 597 | "POOL", |
| 598 | `id="tile-pool-usd">` + usd(123.45), |
| 599 | "draw", |
| 600 | `id="tile-pool-draw">` + usd(50), |
| 601 | "headroom", |
| 602 | `id="tile-pool-headroom">` + usd(73.45), |
| 603 | } { |
| 604 | if !strings.Contains(html, want) { |
| 605 | t.Fatalf("/fleet/economics missing pool tile string %q in:\n%s", want, html) |
| 606 | } |
| 607 | } |
| 608 | } |
| 609 | |
| 610 | // TestEconomicsPagePoolTileNegativeHeadroomWarns: a negative HeadroomUSD (the |
| 611 | // pool is overdrawn this period) must render honestly — never clamped to |
| 612 | // zero (PoolLedger's own doc comment) — AND in the page's existing warning |
| 613 | // styling, per the brief. The headroom span carries class="error" (the same |
| 614 | // class every other on-page warning uses — layout.html's ".error { color: |
| 615 | // var(--bad); }") exactly when HeadroomUSD is negative, never otherwise. |
| 616 | func TestEconomicsPagePoolTileNegativeHeadroomWarns(t *testing.T) { |
| 617 | view := &EconomicsView{ |
| 618 | Period: "mtd", |
| 619 | Pool: PoolLedger{PoolUSD: 10, DrawUSD: 50, HeadroomUSD: -40}, |
| 620 | } |
| 621 | p := page{Title: "Fleet economics", CSRF: "tok", Economics: view} |
| 622 | var buf bytes.Buffer |
| 623 | if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil { |
| 624 | t.Fatalf("render economics: %v", err) |
| 625 | } |
| 626 | html := buf.String() |
| 627 | if !strings.Contains(html, `id="tile-pool-headroom" class="error"`) { |
| 628 | t.Fatalf("/fleet/economics: negative headroom must carry the warning class:\n%s", html) |
| 629 | } |
| 630 | if !strings.Contains(html, `class="error">`+usd(-40)) { |
| 631 | t.Fatalf("/fleet/economics missing the honest negative headroom figure %q in:\n%s", usd(-40), html) |
| 632 | } |
| 633 | |
| 634 | // Positive headroom: no warning class at all. |
| 635 | viewOK := &EconomicsView{Period: "mtd", Pool: PoolLedger{PoolUSD: 50, DrawUSD: 10, HeadroomUSD: 40}} |
| 636 | pOK := page{Title: "Fleet economics", CSRF: "tok", Economics: viewOK} |
| 637 | var bufOK bytes.Buffer |
| 638 | if err := templates.ExecuteTemplate(&bufOK, "economics", pOK); err != nil { |
| 639 | t.Fatalf("render economics (positive headroom): %v", err) |
| 640 | } |
| 641 | if strings.Contains(bufOK.String(), `id="tile-pool-headroom" class="error"`) { |
| 642 | t.Fatalf("/fleet/economics: positive headroom must not carry the warning class:\n%s", bufOK.String()) |
| 643 | } |
| 644 | } |
| 645 | |
| 646 | // TestEconomicsPagePooledMarginIsDash is the carried ruling (Task 6 review, |
| 647 | // point 3): a pooled row's margin cell renders an em-dash, never "0%" — |
| 648 | // PriceAccount's MarginFrac is genuinely 0 for the pool override |
| 649 | // (buildEconomicsView), but "0%" reads as a account LOSING money, not one |
| 650 | // that simply isn't individually priced. Checked on both /fleet/economics |
| 651 | // and /fleet/economics/accounts, server-rendered (no JS in this test suite). |
| 652 | func TestEconomicsPagePooledMarginIsDash(t *testing.T) { |
| 653 | pooledRow := economics.AccountEconomics{Account: fixtureAcct, CostUSD: 5, RevenueUSD: 0, MarginFrac: 0, FreeTierPos: "pooled"} |
| 654 | |
| 655 | view := &EconomicsView{Period: "mtd", Accounts: []economics.AccountEconomics{pooledRow}} |
| 656 | p := page{Title: "Fleet economics", CSRF: "tok", Economics: view} |
| 657 | var buf bytes.Buffer |
| 658 | if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil { |
| 659 | t.Fatalf("render economics: %v", err) |
| 660 | } |
| 661 | html := buf.String() |
| 662 | if !strings.Contains(html, `class="js-acct-margin">—<`) { |
| 663 | t.Fatalf("/fleet/economics: pooled row's margin cell must be an em-dash, not a percentage:\n%s", html) |
| 664 | } |
| 665 | if strings.Contains(html, `class="js-acct-margin">0%<`) { |
| 666 | t.Fatalf("/fleet/economics: pooled row's margin cell must not render \"0%%\":\n%s", html) |
| 667 | } |
| 668 | |
| 669 | rows := pageEconAccounts([]econAccountRow{{Account: fixtureAcct, Meters: map[string]float64{}, Econ: pooledRow}}, econAccountQuery{}, econAccountPageSize) |
| 670 | pAcct := page{Title: "Fleet economics: accounts", CSRF: "tok", Economics: view, EconAccounts: rows, EconQuery: econAccountQuery{}} |
| 671 | var bufAcct bytes.Buffer |
| 672 | if err := templates.ExecuteTemplate(&bufAcct, "economics_accounts", pAcct); err != nil { |
| 673 | t.Fatalf("render economics_accounts: %v", err) |
| 674 | } |
| 675 | htmlAcct := bufAcct.String() |
| 676 | if !strings.Contains(htmlAcct, `class="js-acct-margin">—<`) { |
| 677 | t.Fatalf("/fleet/economics/accounts: pooled row's margin cell must be an em-dash, not a percentage:\n%s", htmlAcct) |
| 678 | } |
| 679 | } |
| 680 | |
| 681 | // TestEconomicsPageFleetMarginLegend is carried ruling 4 PLUS the fix-round |
| 682 | // MINOR 3 (Task 6 review): fleet-only accounts read 100%% margin by design |
| 683 | // (customer hardware, zero cost to the platform — PriceAccount's cost |
| 684 | // formula has no fleet term at all), and a number that looks like a |
| 685 | // modelling error gets an explanation somewhere on the page (dashboard-truth |
| 686 | // rule, AGENTS.md). The title= tooltip on the Margin header is invisible on |
| 687 | // touch/keyboard, so both notes ALSO need a visible one-line legend under |
| 688 | // each table — this asserts the tooltip AND the legend paragraph, on both |
| 689 | // pages, not just "the text appears somewhere" (which the tooltip alone |
| 690 | // would already satisfy and silently let the legend regress). |
| 691 | func TestEconomicsPageFleetMarginLegend(t *testing.T) { |
| 692 | // A margin-column explanation only means anything once the accounts |
| 693 | // table itself renders (both pages hide the table entirely with zero |
| 694 | // rows), so this seeds one row on each — not a claim that the row |
| 695 | // itself is a fleet account, just enough for the header/legend markup |
| 696 | // carrying the explanation to be on screen at all. |
| 697 | row := economics.AccountEconomics{Account: fixtureAcct, CostUSD: 0, RevenueUSD: 10, MarginFrac: 1, FreeTierPos: "paying"} |
| 698 | |
| 699 | view := &EconomicsView{Period: "mtd", Accounts: []economics.AccountEconomics{row}} |
| 700 | p := page{Title: "Fleet economics", CSRF: "tok", Economics: view} |
| 701 | var buf bytes.Buffer |
| 702 | if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil { |
| 703 | t.Fatalf("render economics: %v", err) |
| 704 | } |
| 705 | html := buf.String() |
| 706 | if !strings.Contains(html, `title="Pooled rows`) { |
| 707 | t.Fatalf("/fleet/economics missing the Margin header's title= tooltip:\n%s", html) |
| 708 | } |
| 709 | if !strings.Contains(html, `<p class="small">Margin: pooled rows show an em-dash`) { |
| 710 | t.Fatalf("/fleet/economics missing the VISIBLE margin legend line (MINOR 3 — tooltips alone are invisible on touch/keyboard):\n%s", html) |
| 711 | } |
| 712 | |
| 713 | rows := pageEconAccounts([]econAccountRow{{Account: fixtureAcct, Meters: map[string]float64{}, Econ: row}}, econAccountQuery{}, econAccountPageSize) |
| 714 | pAcct := page{Title: "Fleet economics: accounts", CSRF: "tok", Economics: view, EconAccounts: rows, EconQuery: econAccountQuery{}} |
| 715 | var bufAcct bytes.Buffer |
| 716 | if err := templates.ExecuteTemplate(&bufAcct, "economics_accounts", pAcct); err != nil { |
| 717 | t.Fatalf("render economics_accounts: %v", err) |
| 718 | } |
| 719 | htmlAcct := bufAcct.String() |
| 720 | if !strings.Contains(htmlAcct, `title="Pooled rows`) { |
| 721 | t.Fatalf("/fleet/economics/accounts missing the Margin header's title= tooltip:\n%s", htmlAcct) |
| 722 | } |
| 723 | if !strings.Contains(htmlAcct, `<p class="small">Margin: pooled rows show an em-dash`) { |
| 724 | t.Fatalf("/fleet/economics/accounts missing the VISIBLE margin legend line (MINOR 3):\n%s", htmlAcct) |
| 725 | } |
| 726 | } |
| 727 | |
| 728 | // TestEconomicsKnobDrawerHasV2Knobs proves the shared knob drawer |
| 729 | // (econ_knob_drawer) exposes all seven new economics-v2 knobs the brief |
| 730 | // names, with values sourced from the active scenario — box_month_server_usd |
| 731 | // and box_month_device_usd and mem_gb_hour_usd (Sheet), fleet_break.min_boxes |
| 732 | // and fleet_break.discount, pool.revenue_frac and pool.topup_usd. |
| 733 | func TestEconomicsKnobDrawerHasV2Knobs(t *testing.T) { |
| 734 | sc := economics.DefaultScenario() |
| 735 | sc.Sheet.BoxMonthServerUSD = 7.5 |
| 736 | sc.Sheet.BoxMonthDeviceUSD = 1.5 |
| 737 | sc.Sheet.MemGBHourUSD = 0.02 |
| 738 | sc.FleetBreak.MinBoxes = 12 |
| 739 | sc.FleetBreak.Discount = 0.3 |
| 740 | sc.Pool.RevenueFrac = 0.15 |
| 741 | sc.Pool.TopupUSD = 250 |
| 742 | |
| 743 | view := &EconomicsView{Period: "mtd", Scenario: sc} |
| 744 | p := page{Title: "Fleet economics", CSRF: "tok", Economics: view} |
| 745 | var buf bytes.Buffer |
| 746 | if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil { |
| 747 | t.Fatalf("render economics: %v", err) |
| 748 | } |
| 749 | html := buf.String() |
| 750 | for _, want := range []string{ |
| 751 | `id="knob-sheet-box-month-server-usd" value="7.5"`, |
| 752 | `id="knob-sheet-box-month-device-usd" value="1.5"`, |
| 753 | `id="knob-sheet-mem-gb-hour-usd" value="0.02"`, |
| 754 | `id="knob-fleet-break-min-boxes" value="12"`, |
| 755 | `id="knob-fleet-break-discount" value="0.3"`, |
| 756 | `id="knob-pool-revenue-frac" value="0.15"`, |
| 757 | `id="knob-pool-topup-usd" value="250"`, |
| 758 | } { |
| 759 | if !strings.Contains(html, want) { |
| 760 | t.Fatalf("knob drawer missing %q in:\n%s", want, html) |
| 761 | } |
| 762 | } |
| 763 | } |
| 764 | |
| 765 | // TestEconomicsJSTwinsShape is a structural stand-in for a browser test (this |
| 766 | // suite has no headless DOM — same rationale as |
| 767 | // TestEconomicsPageBillServicesGuardShape): greps the served page's inline |
| 768 | // script for the exact shapes the carried rulings (Task 6 review) demand, so |
| 769 | // a future edit that drops one of them fails a test instead of only a manual |
| 770 | // re-review. |
| 771 | // |
| 772 | // Ruling 1 (JS meter guards + fleet formulas): every new meter read is |
| 773 | // guarded against undefined, the fleet revenue term sits OUTSIDE the |
| 774 | // cost-plus/rate-sheet branch (mirroring compute.go's PriceAccount exactly), |
| 775 | // the volume-break discount applies only when min_boxes>0 AND avg boxes |
| 776 | // clears it, and the rate-sheet arm's mem overage term is present. |
| 777 | // |
| 778 | // Ruling 2 (tier-aware JS): a tierOf/isPoolTier pair mirrors |
| 779 | // economicsview.go's same-named functions, and poolLedger mirrors |
| 780 | // buildEconomicsView's PoolLedger construction — including the exact |
| 781 | // linear-scaling topup term (payload.poolTopupUnitAccrual × the live |
| 782 | // topup_usd knob) rather than any client-side re-derivation of the per-day |
| 783 | // sum. |
| 784 | func TestEconomicsJSTwinsShape(t *testing.T) { |
| 785 | fx := newConsoleFixture(t) |
| 786 | seedEconomicsGolden(t, fx) |
| 787 | c := signInPaul(t, fx) |
| 788 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 789 | t.Fatal(err) |
| 790 | } |
| 791 | status, _, body := get(t, c, fx.ts.URL+"/fleet/economics") |
| 792 | if status != 200 { |
| 793 | t.Fatalf("/fleet/economics = %d, want 200:\n%s", status, body) |
| 794 | } |
| 795 | |
| 796 | for _, want := range []string{ |
| 797 | // Ruling 1: guarded fleet meter reads, fleet term outside the mode |
| 798 | // branch, the SAME bMem binding used in the rate-sheet arm's mem |
| 799 | // overage term. |
| 800 | `(meters.fleet_box_hours_server || 0)`, |
| 801 | `(meters.fleet_box_hours_device || 0)`, |
| 802 | "bMem * sc.sheet.mem_gb_hour_usd", |
| 803 | "revenue += fleetRevenue", |
| 804 | "sc.fleet_break.min_boxes > 0 && avgBoxes >= sc.fleet_break.min_boxes", |
| 805 | // Ruling 2: tier helpers + pool ledger twin, exact linear topup scaling. |
| 806 | "function tierOf(", |
| 807 | "function isPoolTier(", |
| 808 | "function poolLedger(", |
| 809 | "sc.pool.topup_usd * payload.poolTopupUnitAccrual", |
| 810 | // Ruling 3: marginCell renders the em-dash, never "0%", for a pooled row. |
| 811 | "function marginCell(", |
| 812 | `pos === "pooled"`, |
| 813 | // readScenarioFromDOM reads all seven new knobs back into the scenario. |
| 814 | "sc.sheet.box_month_server_usd = numVal(", |
| 815 | "sc.sheet.box_month_device_usd = numVal(", |
| 816 | "sc.sheet.mem_gb_hour_usd = numVal(", |
| 817 | "sc.fleet_break.min_boxes = numVal(", |
| 818 | "sc.fleet_break.discount = numVal(", |
| 819 | "sc.pool.revenue_frac = numVal(", |
| 820 | "sc.pool.topup_usd = numVal(", |
| 821 | // recompute() applies the tier override AFTER priceAccount, so a knob |
| 822 | // tweak can never flip a pooled row back to priced. |
| 823 | "var tiers = payload.tiers || {};", |
| 824 | "isPoolTier(tierOf(tiers, acct))", |
| 825 | `a.freeTierPos = "pooled";`, |
| 826 | } { |
| 827 | if !strings.Contains(body, want) { |
| 828 | t.Fatalf("/fleet/economics inline script missing %q", want) |
| 829 | } |
| 830 | } |
| 831 | } |
| 832 | |
| 833 | // TestEconomicsAccountsPageTierChipAndForm proves the accounts page renders |
| 834 | // a chip-tier-<tier> chip for a tiered account and a per-row operator form |
| 835 | // (progressive: a plain <select>+submit, works without JS) posting to |
| 836 | // /fleet/economics/tier — the door economicspass.go's |
| 837 | // handleEconomicsTierSave already serves (Task 4/5). |
| 838 | func TestEconomicsAccountsPageTierChipAndForm(t *testing.T) { |
| 839 | fx := newConsoleFixture(t) |
| 840 | seedEconomicsGolden(t, fx) |
| 841 | if err := fx.st.SetAccountTier(OpsAccountID, "hosted"); err != nil { |
| 842 | t.Fatal(err) |
| 843 | } |
| 844 | |
| 845 | c := signInPaul(t, fx) |
| 846 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 847 | t.Fatal(err) |
| 848 | } |
| 849 | |
| 850 | status, _, body := get(t, c, fx.ts.URL+"/fleet/economics/accounts") |
| 851 | if status != 200 { |
| 852 | t.Fatalf("/fleet/economics/accounts = %d, want 200:\n%s", status, body) |
| 853 | } |
| 854 | for _, want := range []string{ |
| 855 | `class="chip chip-tier-hosted"`, |
| 856 | `action="/fleet/economics/tier"`, |
| 857 | fmt.Sprintf(`name="account" value="%s"`, fixtureAcct), |
| 858 | `<select name="tier">`, |
| 859 | `value="hosted" selected`, |
| 860 | } { |
| 861 | if !strings.Contains(body, want) { |
| 862 | t.Fatalf("/fleet/economics/accounts missing %q in:\n%s", want, body) |
| 863 | } |
| 864 | } |
| 865 | } |
| 866 | |
| 867 | // TestEconomicsAccountsPageTierFormOnlyForConsoleRows is the fix-round |
| 868 | // regression for IMPORTANT 2 (Task 6 review): a usage-only account — real |
| 869 | // traffic (usage.EdgeRow.Account is read directly by sweepUsage, |
| 870 | // economicspass.go, independent of any registry route) under a sqid that |
| 871 | // never got a console account row (accounts table) — is absent from |
| 872 | // EconomicsView.Tiers entirely (Store.AccountTiers only ever selects live |
| 873 | // accounts-table rows). Its row must still render a plain "unassigned" |
| 874 | // chip, but WITHOUT the per-row tier-set form: POSTing that form's account |
| 875 | // sqid to /fleet/economics/tier can only ever fail with "No such account." |
| 876 | // (handleEconomicsTierSave's AccountBySqid lookup). A REAL console row that |
| 877 | // simply has no tier assigned yet (the explicit "" default — fixtureAcct |
| 878 | // here, left untouched) is a different case entirely and must still get |
| 879 | // the form: index can't tell the two apart, which is exactly why tierKnown |
| 880 | // (server.go) exists. |
| 881 | func TestEconomicsAccountsPageTierFormOnlyForConsoleRows(t *testing.T) { |
| 882 | fx := newConsoleFixture(t) |
| 883 | seedEconomicsGolden(t, fx) |
| 884 | |
| 885 | // A usage-only account: edge traffic under a sqid with no accounts-table |
| 886 | // row at all. A DIFFERENT box than seedEconomicsGolden's "box1", so this |
| 887 | // writes its own EdgeDay object rather than colliding with the one |
| 888 | // seedEconomicsGolden already created (putStoreJSON is create-only). |
| 889 | const ghostAcct = "ghostacct" |
| 890 | date := usage.DayOf(time.Now()) |
| 891 | putStoreJSON(t, fx.rel, usage.EdgeDayKey("boxghost", date), usage.EdgeDay{ |
| 892 | V: 1, Box: "boxghost", Date: date, |
| 893 | Hours: map[string]map[string]usage.EdgeRow{"09": { |
| 894 | "ghost.example.com": {Account: ghostAcct, App: "ghostapp", Requests: 100, BytesOut: 1e6}, |
| 895 | }}, |
| 896 | }) |
| 897 | |
| 898 | c := signInPaul(t, fx) |
| 899 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 900 | t.Fatal(err) |
| 901 | } |
| 902 | |
| 903 | status, _, body := get(t, c, fx.ts.URL+"/fleet/economics/accounts") |
| 904 | if status != 200 { |
| 905 | t.Fatalf("/fleet/economics/accounts = %d, want 200:\n%s", status, body) |
| 906 | } |
| 907 | |
| 908 | // The usage-only ghost account's row: unassigned chip, no form. |
| 909 | rowStart := strings.Index(body, fmt.Sprintf(`data-account="%s"`, ghostAcct)) |
| 910 | if rowStart < 0 { |
| 911 | t.Fatalf("/fleet/economics/accounts missing a row for the usage-only account %q in:\n%s", ghostAcct, body) |
| 912 | } |
| 913 | rowEnd := strings.Index(body[rowStart:], "</tr>") |
| 914 | if rowEnd < 0 { |
| 915 | t.Fatalf("unterminated row for %q in:\n%s", ghostAcct, body) |
| 916 | } |
| 917 | ghostRow := body[rowStart : rowStart+rowEnd] |
| 918 | if !strings.Contains(ghostRow, `class="chip chip-tier-unassigned"`) { |
| 919 | t.Fatalf("usage-only account %q row missing the unassigned chip:\n%s", ghostAcct, ghostRow) |
| 920 | } |
| 921 | if strings.Contains(ghostRow, "<form") { |
| 922 | t.Fatalf("usage-only account %q row must NOT render the tier-set form (no console row to set a tier on):\n%s", ghostAcct, ghostRow) |
| 923 | } |
| 924 | |
| 925 | // A real console-row account with no tier assigned yet (fixtureAcct, |
| 926 | // untouched — the explicit "" default) still gets the form. |
| 927 | fixtureRowStart := strings.Index(body, fmt.Sprintf(`data-account="%s"`, fixtureAcct)) |
| 928 | if fixtureRowStart < 0 { |
| 929 | t.Fatalf("/fleet/economics/accounts missing a row for %q in:\n%s", fixtureAcct, body) |
| 930 | } |
| 931 | fixtureRowEnd := strings.Index(body[fixtureRowStart:], "</tr>") |
| 932 | if fixtureRowEnd < 0 { |
| 933 | t.Fatalf("unterminated row for %q in:\n%s", fixtureAcct, body) |
| 934 | } |
| 935 | fixtureRow := body[fixtureRowStart : fixtureRowStart+fixtureRowEnd] |
| 936 | if !strings.Contains(fixtureRow, `class="chip chip-tier-unassigned"`) { |
| 937 | t.Fatalf("console-row account %q (untiered) row missing the unassigned chip:\n%s", fixtureAcct, fixtureRow) |
| 938 | } |
| 939 | if !strings.Contains(fixtureRow, "<form") || !strings.Contains(fixtureRow, "/fleet/economics/tier") { |
| 940 | t.Fatalf("console-row account %q (untiered) row must still render the tier-set form:\n%s", fixtureAcct, fixtureRow) |
| 941 | } |
| 942 | } |
| 943 | |