| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "strings" |
| 8 | "sync/atomic" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/domains" |
| 13 | "github.com/carlosframework/platform/internal/plans" |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | ) |
| 16 | |
| 17 | const exampleCatalogPath = "../plans/testdata/example-catalog.json" |
| 18 | |
| 19 | func examplePlanCatalog(t *testing.T) *plans.Catalog { |
| 20 | t.Helper() |
| 21 | c, err := plans.LoadCatalog(exampleCatalogPath) |
| 22 | if err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | return c |
| 26 | } |
| 27 | |
| 28 | // bumpedCatalog is the example catalog with free's running_things set to n |
| 29 | // under a new version. |
| 30 | func bumpedCatalog(t *testing.T, version string, n string) *plans.Catalog { |
| 31 | t.Helper() |
| 32 | b, err := os.ReadFile(exampleCatalogPath) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | s := strings.Replace(string(b), `"version": "2026-10-20-v1"`, `"version": "`+version+`"`, 1) |
| 37 | s = strings.Replace(s, `"running_things": 3,`, `"running_things": `+n+`,`, 1) |
| 38 | c, err := plans.ParseCatalog([]byte(s)) |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | return c |
| 43 | } |
| 44 | |
| 45 | // planAccount adds a console account with a valid sqid and returns it. |
| 46 | func planAccount(t *testing.T, fx *consoleFixture, id int64) *Account { |
| 47 | t.Helper() |
| 48 | sqid := domains.Sqid(id) |
| 49 | if err := fx.st.CreateAccount(id, sqid, "acct", "owner"+sqid+"@example.com", time.Unix(1_700_000_000, 0)); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | acct, err := fx.st.AccountBySqid(sqid) |
| 53 | if err != nil || acct == nil { |
| 54 | t.Fatalf("account %s: %v", sqid, err) |
| 55 | } |
| 56 | return acct |
| 57 | } |
| 58 | |
| 59 | func readLimits(t *testing.T, fx *consoleFixture, sqid string) (*plans.Record, string) { |
| 60 | t.Helper() |
| 61 | r, etag, err := plans.GetLimits(context.Background(), fx.rel, sqid) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | return r, etag |
| 66 | } |
| 67 | |
| 68 | func TestPlanLimitsPassOffWithoutACatalog(t *testing.T) { |
| 69 | fx := newConsoleFixture(t) |
| 70 | acct := planAccount(t, fx, 501) |
| 71 | fx.srv.planLimitsPass(context.Background(), planNow) |
| 72 | if r, _ := readLimits(t, fx, acct.Sqid); r != nil { |
| 73 | t.Fatalf("wrote a record with no catalog: %+v", r) |
| 74 | } |
| 75 | if row, _ := fx.st.AccountPlan(acct.ID); row != nil { |
| 76 | t.Fatalf("inserted a plan row with no catalog: %+v", row) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | func TestPlanLimitsPassPublishesDefaults(t *testing.T) { |
| 81 | fx := newConsoleFixture(t) |
| 82 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 83 | acct := planAccount(t, fx, 501) |
| 84 | fx.srv.planLimitsPass(context.Background(), planNow) |
| 85 | r, _ := readLimits(t, fx, acct.Sqid) |
| 86 | if r == nil { |
| 87 | t.Fatal("no record published") |
| 88 | } |
| 89 | if r.Plan != "free" || r.Source != PlanSourceDefault || r.Limits.RunningThings != 3 || r.CatalogID != fx.srv.cfg.PlanCatalog.ID() { |
| 90 | t.Fatalf("record %+v", r) |
| 91 | } |
| 92 | row, _ := fx.st.AccountPlan(acct.ID) |
| 93 | if row.PublishedRevision != row.Revision || row.PublishedCatalog != fx.srv.cfg.PlanCatalog.ID() { |
| 94 | t.Fatalf("row not marked published: %+v", row) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestPlanLimitsPassRepublishesOnCatalogChange(t *testing.T) { |
| 99 | fx := newConsoleFixture(t) |
| 100 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 101 | acct := planAccount(t, fx, 501) |
| 102 | fx.srv.planLimitsPass(context.Background(), planNow) |
| 103 | next := bumpedCatalog(t, "2026-10-21-v2", "4") |
| 104 | fx.srv.cfg.PlanCatalog = next |
| 105 | fx.srv.planLimitsPass(context.Background(), planNow) |
| 106 | r, etag := readLimits(t, fx, acct.Sqid) |
| 107 | if r.CatalogID != next.ID() || r.Limits.RunningThings != 4 { |
| 108 | t.Fatalf("not republished: %+v", r) |
| 109 | } |
| 110 | fx.srv.planLimitsPass(context.Background(), planNow) |
| 111 | if _, again := readLimits(t, fx, acct.Sqid); again != etag { |
| 112 | t.Fatal("an up-to-date record was rewritten") |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | func TestPlanLimitsPassSkipsAnOrphanedAssignment(t *testing.T) { |
| 117 | fx := newConsoleFixture(t) |
| 118 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 119 | orphan := planAccount(t, fx, 501) |
| 120 | other := planAccount(t, fx, 502) |
| 121 | fx.srv.planLimitsPass(context.Background(), planNow) |
| 122 | _, before := readLimits(t, fx, orphan.Sqid) |
| 123 | if _, err := fx.st.SetAccountPlan(orphan.ID, "gold", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | if _, err := fx.st.SetAccountPlan(other.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | fx.srv.planLimitsPass(context.Background(), planNow) |
| 130 | if _, after := readLimits(t, fx, orphan.Sqid); after != before { |
| 131 | t.Fatal("the orphan's last good record was replaced") |
| 132 | } |
| 133 | if row, _ := fx.st.AccountPlan(orphan.ID); row.PublishedRevision == row.Revision { |
| 134 | t.Fatal("the orphan was marked published") |
| 135 | } |
| 136 | if r, _ := readLimits(t, fx, other.Sqid); r.Plan != "personal" { |
| 137 | t.Fatalf("the other account was not published: %+v", r) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | func TestPublishNeverOverwritesANewerRecord(t *testing.T) { |
| 142 | fx := newConsoleFixture(t) |
| 143 | cat := examplePlanCatalog(t) |
| 144 | fx.srv.cfg.PlanCatalog = cat |
| 145 | acct := planAccount(t, fx, 501) |
| 146 | newer := &plans.Record{Account: acct.Sqid, CatalogID: cat.ID(), Plan: "personal", Source: PlanSourceOperator, Revision: 5} |
| 147 | if _, err := plans.PutLimits(context.Background(), fx.rel, newer, ""); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | for range 3 { |
| 151 | if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | } |
| 155 | row, _ := fx.st.AccountPlan(acct.ID) |
| 156 | if err := fx.srv.publishAccountLimits(context.Background(), *row, planNow); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | if r, _ := readLimits(t, fx, acct.Sqid); r.Revision != 5 || r.Plan != "personal" { |
| 160 | t.Fatalf("newer record overwritten: %+v", r) |
| 161 | } |
| 162 | if row, _ := fx.st.AccountPlan(acct.ID); row.PublishedRevision != 3 { |
| 163 | t.Fatalf("published_revision %d, want 3", row.PublishedRevision) |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | // planFailingPuts is a bucket whose next n Puts fail. |
| 168 | type planFailingPuts struct { |
| 169 | release.Store |
| 170 | n atomic.Int32 |
| 171 | } |
| 172 | |
| 173 | func (f *planFailingPuts) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 174 | if f.n.Add(-1) >= 0 { |
| 175 | return "", errors.New("bucket unavailable") |
| 176 | } |
| 177 | return f.Store.Put(ctx, key, body, ifMatch) |
| 178 | } |
| 179 | |
| 180 | func TestPublishRetriesAfterABucketFailure(t *testing.T) { |
| 181 | fx := newConsoleFixture(t) |
| 182 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 183 | acct := planAccount(t, fx, 501) |
| 184 | fp := &planFailingPuts{Store: fx.rel} |
| 185 | fx.srv.cfg.Releases = fp |
| 186 | if _, err := fx.st.EnsureDefaultPlans("free", planNow); err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | fp.n.Store(1000) // every Put fails during the first pass |
| 190 | fx.srv.planLimitsPass(context.Background(), planNow) |
| 191 | if row, _ := fx.st.AccountPlan(acct.ID); row.PublishedRevision != 0 { |
| 192 | t.Fatal("marked published although the write failed") |
| 193 | } |
| 194 | fp.n.Store(0) |
| 195 | fx.srv.planLimitsPass(context.Background(), planNow) |
| 196 | if r, _ := readLimits(t, fx, acct.Sqid); r == nil { |
| 197 | t.Fatal("not published on the next pass") |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | func TestEffectiveLimits(t *testing.T) { |
| 202 | fx := newConsoleFixture(t) |
| 203 | acct := planAccount(t, fx, 501) |
| 204 | if l, err := fx.srv.EffectiveLimits(acct); l != nil || err != nil { |
| 205 | t.Fatalf("no catalog: %+v, %v", l, err) |
| 206 | } |
| 207 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 208 | l, err := fx.srv.EffectiveLimits(acct) |
| 209 | if err != nil || l == nil || l.RunningThings != 3 { |
| 210 | t.Fatalf("default: %+v, %v", l, err) |
| 211 | } |
| 212 | if _, err := fx.st.SetAccountPlan(acct.ID, "personal", map[string]int{"apps": 1}, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| 213 | t.Fatal(err) |
| 214 | } |
| 215 | l, err = fx.srv.EffectiveLimits(acct) |
| 216 | if err != nil || l.RunningThings != 30 { |
| 217 | t.Fatalf("personal + apps: %+v, %v", l, err) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | // Orphaned assignments must not starve the rest: with a batch of two and |
| 222 | // the two lowest accounts orphaned, one pass still reaches the third. |
| 223 | func TestPlanLimitsPassOrphansDoNotStarveLaterAccounts(t *testing.T) { |
| 224 | fx := newConsoleFixture(t) |
| 225 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 226 | defer func(n int) { planLimitsPassBatch = n }(planLimitsPassBatch) |
| 227 | planLimitsPassBatch = 2 |
| 228 | a := planAccount(t, fx, 501) |
| 229 | b := planAccount(t, fx, 502) |
| 230 | c := planAccount(t, fx, 503) |
| 231 | for _, acct := range []*Account{a, b} { |
| 232 | if _, err := fx.st.SetAccountPlan(acct.ID, "gold", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | } |
| 236 | fx.srv.planLimitsPass(context.Background(), planNow) |
| 237 | if r, _ := readLimits(t, fx, c.Sqid); r == nil { |
| 238 | t.Fatal("orphaned accounts starved a later valid account") |
| 239 | } |
| 240 | } |
| 241 | |