Add read-only sponsored candidate inventory prerequisite
2 files changed,
+698
−0
internal/console/sponsoredinventory.go+241 −0internal/console/sponsoredinventory_test.go+457 −0
CI passed — run details
diff --git a/internal/console/sponsoredinventory.go b/internal/console/sponsoredinventory.go| new file mode 100644 |
| index 000000000..bceee6139 |
| --- /dev/null |
| +++ b/internal/console/sponsoredinventory.go |
| @@ -0,0 +1,241 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| +) |
| + |
| +type sponsoredCandidateInventory struct { |
| + Rows []sponsoredCandidateRow |
| +} |
| + |
| +type sponsoredCandidateRow struct { |
| + ID int64 |
| + Sqid, Name string |
| + StoredCreatedAt, DeletedAt int64 |
| + Owners []string |
| + Billing sponsoredCandidateBilling |
| + CheckoutPresent, BillingRetirement bool |
| + Outbox sponsoredCandidateOutbox |
| + OpenBillingReviews, EntitlementReviews int64 |
| +} |
| + |
| +type sponsoredCandidateBilling struct { |
| + Present, House, LiveMode, CustomerPresent bool |
| + Subscription, Status string |
| +} |
| + |
| +type sponsoredCandidateOutbox struct { |
| + Present, LiveMode, SerialOwed bool |
| + Revision, PublishedRevision int64 |
| + Digest, State string |
| +} |
| + |
| +// readSponsoredCandidates observes console-local facts only. It neither |
| +// decides the sponsored cohort nor grants anything. ReadOnly is a convention |
| +// with the current SQLite driver, so every statement below is deliberately a |
| +// SELECT and tests compare the complete logical database state before/after. |
| +func (s *Store) readSponsoredCandidates(ctx context.Context) (sponsoredCandidateInventory, error) { |
| + var zero sponsoredCandidateInventory |
| + if s == nil || s.db == nil || ctx == nil { |
| + return zero, os.ErrInvalid |
| + } |
| + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) |
| + if err != nil { |
| + return zero, err |
| + } |
| + defer tx.Rollback() |
| + out, err := readSponsoredCandidatesTx(ctx, tx, nil) |
| + if err != nil { |
| + return zero, err |
| + } |
| + if err := tx.Commit(); err != nil { |
| + return zero, err |
| + } |
| + return out, nil |
| +} |
| + |
| +// readSponsoredCandidatesTx is split out only for the deterministic |
| +// two-handle snapshot test. afterAccounts runs after the account rows have |
| +// fixed the WAL snapshot and been closed; production always passes nil. |
| +func readSponsoredCandidatesTx(ctx context.Context, tx *sql.Tx, afterAccounts func() error) (sponsoredCandidateInventory, error) { |
| + var zero sponsoredCandidateInventory |
| + if ctx == nil || tx == nil { |
| + return zero, os.ErrInvalid |
| + } |
| + out := sponsoredCandidateInventory{Rows: make([]sponsoredCandidateRow, 0)} |
| + byID := make(map[int64]int) |
| + |
| + // Open configures file-backed console databases for WAL. With SQLite's |
| + // deferred transaction, this first SELECT fixes the snapshot used by every |
| + // later SELECT in this transaction. |
| + rows, err := tx.QueryContext(ctx, `SELECT id,sqid,name,created_at,deleted_at FROM accounts ORDER BY id`) |
| + if err != nil { |
| + return zero, err |
| + } |
| + for rows.Next() { |
| + var row sponsoredCandidateRow |
| + if err := rows.Scan(&row.ID, &row.Sqid, &row.Name, &row.StoredCreatedAt, &row.DeletedAt); err != nil { |
| + rows.Close() |
| + return zero, err |
| + } |
| + if row.ID < 1 || row.Sqid != domains.Sqid(row.ID) { |
| + rows.Close() |
| + return zero, os.ErrInvalid |
| + } |
| + row.Owners = make([]string, 0) |
| + byID[row.ID] = len(out.Rows) |
| + out.Rows = append(out.Rows, row) |
| + } |
| + if err := closeSponsoredRows(rows); err != nil { |
| + return zero, err |
| + } |
| + if afterAccounts != nil { |
| + if err := afterAccounts(); err != nil { |
| + return zero, err |
| + } |
| + } |
| + |
| + rows, err = tx.QueryContext(ctx, `SELECT account_id,address FROM memberships WHERE role=? ORDER BY account_id,address`, RoleOwner) |
| + if err != nil { |
| + return zero, err |
| + } |
| + for rows.Next() { |
| + var accountID int64 |
| + var address string |
| + if err := rows.Scan(&accountID, &address); err != nil { |
| + rows.Close() |
| + return zero, err |
| + } |
| + if i, ok := byID[accountID]; ok { |
| + out.Rows[i].Owners = append(out.Rows[i].Owners, address) |
| + } |
| + } |
| + if err := closeSponsoredRows(rows); err != nil { |
| + return zero, err |
| + } |
| + |
| + rows, err = tx.QueryContext(ctx, `SELECT account_id FROM billing_retirements ORDER BY account_id`) |
| + if err != nil { |
| + return zero, err |
| + } |
| + for rows.Next() { |
| + var accountID int64 |
| + if err := rows.Scan(&accountID); err != nil { |
| + rows.Close() |
| + return zero, err |
| + } |
| + if i, ok := byID[accountID]; ok { |
| + out.Rows[i].BillingRetirement = true |
| + } |
| + } |
| + if err := closeSponsoredRows(rows); err != nil { |
| + return zero, err |
| + } |
| + |
| + rows, err = tx.QueryContext(ctx, `SELECT account_id,house,live_mode,(customer<>''),subscription,status FROM billing_accounts ORDER BY account_id`) |
| + if err != nil { |
| + return zero, err |
| + } |
| + for rows.Next() { |
| + var accountID int64 |
| + var billing sponsoredCandidateBilling |
| + if err := rows.Scan(&accountID, &billing.House, &billing.LiveMode, &billing.CustomerPresent, &billing.Subscription, &billing.Status); err != nil { |
| + rows.Close() |
| + return zero, err |
| + } |
| + if i, ok := byID[accountID]; ok { |
| + billing.Present = true |
| + out.Rows[i].Billing = billing |
| + } |
| + } |
| + if err := closeSponsoredRows(rows); err != nil { |
| + return zero, err |
| + } |
| + |
| + rows, err = tx.QueryContext(ctx, `SELECT account_id FROM billing_checkout_intents ORDER BY account_id`) |
| + if err != nil { |
| + return zero, err |
| + } |
| + for rows.Next() { |
| + var accountID int64 |
| + if err := rows.Scan(&accountID); err != nil { |
| + rows.Close() |
| + return zero, err |
| + } |
| + if i, ok := byID[accountID]; ok { |
| + out.Rows[i].CheckoutPresent = true |
| + } |
| + } |
| + if err := closeSponsoredRows(rows); err != nil { |
| + return zero, err |
| + } |
| + |
| + rows, err = tx.QueryContext(ctx, `SELECT account_id,revision,digest,state,live_mode,published_revision,serial_owed FROM entitlement_outbox ORDER BY account_id`) |
| + if err != nil { |
| + return zero, err |
| + } |
| + for rows.Next() { |
| + var accountID int64 |
| + var outbox sponsoredCandidateOutbox |
| + if err := rows.Scan(&accountID, &outbox.Revision, &outbox.Digest, &outbox.State, &outbox.LiveMode, &outbox.PublishedRevision, &outbox.SerialOwed); err != nil { |
| + rows.Close() |
| + return zero, err |
| + } |
| + if i, ok := byID[accountID]; ok { |
| + outbox.Present = true |
| + out.Rows[i].Outbox = outbox |
| + } |
| + } |
| + if err := closeSponsoredRows(rows); err != nil { |
| + return zero, err |
| + } |
| + |
| + rows, err = tx.QueryContext(ctx, `SELECT account_id,count(*) FROM billing_reviews WHERE resolved_at=0 GROUP BY account_id ORDER BY account_id`) |
| + if err != nil { |
| + return zero, err |
| + } |
| + for rows.Next() { |
| + var accountID, count int64 |
| + if err := rows.Scan(&accountID, &count); err != nil { |
| + rows.Close() |
| + return zero, err |
| + } |
| + if i, ok := byID[accountID]; ok { |
| + out.Rows[i].OpenBillingReviews = count |
| + } |
| + } |
| + if err := closeSponsoredRows(rows); err != nil { |
| + return zero, err |
| + } |
| + |
| + rows, err = tx.QueryContext(ctx, `SELECT account_id,count(*) FROM entitlement_reviews GROUP BY account_id ORDER BY account_id`) |
| + if err != nil { |
| + return zero, err |
| + } |
| + for rows.Next() { |
| + var accountID, count int64 |
| + if err := rows.Scan(&accountID, &count); err != nil { |
| + rows.Close() |
| + return zero, err |
| + } |
| + if i, ok := byID[accountID]; ok { |
| + out.Rows[i].EntitlementReviews = count |
| + } |
| + } |
| + if err := closeSponsoredRows(rows); err != nil { |
| + return zero, err |
| + } |
| + return out, nil |
| +} |
| + |
| +func closeSponsoredRows(rows *sql.Rows) error { |
| + if err := rows.Err(); err != nil { |
| + rows.Close() |
| + return err |
| + } |
| + return rows.Close() |
| +} |
diff --git a/internal/console/sponsoredinventory_test.go b/internal/console/sponsoredinventory_test.go| new file mode 100644 |
| index 000000000..9cbaf2c55 |
| --- /dev/null |
| +++ b/internal/console/sponsoredinventory_test.go |
| @@ -0,0 +1,457 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "encoding/hex" |
| + "fmt" |
| + "path/filepath" |
| + "reflect" |
| + "sort" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| +) |
| + |
| +func TestSponsoredCandidatesCompleteDeterministicRawFacts(t *testing.T) { |
| + empty := readSponsoredTestInventory(t, openSponsoredTestStore(t, "empty")) |
| + if empty.Rows == nil || len(empty.Rows) != 0 { |
| + t.Fatalf("empty Rows=%#v, want non-nil empty slice", empty.Rows) |
| + } |
| + |
| + first := openSponsoredTestStore(t, "first") |
| + second := openSponsoredTestStore(t, "second") |
| + seedSponsoredCoverage(t, first, false) |
| + seedSponsoredCoverage(t, second, true) |
| + |
| + got := readSponsoredTestInventory(t, first) |
| + gotReverse := readSponsoredTestInventory(t, second) |
| + if !reflect.DeepEqual(got, gotReverse) { |
| + t.Fatalf("insertion order changed capture:\nfirst: %#v\nsecond: %#v", got, gotReverse) |
| + } |
| + want := sponsoredCandidateInventory{Rows: []sponsoredCandidateRow{ |
| + { |
| + ID: 1, Sqid: domains.Sqid(1), Name: "ops", StoredCreatedAt: 100, |
| + Owners: []string{"a-owner@example.test", "z-owner@example.test"}, |
| + }, |
| + { |
| + ID: 2, Sqid: domains.Sqid(2), Name: "member-only", StoredCreatedAt: 0, |
| + Owners: []string{}, |
| + }, |
| + { |
| + ID: 3, Sqid: domains.Sqid(3), Name: "ownerless", StoredCreatedAt: -99, |
| + Owners: []string{}, |
| + }, |
| + { |
| + ID: 4, Sqid: domains.Sqid(4), Name: "deleted", StoredCreatedAt: 40, DeletedAt: 400, |
| + Owners: []string{}, |
| + Billing: sponsoredCandidateBilling{Present: true, House: true, Subscription: "sub_deleted", Status: "canceled"}, |
| + }, |
| + { |
| + ID: 5, Sqid: domains.Sqid(5), Name: "retiring", StoredCreatedAt: 50, |
| + Owners: []string{}, |
| + BillingRetirement: true, |
| + }, |
| + { |
| + ID: 6, Sqid: domains.Sqid(6), Name: "full", StoredCreatedAt: 60, |
| + Owners: []string{"full-owner@example.test"}, |
| + Billing: sponsoredCandidateBilling{ |
| + Present: true, LiveMode: true, CustomerPresent: true, |
| + Subscription: "sub_full", Status: "active", |
| + }, |
| + CheckoutPresent: true, |
| + Outbox: sponsoredCandidateOutbox{ |
| + Present: true, LiveMode: true, SerialOwed: true, |
| + Revision: 7, PublishedRevision: 5, Digest: "digest-full", State: "pending", |
| + }, |
| + OpenBillingReviews: 2, |
| + EntitlementReviews: 2, |
| + }, |
| + }} |
| + if !reflect.DeepEqual(got, want) { |
| + t.Fatalf("capture:\n got: %#v\nwant: %#v", got, want) |
| + } |
| + for _, row := range got.Rows { |
| + if row.Owners == nil { |
| + t.Fatalf("account %d has nil Owners", row.ID) |
| + } |
| + if !sort.StringsAreSorted(row.Owners) { |
| + t.Fatalf("account %d Owners are not sorted: %q", row.ID, row.Owners) |
| + } |
| + } |
| + var receipts int |
| + if err := first.db.QueryRow(`SELECT count(*) FROM account_birth_receipts`).Scan(&receipts); err != nil || receipts != 0 { |
| + t.Fatalf("birth receipts=%d err=%v", receipts, err) |
| + } |
| +} |
| + |
| +func TestSponsoredCandidatesReadDoesNotMutateAnyTable(t *testing.T) { |
| + s := openSponsoredTestStore(t, "immutable") |
| + seedSponsoredCoverage(t, s, false) |
| + before := sponsoredLogicalSnapshot(t, s.db) |
| + |
| + got := readSponsoredTestInventory(t, s) |
| + if len(got.Rows) == 0 { |
| + t.Fatal("empty capture") |
| + } |
| + after := sponsoredLogicalSnapshot(t, s.db) |
| + if !reflect.DeepEqual(after, before) { |
| + t.Fatalf("database changed:\nbefore: %#v\nafter: %#v", before, after) |
| + } |
| + var authority int |
| + if err := s.db.QueryRow(`SELECT count(*) FROM entitlement_authority`).Scan(&authority); err != nil || authority != 0 { |
| + t.Fatalf("entitlement authority rows=%d err=%v", authority, err) |
| + } |
| +} |
| + |
| +func TestSponsoredCandidatesWALSnapshotAcrossEveryRead(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "snapshot.db") |
| + reader := openSponsoredStoreAt(t, path) |
| + writer := openSponsoredStoreAt(t, path) |
| + sponsoredInsertAccount(t, reader.db, 2, "before", 20, 0) |
| + sponsoredExec(t, reader.db, `INSERT INTO memberships(account_id,address,role,added_at) VALUES(2,'old-owner@example.test',?,1)`, RoleOwner) |
| + sponsoredExec(t, reader.db, `INSERT INTO billing_accounts(account_id,customer,subscription,status,live_mode) VALUES(2,'cus_old','sub_old','active',0)`) |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + defer cancel() |
| + tx, err := reader.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer tx.Rollback() |
| + got, err := readSponsoredCandidatesTx(ctx, tx, func() error { |
| + writeTx, err := writer.db.BeginTx(ctx, nil) |
| + if err != nil { |
| + return err |
| + } |
| + defer writeTx.Rollback() |
| + for _, step := range []struct { |
| + query string |
| + args []any |
| + }{ |
| + {`DELETE FROM memberships WHERE account_id=2`, nil}, |
| + {`INSERT INTO memberships(account_id,address,role,added_at) VALUES(2,'new-owner@example.test',?,2)`, []any{RoleOwner}}, |
| + {`UPDATE billing_accounts SET customer='cus_new',subscription='sub_new',status='canceled',live_mode=1 WHERE account_id=2`, nil}, |
| + {`INSERT INTO billing_checkout_intents(account_id,id,plan,terms,band,price,quantity,created_at) VALUES(2,'ci_new','personal','terms','band','price',1,2)`, nil}, |
| + {`INSERT INTO billing_retirements(account_id,started_at) VALUES(2,2)`, nil}, |
| + {`INSERT INTO entitlement_outbox(account_id,account,authority,revision,body,digest,live_mode,state,published_revision,published_digest,serial_owed,attempts,next_attempt_at,last_error_code,updated_at) VALUES(2,?,'new-authority',3,?,'new-digest',1,'pending',2,'new-published',1,0,0,'',2)`, []any{domains.Sqid(2), []byte("new-body")}}, |
| + {`INSERT INTO billing_reviews(account_id,subscription,reason,created_at) VALUES(2,'sub_new','new-review',2)`, nil}, |
| + {`INSERT INTO entitlement_reviews(account_id,reason,revision,created_at) VALUES(2,'new-hold',3,2)`, nil}, |
| + {`UPDATE accounts SET deleted_at=200 WHERE id=2`, nil}, |
| + {`INSERT INTO accounts(id,sqid,name,created_at) VALUES(3,?,'added',30)`, []any{domains.Sqid(3)}}, |
| + } { |
| + if _, err := writeTx.ExecContext(ctx, step.query, step.args...); err != nil { |
| + return err |
| + } |
| + } |
| + return writeTx.Commit() |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.Commit(); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := sponsoredCandidateInventory{Rows: []sponsoredCandidateRow{{ |
| + ID: 2, Sqid: domains.Sqid(2), Name: "before", StoredCreatedAt: 20, |
| + Owners: []string{"old-owner@example.test"}, |
| + Billing: sponsoredCandidateBilling{ |
| + Present: true, CustomerPresent: true, Subscription: "sub_old", Status: "active", |
| + }, |
| + }}} |
| + if !reflect.DeepEqual(got, want) { |
| + t.Fatalf("mixed snapshot:\n got: %#v\nwant: %#v", got, want) |
| + } |
| + latest := readSponsoredTestInventory(t, writer) |
| + if len(latest.Rows) != 2 || latest.Rows[0].DeletedAt != 200 || latest.Rows[0].Owners[0] != "new-owner@example.test" || !latest.Rows[0].CheckoutPresent || !latest.Rows[0].Billing.LiveMode || !latest.Rows[0].BillingRetirement || !latest.Rows[0].Outbox.Present || latest.Rows[0].OpenBillingReviews != 1 || latest.Rows[0].EntitlementReviews != 1 { |
| + t.Fatalf("writer changes did not commit as expected: %#v", latest) |
| + } |
| +} |
| + |
| +func TestSponsoredCandidatesFailuresReturnZero(t *testing.T) { |
| + t.Run("cancelled before capture", func(t *testing.T) { |
| + s := openSponsoredTestStore(t, "cancel-before") |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + cancel() |
| + assertSponsoredCaptureError(t, func() (sponsoredCandidateInventory, error) { |
| + return s.readSponsoredCandidates(ctx) |
| + }) |
| + }) |
| + |
| + t.Run("cancelled after accounts", func(t *testing.T) { |
| + s := openSponsoredTestStore(t, "cancel-after") |
| + sponsoredInsertAccount(t, s.db, 2, "account", 20, 0) |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer tx.Rollback() |
| + assertSponsoredCaptureError(t, func() (sponsoredCandidateInventory, error) { |
| + return readSponsoredCandidatesTx(ctx, tx, func() error { |
| + cancel() |
| + return nil |
| + }) |
| + }) |
| + }) |
| + |
| + t.Run("missing schema", func(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "missing.db") |
| + reader := openSponsoredStoreAt(t, path) |
| + writer := openSponsoredStoreAt(t, path) |
| + sponsoredExec(t, writer.db, `DROP TABLE entitlement_reviews`) |
| + assertSponsoredCaptureError(t, func() (sponsoredCandidateInventory, error) { |
| + return reader.readSponsoredCandidates(sponsoredDeadline(t)) |
| + }) |
| + }) |
| + |
| + t.Run("text created_at", func(t *testing.T) { |
| + s := openSponsoredTestStore(t, "text-created") |
| + sponsoredExec(t, s.db, `INSERT INTO accounts(id,sqid,name,created_at) VALUES(2,?,'bad','x')`, domains.Sqid(2)) |
| + assertSponsoredCaptureError(t, func() (sponsoredCandidateInventory, error) { |
| + return s.readSponsoredCandidates(sponsoredDeadline(t)) |
| + }) |
| + }) |
| + |
| + t.Run("null created_at", func(t *testing.T) { |
| + s := openSponsoredTestStore(t, "null-created") |
| + sponsoredExec(t, s.db, `ALTER TABLE accounts RENAME TO accounts_strict`) |
| + sponsoredExec(t, s.db, `CREATE TABLE accounts(id INTEGER PRIMARY KEY,sqid TEXT NOT NULL UNIQUE,name TEXT NOT NULL,created_at INTEGER,deleted_at INTEGER NOT NULL DEFAULT 0,tier TEXT NOT NULL DEFAULT '')`) |
| + sponsoredExec(t, s.db, `INSERT INTO accounts(id,sqid,name,created_at) VALUES(2,?,'bad',NULL)`, domains.Sqid(2)) |
| + assertSponsoredCaptureError(t, func() (sponsoredCandidateInventory, error) { |
| + return s.readSponsoredCandidates(sponsoredDeadline(t)) |
| + }) |
| + }) |
| + |
| + t.Run("missing created_at column", func(t *testing.T) { |
| + s := openSponsoredTestStore(t, "missing-created") |
| + sponsoredExec(t, s.db, `ALTER TABLE accounts DROP COLUMN created_at`) |
| + assertSponsoredCaptureError(t, func() (sponsoredCandidateInventory, error) { |
| + return s.readSponsoredCandidates(sponsoredDeadline(t)) |
| + }) |
| + }) |
| +} |
| + |
| +func TestSponsoredCandidatesRefuseInvalidAccountIdentity(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + id int64 |
| + sqid string |
| + }{ |
| + {name: "nonpositive id", id: 0, sqid: domains.Sqid(0)}, |
| + {name: "mismatched sqid", id: 2, sqid: domains.Sqid(3)}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + s := openSponsoredTestStore(t, strings.ReplaceAll(tc.name, " ", "-")) |
| + sponsoredExec(t, s.db, `INSERT INTO accounts(id,sqid,name,created_at) VALUES(?,?,?,1)`, tc.id, tc.sqid, tc.name) |
| + assertSponsoredCaptureError(t, func() (sponsoredCandidateInventory, error) { |
| + return s.readSponsoredCandidates(sponsoredDeadline(t)) |
| + }) |
| + }) |
| + } |
| +} |
| + |
| +func seedSponsoredCoverage(t *testing.T, s *Store, reverse bool) { |
| + t.Helper() |
| + accounts := []struct { |
| + id, created, deleted int64 |
| + name string |
| + }{ |
| + {1, 100, 0, "ops"}, |
| + {2, 0, 0, "member-only"}, |
| + {3, -99, 0, "ownerless"}, |
| + {4, 40, 400, "deleted"}, |
| + {5, 50, 0, "retiring"}, |
| + {6, 60, 0, "full"}, |
| + } |
| + if reverse { |
| + sort.Slice(accounts, func(i, j int) bool { return accounts[i].id > accounts[j].id }) |
| + } |
| + for _, account := range accounts { |
| + sponsoredInsertAccount(t, s.db, account.id, account.name, account.created, account.deleted) |
| + } |
| + memberships := []struct { |
| + id int64 |
| + address string |
| + role string |
| + }{ |
| + {1, "z-owner@example.test", RoleOwner}, |
| + {1, "member@example.test", RoleMember}, |
| + {1, "a-owner@example.test", RoleOwner}, |
| + {2, "only-member@example.test", RoleMember}, |
| + {6, "full-owner@example.test", RoleOwner}, |
| + {999, "orphan-owner@example.test", RoleOwner}, |
| + } |
| + if reverse { |
| + sort.Slice(memberships, func(i, j int) bool { return memberships[i].address > memberships[j].address }) |
| + } |
| + for i, member := range memberships { |
| + sponsoredExec(t, s.db, `INSERT INTO memberships(account_id,address,role,added_at) VALUES(?,?,?,?)`, member.id, member.address, member.role, i+1) |
| + } |
| + |
| + sponsoredExec(t, s.db, `INSERT INTO billing_retirements(account_id,started_at) VALUES(5,50),(999,99)`) |
| + sponsoredExec(t, s.db, `INSERT INTO billing_accounts(account_id,house,customer,subscription,status,live_mode) VALUES |
| + (4,1,'','sub_deleted','canceled',0), |
| + (6,0,'cus_full','sub_full','active',1), |
| + (999,1,'cus_orphan','sub_orphan','active',1)`) |
| + sponsoredExec(t, s.db, `INSERT INTO billing_checkout_intents(account_id,id,plan,terms,band,price,quantity,created_at) VALUES |
| + (6,'ci_full','personal','terms','band','price',1,60), |
| + (999,'ci_orphan','personal','terms','band','price',1,99)`) |
| + sponsoredExec(t, s.db, `INSERT INTO entitlement_outbox(account_id,account,authority,revision,body,digest,live_mode,state,published_revision,published_digest,serial_owed,attempts,next_attempt_at,last_error_code,updated_at) VALUES |
| + (6,?,'raw-authority',7,?, 'digest-full',1,'pending',5,'published-digest',1,2,70,'raw-error',71), |
| + (999,?,'orphan-authority',1,?, 'digest-orphan',0,'held',0,'',0,0,0,'',99)`, domains.Sqid(6), []byte("not-json"), domains.Sqid(999), []byte("also-not-json")) |
| + sponsoredExec(t, s.db, `INSERT INTO billing_reviews(account_id,subscription,reason,created_at,resolved_at) VALUES |
| + (6,'sub_full','open-a',60,0), |
| + (6,'sub_full','open-b',61,0), |
| + (6,'sub_full','resolved',62,63), |
| + (999,'sub_orphan','open',99,0)`) |
| + sponsoredExec(t, s.db, `INSERT INTO entitlement_reviews(account_id,reason,revision,created_at) VALUES |
| + (6,'hold-a',7,60), |
| + (6,'hold-b',8,61), |
| + (999,'orphan-hold',1,99)`) |
| +} |
| + |
| +func openSponsoredTestStore(t *testing.T, name string) *Store { |
| + t.Helper() |
| + return openSponsoredStoreAt(t, filepath.Join(t.TempDir(), name+".db")) |
| +} |
| + |
| +func openSponsoredStoreAt(t *testing.T, path string) *Store { |
| + t.Helper() |
| + s, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + if err := s.Close(); err != nil { |
| + t.Errorf("close store: %v", err) |
| + } |
| + }) |
| + return s |
| +} |
| + |
| +func sponsoredInsertAccount(t *testing.T, db *sql.DB, id int64, name string, created, deleted int64) { |
| + t.Helper() |
| + sponsoredExec(t, db, `INSERT INTO accounts(id,sqid,name,created_at,deleted_at) VALUES(?,?,?,?,?)`, id, domains.Sqid(id), name, created, deleted) |
| +} |
| + |
| +func sponsoredExec(t *testing.T, db interface { |
| + Exec(string, ...any) (sql.Result, error) |
| +}, query string, args ...any) { |
| + t.Helper() |
| + if _, err := db.Exec(query, args...); err != nil { |
| + t.Fatalf("exec %q: %v", query, err) |
| + } |
| +} |
| + |
| +func sponsoredDeadline(t *testing.T) context.Context { |
| + t.Helper() |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + t.Cleanup(cancel) |
| + return ctx |
| +} |
| + |
| +func readSponsoredTestInventory(t *testing.T, s *Store) sponsoredCandidateInventory { |
| + t.Helper() |
| + got, err := s.readSponsoredCandidates(sponsoredDeadline(t)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return got |
| +} |
| + |
| +func assertSponsoredCaptureError(t *testing.T, capture func() (sponsoredCandidateInventory, error)) { |
| + t.Helper() |
| + got, err := capture() |
| + if err == nil { |
| + t.Fatalf("capture succeeded: %#v", got) |
| + } |
| + if !reflect.DeepEqual(got, sponsoredCandidateInventory{}) { |
| + t.Fatalf("partial result on error: %#v", got) |
| + } |
| +} |
| + |
| +type sponsoredTableSnapshot struct { |
| + Name, Schema string |
| + Rows []string |
| +} |
| + |
| +func sponsoredLogicalSnapshot(t *testing.T, db *sql.DB) []sponsoredTableSnapshot { |
| + t.Helper() |
| + rows, err := db.Query(`SELECT name,sql FROM sqlite_schema WHERE type='table' ORDER BY name`) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var tables []sponsoredTableSnapshot |
| + for rows.Next() { |
| + var table sponsoredTableSnapshot |
| + if err := rows.Scan(&table.Name, &table.Schema); err != nil { |
| + rows.Close() |
| + t.Fatal(err) |
| + } |
| + tables = append(tables, table) |
| + } |
| + if err := rows.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := rows.Err(); err != nil { |
| + t.Fatal(err) |
| + } |
| + for i := range tables { |
| + name := `"` + strings.ReplaceAll(tables[i].Name, `"`, `""`) + `"` |
| + data, err := db.Query(`SELECT * FROM ` + name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + columns, err := data.Columns() |
| + if err != nil { |
| + data.Close() |
| + t.Fatal(err) |
| + } |
| + for data.Next() { |
| + values := make([]any, len(columns)) |
| + pointers := make([]any, len(columns)) |
| + for j := range values { |
| + pointers[j] = &values[j] |
| + } |
| + if err := data.Scan(pointers...); err != nil { |
| + data.Close() |
| + t.Fatal(err) |
| + } |
| + parts := make([]string, len(values)) |
| + for j, value := range values { |
| + parts[j] = sponsoredSnapshotValue(value) |
| + } |
| + tables[i].Rows = append(tables[i].Rows, strings.Join(parts, "|")) |
| + } |
| + if err := data.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := data.Err(); err != nil { |
| + t.Fatal(err) |
| + } |
| + sort.Strings(tables[i].Rows) |
| + } |
| + return tables |
| +} |
| + |
| +func sponsoredSnapshotValue(value any) string { |
| + switch value := value.(type) { |
| + case nil: |
| + return "null" |
| + case int64: |
| + return fmt.Sprintf("int:%d", value) |
| + case float64: |
| + return fmt.Sprintf("float:%016x", value) |
| + case bool: |
| + return fmt.Sprintf("bool:%t", value) |
| + case string: |
| + return fmt.Sprintf("string:%q", value) |
| + case []byte: |
| + return "bytes:" + hex.EncodeToString(value) |
| + default: |
| + return fmt.Sprintf("%T:%v", value, value) |
| + } |
| +} |