|
| 1 | +# Billing Test Patterns |
| 2 | + |
| 3 | +## Suite Hierarchy |
| 4 | + |
| 5 | +``` |
| 6 | +billingtest.BaseSuite (test/billing/suite.go) |
| 7 | + ↳ billingtest.SubscriptionMixin (test/billing/subscription_suite.go) |
| 8 | + ↳ subscriptionsync.SuiteBase (worker/subscriptionsync/service/suitebase_test.go) |
| 9 | +``` |
| 10 | + |
| 11 | +Embed the lowest suite that gives you what you need. Most billing service tests only need `BaseSuite`. Tests involving subscription creation need `SubscriptionMixin`. Tests for the sync algorithm need `SuiteBase`. |
| 12 | + |
| 13 | +## BaseSuite (`test/billing/suite.go`) |
| 14 | + |
| 15 | +Sets up a full in-process stack with a real PostgreSQL database: |
| 16 | + |
| 17 | +1. `testutils.InitPostgresDB(t)` — real Postgres test DB (requires `POSTGRES_HOST=127.0.0.1`) |
| 18 | +2. Atlas migrations unless `TEST_DISABLE_ATLAS` is set (falls back to Ent auto-create) |
| 19 | +3. Full billing service + adapter chain |
| 20 | +4. `ForegroundAdvancementStrategy` — state machine runs synchronously in tests (no async events) |
| 21 | +5. `MockStreamingConnector` for meter queries |
| 22 | +6. `invoicecalc.NewMockableCalculator` for overriding pricing calculations |
| 23 | +7. Sandbox app factory + customer/subject sync hooks |
| 24 | + |
| 25 | +**Key exposed fields:** |
| 26 | +```go |
| 27 | +BillingService billing.Service |
| 28 | +BillingAdapter billing.Adapter |
| 29 | +MockStreamingConnector *streamingtestutils.MockStreamingConnector |
| 30 | +MeterAdapter *metermock.MockRepository |
| 31 | +CustomerService customer.Service |
| 32 | +FeatureService feature.FeatureConnector |
| 33 | +``` |
| 34 | + |
| 35 | +**Namespace isolation:** |
| 36 | +```go |
| 37 | +ns := s.GetUniqueNamespace("my-test") // returns "my-test-{ulid}" |
| 38 | +``` |
| 39 | +Always use unique namespaces to isolate test data between test cases. |
| 40 | + |
| 41 | +## SubscriptionMixin (`test/billing/subscription_suite.go`) |
| 42 | + |
| 43 | +Call `mixin.SetupSuite(t, deps)` from your suite's `SetupSuite`. Adds: |
| 44 | +- `PlanService`, `SubscriptionService`, `SubscriptionAddonService`, `SubscriptionWorkflowService` |
| 45 | +- `EntitlementConnector` (full entitlement/credit/grant stack for metered entitlements) |
| 46 | + |
| 47 | +**Access via:** |
| 48 | +```go |
| 49 | +s.PlanService |
| 50 | +s.SubscriptionService |
| 51 | +s.SubscriptionWorkflowService |
| 52 | +``` |
| 53 | + |
| 54 | +## SuiteBase for Sync Tests (`worker/subscriptionsync/service/suitebase_test.go`) |
| 55 | + |
| 56 | +Embeds both `BaseSuite` and `SubscriptionMixin`. Also provides: |
| 57 | +- `SubscriptionSyncService subscriptionsync.Service` |
| 58 | +- `SubscriptionSyncAdapter subscriptionsync.Adapter` |
| 59 | + |
| 60 | +**Per-test setup** (`BeforeTest`): |
| 61 | +```go |
| 62 | +// Creates fresh per-test state: |
| 63 | +ns := getUniqueTestNamespace(suiteName, testName) |
| 64 | +s.InstallSandboxApp(t, ns) |
| 65 | +s.ProvisionBillingProfile(t, ns) |
| 66 | +// Creates test meter + feature |
| 67 | +// Creates test customer |
| 68 | +``` |
| 69 | + |
| 70 | +**Per-test teardown** (`AfterTest`): |
| 71 | +```go |
| 72 | +clock.UnFreeze() |
| 73 | +s.MockStreamingConnector.Reset() |
| 74 | +// resets feature flags on the service |
| 75 | +``` |
| 76 | + |
| 77 | +## Provisioning Helpers |
| 78 | + |
| 79 | +### `InstallSandboxApp(t, ns)` |
| 80 | +Required before any invoice operations. Installs the sandbox invoicing app in the namespace. |
| 81 | + |
| 82 | +### `ProvisionBillingProfile(t, ns, opts...)` |
| 83 | +Creates a billing profile with option functions: |
| 84 | + |
| 85 | +```go |
| 86 | +s.ProvisionBillingProfile(t, ns, |
| 87 | + billingtest.WithProgressiveBilling(), |
| 88 | + billingtest.WithCollectionInterval(isodate.MustParse("P1D")), |
| 89 | + billingtest.WithManualApproval(), |
| 90 | + billingtest.WithBillingProfileEditFn(func(p *billing.CreateProfileInput) { |
| 91 | + p.WorkflowConfig.Tax.Enabled = true |
| 92 | + }), |
| 93 | +) |
| 94 | +``` |
| 95 | + |
| 96 | +Default: auto-advance, monthly collection, immediate approval. |
| 97 | + |
| 98 | +### Subscription Creation Helpers (on `SuiteBase`) |
| 99 | + |
| 100 | +```go |
| 101 | +// Create from explicit phase definitions |
| 102 | +sub, err := s.createSubscriptionFromPlanPhases([]subscriptiontestutils.CreatePhasesInput{...}) |
| 103 | + |
| 104 | +// Create from a full plan input |
| 105 | +sub, err := s.createSubscriptionFromPlan(plan.CreatePlanInput{...}) |
| 106 | +``` |
| 107 | + |
| 108 | +## Gathering Invoice Helpers (on `SuiteBase`) |
| 109 | + |
| 110 | +```go |
| 111 | +// Assert exactly 1 gathering invoice exists and return it with lines expanded |
| 112 | +gi := s.gatheringInvoice(ctx, ns, customerID) |
| 113 | + |
| 114 | +// Assert no gathering invoice exists |
| 115 | +s.expectNoGatheringInvoice(ctx, ns, customerID) |
| 116 | + |
| 117 | +// Verify lines on an invoice |
| 118 | +s.expectLines(invoice, subscriptionID, []expectedLine{ |
| 119 | + {PhaseKey: "default", ItemKey: "api-calls", ...}, |
| 120 | +}) |
| 121 | +``` |
| 122 | + |
| 123 | +### `recurringLineMatcher{PhaseKey, ItemKey, Version, PeriodMin, PeriodMax}` |
| 124 | +Generates expected `ChildUniqueReferenceID` strings for a range of billing periods: |
| 125 | +```go |
| 126 | +matcher := recurringLineMatcher{ |
| 127 | + PhaseKey: "default", |
| 128 | + ItemKey: "api-calls", |
| 129 | + Version: 0, |
| 130 | + PeriodMin: 0, |
| 131 | + PeriodMax: 2, |
| 132 | +} |
| 133 | +// Generates: {subID}/default/api-calls/v[0]/period[0], /period[1], /period[2] |
| 134 | +``` |
| 135 | + |
| 136 | +## MockStreamingConnector |
| 137 | + |
| 138 | +```go |
| 139 | +// Set meter values returned by queries |
| 140 | +s.MockStreamingConnector.AddSimpleEvent(meterSlug, value, at) |
| 141 | + |
| 142 | +// Or set a fixed return value for all queries |
| 143 | +s.MockStreamingConnector.SetDefaultMeter(meterSlug, value) |
| 144 | + |
| 145 | +// Reset all values (called in AfterTest) |
| 146 | +s.MockStreamingConnector.Reset() |
| 147 | +``` |
| 148 | + |
| 149 | +## MockableInvoiceCalculator |
| 150 | + |
| 151 | +Override the invoice calculator for a single test: |
| 152 | +```go |
| 153 | +s.BillingService.GetInvoiceCalculator().(*invoicecalc.MockableCalculator). |
| 154 | + SetupMock(func(inv *billing.StandardInvoice) { |
| 155 | + // modify the invoice directly before totals are calculated |
| 156 | + }) |
| 157 | +defer s.BillingService.GetInvoiceCalculator().(*invoicecalc.MockableCalculator).Reset() |
| 158 | +``` |
| 159 | + |
| 160 | +## Clock Control |
| 161 | + |
| 162 | +```go |
| 163 | +clock.SetTime(t1) // advance clock without freezing |
| 164 | +clock.FreezeTime(t) // freeze at specific time (t is *testing.T for cleanup) |
| 165 | +clock.UnFreeze() // manual unfreeze (also called in AfterTest) |
| 166 | +clock.ResetTime() // reset to wall clock time |
| 167 | +``` |
| 168 | + |
| 169 | +Always call `clock.UnFreeze()` in `AfterTest` or use `clock.FreezeTime(t)` which registers cleanup automatically. |
| 170 | + |
| 171 | +## Progressive Billing Test Helpers |
| 172 | + |
| 173 | +```go |
| 174 | +// Enable progressive billing on the billing profile |
| 175 | +s.enableProgressiveBilling() |
| 176 | + |
| 177 | +// Set feature flags on the service directly (bypasses profile) |
| 178 | +s.enableProrating() |
| 179 | +``` |
| 180 | + |
| 181 | +## `RemoveMetaForCompare()` |
| 182 | + |
| 183 | +Use before `require.Equal` comparisons to strip DB-only fields: |
| 184 | +```go |
| 185 | +expected.RemoveMetaForCompare() |
| 186 | +actual.RemoveMetaForCompare() |
| 187 | +s.Equal(expected, actual) |
| 188 | +``` |
| 189 | + |
| 190 | +Available on both `StandardInvoice` and `StandardLine`. Strips: `DBState`, `DetailedLines`, IDs, timestamps. |
| 191 | + |
| 192 | +## Running Billing Tests |
| 193 | + |
| 194 | +```bash |
| 195 | +# All billing tests (requires postgres) |
| 196 | +POSTGRES_HOST=127.0.0.1 go test -tags=dynamic -v ./openmeter/billing/... |
| 197 | + |
| 198 | +# Just sync algorithm tests |
| 199 | +POSTGRES_HOST=127.0.0.1 go test -tags=dynamic -v ./openmeter/billing/worker/subscriptionsync/service/... |
| 200 | + |
| 201 | +# Just charges tests |
| 202 | +POSTGRES_HOST=127.0.0.1 go test -tags=dynamic -v ./openmeter/billing/charges/... |
| 203 | + |
| 204 | +# Full billing integration tests (test/ package) |
| 205 | +POSTGRES_HOST=127.0.0.1 go test -tags=dynamic -v ./test/billing/... |
| 206 | +``` |
| 207 | + |
| 208 | +Skip atlas migrations (faster, uses Ent auto-create): |
| 209 | +```bash |
| 210 | +TEST_DISABLE_ATLAS=true POSTGRES_HOST=127.0.0.1 go test -tags=dynamic -v ./openmeter/billing/... |
| 211 | +``` |
0 commit comments