package console import ( "fmt" "net/url" "strings" "testing" "github.com/carlosframework/platform/internal/economics" ) // econRowsFor builds n synthetic rows named acct-000..acct-(n-1), all // "paying", for the pure pagination tests below — pageInstanceRows's own // rowsFor helper (listquery_test.go), rewritten for econAccountRow. func econRowsFor(n int) []econAccountRow { out := make([]econAccountRow, n) for i := range out { acct := fmt.Sprintf("acct-%03d", i) out[i] = econAccountRow{ Account: acct, Meters: map[string]float64{}, Econ: economics.AccountEconomics{Account: acct, FreeTierPos: "paying"}, } } return out } func TestPageEconAccountsFirstAndSecondPage(t *testing.T) { rows := econRowsFor(120) p1 := pageEconAccounts(rows, econAccountQuery{}, econAccountPageSize) if len(p1.Rows) != 50 || p1.NextCursor == "" { t.Fatalf("page1: got %d rows, cursor %q", len(p1.Rows), p1.NextCursor) } if p1.Rows[0].Account != "acct-000" || p1.Rows[49].Account != "acct-049" { t.Fatalf("page1 bounds: first %q last %q", p1.Rows[0].Account, p1.Rows[49].Account) } if p1.Total != 120 { t.Fatalf("p1.Total = %d, want 120", p1.Total) } p2 := pageEconAccounts(rows, econAccountQuery{Cursor: p1.NextCursor}, econAccountPageSize) if len(p2.Rows) != 50 || p2.Rows[0].Account != "acct-050" { t.Fatalf("page2: got %d rows starting %q, want 50 starting acct-050", len(p2.Rows), p2.Rows[0].Account) } p3 := pageEconAccounts(rows, econAccountQuery{Cursor: p2.NextCursor}, econAccountPageSize) if len(p3.Rows) != 20 || p3.NextCursor != "" { t.Fatalf("page3: got %d rows, cursor %q, want 20 rows and no next cursor", len(p3.Rows), p3.NextCursor) } if p3.Rows[0].Account != "acct-100" || p3.Rows[19].Account != "acct-119" { t.Fatalf("page3 bounds: first %q last %q", p3.Rows[0].Account, p3.Rows[19].Account) } } func TestPageEconAccountsQFilter(t *testing.T) { rows := econRowsFor(120) // "acct-01" substring-matches acct-010..acct-019 (10 rows). p := pageEconAccounts(rows, econAccountQuery{Q: "acct-01"}, econAccountPageSize) if p.Total != 10 || len(p.Rows) != 10 { t.Fatalf("Q filter: Total=%d Rows=%d, want 10/10", p.Total, len(p.Rows)) } for _, r := range p.Rows { if r.Account[:7] != "acct-01" { t.Fatalf("row %q does not match Q filter", r.Account) } } } func TestPageEconAccountsPosFilter(t *testing.T) { rows := econRowsFor(120) rows[0].Econ.FreeTierPos = "inside" rows[1].Econ.FreeTierPos = "inside" rows[2].Econ.FreeTierPos = "partial" p := pageEconAccounts(rows, econAccountQuery{Pos: "inside"}, econAccountPageSize) if p.Total != 2 { t.Fatalf("Pos=inside: Total = %d, want 2", p.Total) } for _, r := range p.Rows { if r.Econ.FreeTierPos != "inside" { t.Fatalf("row %q has FreeTierPos %q, want inside", r.Account, r.Econ.FreeTierPos) } } // Counts totals every position across ALL rows, filtered or not. if p.Counts["inside"] != 2 || p.Counts["partial"] != 1 || p.Counts["paying"] != 117 { t.Fatalf("Counts = %+v, want inside:2 partial:1 paying:117", p.Counts) } var insideLink, payingLink *econPosLink for i := range p.Links { switch p.Links[i].Word { case "inside": insideLink = &p.Links[i] case "paying": payingLink = &p.Links[i] } } if insideLink == nil || !insideLink.Active || insideLink.Count != 2 { t.Fatalf("inside link = %+v, want Active=true Count=2", insideLink) } if payingLink == nil || payingLink.Active || payingLink.Count != 117 { t.Fatalf("paying link = %+v, want Active=false Count=117", payingLink) } } // TestPageEconAccountsPooledFilterAndCount: "pooled" (Task 5's fourth // FreeTierPos word, IMPORTANT 2 fix round) must filter and count exactly // like the other three positions — pos=pooled is a real, accepted filter, // not silently cleared by econPosFromParam, and Counts/Links include it. func TestPageEconAccountsPooledFilterAndCount(t *testing.T) { rows := econRowsFor(120) rows[0].Econ.FreeTierPos = "pooled" rows[1].Econ.FreeTierPos = "pooled" rows[2].Econ.FreeTierPos = "partial" q := econAccountQuery{Pos: econPosFromParam("pooled")} if q.Pos != "pooled" { t.Fatalf("econPosFromParam(%q) = %q, want %q (pos=pooled must be an accepted filter)", "pooled", q.Pos, "pooled") } p := pageEconAccounts(rows, q, econAccountPageSize) if p.Total != 2 { t.Fatalf("Pos=pooled: Total = %d, want 2", p.Total) } for _, r := range p.Rows { if r.Econ.FreeTierPos != "pooled" { t.Fatalf("row %q has FreeTierPos %q, want pooled", r.Account, r.Econ.FreeTierPos) } } if p.Counts["pooled"] != 2 || p.Counts["partial"] != 1 || p.Counts["paying"] != 117 { t.Fatalf("Counts = %+v, want pooled:2 partial:1 paying:117", p.Counts) } var pooledLink *econPosLink for i := range p.Links { if p.Links[i].Word == "pooled" { pooledLink = &p.Links[i] } } if pooledLink == nil || !pooledLink.Active || pooledLink.Count != 2 { t.Fatalf("pooled link = %+v, want Active=true Count=2", pooledLink) } } // TestPageEconAccountsQMatchesSqidOrNameCaseInsensitive (account-names // batch): Q matches EITHER the sqid or the display Name, and the WHOLE match // is case-insensitive — a deliberate divergence from the Running tab's Q // (listquery.go's instanceQuery.Q, a case-sensitive substring match on // host), per Paul's names request implying human-friendly search rather than // wire-shaped exactness. econRowsFor's rows carry no Name by default (zero // value ""), so a Q that matches nothing's Name still falls through to the // sqid half exactly as before. func TestPageEconAccountsQMatchesSqidOrNameCaseInsensitive(t *testing.T) { rows := econRowsFor(5) rows[0].Name = "Ops Team" rows[1].Name = "Widgets Inc" // Matches by name. p := pageEconAccounts(rows, econAccountQuery{Q: "ops"}, econAccountPageSize) if p.Total != 1 || p.Rows[0].Account != rows[0].Account { t.Fatalf("Q=ops (name match): Total=%d, want 1 matching %q", p.Total, rows[0].Account) } // Name match is case-insensitive. p2 := pageEconAccounts(rows, econAccountQuery{Q: "OPS TEAM"}, econAccountPageSize) if p2.Total != 1 || p2.Rows[0].Account != rows[0].Account { t.Fatalf("Q=OPS TEAM (uppercase name match): Total=%d, want 1 matching %q", p2.Total, rows[0].Account) } // Sqid still matches, now case-insensitively too. p3 := pageEconAccounts(rows, econAccountQuery{Q: "ACCT-002"}, econAccountPageSize) if p3.Total != 1 || p3.Rows[0].Account != "acct-002" { t.Fatalf("Q=ACCT-002 (uppercase sqid match): Total=%d, want 1 matching acct-002", p3.Total) } // A query matching neither sqid nor name: no rows. p4 := pageEconAccounts(rows, econAccountQuery{Q: "nonesuch"}, econAccountPageSize) if p4.Total != 0 { t.Fatalf("Q=nonesuch: Total=%d, want 0", p4.Total) } } func TestPageEconAccountsGarbageCursorStartsOver(t *testing.T) { rows := econRowsFor(5) p := pageEconAccounts(rows, econAccountQuery{Cursor: "!!not-base64!!"}, econAccountPageSize) if len(p.Rows) != 5 { t.Fatalf("bad cursor must degrade to page one: got %d rows", len(p.Rows)) } } // TestPageEconAccountsNextURLCarriesPeriod is the fix-round regression for // the silent-wrong-data bug: NextURL (and, by the same econLinkURL builder, // AllURL/Links[].Href) must carry period="last"/"7d" so a "Next 50" click // doesn't resume a cursor computed against one period's row order under // another period's numbers — but must OMIT period=mtd, mtd being the // implicit default every other link on this page already omits it for. func TestPageEconAccountsNextURLCarriesPeriod(t *testing.T) { rows := econRowsFor(120) pLast := pageEconAccounts(rows, econAccountQuery{Period: "last"}, econAccountPageSize) if pLast.NextCursor == "" { t.Fatalf("expected a NextCursor over 120 rows at page size %d", econAccountPageSize) } if !strings.Contains(pLast.NextURL, "period=last") { t.Fatalf("NextURL = %q, want it to carry period=last", pLast.NextURL) } if !strings.Contains(pLast.AllURL, "period=last") { t.Fatalf("AllURL = %q, want it to carry period=last", pLast.AllURL) } pMTD := pageEconAccounts(rows, econAccountQuery{Period: "mtd"}, econAccountPageSize) if strings.Contains(pMTD.NextURL, "period=") { t.Fatalf("NextURL = %q, want no period param for the default mtd", pMTD.NextURL) } if strings.Contains(pMTD.AllURL, "period=") { t.Fatalf("AllURL = %q, want no period param for the default mtd", pMTD.AllURL) } // The zero value (Period: "") behaves identically to "mtd" — omitted. pZero := pageEconAccounts(rows, econAccountQuery{}, econAccountPageSize) if strings.Contains(pZero.NextURL, "period=") { t.Fatalf("NextURL = %q, want no period param when Period is unset", pZero.NextURL) } // A pos-filter link (Links[].Href) carries period too — resuming the // SAME bug from a different link on the page would be an incomplete fix. rows[0].Econ.FreeTierPos = "inside" pLastFiltered := pageEconAccounts(rows, econAccountQuery{Period: "last"}, econAccountPageSize) var insideHref string for _, l := range pLastFiltered.Links { if l.Word == "inside" { insideHref = l.Href } } if !strings.Contains(insideHref, "period=last") { t.Fatalf("inside link Href = %q, want it to carry period=last", insideHref) } } // TestEconPeriodURLPreservesQAndPosDropsCursor is the mirror fix: the // period-switch nav preserves Q and Pos (an operator's search survives a // period switch) but never carries a cursor — econAccountQuery has no // Cursor parameter for econPeriodURL to accidentally thread through in the // first place, so this pins that shape at the signature level too. func TestEconPeriodURLPreservesQAndPosDropsCursor(t *testing.T) { q := econAccountQuery{Q: "acct-01", Pos: "partial", Cursor: "some-cursor-that-must-never-appear"} href := econPeriodURL(q, "last") if !strings.Contains(href, "q=acct-01") { t.Fatalf("href = %q, want it to preserve q=acct-01", href) } if !strings.Contains(href, "pos=partial") { t.Fatalf("href = %q, want it to preserve pos=partial", href) } if !strings.Contains(href, "period=last") { t.Fatalf("href = %q, want it to carry period=last", href) } if strings.Contains(href, "cursor") { t.Fatalf("href = %q, must never carry a cursor across a period switch", href) } // Switching TO mtd omits period= (the default), same "omit when mtd" rule. hrefMTD := econPeriodURL(q, "mtd") if strings.Contains(hrefMTD, "period=") { t.Fatalf("href = %q, want no period param when switching to the default mtd", hrefMTD) } } func TestParseEconAccountQuery(t *testing.T) { q := parseEconAccountQuery(url.Values{"q": {" acct-01 "}, "pos": {"inside"}, "cursor": {"abc"}}) if q.Q != "acct-01" || q.Pos != "inside" || q.Cursor != "abc" { t.Fatalf("%+v", q) } // An unrecognized pos value is no filter, not an error. q2 := parseEconAccountQuery(url.Values{"pos": {"nonsense"}}) if q2.Pos != "" { t.Fatalf("unknown pos = %q, want \"\" (no filter)", q2.Pos) } }