| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "net/url" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/economics" |
| 10 | ) |
| 11 | |
| 12 | // econRowsFor builds n synthetic rows named acct-000..acct-(n-1), all |
| 13 | // "paying", for the pure pagination tests below — pageInstanceRows's own |
| 14 | // rowsFor helper (listquery_test.go), rewritten for econAccountRow. |
| 15 | func econRowsFor(n int) []econAccountRow { |
| 16 | out := make([]econAccountRow, n) |
| 17 | for i := range out { |
| 18 | acct := fmt.Sprintf("acct-%03d", i) |
| 19 | out[i] = econAccountRow{ |
| 20 | Account: acct, |
| 21 | Meters: map[string]float64{}, |
| 22 | Econ: economics.AccountEconomics{Account: acct, FreeTierPos: "paying"}, |
| 23 | } |
| 24 | } |
| 25 | return out |
| 26 | } |
| 27 | |
| 28 | func TestPageEconAccountsFirstAndSecondPage(t *testing.T) { |
| 29 | rows := econRowsFor(120) |
| 30 | p1 := pageEconAccounts(rows, econAccountQuery{}, econAccountPageSize) |
| 31 | if len(p1.Rows) != 50 || p1.NextCursor == "" { |
| 32 | t.Fatalf("page1: got %d rows, cursor %q", len(p1.Rows), p1.NextCursor) |
| 33 | } |
| 34 | if p1.Rows[0].Account != "acct-000" || p1.Rows[49].Account != "acct-049" { |
| 35 | t.Fatalf("page1 bounds: first %q last %q", p1.Rows[0].Account, p1.Rows[49].Account) |
| 36 | } |
| 37 | if p1.Total != 120 { |
| 38 | t.Fatalf("p1.Total = %d, want 120", p1.Total) |
| 39 | } |
| 40 | |
| 41 | p2 := pageEconAccounts(rows, econAccountQuery{Cursor: p1.NextCursor}, econAccountPageSize) |
| 42 | if len(p2.Rows) != 50 || p2.Rows[0].Account != "acct-050" { |
| 43 | t.Fatalf("page2: got %d rows starting %q, want 50 starting acct-050", len(p2.Rows), p2.Rows[0].Account) |
| 44 | } |
| 45 | |
| 46 | p3 := pageEconAccounts(rows, econAccountQuery{Cursor: p2.NextCursor}, econAccountPageSize) |
| 47 | if len(p3.Rows) != 20 || p3.NextCursor != "" { |
| 48 | t.Fatalf("page3: got %d rows, cursor %q, want 20 rows and no next cursor", len(p3.Rows), p3.NextCursor) |
| 49 | } |
| 50 | if p3.Rows[0].Account != "acct-100" || p3.Rows[19].Account != "acct-119" { |
| 51 | t.Fatalf("page3 bounds: first %q last %q", p3.Rows[0].Account, p3.Rows[19].Account) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | func TestPageEconAccountsQFilter(t *testing.T) { |
| 56 | rows := econRowsFor(120) |
| 57 | // "acct-01" substring-matches acct-010..acct-019 (10 rows). |
| 58 | p := pageEconAccounts(rows, econAccountQuery{Q: "acct-01"}, econAccountPageSize) |
| 59 | if p.Total != 10 || len(p.Rows) != 10 { |
| 60 | t.Fatalf("Q filter: Total=%d Rows=%d, want 10/10", p.Total, len(p.Rows)) |
| 61 | } |
| 62 | for _, r := range p.Rows { |
| 63 | if r.Account[:7] != "acct-01" { |
| 64 | t.Fatalf("row %q does not match Q filter", r.Account) |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | func TestPageEconAccountsPosFilter(t *testing.T) { |
| 70 | rows := econRowsFor(120) |
| 71 | rows[0].Econ.FreeTierPos = "inside" |
| 72 | rows[1].Econ.FreeTierPos = "inside" |
| 73 | rows[2].Econ.FreeTierPos = "partial" |
| 74 | |
| 75 | p := pageEconAccounts(rows, econAccountQuery{Pos: "inside"}, econAccountPageSize) |
| 76 | if p.Total != 2 { |
| 77 | t.Fatalf("Pos=inside: Total = %d, want 2", p.Total) |
| 78 | } |
| 79 | for _, r := range p.Rows { |
| 80 | if r.Econ.FreeTierPos != "inside" { |
| 81 | t.Fatalf("row %q has FreeTierPos %q, want inside", r.Account, r.Econ.FreeTierPos) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // Counts totals every position across ALL rows, filtered or not. |
| 86 | if p.Counts["inside"] != 2 || p.Counts["partial"] != 1 || p.Counts["paying"] != 117 { |
| 87 | t.Fatalf("Counts = %+v, want inside:2 partial:1 paying:117", p.Counts) |
| 88 | } |
| 89 | |
| 90 | var insideLink, payingLink *econPosLink |
| 91 | for i := range p.Links { |
| 92 | switch p.Links[i].Word { |
| 93 | case "inside": |
| 94 | insideLink = &p.Links[i] |
| 95 | case "paying": |
| 96 | payingLink = &p.Links[i] |
| 97 | } |
| 98 | } |
| 99 | if insideLink == nil || !insideLink.Active || insideLink.Count != 2 { |
| 100 | t.Fatalf("inside link = %+v, want Active=true Count=2", insideLink) |
| 101 | } |
| 102 | if payingLink == nil || payingLink.Active || payingLink.Count != 117 { |
| 103 | t.Fatalf("paying link = %+v, want Active=false Count=117", payingLink) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // TestPageEconAccountsPooledFilterAndCount: "pooled" (Task 5's fourth |
| 108 | // FreeTierPos word, IMPORTANT 2 fix round) must filter and count exactly |
| 109 | // like the other three positions — pos=pooled is a real, accepted filter, |
| 110 | // not silently cleared by econPosFromParam, and Counts/Links include it. |
| 111 | func TestPageEconAccountsPooledFilterAndCount(t *testing.T) { |
| 112 | rows := econRowsFor(120) |
| 113 | rows[0].Econ.FreeTierPos = "pooled" |
| 114 | rows[1].Econ.FreeTierPos = "pooled" |
| 115 | rows[2].Econ.FreeTierPos = "partial" |
| 116 | |
| 117 | q := econAccountQuery{Pos: econPosFromParam("pooled")} |
| 118 | if q.Pos != "pooled" { |
| 119 | t.Fatalf("econPosFromParam(%q) = %q, want %q (pos=pooled must be an accepted filter)", "pooled", q.Pos, "pooled") |
| 120 | } |
| 121 | |
| 122 | p := pageEconAccounts(rows, q, econAccountPageSize) |
| 123 | if p.Total != 2 { |
| 124 | t.Fatalf("Pos=pooled: Total = %d, want 2", p.Total) |
| 125 | } |
| 126 | for _, r := range p.Rows { |
| 127 | if r.Econ.FreeTierPos != "pooled" { |
| 128 | t.Fatalf("row %q has FreeTierPos %q, want pooled", r.Account, r.Econ.FreeTierPos) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | if p.Counts["pooled"] != 2 || p.Counts["partial"] != 1 || p.Counts["paying"] != 117 { |
| 133 | t.Fatalf("Counts = %+v, want pooled:2 partial:1 paying:117", p.Counts) |
| 134 | } |
| 135 | |
| 136 | var pooledLink *econPosLink |
| 137 | for i := range p.Links { |
| 138 | if p.Links[i].Word == "pooled" { |
| 139 | pooledLink = &p.Links[i] |
| 140 | } |
| 141 | } |
| 142 | if pooledLink == nil || !pooledLink.Active || pooledLink.Count != 2 { |
| 143 | t.Fatalf("pooled link = %+v, want Active=true Count=2", pooledLink) |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | // TestPageEconAccountsQMatchesSqidOrNameCaseInsensitive (account-names |
| 148 | // batch): Q matches EITHER the sqid or the display Name, and the WHOLE match |
| 149 | // is case-insensitive — a deliberate divergence from the Running tab's Q |
| 150 | // (listquery.go's instanceQuery.Q, a case-sensitive substring match on |
| 151 | // host), per Paul's names request implying human-friendly search rather than |
| 152 | // wire-shaped exactness. econRowsFor's rows carry no Name by default (zero |
| 153 | // value ""), so a Q that matches nothing's Name still falls through to the |
| 154 | // sqid half exactly as before. |
| 155 | func TestPageEconAccountsQMatchesSqidOrNameCaseInsensitive(t *testing.T) { |
| 156 | rows := econRowsFor(5) |
| 157 | rows[0].Name = "Ops Team" |
| 158 | rows[1].Name = "Widgets Inc" |
| 159 | |
| 160 | // Matches by name. |
| 161 | p := pageEconAccounts(rows, econAccountQuery{Q: "ops"}, econAccountPageSize) |
| 162 | if p.Total != 1 || p.Rows[0].Account != rows[0].Account { |
| 163 | t.Fatalf("Q=ops (name match): Total=%d, want 1 matching %q", p.Total, rows[0].Account) |
| 164 | } |
| 165 | |
| 166 | // Name match is case-insensitive. |
| 167 | p2 := pageEconAccounts(rows, econAccountQuery{Q: "OPS TEAM"}, econAccountPageSize) |
| 168 | if p2.Total != 1 || p2.Rows[0].Account != rows[0].Account { |
| 169 | t.Fatalf("Q=OPS TEAM (uppercase name match): Total=%d, want 1 matching %q", p2.Total, rows[0].Account) |
| 170 | } |
| 171 | |
| 172 | // Sqid still matches, now case-insensitively too. |
| 173 | p3 := pageEconAccounts(rows, econAccountQuery{Q: "ACCT-002"}, econAccountPageSize) |
| 174 | if p3.Total != 1 || p3.Rows[0].Account != "acct-002" { |
| 175 | t.Fatalf("Q=ACCT-002 (uppercase sqid match): Total=%d, want 1 matching acct-002", p3.Total) |
| 176 | } |
| 177 | |
| 178 | // A query matching neither sqid nor name: no rows. |
| 179 | p4 := pageEconAccounts(rows, econAccountQuery{Q: "nonesuch"}, econAccountPageSize) |
| 180 | if p4.Total != 0 { |
| 181 | t.Fatalf("Q=nonesuch: Total=%d, want 0", p4.Total) |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | func TestPageEconAccountsGarbageCursorStartsOver(t *testing.T) { |
| 186 | rows := econRowsFor(5) |
| 187 | p := pageEconAccounts(rows, econAccountQuery{Cursor: "!!not-base64!!"}, econAccountPageSize) |
| 188 | if len(p.Rows) != 5 { |
| 189 | t.Fatalf("bad cursor must degrade to page one: got %d rows", len(p.Rows)) |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | // TestPageEconAccountsNextURLCarriesPeriod is the fix-round regression for |
| 194 | // the silent-wrong-data bug: NextURL (and, by the same econLinkURL builder, |
| 195 | // AllURL/Links[].Href) must carry period="last"/"7d" so a "Next 50" click |
| 196 | // doesn't resume a cursor computed against one period's row order under |
| 197 | // another period's numbers — but must OMIT period=mtd, mtd being the |
| 198 | // implicit default every other link on this page already omits it for. |
| 199 | func TestPageEconAccountsNextURLCarriesPeriod(t *testing.T) { |
| 200 | rows := econRowsFor(120) |
| 201 | |
| 202 | pLast := pageEconAccounts(rows, econAccountQuery{Period: "last"}, econAccountPageSize) |
| 203 | if pLast.NextCursor == "" { |
| 204 | t.Fatalf("expected a NextCursor over 120 rows at page size %d", econAccountPageSize) |
| 205 | } |
| 206 | if !strings.Contains(pLast.NextURL, "period=last") { |
| 207 | t.Fatalf("NextURL = %q, want it to carry period=last", pLast.NextURL) |
| 208 | } |
| 209 | if !strings.Contains(pLast.AllURL, "period=last") { |
| 210 | t.Fatalf("AllURL = %q, want it to carry period=last", pLast.AllURL) |
| 211 | } |
| 212 | |
| 213 | pMTD := pageEconAccounts(rows, econAccountQuery{Period: "mtd"}, econAccountPageSize) |
| 214 | if strings.Contains(pMTD.NextURL, "period=") { |
| 215 | t.Fatalf("NextURL = %q, want no period param for the default mtd", pMTD.NextURL) |
| 216 | } |
| 217 | if strings.Contains(pMTD.AllURL, "period=") { |
| 218 | t.Fatalf("AllURL = %q, want no period param for the default mtd", pMTD.AllURL) |
| 219 | } |
| 220 | |
| 221 | // The zero value (Period: "") behaves identically to "mtd" — omitted. |
| 222 | pZero := pageEconAccounts(rows, econAccountQuery{}, econAccountPageSize) |
| 223 | if strings.Contains(pZero.NextURL, "period=") { |
| 224 | t.Fatalf("NextURL = %q, want no period param when Period is unset", pZero.NextURL) |
| 225 | } |
| 226 | |
| 227 | // A pos-filter link (Links[].Href) carries period too — resuming the |
| 228 | // SAME bug from a different link on the page would be an incomplete fix. |
| 229 | rows[0].Econ.FreeTierPos = "inside" |
| 230 | pLastFiltered := pageEconAccounts(rows, econAccountQuery{Period: "last"}, econAccountPageSize) |
| 231 | var insideHref string |
| 232 | for _, l := range pLastFiltered.Links { |
| 233 | if l.Word == "inside" { |
| 234 | insideHref = l.Href |
| 235 | } |
| 236 | } |
| 237 | if !strings.Contains(insideHref, "period=last") { |
| 238 | t.Fatalf("inside link Href = %q, want it to carry period=last", insideHref) |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | // TestEconPeriodURLPreservesQAndPosDropsCursor is the mirror fix: the |
| 243 | // period-switch nav preserves Q and Pos (an operator's search survives a |
| 244 | // period switch) but never carries a cursor — econAccountQuery has no |
| 245 | // Cursor parameter for econPeriodURL to accidentally thread through in the |
| 246 | // first place, so this pins that shape at the signature level too. |
| 247 | func TestEconPeriodURLPreservesQAndPosDropsCursor(t *testing.T) { |
| 248 | q := econAccountQuery{Q: "acct-01", Pos: "partial", Cursor: "some-cursor-that-must-never-appear"} |
| 249 | href := econPeriodURL(q, "last") |
| 250 | if !strings.Contains(href, "q=acct-01") { |
| 251 | t.Fatalf("href = %q, want it to preserve q=acct-01", href) |
| 252 | } |
| 253 | if !strings.Contains(href, "pos=partial") { |
| 254 | t.Fatalf("href = %q, want it to preserve pos=partial", href) |
| 255 | } |
| 256 | if !strings.Contains(href, "period=last") { |
| 257 | t.Fatalf("href = %q, want it to carry period=last", href) |
| 258 | } |
| 259 | if strings.Contains(href, "cursor") { |
| 260 | t.Fatalf("href = %q, must never carry a cursor across a period switch", href) |
| 261 | } |
| 262 | |
| 263 | // Switching TO mtd omits period= (the default), same "omit when mtd" rule. |
| 264 | hrefMTD := econPeriodURL(q, "mtd") |
| 265 | if strings.Contains(hrefMTD, "period=") { |
| 266 | t.Fatalf("href = %q, want no period param when switching to the default mtd", hrefMTD) |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | func TestParseEconAccountQuery(t *testing.T) { |
| 271 | q := parseEconAccountQuery(url.Values{"q": {" acct-01 "}, "pos": {"inside"}, "cursor": {"abc"}}) |
| 272 | if q.Q != "acct-01" || q.Pos != "inside" || q.Cursor != "abc" { |
| 273 | t.Fatalf("%+v", q) |
| 274 | } |
| 275 | // An unrecognized pos value is no filter, not an error. |
| 276 | q2 := parseEconAccountQuery(url.Values{"pos": {"nonsense"}}) |
| 277 | if q2.Pos != "" { |
| 278 | t.Fatalf("unknown pos = %q, want \"\" (no filter)", q2.Pos) |
| 279 | } |
| 280 | } |
| 281 | |