809 lines
35 KiB
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())
|
|
}
|
|
}
|