textmachine/platform/internal/runs/bank_test.go

809 lines
35 KiB
Go

package runs
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"textmachine/platform/internal/ingest"
"textmachine/platform/internal/money"
"textmachine/platform/internal/pgstore"
"textmachine/platform/internal/pricing"
"textmachine/platform/internal/runner"
)
// The correction door's service half (bank.go): the verdict over the engine's exit band, and the
// per-book serialization against the run lifecycle.
type fakeBankApplier struct {
mu sync.Mutex
out runner.BankApplyOutcome
err error
calls int
lastDoc []byte
// entered/release, when set, gate the call so a test can hold the verb mid-flight.
entered chan struct{}
release chan struct{}
}
func (f *fakeBankApplier) BankApply(_ context.Context, _, _, decisionsPath string, _ bool) (runner.BankApplyOutcome, error) {
f.mu.Lock()
f.calls++
f.lastDoc, _ = os.ReadFile(decisionsPath)
entered, release := f.entered, f.release
f.mu.Unlock()
if entered != nil {
close(entered)
<-release
}
return f.out, f.err
}
func okReport(mode string) ingest.BankReport {
return ingest.BankReport{
Version: ingest.DecisionsReportVersion, BookID: "bk", Mode: mode,
Depth: ingest.DepthEditWave, Changed: true,
Accepted: []ingest.AcceptedDecision{{Index: 0, Action: "decline", ID: "tm_1", Src: "蛊", State: "applied"}},
}
}
// The verdict table: every exit class of the verb lands on ITS remedy and no two remedies merge.
func TestBankVerdictKeepsTheRemediesApart(t *testing.T) {
svc := service(t, &fakeRunner{}, nil, time.Now())
in := BankCorrectionsInput{BookID: "bk", Preview: false}
refused := okReport("refused")
refused.Rejected = []ingest.RejectedDecision{{Index: 0, Reason: "inert"}}
cases := []struct {
name string
out runner.BankApplyOutcome
want error
}{
{"refused = the user re-decides", runner.BankApplyOutcome{ExitCode: ingest.ExitDecisionsRejected, Exited: true, Report: refused, Decoded: true}, &ErrBankRefused{}},
{"incomplete = re-send the same", runner.BankApplyOutcome{ExitCode: ingest.ExitWriteIncomplete, Exited: true, Report: okReport("write_incomplete"), Decoded: true}, ErrBankIncomplete},
// Class 12 from the VERB is never a run: the live run was excluded under the mutex before the
// spawn, so the holder is transient and the word is «retry later» — `run_in_flight` comes only
// from the run row's own check (PD-400).
{"a held project = a transient holder, retry later", runner.BankApplyOutcome{ExitCode: ingest.ExitProjectLocked, Exited: true}, ErrBankUnavailable},
{"an unmigrated schema = the operator", runner.BankApplyOutcome{ExitCode: ingest.ExitSchemaMismatch, Exited: true}, ErrBankUnavailable},
{"a stop mid-call = retry later", runner.BankApplyOutcome{ExitCode: ingest.ExitStopped, Exited: true}, ErrBankUnavailable},
{"an unknown refusal class = the operator", runner.BankApplyOutcome{ExitCode: ingest.ExitRefusedOther, Exited: true}, ErrBankUnavailable},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := svc.bankVerdict(t.Context(), in, tc.out)
var refusal *ErrBankRefused
if wantRefusal := errors.As(tc.want, new(*ErrBankRefused)); wantRefusal {
if !errors.As(err, &refusal) || len(refusal.Refusals) != 1 {
t.Fatalf("got %v, want the refusal with its reasons", err)
}
return
}
if !errors.Is(err, tc.want) {
t.Errorf("got %v, want %v", err, tc.want)
}
})
}
// The config class and exit 1 are the deployment's own fault: an internal error, none of the
// three user-facing remedies.
for _, code := range []int{ingest.ExitFailure, ingest.ExitConfigInvalid, ingest.ExitSourceUnreadable} {
_, err := svc.bankVerdict(t.Context(), in, runner.BankApplyOutcome{ExitCode: code, Exited: true})
if err == nil || errors.Is(err, ErrBankUnavailable) || errors.Is(err, ErrBankIncomplete) ||
errors.Is(err, pgstore.ErrRunInFlight) {
t.Errorf("exit %d answered %v, want a plain internal failure", code, err)
}
}
// A clean exit whose report did not arrive is not the engine — never a receipt.
if _, err := svc.bankVerdict(t.Context(), in, runner.BankApplyOutcome{ExitCode: 0, Exited: true}); err == nil {
t.Error("a clean exit without a report was believed")
}
// The report's mode is cross-checked against the asked act: an APPLY report for a preview call
// would tell the user their correction landed when nothing may have.
preview := in
preview.Preview = true
if _, err := svc.bankVerdict(t.Context(), preview,
runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}); err == nil {
t.Error("an apply-mode report was served as a preview receipt")
}
}
// The receipt's shaping: the signature block is null exactly when no stop exists to count against.
func TestBankReceiptCarriesTheSignatureOnlyWhenAStopExists(t *testing.T) {
svc := service(t, &fakeRunner{}, nil, time.Now())
in := BankCorrectionsInput{BookID: "bk", Preview: false}
rep := okReport("apply")
rep.PreexistingProblems = []string{"a delta that would not load"}
rec, err := svc.bankReceipt(in, rep)
if err != nil {
t.Fatal(err)
}
if rec.Signature != nil {
t.Errorf("no stop yet, and the receipt counts against %+v", rec.Signature)
}
if rec.PreexistingFaults != 1 || rec.Depth != ingest.DepthEditWave || !rec.Changed {
t.Errorf("receipt: %+v", rec)
}
rep.Signature = ingest.SignatureState{Map: "/x/y.mined-signature.yaml", Surfaces: 3, Undecided: 1}
if rec, err = svc.bankReceipt(in, rep); err != nil || rec.Signature == nil || rec.Signature.Surfaces != 3 {
t.Errorf("with a stop: %+v (%v)", rec, err)
}
}
// Under a live run the door refuses at once with the run's own word — the verb is never spawned, so
// a transient flock can never masquerade as a translation (canon: wait for the stop or the end).
func TestCorrectionsRefuseWhileTheBookIsBeingTranslated(t *testing.T) {
f := newFixture(t, "10", 500)
fake := &fakeBankApplier{out: runner.BankApplyOutcome{ExitCode: 0, Exited: true}}
f.svc.Bank = fake
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(10)}); err != nil {
t.Fatal(err)
}
_, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: true,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
})
if !errors.Is(err, pgstore.ErrRunInFlight) {
t.Fatalf("corrections under a live run answered %v, want run_in_flight", err)
}
if fake.calls != 0 {
t.Error("the verb was spawned under a live run")
}
}
// The signing stop's row closes one sweep AFTER its screen opens: the journal's bank_stop event
// moves the status to awaiting_bank while finished_at waits for the exit marker. The door must
// open on that window — refusing run_in_flight there refuses the very screen the platform just
// announced (workflow finding, P9). The case above pins the other side: a live row NOT at the
// stop still refuses.
func TestTheDoorOpensOnTheSigningStopWindow(t *testing.T) {
f := newFixture(t, "10", 500)
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), VerifyBank: true,
Chapters: order(10)})
if err != nil {
t.Fatal(err)
}
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
t.Fatal(err)
}
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
// What the sink writes when the engine reports the bank stop: the status moves and the run
// stays LIVE, because the attempt has not ended yet (pgstore/sink.go, TypeBankStop).
if _, err := f.store.Pool().Exec(f.ctx,
`update runs set status = 'awaiting_bank' where id = $1`, run.ID); err != nil {
t.Fatal(err)
}
rec, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
})
if err != nil {
t.Fatalf("the door refused the signing stop's own window: %v", err)
}
if fake.calls != 1 {
t.Fatalf("the verb ran %d times, want 1", fake.calls)
}
if rec.Preview {
t.Error("an apply came back as a preview")
}
}
// The engine's byte ceiling is measured on the RENDERED document BEFORE the verb is spawned, and
// the refusal is the size word (413 upstream), never the engine's «re-decide» class (workflow
// finding, P9: with HTML escaping off the band above the wire cap is the envelope's few bytes,
// and this gate is what answers it).
func TestARenderedDocumentOverTheEngineCapIsRefusedBeforeTheSpawn(t *testing.T) {
f := newFixture(t, "10", 500)
fake := &fakeBankApplier{}
f.svc.Bank = fake
_, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: true,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1",
Note: strings.Repeat("g", ingest.MaxDecisionsDocument)}},
})
if !errors.Is(err, ErrBankDocumentTooLarge) {
t.Fatalf("an over-cap render answered %v, want ErrBankDocumentTooLarge", err)
}
if fake.calls != 0 {
t.Error("the verb was spawned for a document it cannot read")
}
}
// The happy path against a real store: the rendered document reaches the verb — versioned, with the
// book's own id — and the receipt answers the report. A stranger's book stays a 404.
func TestCorrectionsRenderTheSeamDocumentAndAnswerTheReceipt(t *testing.T) {
f := newFixture(t, "10", 500)
rep := okReport("projection")
rep.BookID = f.bookID(t)
fake := &fakeBankApplier{out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: rep, Decoded: true}}
f.svc.Bank = fake
rec, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: true,
Decisions: []ingest.BankDecision{{Action: "decline", Src: "蛊"}},
})
if err != nil {
t.Fatal(err)
}
if !rec.Preview || len(rec.Accepted) != 1 {
t.Errorf("receipt: %+v", rec)
}
doc := string(fake.lastDoc)
if !strings.Contains(doc, `"decisions_version":"tm-bank-decisions-v1"`) ||
!strings.Contains(doc, `"book_id":"`+f.bookID(t)+`"`) {
t.Errorf("the verb was handed %s", doc)
}
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u2", BookID: f.bookID(t), Preview: true,
Decisions: []ingest.BankDecision{{Action: "decline", Src: "蛊"}},
}); !errors.Is(err, pgstore.ErrNoBook) {
t.Errorf("a stranger's book answered %v, want ErrNoBook", err)
}
}
// The serialization the synchronous form stands on: a resume does not re-open the run while the
// verb is mid-flight on the same book — the spawned engine would die on the verb's flock, a wasted
// attempt (§3.2 of the pack; the mirror half is the run_in_flight refusal above).
func TestAResumeWaitsOutALiveCorrectionCall(t *testing.T) {
f := newFixture(t, "10", 500)
runID := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), VerifyBank: true,
Chapters: order(100)}, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "3",
At: f.now.Add(time.Second)})
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
entered: make(chan struct{}), release: make(chan struct{}),
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
corrections := make(chan error, 1)
go func() {
_, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
})
corrections <- err
}()
<-fake.entered // the verb is mid-flight and the book's lock is held
resumed := make(chan error, 1)
go func() {
_, err := f.svc.Resume(f.ctx, "u1", runID)
resumed <- err
}()
select {
case err := <-resumed:
t.Fatalf("the resume completed (%v) while the correction verb held the book", err)
case <-time.After(150 * time.Millisecond):
}
close(fake.release)
if err := <-corrections; err != nil {
t.Fatalf("corrections: %v", err)
}
if err := <-resumed; err != nil {
t.Fatalf("resume after the verb: %v", err)
}
}
// An idle book keeps no lock entry: the per-book map must not grow with every book a process ever
// touched — and a waiter that LEAVES on its context must not leak its entry either.
func TestTheBookLockTableDoesNotLeak(t *testing.T) {
svc := service(t, &fakeRunner{}, nil, time.Now())
unlock, err := svc.lockBook(t.Context(), "bk_1")
if err != nil {
t.Fatal(err)
}
second := make(chan struct{})
go func() {
u, err := svc.lockBook(t.Context(), "bk_1")
if err == nil {
u()
}
close(second)
}()
unlock()
<-second
svc.booksMu.Lock()
defer svc.booksMu.Unlock()
if len(svc.books) != 0 {
t.Errorf("%d lock entries survive their books", len(svc.books))
}
}
// A caller whose context ends while it WAITS leaves the queue with an error instead of holding a
// place in it to do the work for a dead request — the wait, not just the hold, is bounded
// (workflow finding, P9).
func TestAWaiterWhoseContextEndsLeavesTheBookQueue(t *testing.T) {
svc := service(t, &fakeRunner{}, nil, time.Now())
unlock, err := svc.lockBook(t.Context(), "bk_1")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
waited := make(chan error, 1)
go func() {
u, err := svc.lockBook(ctx, "bk_1")
if err == nil {
u()
}
waited <- err
}()
cancel()
select {
case err := <-waited:
if err == nil {
t.Fatal("a cancelled waiter took the lock anyway")
}
case <-time.After(2 * time.Second):
t.Fatal("a cancelled waiter is still in the queue")
}
unlock()
svc.booksMu.Lock()
defer svc.booksMu.Unlock()
if len(svc.books) != 0 {
t.Errorf("%d lock entries survive the cancelled waiter", len(svc.books))
}
}
// Only the book's LATEST run may be resumed: an older one re-opened over a newer would open its
// bar on the neighbour's finished chapters, and the whole read surface (lastRun by started_at)
// would keep quoting the finished neighbour while the resumed run burns money invisibly (workflow
// finding, P9; PD-402 keeps the read half).
func TestAnOlderRunCannotBeResumedOverANewerOne(t *testing.T) {
f := newFixture(t, "10", 500)
older := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
later := f.now.Add(time.Minute)
f.svc.Now = func() time.Time { return later }
newer := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: later.Add(time.Second)})
if _, err := f.svc.Resume(f.ctx, "u1", older); !errors.Is(err, ErrNotResumable) {
t.Fatalf("resuming an older run answered %v, want ErrNotResumable", err)
}
if _, err := f.svc.Resume(f.ctx, "u1", newer); err != nil {
t.Fatalf("the latest run must stay resumable: %v", err)
}
}
// Boot sweeps decision documents an unclean death left in the state directory — nothing else
// deletes by that mask, and each carries up to a megabyte of a user's own corrections (workflow
// finding, P9; PD-409). Files outside the mask are not the sweep's to touch.
func TestBootSweepsOrphanedCorrectionDocuments(t *testing.T) {
svc := service(t, &fakeRunner{}, nil, time.Now())
orphan := filepath.Join(svc.Cfg.StateDir, "bank-corrections-123.json")
stranger := filepath.Join(svc.Cfg.StateDir, "run-1.exit")
for _, p := range []string{orphan, stranger} {
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
svc.SweepCorrectionScratch()
if _, err := os.Stat(orphan); !errors.Is(err, os.ErrNotExist) {
t.Error("the orphaned decision document survived the boot sweep")
}
if _, err := os.Stat(stranger); err != nil {
t.Error("the sweep touched a file outside its mask")
}
}
// The bank-move fact is stamped by an APPLY from the door's OWN receipt and never by a preview:
// the engine's status projection is blind to a correction until the next translate folds the bank
// in (errata 28.08-к), so the receipt — changed, or already_applied on a retry — is the only
// witness that cannot lie about what the door just did (P10 §3.1).
func TestACorrectionRecordsTheBankMoveAndAPreviewDoesNot(t *testing.T) {
f := newFixture(t, "10", 500)
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("projection"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: true,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
book, err := f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t))
if err != nil {
t.Fatal(err)
}
if book.BankMoved {
t.Fatal("a PREVIEW recorded a bank move")
}
fake.mu.Lock()
fake.out.Report.Mode = "apply"
fake.mu.Unlock()
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
if book, err = f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)); err != nil || !book.BankMoved {
t.Fatalf("the apply left no fact: %+v (%v)", book, err)
}
// A retry of the same document answers changed:false with already_applied states — and still
// proves the move, which is what makes a failed fact-write retryable (corrected()).
retry := okReport("apply")
retry.Changed = false
retry.Accepted = []ingest.AcceptedDecision{{Index: 0, Action: "decline", ID: "tm_1", State: "already_applied"}}
retry.BookID = f.bookID(t)
if err := f.svc.Store.ClearBankMove(f.ctx, f.bookID(t)); err != nil {
t.Fatal(err)
}
fake.mu.Lock()
fake.out.Report = retry
fake.mu.Unlock()
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
if book, err = f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)); err != nil || !book.BankMoved {
t.Fatalf("an already_applied retry did not re-stamp the fact: %+v (%v)", book, err)
}
}
// The bank-move fact outlives the client that asked for it. THE money case (PD-425): the correction
// verb comes back CLEAN on an already-cancelled call — runner.BankApply returns (outcome, nil)
// whenever the process exited, and the engine's last look at its context is before its writes — so a
// correction whose files LANDED reaches the fact-write with the request context already dead. On
// r.Context() the write is refused by pgx, `bank_moved_at` stays NULL forever (no second writer, no
// sweep), and the next ordinary run is admitted without --resnapshot and dies on the engine's
// snapshot guard AFTER its hold: the user pays for an attempt that does nothing. The remedy the door
// names for a failed write — «re-send the same document» — is by construction addressed to a client
// that is gone.
//
// The verb is held mid-flight and the cancellation lands WHILE it runs, because that is the only
// shape that reaches the write: a context cancelled before the call never gets past lockBook, which
// selects on ctx.Done and answers ErrBankUnavailable.
//
// Mutation caught: RecordBankMove on the caller's context.
func TestTheBankMoveFactSurvivesAClientThatHungUp(t *testing.T) {
f := newFixture(t, "10", 500)
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
entered: make(chan struct{}),
release: make(chan struct{}),
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
ctx, cancel := context.WithCancel(f.ctx)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := f.svc.ApplyBankCorrections(ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
})
done <- err
}()
<-fake.entered // the verb is mid-flight and the book lock is held
cancel() // the tab closes; the engine's writes have already landed
close(fake.release)
if err := <-done; err != nil {
t.Fatalf("the door refused a correction whose files landed: %v", err)
}
book, err := f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t))
if err != nil {
t.Fatal(err)
}
if !book.BankMoved {
t.Fatal("the bank-move fact died with the client: the next ordinary run is admitted without --resnapshot and dies on the engine's guard after taking its hold")
}
}
// A start over a moved bank decides BOTH consents once, stores them on the row, and the spawn
// renders them — the argv is the admission's decision, stable across respawns (P10 §3.1). The cap
// is FUNDED: the run's own hold, never a projection (which does not exist at admission).
func TestAStartOverAMovedBankCarriesBothConsentsToTheSpawn(t *testing.T) {
f := newFixture(t, "10", 500)
f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(2)})
if err != nil {
t.Fatal(err)
}
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
t.Fatal(err)
}
f.runner.mu.Lock()
spec := f.runner.started[len(f.runner.started)-1]
f.runner.mu.Unlock()
joined := strings.Join(spec.Args, " ")
if !strings.Contains(joined, "--resnapshot") {
t.Errorf("the spawn carries no --resnapshot: %q", joined)
}
// The consent IS the hold, funded by construction — written as the arithmetic rather than as a
// literal so the assertion says why it expects what it expects.
if want := "--accept-rebill=" + fixtureHold(2).USD(); !strings.Contains(joined, want) {
t.Errorf("the spawn's consent is not the run's own hold (%s): %q", want, joined)
}
}
// The fact is retired EXPLICITLY and only by success: a run that died on the guard (exit 1, $0)
// leaves it standing — so the next admission still carries the flags instead of looping the guard
// forever (adversarial K6) — and a ready resnapshot run clears it.
func TestAFailedRunKeepsTheFactAndAReadyRunRetiresIt(t *testing.T) {
f := newFixture(t, "10", 500)
f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(2)}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "1", At: f.now.Add(2 * time.Second)})
book, err := f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t))
if err != nil {
t.Fatal(err)
}
if !book.BankMoved {
t.Fatal("a FAILED run retired the fact: the next run would die on the guard with no flags")
}
f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(2)}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "0", At: f.now.Add(3 * time.Second)})
if book, err = f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)); err != nil || book.BankMoved {
t.Fatalf("a READY resnapshot run did not retire the fact: %+v (%v)", book, err)
}
}
// The re-pass door (P10 §3.2, the deferred-projection form): on a book whose bank moved, a
// re-pass admission PASSES — a zero-chapter row whose hold is the whole book's chapter price
// («up to your hold»; untouched units come back at $0 and the difference is released) — and on a
// book without the move it refuses with its own word.
func TestTheRePassDoorAdmitsOnAMovedBankAndRefusesWithoutOne(t *testing.T) {
f := newFixture(t, "10", 5)
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true}); !errors.Is(err, ErrRePassUnavailable) {
t.Fatalf("a re-pass with no moved bank answered %v, want ErrRePassUnavailable", err)
}
f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true})
if err != nil {
t.Fatalf("the re-pass door refused a moved bank: %v", err)
}
var chapters int
var hold int64
if err := f.store.Pool().QueryRow(f.ctx,
`select ceiling_chapters from runs where id = $1`, run.ID).Scan(&chapters); err != nil {
t.Fatal(err)
}
if err := f.store.Pool().QueryRow(f.ctx,
`select amount_micro_usd from credit_ledger where kind = 'hold' order by id desc limit 1`).
Scan(&hold); err != nil {
t.Fatal(err)
}
if chapters != 0 {
t.Errorf("a re-pass bought %d chapters, want 0", chapters)
}
// The WHOLE book's projected price, held; the unspent part is released on settlement. ⚠ Priced
// from the engine's projection now, not from a per-chapter constant — the re-pass may in the worst
// case re-make everything the correction touched, so its hold is the whole book's own quote.
if want := -int64(fixtureHold(5)); hold != want {
t.Errorf("the re-pass hold is %d micro-USD, want the whole book's %d", hold, want)
}
}
// A RESUME over a moved bank grants the consents on re-open — a correction after a ceiling pause
// mid-edit is the mine's flagship shape — and they land on the ROW, where every later spawn of
// every attempt reads them (P10 §3.1).
func TestAResumeOverAMovedBankGrantsTheConsents(t *testing.T) {
f := newFixture(t, "10", 500)
runID := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
if _, err := f.svc.Resume(f.ctx, "u1", runID); err != nil {
t.Fatal(err)
}
var resnapshot bool
var consent int64
if err := f.store.Pool().QueryRow(f.ctx,
`select resnapshot, accept_rebill_micro from runs where id = $1`, runID).
Scan(&resnapshot, &consent); err != nil {
t.Fatal(err)
}
// The funded cap: the run's whole budget — what three chapters were sold for.
if want := int64(fixtureHold(3)); !resnapshot || consent != want {
t.Fatalf("the resumed row carries resnapshot=%v consent=%d, want true/%d", resnapshot, consent, want)
}
}
// A re-pass is bought again, not resumed (P10, adversarial K4): its budget is not chapter-derived,
// and an interrupted one leaves the fact standing — the purchase is simply available again.
func TestARePassIsBoughtAgainNotResumed(t *testing.T) {
f := newFixture(t, "10", 5)
f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true})
if err != nil {
t.Fatal(err)
}
// The re-pass is interrupted: the row closes as stopped, the fact stays (only ready retires it).
//
// ⛔ AND THE DOOR'S OTHER REFUSALS ARE CLOSED OFF, because `errors.Is(…, ErrNotResumable)` cannot
// tell them from this one: measured 11.09 with the guard DELETED, this test passed 8 times out of
// 8 on «its previous attempt is still being settled» (the attempt's reservation was left open)
// and on «a newer run of this book exists» (the fixture's clock is frozen, so both runs of the
// book carry the same `started_at` and `newestRun` breaks the tie by id). The guard's own words
// are asserted below for the same reason.
if _, err := f.store.Pool().Exec(f.ctx,
`update runs set status = 'stopped', finished_at = $2, stop_requested_at = $2, settled_at = $2,
started_at = $2
where id = $1`, run.ID, f.now.Add(2*time.Second)); err != nil {
t.Fatal(err)
}
if _, err := f.store.Pool().Exec(f.ctx, `
update reservations set state = 'released', closed_at = $2
where engine_run_id like $1 || '#%' and state = 'open'`, run.ID, f.now.Add(2*time.Second)); err != nil {
t.Fatal(err)
}
resumed, err := f.svc.Resume(f.ctx, "u1", run.ID)
if !errors.Is(err, ErrNotResumable) {
t.Fatalf("resuming a re-pass answered %v (run %+v), want ErrNotResumable (bought again)", err, resumed)
}
if !strings.Contains(err.Error(), "bought again") {
t.Fatalf("the refusal is %q, and K4 is about ONE of them: a re-pass is bought again rather than "+
"resumed. Any other refusal satisfies errors.Is and leaves this door unmeasured", err)
}
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true}); err != nil {
t.Fatalf("re-buying the interrupted re-pass was refused: %v", err)
}
}
// The consent a RESUME grants is the figure the run was SOLD for, read from its first hold — not the
// same chapters priced again at today's rate, which is the same figure only while the rate stands
// still (PD-168's second site, the funded consent).
//
// Mutation caught: `consent = budget` in reopen replaced by a fresh quote of the order — the
// per-chapter rate this line used to name is gone, and the drift it guards is the same one.
func TestAResumeOverAMovedBankGrantsTheConsentTheRunWasSoldFor(t *testing.T) {
f := newFixture(t, "10", 500)
// Part of the hold is SPENT before the stop, so the consent — the run's whole budget — and the
// remainder the resume re-holds are different numbers, and a consent set to the remainder is
// caught rather than coinciding.
runID := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 30_000,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
doubled, err := pricing.New(2 * pricing.DefaultHoldFactorPercent)
if err != nil {
t.Fatal(err)
}
f.svc.Pricing = doubled
if _, err := f.svc.Resume(f.ctx, "u1", runID); err != nil {
t.Fatal(err)
}
var consent int64
if err := f.store.Pool().QueryRow(f.ctx,
`select accept_rebill_micro from runs where id = $1`, runID).Scan(&consent); err != nil {
t.Fatal(err)
}
// Three chapters at the cushion of the PURCHASE — the hold the user agreed to, not what today's
// setting would reserve.
if want := int64(fixtureHold(3)); consent != want {
t.Fatalf("the resumed row carries consent=%d, want %d (the run's own hold), not %d at today's setting",
consent, want, int64(fixtureHoldAt(2*pricing.DefaultHoldFactorPercent, 3)))
}
}
// An interrupted RE-PASS is restarted by the sweep with what is left of its hold. Its
// `ceiling_chapters` is 0, so a rate-derived budget reads as «spent» and would pause a paid re-pass
// as `credit_exhausted` — the defect PD-168 names for the downward rate move, in its purest form.
// The user's resume of an ENDED re-pass stays refused (bought again, K4), because that door's reasons
// stand on their own; a re-pass that is still GOING is answered with the run like any other live one
// (resumeorder_test.go). The reconciler's automatic continuation is what this pins.
//
// Mutation caught: `budget, err := s.Store.RunBudget(...)` in reopen replaced by a fresh quote of the order (the run pauses).
func TestAnInterruptedRePassIsRestartedWithWhatIsLeftOfItsHold(t *testing.T) {
f := newFixture(t, "10", 5)
f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true})
if err != nil {
t.Fatal(err)
}
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
t.Fatal(err)
}
hold := f.account(t).Reserved // the whole book's projected price
if want := fixtureHold(5); hold != want {
t.Fatalf("the re-pass holds %s, want %s", hold.USD(), want.USD())
}
// The machine reboots with a third of the hold spent.
spent := money.MicroUSD(50_000)
f.engine.set(statusSpending(spent), nil)
f.runner.alive = false
f.svc.Now = func() time.Time { return f.now.Add(3 * time.Hour) }
if err := f.svc.Sweep(f.ctx); err != nil {
t.Fatal(err)
}
live, err := f.store.ListLiveRuns(f.ctx)
if err != nil {
t.Fatal(err)
}
if len(live) != 1 || live[0].RunID != run.ID || live[0].AttemptNo != 2 {
t.Fatalf("the interrupted re-pass was not restarted: %+v", live)
}
if want := hold - spent; live[0].Ceiling != want {
t.Errorf("the restarted re-pass holds %s, want %s (its hold less what it spent)", live[0].Ceiling.USD(), want.USD())
}
if !live[0].Resnapshot || live[0].AcceptRebill != hold {
t.Errorf("the consents did not travel to the restart: resnapshot=%v consent=%s", live[0].Resnapshot, live[0].AcceptRebill.USD())
}
}