829 lines
40 KiB
Go
829 lines
40 KiB
Go
package runs
|
||
|
||
import (
|
||
"errors"
|
||
"slices"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"textmachine/platform/internal/money"
|
||
"textmachine/platform/internal/pgstore"
|
||
"textmachine/platform/internal/pricing"
|
||
"textmachine/platform/internal/runner"
|
||
)
|
||
|
||
// argvOf is the argv of the LAST unit this fixture started.
|
||
func argvOf(t *testing.T, f *fixture) []string {
|
||
t.Helper()
|
||
starts := f.runner.starts()
|
||
if len(starts) == 0 {
|
||
t.Fatal("no unit was started")
|
||
}
|
||
return starts[len(starts)-1].Args
|
||
}
|
||
|
||
// flagValue reads the value of a `--flag value` pair, or "" when the flag is absent.
|
||
func flagValue(args []string, flag string) string {
|
||
if i := slices.Index(args, flag); i >= 0 && i+1 < len(args) {
|
||
return args[i+1]
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ⛔ THE ORDER REACHES THE ENGINE, and until this pack it did not. The platform sold CHAPTERS and
|
||
// handed the engine a DOLLAR bound alone — so «buy ten chapters» arrived as a sum, and a sum buys
|
||
// whatever it buys: measured on real ledgers, ten chapters' worth of money bought sixteen to
|
||
// twenty-six (D39.165 §1). The knob said chapters and meant money.
|
||
//
|
||
// `--max-units` is the same quantity on both sides of the seam: the manifest's unit count is where
|
||
// this platform's per-chapter figure comes from, so the conversion is exact rather than estimated.
|
||
//
|
||
// Mutation caught: dropping maxUnits from TranslateArgs, or from spec.
|
||
func TestAPartialOrderReachesTheEngineAsAVolumeAndNotOnlyAsMoney(t *testing.T) {
|
||
f := newFixture(t, "10", 500)
|
||
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(7)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
args := argvOf(t, f)
|
||
// The fixture ships one unit per chapter, so seven chapters are seven units.
|
||
if got := flagValue(args, "--max-units"); got != "7" {
|
||
t.Fatalf("the engine was given --max-units %q, want 7: %q", got, strings.Join(args, " "))
|
||
}
|
||
// And the MONEY bound is still there beside it: the two are orthogonal, one caps this run's work
|
||
// and the other the book's cumulative spend.
|
||
if !slices.Contains(args, "--ceiling-usd") {
|
||
t.Errorf("the volume bound replaced the money bound: %q", strings.Join(args, " "))
|
||
}
|
||
}
|
||
|
||
// The WHOLE-BOOK order carries no volume bound at all, and zero is the flag's own word for
|
||
// «unbounded» — so it must not be passed as a number either. A `--max-units 0` would be an order for
|
||
// nothing.
|
||
func TestAWholeBookOrderCarriesNoVolumeBound(t *testing.T) {
|
||
f := newFixture(t, "10", 5)
|
||
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if args := argvOf(t, f); slices.Contains(args, "--max-units") {
|
||
t.Errorf("a whole-book order was bounded by volume: %q", strings.Join(args, " "))
|
||
}
|
||
// …and the book row says so in the only way that survives a re-cut: no boundary at all.
|
||
var chapterID, unitID *string
|
||
if err := f.store.Pool().QueryRow(f.ctx,
|
||
`select ordered_through_chapter_id, ordered_through_unit_id from books where id = $1`,
|
||
f.bookID(t)).Scan(&chapterID, &unitID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if chapterID != nil || unitID != nil {
|
||
t.Errorf("the whole book froze a boundary: chapter %v unit %v", chapterID, unitID)
|
||
}
|
||
}
|
||
|
||
// The allowance is what is LEFT of the order, recomputed at every spawn. Frozen at admission, a run
|
||
// respawned after a restart would be handed its whole order a second time and the promise «you
|
||
// bought N units» would stop being about N.
|
||
//
|
||
// Mutation caught: maxUnitsFor ignoring SpawnOrder.Delivered.
|
||
func TestTheVolumeAllowanceIsWhatIsLeftOfTheOrderAtEverySpawn(t *testing.T) {
|
||
f := newFixture(t, "10", 500)
|
||
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(10)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := flagValue(argvOf(t, f), "--max-units"); got != "10" {
|
||
t.Fatalf("the first spawn was given %q, want 10", got)
|
||
}
|
||
// Four chapters of the order come back delivered, and the machine reboots.
|
||
//
|
||
// ⚠ Written as RESOLUTION ROWS and not as chapter counters, because the rows are what the
|
||
// allowance is computed from and the counters are derived from them: a fixture that moved only
|
||
// the counter would describe a state the materializer cannot produce, and would pass while the
|
||
// arithmetic read something else.
|
||
if _, err := f.store.Pool().Exec(f.ctx, `
|
||
insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at)
|
||
select $1, g, 0, 'edit', true, false, now() from generate_series(1, 4) g`, f.bookID(t)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
f.engine.set(statusSpending(money.MicroUSD(100_000)), nil)
|
||
f.runner.alive = false
|
||
f.svc.Now = func() time.Time { return f.now.Add(2 * time.Hour) }
|
||
if err := f.svc.Sweep(f.ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := flagValue(argvOf(t, f), "--max-units")
|
||
if got != "6" {
|
||
t.Fatalf("the respawn was given --max-units %q, want the six units of the order still owed", got)
|
||
}
|
||
}
|
||
|
||
// ⛔ AND IT NEVER FALLS TO ZERO BY ARITHMETIC, because zero is the flag's word for «no bound». A run
|
||
// respawned after its order was fully delivered would otherwise be handed the whole book.
|
||
func TestAFullyDeliveredOrderIsRespawnedWithOneUnitAndNotWithNoBoundAtAll(t *testing.T) {
|
||
l := pgstore.LiveRun{RunID: "run_1", OrderedChapters: 10}
|
||
o := pgstore.SpawnOrder{Resolved: true, Units: 10, Delivered: 10,
|
||
Order: pgstore.BookOrder{ThroughChapterID: "c10"}}
|
||
n, err := maxUnitsFor(l, o)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 1 {
|
||
t.Fatalf("a fully delivered order was given --max-units %d; zero means UNBOUNDED", n)
|
||
}
|
||
o.Delivered = 40 // more delivered than ordered: free and carried units ride outside the grant
|
||
if n, err := maxUnitsFor(l, o); err != nil || n != 1 {
|
||
t.Fatalf("an over-delivered order was given %d (%v)", n, err)
|
||
}
|
||
}
|
||
|
||
// ⛔ A BOOK CUT AGAIN UNDER A PURCHASE IS A REFUSAL, not a substituted number. The order's boundary is
|
||
// stored as an IDENTITY so that a re-cut makes it stop resolving; reading the dangling reference as
|
||
// «the whole book» would sell MORE than was bought and as «nothing» would sell less. Both are the
|
||
// silent change of a paid order that the identity form exists to prevent.
|
||
func TestAnOrderThatNoLongerNamesABoundaryRefusesToSpawn(t *testing.T) {
|
||
l := pgstore.LiveRun{RunID: "run_1", OrderedChapters: 10}
|
||
dangling := pgstore.SpawnOrder{Resolved: false, Units: 0,
|
||
Order: pgstore.BookOrder{ThroughChapterID: "a chapter this cut does not have"}}
|
||
if _, err := maxUnitsFor(l, dangling); !errors.Is(err, ErrOrderUnresolvable) {
|
||
t.Fatalf("an unresolvable order answered %v, want ErrOrderUnresolvable", err)
|
||
}
|
||
// The whole-book order has no boundary to lose and is unaffected by any re-cut, which is the
|
||
// other half of why it stores none. On a FIRST run it takes no volume bound at all.
|
||
if n, err := maxUnitsFor(l, pgstore.SpawnOrder{Resolved: true}); err != nil || n != 0 {
|
||
t.Fatalf("a whole-book order answered %d (%v)", n, err)
|
||
}
|
||
// ⛔ AND THE RE-PASS, which is the one shape that buys no volume: the book's own order says
|
||
// nothing about it, and bounding it by that order would leave a re-pass the buyer paid a whole
|
||
// book's projection for re-making a single unit.
|
||
repass := pgstore.LiveRun{RunID: "run_1", OrderedChapters: 0}
|
||
if n, err := maxUnitsFor(repass, pgstore.SpawnOrder{Resolved: true, Units: 4, Delivered: 4,
|
||
Order: pgstore.BookOrder{ThroughChapterID: "c2"}}); err != nil || n != 0 {
|
||
t.Fatalf("a re-pass was bounded by the book's order: %d (%v)", n, err)
|
||
}
|
||
}
|
||
|
||
// ⛔ PD-422: `--resnapshot` RIDES EVERY CONTINUATION, not only a run over an edited bank.
|
||
//
|
||
// The auto-bank grows by MINING during an ordinary run; mining moves the ENRICHED memory version;
|
||
// the enriched version is folded into the EDIT wave's snapshot alone. So the SECOND purchase of a
|
||
// mining book meets its own already-pinned edit jobs under a moved snapshot, the drift guard stops
|
||
// the engine with exit 1 — which this platform can only report as `failed` — and it does so AFTER
|
||
// the hold was taken, with nothing translated. The condition used to be the correction door's flag
|
||
// alone, which no amount of mining ever sets.
|
||
//
|
||
// Mutation caught: `resnapshot := book.BankMoved` — the condition as it shipped.
|
||
func TestASecondPurchaseCarriesResnapshotEvenWithoutABankCorrection(t *testing.T) {
|
||
f := newFixture(t, "10", 500)
|
||
first, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := f.svc.Spawn(f.ctx, first.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// The FIRST purchase carries none: there is nothing pinned yet to be re-pinned, and a flag that
|
||
// rode every run would be a consent nobody needed.
|
||
if args := argvOf(t, f); slices.Contains(args, "--resnapshot") {
|
||
t.Fatalf("the first purchase of a book carries --resnapshot: %q", strings.Join(args, " "))
|
||
}
|
||
// It ends cleanly. NO bank correction happens — this is the ordinary path, which is the whole
|
||
// point: mining moved the bank and nothing recorded that fact.
|
||
live := f.live(t)
|
||
if err := runner.WriteMarker(f.svc.markerPath(live.RunID, live.AttemptNo),
|
||
runner.Marker{Unit: live.UnitName, Result: "exit-code", Code: "exited", Status: "0"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := f.svc.Sweep(f.ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
second, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := f.svc.Spawn(f.ctx, second.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
args := argvOf(t, f)
|
||
if !slices.Contains(args, "--resnapshot") {
|
||
t.Fatalf("the second purchase of a book carries no --resnapshot, so the drift guard kills it "+
|
||
"after the hold (PD-422): %q", strings.Join(args, " "))
|
||
}
|
||
// …and the consent that rides with it is FUNDED — the run's own hold, never the blanket form: a
|
||
// projection grown past what the buyer saw must refuse, not be bought silently.
|
||
if want := "--accept-rebill=" + fixtureHold(3).USD(); !slices.Contains(args, want) {
|
||
t.Errorf("the continuation's consent is not its own hold (%s): %q", want, strings.Join(args, " "))
|
||
}
|
||
}
|
||
|
||
// ⛔ A BOOK THE ENGINE HAS NOT PRICED IS NOT SOLD. This is the line the per-chapter constant used to
|
||
// stand on: $0.03 was measured 4.47× low and made the last chapters of every book unbuyable at any
|
||
// balance, so replacing it with a quieter guess would keep the shape of the defect. There is one
|
||
// source for what a book costs, and when it has not spoken the platform says so.
|
||
//
|
||
// Mutation caught: any fallback in Order or Start when Priced is false.
|
||
func TestAnUnpricedBookIsRefusedRatherThanSoldAtAGuess(t *testing.T) {
|
||
f := newFixture(t, "10", 5)
|
||
if _, err := f.store.Pool().Exec(f.ctx, `
|
||
update books set expected_micro_usd = null, book_once_micro_usd = null, step_max_micro_usd = null
|
||
where id = $1`, f.bookID(t)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := f.svc.Order(f.ctx, "u1", f.bookID(t)); !errors.Is(err, ErrNotPriced) {
|
||
t.Errorf("the order form over an unpriced book answered %v, want ErrNotPriced", err)
|
||
}
|
||
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(2)}); !errors.Is(err, ErrNotPriced) {
|
||
t.Errorf("a purchase of an unpriced book answered %v, want ErrNotPriced", err)
|
||
}
|
||
// Nothing moved: a refusal before the hold is the only kind worth having.
|
||
if acct := f.account(t); acct.Reserved != 0 {
|
||
t.Errorf("a refused purchase reserved %s", acct.Reserved.USD())
|
||
}
|
||
// ⚠ A book carrying only PART of a projection is refused for the same reason and by the same
|
||
// answer: two of three figures is a document this build did not read whole, and the one that
|
||
// would be missing is exactly the one whose absence is silent.
|
||
if _, err := f.store.Pool().Exec(f.ctx, `
|
||
update books set expected_micro_usd = 150000, book_once_micro_usd = 0 where id = $1`,
|
||
f.bookID(t)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := f.svc.Order(f.ctx, "u1", f.bookID(t)); !errors.Is(err, ErrNotPriced) {
|
||
t.Errorf("a book missing only its step_max was priced: %v", err)
|
||
}
|
||
}
|
||
|
||
// The order form answers the buyer's question over a REAL book, end to end: the verdict, what the
|
||
// balance covers, and the pair «expected / reserved».
|
||
func TestTheOrderFormAnswersOverARealBook(t *testing.T) {
|
||
// Five chapters at the fixture's $0.03: the whole book is affordable at $10 and not at $0.10.
|
||
rich := newFixture(t, "10", 5)
|
||
got, err := rich.svc.Order(rich.ctx, "u1", rich.bookID(t))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got.Verdict != pricing.VerdictCoversAll || got.ChaptersLeft != 5 || got.AffordableChapters != 5 {
|
||
t.Fatalf("a $10 balance over a $0.15 book: %+v", got.Options)
|
||
}
|
||
if got.Whole.Hold != fixtureHold(5) || got.Whole.Expected != fixtureChapterUSD*5 {
|
||
t.Errorf("the estimate is %s expected / %s held, want %s / %s",
|
||
got.Whole.Expected.USD(), got.Whole.Hold.USD(),
|
||
(fixtureChapterUSD * 5).USD(), fixtureHold(5).USD())
|
||
}
|
||
if got.MinHold != fixtureStepMaxUSD {
|
||
t.Errorf("the money slider's minimum is %s, want the engine's own step", got.MinHold.USD())
|
||
}
|
||
if !got.ChapterOrders || got.Structure != "detected" {
|
||
t.Errorf("a detected cut was not offered in chapters: %+v", got)
|
||
}
|
||
|
||
poor := newFixture(t, "0.15", 5)
|
||
got, err = poor.svc.Order(poor.ctx, "u1", poor.bookID(t))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got.Verdict != pricing.VerdictCoversPart || got.AffordableChapters == 0 ||
|
||
got.AffordableChapters >= 5 {
|
||
t.Fatalf("a $0.15 balance over a book that reserves %s: %+v", fixtureHold(5).USD(), got.Options)
|
||
}
|
||
}
|
||
|
||
// The three kinds of order end up as three DIFFERENT boundaries on the book row, and each is an
|
||
// identity. The chapter number rides beside its identity as a LABEL — which is what makes a shifted
|
||
// number visible rather than authoritative.
|
||
func TestEachKindOfOrderStoresItsOwnBoundaryAsAnIdentity(t *testing.T) {
|
||
f := newFixture(t, "10", 5)
|
||
book := f.bookID(t)
|
||
read := func() (chapterID, unitID *string, number *int) {
|
||
t.Helper()
|
||
if err := f.store.Pool().QueryRow(f.ctx, `
|
||
select ordered_through_chapter_id, ordered_through_unit_id, ordered_through_chapter_number
|
||
from books where id = $1`, book).Scan(&chapterID, &unitID, &number); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return
|
||
}
|
||
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(3)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
chapterID, unitID, number := read()
|
||
if chapterID == nil || *chapterID != "c3" || unitID != nil || number == nil || *number != 3 {
|
||
t.Fatalf("a chapter order stored chapter=%v unit=%v number=%v", chapterID, unitID, number)
|
||
}
|
||
f.finish(t, run.ID)
|
||
|
||
// A CHARACTER order: the fixture's units are 1000 runes each, so 2500 buys three of them —
|
||
// rounded UP to the unit that contains the 2500th rune.
|
||
chars := int64(2500)
|
||
run, err = f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Characters: &chars})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
chapterID, unitID, number = read()
|
||
if unitID == nil || *unitID != "u3" || chapterID != nil {
|
||
t.Fatalf("a character order stored chapter=%v unit=%v", chapterID, unitID)
|
||
}
|
||
if number != nil {
|
||
t.Errorf("a character order invented a chapter label: %v", number)
|
||
}
|
||
f.finish(t, run.ID)
|
||
|
||
// The WHOLE BOOK: no boundary of any kind, which is the only form that keeps meaning the whole
|
||
// book after a re-cut.
|
||
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if chapterID, unitID, _ = read(); chapterID != nil || unitID != nil {
|
||
t.Errorf("the whole book stored chapter=%v unit=%v", chapterID, unitID)
|
||
}
|
||
}
|
||
|
||
// A character order over a book whose chapters ARE recognised still resolves to units, and an order
|
||
// in CHAPTERS over a book whose cut cannot be sold against is refused with its own word rather than
|
||
// silently rounded to the whole book.
|
||
func TestAChapterOrderIsRefusedOnACutThatCannotBeSoldAgainst(t *testing.T) {
|
||
for _, structure := range []string{"none", "declared", "a word a later engine grew"} {
|
||
f := newFixture(t, "10", 5)
|
||
if _, err := f.store.Pool().Exec(f.ctx,
|
||
`update books set structure = $2 where id = $1`, f.bookID(t), structure); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(2)})
|
||
if !errors.Is(err, ErrChapterOrdersUnavailable) {
|
||
t.Errorf("structure %q accepted a chapter order: %v", structure, err)
|
||
}
|
||
if acct := f.account(t); acct.Reserved != 0 {
|
||
t.Errorf("structure %q: a refused order reserved %s", structure, acct.Reserved.USD())
|
||
}
|
||
// The whole book and a character order are both still available — the refusal is of the UNIT
|
||
// the order is phrased in, not of the book.
|
||
chars := int64(1500)
|
||
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Characters: &chars}); err != nil {
|
||
t.Errorf("structure %q refused a character order too: %v", structure, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// A character order that reaches the end of the book IS the whole book, and is recorded as such: a
|
||
// boundary on the last unit would freeze an order that a re-cut then breaks for no reason.
|
||
func TestACharacterOrderThatReachesTheEndIsTheWholeBook(t *testing.T) {
|
||
f := newFixture(t, "10", 5)
|
||
chars := int64(999_999)
|
||
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Characters: &chars}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var unitID *string
|
||
if err := f.store.Pool().QueryRow(f.ctx,
|
||
`select ordered_through_unit_id from books where id = $1`, f.bookID(t)).Scan(&unitID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if unitID != nil {
|
||
t.Errorf("an order for more characters than the book has froze a boundary at %q", *unitID)
|
||
}
|
||
}
|
||
|
||
// finish closes a live run so the fixture can buy again: one live run per book by construction.
|
||
func (f *fixture) finish(t *testing.T, runID string) {
|
||
t.Helper()
|
||
if _, err := f.store.Pool().Exec(f.ctx,
|
||
`update runs set finished_at = now(), status = 'stopped' where id = $1`, runID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := f.store.Pool().Exec(f.ctx,
|
||
`update reservations set state = 'released', closed_at = now()
|
||
where engine_run_id like $1 and state = 'open'`, runID+"#%"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// ⛔ THE SERVER RE-JUDGES THE ORDER, and this is the half PD-375 found untested under the previous
|
||
// shape: the bound that decides HOW MUCH MONEY IS RESERVED can never be one the caller chose
|
||
// unilaterally. The options read is a READ, the balance moves under it, and a client is forbidden to
|
||
// clamp on its own account.
|
||
//
|
||
// Two halves, and both are asserted because removing either used to pass the whole battery:
|
||
// - an order the balance CANNOT carry is refused, before the hold, with nothing moved;
|
||
// - an order LARGER than the book is the whole book, not an error and not a larger reservation.
|
||
//
|
||
// Mutation caught: dropping the `quote.Hold > acct.Balance` refusal in Start; letting Quote reserve
|
||
// for more chapters than the book has.
|
||
func TestAnOrderTheBalanceCannotCarryIsRefusedBeforeTheHold(t *testing.T) {
|
||
// $0.10 against a five-chapter book whose whole order reserves more than that.
|
||
f := newFixture(t, "0.10", 5)
|
||
if fixtureHold(5) <= money.MicroUSD(100_000) {
|
||
t.Fatalf("the fixture does not test what it claims: five chapters reserve %s, which $0.10 covers",
|
||
fixtureHold(5).USD())
|
||
}
|
||
before := f.account(t)
|
||
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(5)})
|
||
// ⚠ ITS OWN ERROR, and not ErrCeilingOutOfBounds. That one means «the options moved between the
|
||
// read and the call» and reaches the wire as `bounds_moved`, which promises a client that
|
||
// re-reading and retrying will work. Here nothing moved and retrying is futile: what is missing
|
||
// is money, and the remedy is to top up. This is the refusal the whole order form exists to make
|
||
// honest, and it used to travel under the other word.
|
||
if !errors.Is(err, ErrBalanceCannotCarry) {
|
||
t.Fatalf("an unaffordable order answered %v, want ErrBalanceCannotCarry", err)
|
||
}
|
||
if after := f.account(t); after.Reserved != before.Reserved || after.Balance != before.Balance {
|
||
t.Fatalf("money moved on a refused order: before %+v, after %+v", before, after)
|
||
}
|
||
if live, err := f.store.ListLiveRuns(f.ctx); err != nil || len(live) != 0 {
|
||
t.Fatalf("a refused order left a live run: %+v (%v)", live, err)
|
||
}
|
||
// And an order LARGER than the book is the whole book — the reservation is the book's, never the
|
||
// number the caller typed.
|
||
rich := newFixture(t, "10", 5)
|
||
if _, err := rich.svc.Start(rich.ctx, StartRequest{UserID: "u1", BookID: rich.bookID(t), Chapters: order(9999)}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := rich.account(t).Reserved; got != fixtureHold(5) {
|
||
t.Errorf("an order for 9999 chapters of a five-chapter book reserved %s, want the book's own %s",
|
||
got.USD(), fixtureHold(5).USD())
|
||
}
|
||
}
|
||
|
||
// ⛔ THE ORDER FORM'S PROMISE HAS TO SURVIVE THE CLICK, and until an adversarial pass looked it did
|
||
// not. The form answers, before the click, whether the book-wide consistency pass is funded; the
|
||
// form is a READ and the balance moves under it — another book's hold, a correction — and the hold
|
||
// taken at admission is computed against the balance AS IT IS. So a buyer shown «funded» could be
|
||
// sold a run that was not, and the passes DEGRADE rather than halt: the book arrives and only its
|
||
// terms wander. Consistency of terms across a book is the owner's first stated priority (D39.198),
|
||
// and a refusal shaped like normal work is the class D39.202 §3 names.
|
||
//
|
||
// The run carries what it actually got, so the question is answerable after the click too.
|
||
//
|
||
// Mutation caught: dropping BondFunded from the StartRunInput, or reading it from anything but the
|
||
// quote the admission acted on.
|
||
func TestWhatTheRunActuallyGotIsRecordedAndNotOnlyWhatTheFormPromised(t *testing.T) {
|
||
// A book-level bound the balance CAN carry: the run is sold with the pass funded.
|
||
f := newFixture(t, "10", 3)
|
||
if _, err := f.store.Pool().Exec(f.ctx,
|
||
`update books set book_once_micro_usd = 2000000 where id = $1`, f.bookID(t)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
form, err := f.svc.Order(f.ctx, "u1", f.bookID(t))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !form.Whole.BondFunded {
|
||
t.Fatalf("a $10 balance does not fund a $2 book-level pass: %+v", form.Options)
|
||
}
|
||
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !f.run(t, run.ID).BondFunded {
|
||
t.Error("a run sold with the pass funded does not say so")
|
||
}
|
||
|
||
// …and the case the form CANNOT promise: a balance that carries the chapters and not the bound.
|
||
poor := newFixture(t, "0.5", 3)
|
||
if _, err := poor.store.Pool().Exec(poor.ctx,
|
||
`update books set book_once_micro_usd = 2000000 where id = $1`, poor.bookID(t)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
form, err = poor.svc.Order(poor.ctx, "u1", poor.bookID(t))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if form.Verdict != pricing.VerdictCoversAll || form.Whole.BondFunded {
|
||
t.Fatalf("the chapters are covered and the bound is not: %+v", form.Options)
|
||
}
|
||
run, err = poor.svc.Start(poor.ctx, StartRequest{UserID: "u1", BookID: poor.bookID(t)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if poor.run(t, run.ID).BondFunded {
|
||
t.Error("a run sold WITHOUT the book-wide pass claims it is funded, which is the silence " +
|
||
"this column exists to break")
|
||
}
|
||
}
|
||
|
||
// ⛔ A WHOLE-BOOK CONTINUATION IS BOUNDED BY VOLUME TOO, and the reason is the engine's, not this
|
||
// platform's caution: with no ceiling in force the engine builds NO volume scope, and the scope is
|
||
// what carries its protective order of work — new book before re-made book. Without one, a
|
||
// continuation carrying `--resnapshot` walks the edit wave in BOOK order and re-pays the beginning
|
||
// of the book before editing the chapters just bought.
|
||
//
|
||
// That is exactly the argument this platform made for riding `--resnapshot` on every continuation
|
||
// (PD-422), and it held only for PARTIAL orders until this bound existed. Found by an adversarial
|
||
// pass reading the engine, not by a failure here.
|
||
//
|
||
// The FIRST run of a book gets no bound: nothing is delivered, so nothing can be re-made.
|
||
//
|
||
// Mutation caught: returning 0 for a whole-book order unconditionally.
|
||
func TestAWholeBookContinuationIsGivenTheRemainderAsItsVolume(t *testing.T) {
|
||
f := newFixture(t, "10", 5)
|
||
first, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := f.svc.Spawn(f.ctx, first.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if args := argvOf(t, f); slices.Contains(args, "--max-units") {
|
||
t.Fatalf("the FIRST whole-book run is bounded by volume: %q", strings.Join(args, " "))
|
||
}
|
||
// It delivers two of the five chapters and ends.
|
||
if _, err := f.store.Pool().Exec(f.ctx, `
|
||
insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at)
|
||
select $1, g, 0, 'edit', true, false, now() from generate_series(1, 2) g`, f.bookID(t)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
live := f.live(t)
|
||
if err := runner.WriteMarker(f.svc.markerPath(live.RunID, live.AttemptNo),
|
||
runner.Marker{Unit: live.UnitName, Result: "exit-code", Code: "exited", Status: "0"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := f.svc.Sweep(f.ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
second, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := f.svc.Spawn(f.ctx, second.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
args := argvOf(t, f)
|
||
if got := flagValue(args, "--max-units"); got != "3" {
|
||
t.Fatalf("a whole-book CONTINUATION was given --max-units %q, want the three units still "+
|
||
"owed — without a ceiling the engine builds no scope and re-pays the beginning of the "+
|
||
"book before editing what was bought: %q", got, strings.Join(args, " "))
|
||
}
|
||
if !slices.Contains(args, "--resnapshot") {
|
||
t.Errorf("the continuation carries no --resnapshot: %q", strings.Join(args, " "))
|
||
}
|
||
}
|
||
|
||
// A stranger's book is INDISTINGUISHABLE from a missing one on the order form, exactly as it is
|
||
// everywhere else in this package: telling the two apart is what would let anyone enumerate other
|
||
// people's libraries by asking what a book costs.
|
||
//
|
||
// Pinned here because the form is a NEW path with its own query, and ownership on it is that query's
|
||
// own `where b.owner_id = $2` rather than a guard somebody remembered to put in front of it — a
|
||
// guard is exactly the thing a later refactor drops.
|
||
//
|
||
// Mutation caught: dropping `b.owner_id = $2` from ReadBookForOrder or RemainingUnits.
|
||
func TestTheOrderFormDoesNotAnswerAboutSomebodyElsesBook(t *testing.T) {
|
||
f := newFixture(t, "10", 5)
|
||
if _, err := f.store.Pool().Exec(f.ctx,
|
||
`insert into users (id, email) values ('u2','u2@example.org')`); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := f.svc.Order(f.ctx, "u2", f.bookID(t)); !errors.Is(err, pgstore.ErrNoBook) {
|
||
t.Errorf("the order form over a stranger's book answered %v, want ErrNoBook", err)
|
||
}
|
||
if _, err := f.svc.Order(f.ctx, "u1", "bk_THISDOESNOTEXIST"); !errors.Is(err, pgstore.ErrNoBook) {
|
||
t.Errorf("the order form over a missing book answered %v, want ErrNoBook", err)
|
||
}
|
||
// The purchase path too, and it must not move money on the way to refusing.
|
||
before := f.account(t)
|
||
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u2", BookID: f.bookID(t)}); !errors.Is(err, pgstore.ErrNoBook) {
|
||
t.Errorf("a stranger's purchase answered %v, want ErrNoBook", err)
|
||
}
|
||
if after := f.account(t); after.Reserved != before.Reserved {
|
||
t.Errorf("a stranger's refused purchase moved the owner's money: %s → %s",
|
||
before.Reserved.USD(), after.Reserved.USD())
|
||
}
|
||
// …and the character order's own read, which is a SECOND query with its own ownership clause.
|
||
if got, err := f.store.RemainingUnits(f.ctx, "u2", f.bookID(t)); err != nil || len(got) != 0 {
|
||
t.Errorf("a stranger read %d units of somebody else's book (%v)", len(got), err)
|
||
}
|
||
}
|
||
|
||
// ⛔ THE ALLOWANCE IS IN UNITS AND THE ORDER IS IN CHAPTERS, and until this test nothing in the zone
|
||
// could tell the two apart: every other fixture gives a chapter exactly one unit, so `chapters` and
|
||
// `units` were the same number everywhere and swapping one for the other stayed green.
|
||
//
|
||
// Three chapters of four units each: an order of two chapters is EIGHT units, not two.
|
||
//
|
||
// Mutation caught: `maxUnitsFor` returning `l.OrderedChapters - o.Delivered` — the unit of measure
|
||
// swapped for the one beside it.
|
||
func TestTheVolumeAllowanceCountsUnitsAndNotChapters(t *testing.T) {
|
||
f := newFixture(t, "10", 5)
|
||
book := multiUnitBook(t, f, "multi", 3, 4)
|
||
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(2)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := flagValue(argvOf(t, f), "--max-units"); got != "8" {
|
||
t.Fatalf("an order of two four-unit chapters was given --max-units %q, want 8: the allowance "+
|
||
"is counted in the unit the ENGINE ships, and two is what it would be if the chapter count "+
|
||
"were handed over instead", got)
|
||
}
|
||
// …and what was SOLD is still counted in chapters, because that is what the buyer chose and what
|
||
// the run's own bar is measured in.
|
||
if got := f.run(t, run.ID).OrderedChapters; got == nil || *got != 2 {
|
||
t.Errorf("the run says it bought %v chapters", got)
|
||
}
|
||
// The hold is the units' money, not the chapters': eight units at the fixture's rate.
|
||
if got, want := f.account(t).Reserved, fixtureHold(8); got != want {
|
||
t.Errorf("the order reserved %s, want %s — the price of eight units", got.USD(), want.USD())
|
||
}
|
||
}
|
||
|
||
// ⛔ A CHARACTER ORDER'S BAR MOVES, and before this it could not: the order buys a PREFIX of a
|
||
// chapter, a chapter counts as done only once EVERY unit in it is done, so the run read `0/N` for
|
||
// its whole life and `delivered_chapters: 0` for ever.
|
||
//
|
||
// ⚠ THE PLATFORM ALREADY CALLS THAT STATE INADMISSIBLE — in its own words, in the admission that
|
||
// refuses a book with no materialised tree: «every counter the screen shows is a count over
|
||
// chapters, so the run would read 0/total for its entire life while spending» (PD-405). Introducing
|
||
// the same state through the character order would be this pack contradicting itself, and the
|
||
// character order is not a corner: `declared` is led as `none`, so today it is the ONLY partial
|
||
// purchase available for every EPUB and every non-CJK txt.
|
||
//
|
||
// Mutation caught: dropping the `r.ordered_units is not null` branch from runDone/runTotal, or
|
||
// leaving OrderedUnits nil on a character order.
|
||
func TestACharacterOrdersBarIsCountedInWhatItActuallyBought(t *testing.T) {
|
||
f := newFixture(t, "10", 5)
|
||
// One chapter of six units: the shape of a book whose chapters cannot be sold against.
|
||
book := multiUnitBook(t, f, "one-chapter", 1, 6)
|
||
chars := int64(2500) // 1000 runes a unit ⇒ three units
|
||
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Characters: &chars})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := f.run(t, run.ID)
|
||
// Two passes over three units: the same shape the chapter bar has, one level finer.
|
||
if got.Progress.Total != 6 || got.Progress.Done != 0 {
|
||
t.Fatalf("a three-unit order opens at %d/%d, want 0/6", got.Progress.Done, got.Progress.Total)
|
||
}
|
||
if got.DeliveredChapters != nil {
|
||
t.Errorf("a run that bought a prefix of a chapter reports %d chapters delivered; it has "+
|
||
"delivered no CHAPTER, and 0 reads as «nothing happened»", *got.DeliveredChapters)
|
||
}
|
||
// The engine drafts the three it was sold. The chapter is NOT finished — three of six units — so
|
||
// the chapter bar would still read zero here, and that is the whole defect.
|
||
if _, err := f.store.Pool().Exec(f.ctx, `
|
||
insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at)
|
||
select $1, 1, g - 1, 'draft', true, false, now() from generate_series(1, 3) g`, book); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got = f.run(t, run.ID)
|
||
if got.Progress.Done != 3 {
|
||
t.Fatalf("after the draft pass over all three units the bar reads %d/%d: counted in chapters "+
|
||
"it can never move, because the chapter holds six", got.Progress.Done, got.Progress.Total)
|
||
}
|
||
if got.Progress.Stage != "editing" {
|
||
t.Errorf("the draft pass over what was bought is done and the caption says %q", got.Progress.Stage)
|
||
}
|
||
// …and the edit pass closes it.
|
||
if _, err := f.store.Pool().Exec(f.ctx, `
|
||
insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at)
|
||
select $1, 1, g - 1, 'edit', true, false, now() from generate_series(1, 3) g`, book); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got = f.run(t, run.ID); got.Progress.Done != 6 || got.Progress.Total != 6 {
|
||
t.Errorf("a fully delivered three-unit order reads %d/%d", got.Progress.Done, got.Progress.Total)
|
||
}
|
||
}
|
||
|
||
// The ORDINARY shape is untouched: a run that bought whole chapters keeps the bar it always had,
|
||
// counted in chapters, with `delivered_chapters` a number rather than null. Asserted beside the test
|
||
// above because the unit-shaped branch is a NEW first case in three SQL expressions every existing
|
||
// bar reads, and «the new branch is not entered» is the property that keeps thirty other tests true.
|
||
func TestAChapterOrderKeepsTheBarItAlwaysHad(t *testing.T) {
|
||
f := newFixture(t, "10", 5)
|
||
book := multiUnitBook(t, f, "chapters", 3, 2)
|
||
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(2)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := f.run(t, run.ID)
|
||
if got.Progress.Total != 4 {
|
||
t.Fatalf("a two-chapter order over an editor pipeline opens with a total of %d, want 4 — two "+
|
||
"passes over two CHAPTERS, not over their four units", got.Progress.Total)
|
||
}
|
||
if got.DeliveredChapters == nil || *got.DeliveredChapters != 0 {
|
||
t.Errorf("a chapter order reports delivered chapters as %v, want 0 rather than null", got.DeliveredChapters)
|
||
}
|
||
// …and the pair that says which unit this run is measured in points the other way from the
|
||
// character order's: the chapter figure is present, the unit figure is not. Exactly one is set,
|
||
// and this is the half that would go silently null if the branch asked «is this a partial order»
|
||
// instead of «was this sold in characters».
|
||
if got.OrderedChapters == nil || *got.OrderedChapters != 2 {
|
||
t.Errorf("a two-chapter order publishes ordered_chapters %v, want 2", got.OrderedChapters)
|
||
}
|
||
if got.OrderedUnits != nil {
|
||
t.Errorf("a run sold in chapters publishes ordered_units %d; the two are exclusive", *got.OrderedUnits)
|
||
}
|
||
// One whole chapter delivered moves both the bar and the delivered count.
|
||
if _, err := f.store.Pool().Exec(f.ctx, `
|
||
update chapters set units_draft_done = 2, units_edit_done = 2
|
||
where book_id = $1 and number = 1`, book); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got = f.run(t, run.ID)
|
||
if got.DeliveredChapters == nil || *got.DeliveredChapters != 1 {
|
||
t.Errorf("one whole chapter delivered reports %v", got.DeliveredChapters)
|
||
}
|
||
if got.Progress.Done != 2 {
|
||
t.Errorf("one whole chapter through both passes reads %d/%d", got.Progress.Done, got.Progress.Total)
|
||
}
|
||
}
|
||
|
||
// ⛔ A CHARACTER ORDER PUBLISHES ITS VOLUME IN THE UNIT IT WAS SOLD IN, and says NOTHING in chapters.
|
||
//
|
||
// ⚠ THIS TEST REPLACES ONE THAT PINNED THE OPPOSITE, and the replacement is ordered rather than
|
||
// convenient (D39.183 — declared in the pack's report). Its predecessor,
|
||
// `TestACharacterOrderReportsTheChapterSpanItReachesIntoAndNotZero`, pinned the SPAN — how many
|
||
// chapters the order reaches into — because that was what the build published and the contract
|
||
// described a zero. Measuring it is what killed it: the span OVERSTATES what was bought by as much as
|
||
// a whole chapter, and worst on the cheapest order there is. One unit out of four in the first
|
||
// chapter published «1 chapter», which a person reads as «my money reached the end of chapter one».
|
||
// The guarantee did not disappear — it moved: what the buyer got is now published as `ordered_units`,
|
||
// in the unit they asked in, and `ordered_chapters` is null, which is the honest answer to a question
|
||
// that does not apply.
|
||
//
|
||
// ⚠ The row underneath still holds the span — see pgstore.runOrderedChapters — because
|
||
// `ceiling_chapters = 0` is the RE-PASS's mark and three internal readers depend on it.
|
||
//
|
||
// Mutation caught: publishing the span again; nulling BOTH figures; nulling the chapter figure for a
|
||
// CHAPTER order (the branch is `ordered_units is not null`, not «is this a partial order»).
|
||
func TestACharacterOrdersVolumeIsPublishedInTheUnitItWasSoldIn(t *testing.T) {
|
||
f := newFixture(t, "10", 500)
|
||
book := multiUnitBook(t, f, "span", 3, 4) // three chapters, four units each, 1000 runes a unit
|
||
// 4500 runes ⇒ five units ⇒ the whole of chapter one and one unit of chapter two.
|
||
chars := int64(4500)
|
||
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Characters: &chars})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := f.run(t, run.ID)
|
||
if got.OrderedChapters != nil {
|
||
t.Errorf("a run sold in characters reports ordered_chapters %d: the chapter figure it used to "+
|
||
"publish was the SPAN it reached into, and it overstated what was bought", *got.OrderedChapters)
|
||
}
|
||
if got.OrderedUnits == nil || *got.OrderedUnits != 5 {
|
||
t.Errorf("a five-unit order publishes ordered_units %v, want 5 — the unit it was sold in", got.OrderedUnits)
|
||
}
|
||
// …and the two figures that DO speak the order's own unit say so, which is what makes the span
|
||
// readable rather than misleading: the bar counts units and no chapter is claimed as delivered.
|
||
if got.Progress.Total != 10 {
|
||
t.Errorf("the bar of a five-unit order over two waves reads /%d, want /10", got.Progress.Total)
|
||
}
|
||
if got.DeliveredChapters != nil {
|
||
t.Errorf("delivered_chapters is %d rather than null on a unit-shaped run", *got.DeliveredChapters)
|
||
}
|
||
// ⚠ AND THE ORDER CLOSED A WHOLE CHAPTER — chapter one, all four of its units — while still being
|
||
// unit-shaped. So «unit-shaped» means «phrased in characters», NOT «closes no whole chapter»,
|
||
// which is how three doc comments and the canon describe it.
|
||
if got.Progress.Done != 0 {
|
||
t.Errorf("nothing has been delivered yet and the bar reads %d", got.Progress.Done)
|
||
}
|
||
}
|
||
|
||
// ⛔ A BOOK WITH NOTHING LEFT IS REFUSED, AND THAT REFUSAL IS WHAT MAKES `ordered_chapters: 0` MEAN
|
||
// «RE-PASS» AND NOTHING ELSE.
|
||
//
|
||
// The contract now tells a client to read the bar's unit off that field, and its FIRST case is «zero
|
||
// ⇒ a re-pass, `total` is 1, do not render it as chapters». That rule is only safe because no other
|
||
// shape can put a zero there — and one nearly can: `pricing.Quote` clamps a whole-book order to what
|
||
// remains, so over a fully delivered book it quotes ZERO chapters, and the store's own admission
|
||
// check refuses a zero only when `Resnapshot` is false, which a continuation over a moved bank sets
|
||
// to true. The guard in `Start` is what closes that door, ahead of the store.
|
||
//
|
||
// Without it such a run would land with `ceiling_chapters = 0` and be INDISTINGUISHABLE FROM A
|
||
// RE-PASS on the wire: bar 0/1, `stage: re_pass`, `delivered_chapters: 0`. The buyer would be shown
|
||
// somebody else's shape of work.
|
||
//
|
||
// Mutation caught: dropping the `quote.Chapters == 0` guard; moving it after the hold is taken;
|
||
// letting Quote return the remainder count without clamping.
|
||
func TestABookWithNothingLeftIsRefusedRatherThanSoldAsAZeroChapterRun(t *testing.T) {
|
||
f := newFixture(t, "10", 500)
|
||
book := multiUnitBook(t, f, "done", 3, 2)
|
||
// Every unit of every chapter delivered in the wave the book is measured in.
|
||
if _, err := f.store.Pool().Exec(f.ctx, `
|
||
insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at)
|
||
select $1, c, u - 1, 'edit', true, false, now()
|
||
from generate_series(1, 3) c, generate_series(1, 2) u`, book); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book})
|
||
if err == nil {
|
||
t.Fatal("a fully delivered book was sold a run; on the wire it would be indistinguishable " +
|
||
"from a re-pass, because both carry ordered_chapters 0")
|
||
}
|
||
if !errors.Is(err, ErrCeilingOutOfBounds) {
|
||
t.Errorf("the refusal came as %v; the client needs the one that means «re-read the options»", err)
|
||
}
|
||
// …and the money did not move: the refusal is ahead of the hold.
|
||
if got := f.account(t).Reserved; got != 0 {
|
||
t.Errorf("a refused order reserved %s", got.USD())
|
||
}
|
||
}
|