765 lines
32 KiB
Go
765 lines
32 KiB
Go
package pgstore
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"textmachine/platform/internal/money"
|
|
)
|
|
|
|
// The balance cache and the ledger are two representations of the same fact, and money is the one
|
|
// place where "usually consistent" is not a property. This asserts they agree after EVERY step.
|
|
// Mutation caught: updating account_balances outside the ledger's transaction, or skipping it.
|
|
func TestCreditLifecycleKeepsTheCacheEqualToTheLedger(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk1','u1','蛊真人','zh','ru','not_started','/srv/books/bk1','gzr')`)
|
|
now := time.Now().UTC()
|
|
|
|
check := func(step string, want money.MicroUSD) {
|
|
t.Helper()
|
|
a, err := s.ReadAccount(ctx, "u1")
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", step, err)
|
|
}
|
|
if a.Balance != a.LedgerSum {
|
|
t.Fatalf("%s: cached balance %d disagrees with the ledger %d", step, a.Balance, a.LedgerSum)
|
|
}
|
|
if a.Balance != want {
|
|
t.Fatalf("%s: balance = %s, want %s", step, a.Balance.USD(), want.USD())
|
|
}
|
|
}
|
|
|
|
if _, err := s.Grant(ctx, "u1", 5*money.PerUSD, "admin", "g1", "free tier", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check("after the grant", 5*money.PerUSD)
|
|
|
|
if err := s.Hold(ctx, "u1", "bk1", "run-1", 2*money.PerUSD, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check("while a run is held", 3*money.PerUSD)
|
|
|
|
// The attempt cost less than it reserved: the difference comes back.
|
|
if err := s.Settle(ctx, "run-1", 1_200_000, BasisComplete, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check("after settlement", 5*money.PerUSD-1_200_000)
|
|
|
|
// A second settlement of the same attempt changes nothing: the reservation is no longer open.
|
|
if err := s.Settle(ctx, "run-1", 1_200_000, BasisComplete, now); !errors.Is(err, ErrNoReservation) {
|
|
t.Fatalf("a repeated settlement must be refused, got %v", err)
|
|
}
|
|
check("after a repeated settlement", 5*money.PerUSD-1_200_000)
|
|
|
|
// A run that never spent gives its whole reservation back.
|
|
if err := s.Hold(ctx, "u1", "bk1", "run-2", 1*money.PerUSD, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check("while the second run is held", 5*money.PerUSD-1_200_000-1*money.PerUSD)
|
|
if err := s.Release(ctx, "run-2", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check("after release", 5*money.PerUSD-1_200_000)
|
|
}
|
|
|
|
// Idempotency is what makes a retried worker safe. Mutation caught: dropping the ON CONFLICT clause
|
|
// (the insert then fails) or moving the balance update outside the "actually inserted" branch (the
|
|
// balance then doubles while the ledger does not).
|
|
func TestGrantIsIdempotentBySource(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
now := time.Now().UTC()
|
|
|
|
for i := range 3 {
|
|
applied, err := s.Grant(ctx, "u1", 5*money.PerUSD, "admin", "same-key", "free tier", now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The caller must be able to tell "credited" from "already spent": a CLI that prints
|
|
// success on the second call tells an operator money moved when it did not.
|
|
if applied != (i == 0) {
|
|
t.Fatalf("call %d reported applied=%v", i, applied)
|
|
}
|
|
}
|
|
got, err := s.Balance(ctx, "u1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != 5*money.PerUSD {
|
|
t.Fatalf("three identical grants credited %s", got.USD())
|
|
}
|
|
var rows int
|
|
if err := s.pool.QueryRow(ctx, `select count(*) from credit_ledger where user_id='u1'`).Scan(&rows); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rows != 1 {
|
|
t.Fatalf("ledger has %d rows for one grant", rows)
|
|
}
|
|
}
|
|
|
|
// The hold is the enforcement half of the design: credit that is reserved is not available to the
|
|
// next run. Mutation caught: removing the balance check. The FOR UPDATE that serialises it is a
|
|
// CONCURRENCY property and no sequential test can see it — that one is pinned below.
|
|
func TestHoldRefusesMoreThanTheBalance(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk1','u1','蛊真人','zh','ru','not_started','/srv/books/bk1','gzr')`)
|
|
now := time.Now().UTC()
|
|
if _, err := s.Grant(ctx, "u1", 1*money.PerUSD, "admin", "g1", "free tier", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := s.Hold(ctx, "u1", "bk1", "run-1", 2*money.PerUSD, now); !errors.Is(err, ErrInsufficientCredit) {
|
|
t.Fatalf("a hold beyond the balance must be refused, got %v", err)
|
|
}
|
|
// And the refusal left nothing behind.
|
|
got, err := s.Balance(ctx, "u1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != 1*money.PerUSD {
|
|
t.Fatalf("balance moved on a refused hold: %s", got.USD())
|
|
}
|
|
var reservations int
|
|
if err := s.pool.QueryRow(ctx, `select count(*) from reservations`).Scan(&reservations); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if reservations != 0 {
|
|
t.Fatalf("a refused hold left %d reservations", reservations)
|
|
}
|
|
|
|
// An account with no credit at all is refused the same way, not crashed.
|
|
seedUser(t, s, ctx, "u2")
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk2','u2','x','zh','ru','not_started','/srv/books/bk2','x')`)
|
|
if err := s.Hold(ctx, "u2", "bk2", "run-2", 1, now); !errors.Is(err, ErrInsufficientCredit) {
|
|
t.Fatalf("an account with no ledger must be refused, got %v", err)
|
|
}
|
|
}
|
|
|
|
// The sign rules are DDL, so a sign error in the code that writes money fails at the write instead
|
|
// of quietly topping an account up.
|
|
func TestLedgerRefusesWrongSigns(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
t.Run("a grant is never a debit", func(t *testing.T) {
|
|
assertViolation(t, s, ctx, "credit_ledger_sign",
|
|
`insert into credit_ledger (user_id, kind, amount_micro_usd, source, source_id)
|
|
values ('u1','grant',-1,'x','1')`)
|
|
})
|
|
t.Run("a hold is never a credit", func(t *testing.T) {
|
|
assertViolation(t, s, ctx, "credit_ledger_sign",
|
|
`insert into credit_ledger (user_id, kind, amount_micro_usd, source, source_id)
|
|
values ('u1','hold',1,'x','2')`)
|
|
})
|
|
t.Run("a settlement never credits", func(t *testing.T) {
|
|
assertViolation(t, s, ctx, "credit_ledger_sign",
|
|
`insert into credit_ledger (user_id, kind, amount_micro_usd, source, source_id)
|
|
values ('u1','settlement',1,'x','3')`)
|
|
})
|
|
t.Run("an adjustment carries a reason", func(t *testing.T) {
|
|
assertViolation(t, s, ctx, "credit_ledger_adjustment_has_note",
|
|
`insert into credit_ledger (user_id, kind, amount_micro_usd, source, source_id)
|
|
values ('u1','adjustment',5,'x','4')`)
|
|
})
|
|
t.Run("a closed reservation has a closing time", func(t *testing.T) {
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`)
|
|
assertViolation(t, s, ctx, "reservations_closed_has_time",
|
|
`insert into reservations (engine_run_id, user_id, book_id, amount_micro_usd, ceiling_micro_usd, state)
|
|
values ('r1','u1','bk1',1,1,'settled')`)
|
|
})
|
|
}
|
|
|
|
// The payer must own the book. The database refuses it, not a check the next caller has to
|
|
// remember: charging one account for another's translation is the money shape of API1 BOLA.
|
|
// Mutation caught: inserting the reservation without the owner condition, or dropping the
|
|
// composite foreign key.
|
|
func TestHoldRefusesAnotherAccountsBook(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
seedUser(t, s, ctx, "u2")
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`)
|
|
now := time.Now().UTC()
|
|
if _, err := s.Grant(ctx, "u2", 5*money.PerUSD, "admin", "g", "", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Hold(ctx, "u2", "bk1", "run-1", money.PerUSD, now); !errors.Is(err, ErrNotOwner) {
|
|
t.Fatalf("holding against another account's book returned %v", err)
|
|
}
|
|
a, err := s.ReadAccount(ctx, "u2")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a.Balance != 5*money.PerUSD {
|
|
t.Fatalf("the refused hold moved money: %s", a.Balance.USD())
|
|
}
|
|
}
|
|
|
|
// A hold that debits nothing reserves nothing. If the ledger already holds this attempt id, the
|
|
// insert is a no-op and reporting success would spawn a run against credit nobody set aside.
|
|
func TestSecondHoldOnOneAttemptIsRefused(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`)
|
|
now := time.Now().UTC()
|
|
if _, err := s.Grant(ctx, "u1", 5*money.PerUSD, "admin", "g", "", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Hold(ctx, "u1", "bk1", "run-1", money.PerUSD, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Release(ctx, "run-1", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Same attempt id again: the reservation row is gone from `open`, but the ledger key is spent.
|
|
if err := s.Hold(ctx, "u1", "bk1", "run-1", money.PerUSD, now); err == nil {
|
|
t.Fatal("a second hold on one attempt id was accepted; it debited nothing")
|
|
}
|
|
}
|
|
|
|
// Spending more than was reserved means the engine's ceiling did not hold. The account is not the
|
|
// place to absorb that: the settlement is capped and the row says so.
|
|
// Mutation caught: settling the reported amount unchecked (the balance then goes negative).
|
|
func TestSettlementIsCappedAtTheHold(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`)
|
|
now := time.Now().UTC()
|
|
if _, err := s.Grant(ctx, "u1", 5*money.PerUSD, "admin", "g", "", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Hold(ctx, "u1", "bk1", "run-1", 2*money.PerUSD, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Settle(ctx, "run-1", 500*money.PerUSD, BasisComplete, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a, err := s.ReadAccount(ctx, "u1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a.Balance != 3*money.PerUSD {
|
|
t.Fatalf("balance = %s, want the hold and nothing more taken", a.Balance.USD())
|
|
}
|
|
var note string
|
|
if err := s.pool.QueryRow(ctx,
|
|
`select note from credit_ledger where kind='settlement'`).Scan(¬e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if note == "" {
|
|
t.Fatal("a capped settlement must say so in the row")
|
|
}
|
|
}
|
|
|
|
// A book with money reserved against it cannot be deleted. Cascading here would leave the `hold`
|
|
// row in the ledger with nothing left to release it.
|
|
func TestBookWithAnOpenHoldCannotBeDeleted(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`)
|
|
now := time.Now().UTC()
|
|
if _, err := s.Grant(ctx, "u1", 5*money.PerUSD, "admin", "g", "", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Hold(ctx, "u1", "bk1", "run-1", money.PerUSD, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.DeleteBook(ctx, "bk1"); err == nil {
|
|
t.Fatal("a book with an open hold was deleted; its ledger debit is now unreleasable")
|
|
}
|
|
if err := s.Release(ctx, "run-1", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.DeleteBook(ctx, "bk1"); err != nil {
|
|
t.Fatalf("a book with no open hold must be deletable: %v", err)
|
|
}
|
|
// The money history stays: the ledger is the financial record, the reservation was bookkeeping.
|
|
a, err := s.ReadAccount(ctx, "u1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a.Balance != 5*money.PerUSD || a.Balance != a.LedgerSum {
|
|
t.Fatalf("deleting the book moved money: %s", a.Balance.USD())
|
|
}
|
|
}
|
|
|
|
// PD-26/PD-52. Every money operation takes the balance row lock FIRST, and the rule is only worth
|
|
// having if a test notices its removal. The cycle needs a settlement and a hold that touch the same
|
|
// reservation row: with the lock taken first in both, Settle waits for the balance before it touches
|
|
// the row, so Hold never waits on a row while holding what Settle wants. Take it out of
|
|
// closeReservation and the two acquire in opposite orders.
|
|
//
|
|
// Written by acceptance; re-measured here on PostgreSQL 18.4: with the lock order inverted it fails
|
|
// 5 runs out of 5, at 5-10 deadlocks per 150 rounds, and with the fix it is green. Probabilistic in
|
|
// the failing direction, which is why the round count stays high.
|
|
// Mutation caught: deleting the lockBalance call from closeReservation.
|
|
func TestHoldAndSettleOnTheSameAttemptDoNotDeadlock(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`)
|
|
now := time.Now().UTC()
|
|
if _, err := s.Grant(ctx, "u1", 100000*money.PerUSD, "admin", "g", "", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var mu sync.Mutex
|
|
var deadlocks int
|
|
note := func(err error) {
|
|
if err != nil && strings.Contains(err.Error(), "deadlock") {
|
|
mu.Lock()
|
|
deadlocks++
|
|
mu.Unlock()
|
|
}
|
|
}
|
|
for i := range 150 {
|
|
id := fmt.Sprintf("run-%d", i)
|
|
if err := s.Hold(ctx, "u1", "bk1", id, money.PerUSD, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var wg sync.WaitGroup
|
|
wg.Go(func() { note(s.Settle(ctx, id, money.PerUSD/2, BasisComplete, now)) })
|
|
wg.Go(func() { note(s.Hold(ctx, "u1", "bk1", id, money.PerUSD, now)) })
|
|
wg.Wait()
|
|
}
|
|
|
|
a, err := s.ReadAccount(ctx, "u1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a.Balance != a.LedgerSum {
|
|
t.Fatalf("the cache drifted from the ledger: %s against %s", a.Balance.USD(), a.LedgerSum.USD())
|
|
}
|
|
if deadlocks > 0 {
|
|
t.Fatalf("%d deadlocks in 150 rounds: the lock order is not the same in both paths", deadlocks)
|
|
}
|
|
}
|
|
|
|
// PD-56. A typo in an account id is the commonest operator error, and every money entry point must
|
|
// name it the same way. Before this, Balance said "no such account" while Grant and Adjust returned
|
|
// the Postgres constraint name — which reads as a broken database, not a mistyped id.
|
|
// Mutation caught: dropping the constraint check in appendLedger.
|
|
func TestMoneyOperationsAgreeOnAMissingAccount(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := time.Now().UTC()
|
|
grant := func() error { _, err := s.Grant(ctx, "no-such-user", money.PerUSD, "admin", "k1", "", now); return err }
|
|
adjust := func() error {
|
|
_, err := s.Adjust(ctx, "no-such-user", money.PerUSD, "admin", "k2", "why", now)
|
|
return err
|
|
}
|
|
balance := func() error { _, err := s.Balance(ctx, "no-such-user"); return err }
|
|
read := func() error { _, err := s.ReadAccount(ctx, "no-such-user"); return err }
|
|
for name, call := range map[string]func() error{
|
|
"grant": grant, "adjust": adjust, "balance": balance, "read account": read,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if err := call(); !errors.Is(err, ErrNoAccount) {
|
|
t.Fatalf("got %v, want ErrNoAccount", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The row lock, not the comparison, is what stops two runs from spending the same credit. Each hold
|
|
// here is affordable on its own and they are not affordable together, so without FOR UPDATE both
|
|
// read the same balance, both pass the check, and the account goes negative — the cache and the
|
|
// ledger drifting together, which is why the invariant assertions elsewhere cannot see it either.
|
|
// Found by review: the sequential test above claimed this and could not deliver it.
|
|
// Mutation caught: dropping `for update` from lockBalance.
|
|
func TestConcurrentHoldsCannotOvercommitAnAccount(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`)
|
|
now := time.Now().UTC()
|
|
|
|
const rounds = 60
|
|
for i := range rounds {
|
|
user := fmt.Sprintf("u-%d", i)
|
|
book := fmt.Sprintf("bk-%d", i)
|
|
seedUser(t, s, ctx, user)
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ($1,$2,'x','zh','ru','not_started','/srv/books/x','x')`, book, user)
|
|
// Ten dollars, and two runs that each want six.
|
|
if _, err := s.Grant(ctx, user, 10*money.PerUSD, "admin", "g", "", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
var granted int
|
|
for j := range 2 {
|
|
wg.Go(func() {
|
|
err := s.Hold(ctx, user, book, fmt.Sprintf("run-%d-%d", i, j), 6*money.PerUSD, now)
|
|
switch {
|
|
case err == nil:
|
|
mu.Lock()
|
|
granted++
|
|
mu.Unlock()
|
|
case errors.Is(err, ErrInsufficientCredit):
|
|
default:
|
|
mu.Lock()
|
|
t.Errorf("round %d: unexpected hold error: %v", i, err)
|
|
mu.Unlock()
|
|
}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
|
|
a, err := s.ReadAccount(ctx, user)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if granted != 1 {
|
|
t.Fatalf("round %d: %d of two competing holds were granted from one balance; balance is now %s",
|
|
i, granted, a.Balance.USD())
|
|
}
|
|
if a.Balance < 0 {
|
|
t.Fatalf("round %d: balance went negative (%s): two runs spent the same credit", i, a.Balance.USD())
|
|
}
|
|
if a.Balance != a.LedgerSum {
|
|
t.Fatalf("round %d: cache %s disagrees with the ledger %s", i, a.Balance.USD(), a.LedgerSum.USD())
|
|
}
|
|
}
|
|
}
|
|
|
|
// `blocked` names the book whose hold ACTUALLY shortens the scale — the largest — and not whichever
|
|
// was opened first. The sum is what decides that the scale is shorter, so naming a $0.03 hold beside
|
|
// a $9.60 one sends the user off to cancel a run that would free almost nothing.
|
|
//
|
|
// Mutation caught: ordering by opened_at.
|
|
func TestTheHoldThatIsNamedIsTheOneThatWouldFreeTheMost(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
|
for _, b := range []string{"bk_small", "bk_large", "bk_own"} {
|
|
seedBook(t, s, ctx, b, "u1", 500)
|
|
}
|
|
// The SMALL hold is opened first, so an answer by age and an answer by amount differ.
|
|
if err := s.Hold(ctx, "u1", "bk_small", "eng_small", money.MicroUSD(30_000), now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Hold(ctx, "u1", "bk_large", "eng_large", money.MicroUSD(9_600_000), now.Add(time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
book, held, err := s.CreditHeldBy(ctx, "u1", "bk_own")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if held != money.MicroUSD(9_630_000) {
|
|
t.Fatalf("the held sum is %s: this fixture is not the one it claims", held.USD())
|
|
}
|
|
if book != "bk_large" {
|
|
t.Errorf("blocked names %q, want the book holding most of the credit", book)
|
|
}
|
|
}
|
|
|
|
// A book's OWN hold is never what is holding it down. The test above looks like it covers the
|
|
// exclusion and does not: in its fixture the excluded book has no hold at all, so the clause is
|
|
// never executed (mutation landed 21.08 — removing `and book_id <> $2` passed the whole battery).
|
|
//
|
|
// What it costs is on the wire and it is money-shaped. The sum feeds `blocked`, which answers "why
|
|
// is this scale shorter than the account could afford"; counting the book's own hold inflates that
|
|
// sum, and the run-options screen then invites the user to go and stop the run of THIS book to make
|
|
// room for THIS book. Stopping it frees the hold and leaves the scale exactly where it was — the
|
|
// hold is a debit already excluded from the balance — so the advice cannot even be right by accident.
|
|
//
|
|
// Mutation caught: dropping `and book_id <> $2` from either subquery of CreditHeldBy.
|
|
func TestABooksOwnHoldIsNotWhatIsHoldingItDown(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
|
for _, b := range []string{"bk_own", "bk_other"} {
|
|
seedBook(t, s, ctx, b, "u1", 500)
|
|
}
|
|
// The book being ASKED about holds the most, so an answer that forgot to exclude it names it.
|
|
if err := s.Hold(ctx, "u1", "bk_own", "eng_own", money.MicroUSD(9_600_000), now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Hold(ctx, "u1", "bk_other", "eng_other", money.MicroUSD(30_000), now.Add(time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
book, held, err := s.CreditHeldBy(ctx, "u1", "bk_own")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if book != "bk_other" {
|
|
t.Errorf("blocked names %q: a book cannot be blocked by itself, and stopping that run frees nothing", book)
|
|
}
|
|
if held != money.MicroUSD(30_000) {
|
|
t.Errorf("the held sum is %s and only %s belongs to another book: an inflated sum is what turns the answer into advice to stop a run that would free nothing",
|
|
held.USD(), money.MicroUSD(30_000).USD())
|
|
}
|
|
}
|
|
|
|
// The share is of everything ever ADDED to the account, and on the beta that is not the `grant` rows:
|
|
// the signup grant is zero and an operator tops an account up with `adjust`, which writes another
|
|
// kind. Counted over grants alone, an account holding $20 that can start runs answered
|
|
// `remaining_percent: 0` — the same "you have no money" a funded user must never be told.
|
|
//
|
|
// Mutation caught: narrowing the denominator back to kind = 'grant'.
|
|
func TestTheShareCountsEveryWayCreditWasAdded(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := time.Now().UTC().Truncate(time.Millisecond)
|
|
seedUser(t, s, ctx, "beta")
|
|
if _, err := s.Adjust(ctx, "beta", money.MicroUSD(20_000_000), "test", "topup", "beta top-up", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u, err := s.ReadUsage(ctx, "beta")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if u.RemainingPercent != 100 || !u.Spendable || u.PausedReason != "" {
|
|
t.Errorf("an account topped up with an adjustment: %+v", u)
|
|
}
|
|
// …and a NEGATIVE adjustment is a correction, on the other side of the fraction like a spend.
|
|
if _, err := s.Adjust(ctx, "beta", money.MicroUSD(-15_000_000), "test", "fix", "correction", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if u, err = s.ReadUsage(ctx, "beta"); err != nil || u.RemainingPercent != 25 {
|
|
t.Errorf("after a correction: %+v (%v)", u, err)
|
|
}
|
|
}
|
|
|
|
// The two tests below exist because this pack MEASURED that nothing exercised the statements they
|
|
// cover: OpenReservations ran only ever against an EMPTY result set, so its five Scan targets — two
|
|
// of them money — were checked by nothing, and Balance's null branch was never reached. sqlc now
|
|
// generates the Scan, which is what stops the SQL and the Scan from disagreeing; these pin the half
|
|
// sqlc cannot reach, which is that a row lands in the right FIELD of the domain type.
|
|
func TestAnOpenHoldIsListedWithItsOwnAmountAndBook(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u_res")
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk_res','u_res','蛊真人','zh','ru','not_started','/srv/books/bk_res','gzr')`)
|
|
now := time.Now().UTC()
|
|
if _, err := s.Grant(ctx, "u_res", 5*money.PerUSD, "admin", "g_res", "free tier", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Hold(ctx, "u_res", "bk_res", "run_res#1", money.MicroUSD(1_250_000), now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
open, err := s.OpenReservations(ctx, "u_res")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(open) != 1 {
|
|
t.Fatalf("want exactly one open hold, got %d", len(open))
|
|
}
|
|
r := open[0]
|
|
// Every field asserted by name, and the two ids carry DIFFERENT values on purpose: a fixture that
|
|
// reuses one value cannot tell a transposition of the two strings from a correct read. The same
|
|
// goes for the two money columns, which are equal by construction today — so the assertion that
|
|
// catches a transposition between them is the one on the VALUE, kept distinct from the balance.
|
|
if r.EngineRunID != "run_res#1" {
|
|
t.Errorf("EngineRunID = %q, want run_res#1 (a swap with BookID reads exactly like this)", r.EngineRunID)
|
|
}
|
|
if r.BookID != "bk_res" {
|
|
t.Errorf("BookID = %q, want bk_res", r.BookID)
|
|
}
|
|
if r.Amount != money.MicroUSD(1_250_000) {
|
|
t.Errorf("Amount = %d, want 1250000", r.Amount)
|
|
}
|
|
if r.OpenedAt.IsZero() {
|
|
t.Error("OpenedAt is zero: the timestamp column did not reach its field")
|
|
}
|
|
|
|
// ⚠ The amount/ceiling pair needs a row that `Hold` CANNOT produce, and this is why. holdTx writes
|
|
// both columns from one parameter (`select $1, $2, $3, $4, $4, ...`), so on every row this API can
|
|
// create they are equal — and an assertion over two equal values cannot tell a transposition from
|
|
// a correct read. Measured, not assumed: with the fixture above alone, a planted swap of the two
|
|
// money fields SURVIVED this test. So the pair is given different values directly, which is
|
|
// legitimate because what is under test here is the READ mapping and not how the row was written.
|
|
exec(t, s, ctx, `insert into reservations
|
|
(engine_run_id, user_id, book_id, amount_micro_usd, ceiling_micro_usd, state, opened_at)
|
|
values ('run_res#2','u_res','bk_res', 700000, 900000, 'open', now() + interval '1 minute')`)
|
|
// A CLOSED hold, so that the predicate this function is NAMED for is visible to the test. Without
|
|
// it, dropping `state = 'open'` from the query changed nothing here and the whole battery stayed
|
|
// green — measured. The cost of that is an operator being shown settled holds as money still
|
|
// frozen, under a doc comment promising the opposite.
|
|
exec(t, s, ctx, `insert into reservations
|
|
(engine_run_id, user_id, book_id, amount_micro_usd, ceiling_micro_usd, state, opened_at, closed_at)
|
|
values ('run_res#3','u_res','bk_res', 500000, 500000, 'released', now(), now())`)
|
|
open, err = s.OpenReservations(ctx, "u_res")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(open) != 2 {
|
|
t.Fatalf("want exactly the two OPEN holds, got %d: a released hold is not money anybody can give back", len(open))
|
|
}
|
|
for _, r := range open {
|
|
if r.EngineRunID == "run_res#3" {
|
|
t.Error("a released hold was listed as open")
|
|
}
|
|
}
|
|
// Oldest first, as the query's `order by opened_at` promises: the operator reads this list top
|
|
// down and the order is the only thing saying which hold has been stuck longest.
|
|
if open[0].EngineRunID != "run_res#1" || open[1].EngineRunID != "run_res#2" {
|
|
t.Errorf("holds are not oldest-first: got %s then %s", open[0].EngineRunID, open[1].EngineRunID)
|
|
}
|
|
second := &open[1]
|
|
if second.Amount != money.MicroUSD(700_000) {
|
|
t.Errorf("Amount = %d, want 700000 (900000 means it was read from ceiling_micro_usd)", second.Amount)
|
|
}
|
|
if second.Ceiling != money.MicroUSD(900_000) {
|
|
t.Errorf("Ceiling = %d, want 900000 (700000 means it was read from amount_micro_usd)", second.Ceiling)
|
|
}
|
|
}
|
|
|
|
func TestAnAccountWithNoLedgerRowsHasNoCreditRatherThanAnError(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
// An account that exists and has never been credited has no balance row either, so the LEFT JOIN
|
|
// produces NULL. That is the case the generated layer ERRORS on unless the query coalesces it —
|
|
// the column is `not null` in the table, so the generator cannot see that the join makes it
|
|
// nullable — and it is the case no test reached before this one.
|
|
seedUser(t, s, ctx, "u_bare")
|
|
got, err := s.Balance(ctx, "u_bare")
|
|
if err != nil {
|
|
t.Fatalf("an account with no ledger rows must not be an error: %v", err)
|
|
}
|
|
if got != 0 {
|
|
t.Errorf("balance = %d, want 0", got)
|
|
}
|
|
// And an account that does not exist stays distinguishable from one that is merely empty.
|
|
if _, err := s.Balance(ctx, "u_absent"); !errors.Is(err, ErrNoAccount) {
|
|
t.Errorf("a missing account must answer ErrNoAccount, got %v", err)
|
|
}
|
|
}
|
|
|
|
// Every settlement row says its figure is a floor (PD-441): the charge is the engine's committed
|
|
// meter, and a call cancelled in flight is recorded there at zero.
|
|
//
|
|
// The halted basis must say MORE than the complete one — a cure that labelled every row identically
|
|
// would satisfy "the note is not empty" and tell an operator nothing about which rows to distrust.
|
|
func TestEverySettlementSaysThatItsFigureIsAFloor(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk1','u1','蛊真人','zh','ru','not_started','/srv/books/bk1','gzr')`)
|
|
now := time.Now().UTC()
|
|
if _, err := s.Grant(ctx, "u1", 5*money.PerUSD, "admin", "g1", "free tier", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
noteOf := func(t *testing.T, key string) string {
|
|
t.Helper()
|
|
var note string
|
|
if err := s.pool.QueryRow(ctx,
|
|
`select note from credit_ledger where kind = 'settlement' and source_id = $1`, key).Scan(¬e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return note
|
|
}
|
|
for _, c := range []struct {
|
|
key string
|
|
basis SettlementBasis
|
|
want []string
|
|
}{
|
|
{"run-c#1", BasisComplete, []string{"at least"}},
|
|
{"run-h#1", BasisHalted, []string{"at least", "cut off mid-work", "PD-441"}},
|
|
} {
|
|
if err := s.Hold(ctx, "u1", "bk1", c.key, money.PerUSD, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Settle(ctx, c.key, money.PerUSD/2, c.basis, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
note := noteOf(t, c.key)
|
|
for _, w := range c.want {
|
|
if !strings.Contains(note, w) {
|
|
t.Errorf("the %s settlement's note does not carry %q, so the row reads as a price: %q",
|
|
c.basis, w, note)
|
|
}
|
|
}
|
|
}
|
|
if noteOf(t, "run-c#1") == noteOf(t, "run-h#1") {
|
|
t.Error("both bases wrote the same note: an operator cannot tell which rows are short for a " +
|
|
"reason we know of, which is the whole of what the label buys")
|
|
}
|
|
// The cap joins the basis rather than replacing it: a settlement that is both capped and cut off
|
|
// is the row an operator most needs to read twice.
|
|
if err := s.Hold(ctx, "u1", "bk1", "run-x#1", money.PerUSD/4, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Settle(ctx, "run-x#1", money.PerUSD, BasisHalted, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
capped := noteOf(t, "run-x#1")
|
|
if !strings.Contains(capped, "at least") || !strings.Contains(capped, "capped at the hold") {
|
|
t.Errorf("a capped settlement lost one of its two facts: %q", capped)
|
|
}
|
|
}
|
|
|
|
// The two independent paths to the same money, on rows this test writes: the raw ledger read with
|
|
// SQL, and the platform's own read model. A cached balance that can drift from its source is a second
|
|
// source of truth about money, and the sum is the one place that catches the drift.
|
|
//
|
|
// It prints both under `-v` so the figures in a report are reproducible by name rather than quoted
|
|
// from a scratch database somebody has since dropped.
|
|
func TestTheRawLedgerAndTheReadModelAgreeOnWhatWasSpent(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
|
|
values ('bk1','u1','蛊真人','zh','ru','not_started','/srv/books/bk1','gzr')`)
|
|
now := time.Now().UTC()
|
|
if _, err := s.Grant(ctx, "u1", 5*money.PerUSD, "admin", "g1", "free tier", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// One attempt the engine ended itself and one it was cut off on — the pair PD-441 is about.
|
|
for _, c := range []struct {
|
|
key string
|
|
basis SettlementBasis
|
|
spent money.MicroUSD
|
|
}{
|
|
{"run_A#1", BasisComplete, 17_409},
|
|
{"run_B#1", BasisHalted, 63_404},
|
|
} {
|
|
if err := s.Hold(ctx, "u1", "bk1", c.key, money.PerUSD, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Settle(ctx, c.key, c.spent, c.basis, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx,
|
|
`select kind, amount_micro_usd, source_id, note from credit_ledger where user_id = 'u1' order by id`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var sum int64
|
|
var n int
|
|
for rows.Next() {
|
|
var kind, src, note string
|
|
var amt int64
|
|
if err := rows.Scan(&kind, &amt, &src, ¬e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum, n = sum+amt, n+1
|
|
t.Logf("ledger: %-13s %+10d %-9s %s", kind, amt, src, note)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a, err := s.ReadAccount(ctx, "u1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Logf("raw ledger: %d rows, %d micro · read model: balance %s, ledger sum %s",
|
|
n, sum, a.Balance.USD(), a.LedgerSum.USD())
|
|
if int64(a.Balance) != sum || int64(a.LedgerSum) != sum {
|
|
t.Fatalf("the two paths disagree: raw %d, read model %d/%d", sum, a.Balance, a.LedgerSum)
|
|
}
|
|
if n != 7 {
|
|
t.Fatalf("the ledger holds %d rows, want the grant plus a hold, release and settlement per attempt", n)
|
|
}
|
|
}
|