253 lines
12 KiB
Go
253 lines
12 KiB
Go
package pgstore
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"textmachine/platform/internal/money"
|
|
)
|
|
|
|
// settle_guards_test.go: the two clauses of the settlement path that nothing executed.
|
|
//
|
|
// Both are the class PD-333 and PD-334 named — a caveat on the money path no test ever runs — and
|
|
// both are unreachable from the ONLY production caller today. That is exactly why they are pinned
|
|
// here and not left to the caller's own tests: an unreachable guard is one a later edit deletes
|
|
// without a single test going red, and the next caller inherits the hole.
|
|
|
|
// The negative-spend guard in Settle (register row PD-394). Removing it left the FULL battery green,
|
|
// all eighteen packages.
|
|
//
|
|
// Reachability today is zero and named rather than assumed: `attemptSpend` clamps a backwards meter
|
|
// to zero, so the reconciler never hands a negative figure down. What holds if the guard goes is the
|
|
// SCHEMA — a negative spend would write a settlement row with a positive amount, which
|
|
// `credit_ledger_sign` refuses — so the protection is TRANSITIVE, held by a constraint written for
|
|
// another purpose. This pin makes the guard hold its own weight.
|
|
//
|
|
// Mutation caught: deleting `if spent < 0` from Settle.
|
|
func TestSettlementRefusesANegativeSpendBeforeItReachesTheLedger(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
|
seedBook(t, s, ctx, "bk1", "u1", 500)
|
|
key := "run_neg#1"
|
|
if err := s.Hold(ctx, "u1", "bk1", key, money.MicroUSD(200_000), now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := balanceOf(t, s, ctx, "u1")
|
|
|
|
// ⚠ THE GUARD'S OWN ERROR and not merely "an error", which is what the first version of this pin
|
|
// asserted — and the planted mutation walked straight through it. With the guard deleted, Settle
|
|
// proceeds into its transaction and the SCHEMA refuses the row (`credit_ledger_sign`), so an
|
|
// error comes back either way and the pin proved nothing about the guard. That is precisely the
|
|
// transitive protection PD-394 describes, caught by this pack's own planting.
|
|
err := s.Settle(ctx, key, money.MicroUSD(-1), BasisComplete, now)
|
|
if !errors.Is(err, ErrNegativeSpend) {
|
|
t.Fatalf("Settle answered %v, want %v: a negative spend would CREDIT the account for having "+
|
|
"run something, and it must be refused HERE rather than by a constraint written for "+
|
|
"another purpose", err, ErrNegativeSpend)
|
|
}
|
|
// The refusal has to leave the world alone, which is the half a status check would miss: a guard
|
|
// that refuses AFTER closing the reservation strands the hold instead of protecting it.
|
|
if after := balanceOf(t, s, ctx, "u1"); after != before {
|
|
t.Errorf("the refused settlement moved the balance from %s to %s", before.USD(), after.USD())
|
|
}
|
|
open, err := s.AttemptReservationOpen(ctx, "run_neg", 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !open {
|
|
t.Error("the refused settlement closed the reservation anyway: the hold is now stranded")
|
|
}
|
|
}
|
|
|
|
// The `applied` flag of the settlement's own ledger write (found by this pack; not a register row
|
|
// when it was written).
|
|
//
|
|
// Settle was the odd one of the three places that call appendLedger: `holdTx` answers
|
|
// ErrDuplicateHold on a spent key, `releaseHold` answers ErrReleaseKeySpent and rolls back — and
|
|
// Settle discarded the flag. A spent `run_settle` key therefore charged NOTHING while the hold had
|
|
// already gone back whole, and the caller was told nil: the account keeps the money for a run it
|
|
// used. Reachability today is zero for the same reason as PD-394's guard, and the sequence that
|
|
// reaches it is the out-of-band one PD-97 already names.
|
|
//
|
|
// The key is spent HERE the only way it can be — by hand — because the code path that would do it
|
|
// is the one that must not exist.
|
|
//
|
|
// Mutation caught: `_, err = appendLedger(...)` in place of the checked form.
|
|
func TestASettlementWhoseKeyWasSpentIsRefusedRatherThanSilent(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
|
seedBook(t, s, ctx, "bk1", "u1", 500)
|
|
key := "run_spent#1"
|
|
if err := s.Hold(ctx, "u1", "bk1", key, money.MicroUSD(200_000), now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The settlement key, already posted. This is the state PD-97 describes on the release side:
|
|
// the money moved once out of band and the reservation is open again on the same attempt id.
|
|
exec(t, s, ctx, `
|
|
insert into credit_ledger (user_id, kind, amount_micro_usd, source, source_id, note, created_at)
|
|
values ('u1', 'settlement', -1, 'run_settle', $1, 'posted out of band', $2)`, key, now)
|
|
before := balanceOf(t, s, ctx, "u1")
|
|
|
|
err := s.Settle(ctx, key, money.MicroUSD(50_000), BasisComplete, now)
|
|
if !errors.Is(err, ErrSettlementKeySpent) {
|
|
t.Fatalf("Settle answered %v, want %v: a settlement that posts nothing while the hold goes "+
|
|
"back whole is a run the account paid for and did not", err, ErrSettlementKeySpent)
|
|
}
|
|
// The whole point of REPORTING rather than applying: the transaction rolls back, so the hold that
|
|
// was about to be returned for free is still reserved and a person can look at it.
|
|
if after := balanceOf(t, s, ctx, "u1"); after != before {
|
|
t.Errorf("the refused settlement still moved the balance, %s -> %s: the release went "+
|
|
"through and the charge did not", before.USD(), after.USD())
|
|
}
|
|
open, err := s.AttemptReservationOpen(ctx, "run_spent", 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !open {
|
|
t.Error("the reservation was closed by a settlement that charged nothing")
|
|
}
|
|
}
|
|
|
|
func balanceOf(t *testing.T, s *Store, ctx context.Context, user string) money.MicroUSD {
|
|
t.Helper()
|
|
b, err := s.Balance(ctx, user)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// `run abandon` on a run with MORE THAN ONE orphaned hold closes them all, and only then records the
|
|
// run's money as resolved.
|
|
//
|
|
// ⚠ The state is built by hand and that is deliberate and labelled: no ordinary path produces it,
|
|
// because `reopen` refuses to start an attempt while the previous one's reservation is open. But a
|
|
// run that needs this command is by definition one whose ordinary path came apart — and the failure
|
|
// mode if only the first is closed is the worst kind: `settled_at` says the money is resolved, the
|
|
// operator is told the hold came back whole, and a second hold stays debited from the account with
|
|
// nothing pointing at it. Same reasoning as `TestAReleaseWhoseKeyWasSpentIsRefusedRatherThanSilent`,
|
|
// which builds its own impossible state for the same reason.
|
|
//
|
|
// Mutation caught: reading the orphans with QueryRow (one row) instead of Query; stamping settled_at
|
|
// before the loop.
|
|
func TestAbandoningASettlementClosesEveryOrphanedHoldOfTheRun(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
|
seedBook(t, s, ctx, "bk1", "u1", 500)
|
|
exec(t, s, ctx, `insert into runs (id, book_id, status, verify_bank, ceiling_chapters, started_at,
|
|
finished_at, revision)
|
|
values ('run_two','bk1','failed',false,10,$1,$1,1)`, now)
|
|
for _, attempt := range []int{1, 2} {
|
|
key := ReservationKey("run_two", attempt)
|
|
// reconcile_failures is part of the state and not decoration: the command admits only a
|
|
// settlement that has actually failed, so a fixture without it would be testing the refusal.
|
|
exec(t, s, ctx, `insert into run_attempts (run_id, attempt_no, started_at, ended_at, last_offset,
|
|
engine_run_id, spend_baseline_micro_usd,
|
|
reconcile_failures)
|
|
values ('run_two',$1,$2,$2,0,$3,0,6)`, attempt, now, key)
|
|
if err := s.Hold(ctx, "u1", "bk1", key, money.MicroUSD(200_000), now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
before := balanceOf(t, s, ctx, "u1")
|
|
|
|
verdict, err := s.AbandonRun(ctx, AbandonOrder{RunID: "run_two", Reason: "the engine build was removed", ReleaseHold: false, Now: now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if verdict != AbandonedSettlement {
|
|
t.Errorf("verdict %q, want %q", verdict, AbandonedSettlement)
|
|
}
|
|
if after := balanceOf(t, s, ctx, "u1"); after != before+money.MicroUSD(400_000) {
|
|
t.Errorf("balance %s -> %s: only part of what this run was holding came back, and the "+
|
|
"operator was told it came back whole", before.USD(), after.USD())
|
|
}
|
|
for _, attempt := range []int{1, 2} {
|
|
open, err := s.AttemptReservationOpen(ctx, "run_two", attempt)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if open {
|
|
t.Errorf("attempt %d still holds the account's credit after the verdict", attempt)
|
|
}
|
|
}
|
|
var settled *time.Time
|
|
if err := s.pool.QueryRow(ctx, `select settled_at from runs where id = 'run_two'`).Scan(&settled); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if settled == nil {
|
|
t.Error("the run is still recorded as unsettled although every hold of it is closed")
|
|
}
|
|
// And a second verdict says the state rather than doing anything.
|
|
if _, err := s.AbandonRun(ctx, AbandonOrder{RunID: "run_two", Reason: "again", ReleaseHold: false, Now: now}); !errors.Is(err, ErrMoneyAlreadyClosed) {
|
|
t.Errorf("a second verdict answered %v, want %v", err, ErrMoneyAlreadyClosed)
|
|
}
|
|
}
|
|
|
|
// A settlement that has not reached the THRESHOLD is refused by `run abandon`, and refused because
|
|
// of the MONEY — even though the operator's own table already shows it.
|
|
//
|
|
// This command gives the hold back WHOLE. Every settlement is briefly open — an attempt's `ended_at`
|
|
// is stamped by one transaction and its reservation closed by a later one — so a branch keyed on the
|
|
// run being finished would let an operator hand back the entire hold of a run that finished normally,
|
|
// spent real money, and was about to be settled correctly on the next tick. Nothing outside would
|
|
// ever notice: the run leaves every worklist.
|
|
//
|
|
// The admitted set is therefore exactly the set the operator's own table SHOWS as `settling`, which
|
|
// is the same floor (`reconcile_failures >= 1`) StalledRuns applies.
|
|
//
|
|
// Mutation caught: dropping `and a.reconcile_failures >= 1` from abandonSettlement's admission.
|
|
func TestASettlementThatHasNotFailedIsRefusedRatherThanGivenAway(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
|
seedBook(t, s, ctx, "bk1", "u1", 500)
|
|
key := ReservationKey("run_healthy", 1)
|
|
exec(t, s, ctx, `insert into runs (id, book_id, status, verify_bank, ceiling_chapters, started_at,
|
|
finished_at, revision)
|
|
values ('run_healthy','bk1','ready',false,10,$1,$1,1)`, now)
|
|
// ONE failure: enough for the operator's table to show the row, deliberately NOT enough for the
|
|
// command that writes the hold off. That gap is the whole point of the two numbers.
|
|
exec(t, s, ctx, `insert into run_attempts (run_id, attempt_no, started_at, ended_at, last_offset,
|
|
engine_run_id, spend_baseline_micro_usd,
|
|
reconcile_failures)
|
|
values ('run_healthy',1,$1,$1,0,$2,0,1)`, now, key)
|
|
if err := s.Hold(ctx, "u1", "bk1", key, money.MicroUSD(200_000), now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := balanceOf(t, s, ctx, "u1")
|
|
|
|
if _, err := s.AbandonRun(ctx, AbandonOrder{RunID: "run_healthy", Reason: "looks stuck to me", ReleaseHold: false, Now: now}); !errors.Is(err, ErrSettlementNotStuck) {
|
|
t.Fatalf("AbandonRun answered %v, want %v: one failed tick is a row an operator can SEE, not "+
|
|
"a hold they may write off — returning it whole would charge nothing for whatever the "+
|
|
"run actually spent", err, ErrSettlementNotStuck)
|
|
}
|
|
if after := balanceOf(t, s, ctx, "u1"); after != before {
|
|
t.Errorf("the refused verdict moved the balance %s -> %s", before.USD(), after.USD())
|
|
}
|
|
open, err := s.AttemptReservationOpen(ctx, "run_healthy", 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !open {
|
|
t.Error("the refused verdict closed the reservation anyway")
|
|
}
|
|
}
|
|
|
|
// The floor `run abandon` acts on and the threshold everything else measures against are ONE number.
|
|
//
|
|
// They are written in two packages — `runs.StalledAfter` cannot travel here, because `internal/runs`
|
|
// depends on this package and not the other way — so the only thing keeping them equal is this
|
|
// assertion. If they drift, the command either refuses rows the operator's own table calls stalled,
|
|
// or writes off holds the table never showed as stalled at all.
|
|
//
|
|
// Mutation caught: changing either constant alone.
|
|
func TestTheAbandonFloorIsTheStalledThreshold(t *testing.T) {
|
|
// The literal is repeated on purpose: importing `runs` here would be an import cycle, and a test
|
|
// that read the same constant twice would assert nothing.
|
|
if abandonAfter != 5 {
|
|
t.Errorf("abandonAfter is %d; runs.StalledAfter is 5, and the two are one number: an "+
|
|
"operator may only write off a hold the threshold has already called stalled", abandonAfter)
|
|
}
|
|
}
|