carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
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.
24func 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.
64func 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.
99func 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.
135func 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.
195func 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".
208func 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.
252func 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.
278func 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.
325func 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.
369func 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.
412func 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.
446func 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 "&amp;" inside an href
495 // attribute value.
496 if !strings.Contains(body, `href="?period=last&amp;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).
522func 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.
584func 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.
616func 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).
652func 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).
691func 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.
733func 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.
784func 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).
838func 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.
881func 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