package pgstore import ( "context" "errors" "testing" "time" "textmachine/platform/internal/money" ) // The library is what the read handles serve, and a book put in the read model by ANY route is a // book the API lists: the development intake and the contract's upload are two writers of one row, // not two worlds. func TestTheLibraryListsWhateverWasRegisteredAndNobodyElsesBooks(t *testing.T) { s, ctx := testDB(t) now := fundedAccount(t, s, ctx, "u1", "10") fundedAccount(t, s, ctx, "u2", "10") mine, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "蛊真人", SourceLang: "zh", TargetLang: "ru", ChapterCount: 500, CharacterCount: 23_000_000, Workdir: "/srv/books/a", Now: now}) if err != nil { t.Fatal(err) } if _, err := s.AddBook(ctx, NewBook{OwnerID: "u2", Title: "another", SourceLang: "ja", TargetLang: "ru", ChapterCount: 10, Workdir: "/srv/books/b", Now: now}); err != nil { t.Fatal(err) } lib, err := s.ListBooks(ctx, "u1", 0, "") if err != nil { t.Fatal(err) } if len(lib.Books) != 1 || lib.Books[0].ID != mine { t.Fatalf("library: %+v", lib.Books) } b := lib.Books[0] if b.Status != "not_started" || b.ChapterCount != 500 || b.CharacterCount != 23_000_000 { t.Errorf("book row: %+v", b) } // The library row carries no bar at all since 0.3.0: progress belongs to the RUN, and the book's // own figure is chapters_done against chapter_count. if b.ChaptersDone != 0 || b.NoteCount != 0 || b.StructureVersion != 0 { t.Errorf("a book that never ran carries progress: %+v", b) } if _, err := s.AddBook(ctx, NewBook{OwnerID: "nobody", Title: "x", SourceLang: "zh", TargetLang: "ru", ChapterCount: 1, Workdir: "/srv/x", Now: now}); !errors.Is(err, ErrNoAccount) { t.Errorf("adding a book to a missing account gave %v, want ErrNoAccount", err) } } // Keyset pagination, and the assertion is the one that catches the classic off-by-one: the last page // must not carry a cursor, or the client follows it forever. func TestTheLibraryPagesForwardAndStopsCleanly(t *testing.T) { s, ctx := testDB(t) now := fundedAccount(t, s, ctx, "u1", "10") for i := range 5 { if _, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "b", SourceLang: "zh", TargetLang: "ru", ChapterCount: 1, Workdir: "/srv/books/x", Now: now.Add(time.Duration(i) * time.Second)}); err != nil { t.Fatal(err) } } seen := map[string]bool{} cursor := "" for page := 0; ; page++ { if page > 5 { t.Fatal("pagination did not terminate") } lib, err := s.ListBooks(ctx, "u1", 2, cursor) if err != nil { t.Fatal(err) } for _, b := range lib.Books { if seen[b.ID] { t.Fatalf("book %s came back on two pages", b.ID) } seen[b.ID] = true } if lib.NextCursor == "" { break } cursor = lib.NextCursor } if len(seen) != 5 { t.Fatalf("paged over %d books, want 5", len(seen)) } // Rejecting a cursor this collection cannot use is the SERVER's duty: the client holds an opaque // string and cannot judge it. if _, err := s.ListBooks(ctx, "u1", 2, "not-a-cursor"); !errors.Is(err, ErrBadCursor) { t.Errorf("a malformed cursor gave %v, want ErrBadCursor", err) } } // The card carries the book's OWN revision when it has never run, and the run's when it has: the // field orders the client's reads, and a card without one gives it nothing to compare. func TestTheBookCardCarriesARevisionEitherWay(t *testing.T) { s, ctx := testDB(t) now := fundedAccount(t, s, ctx, "u1", "10") id, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "b", SourceLang: "zh", TargetLang: "ru", ChapterCount: 50, Workdir: "/srv/books/a", Now: now}) if err != nil { t.Fatal(err) } book, run, err := s.GetBook(ctx, "u1", id) if err != nil { t.Fatal(err) } if run != nil { t.Fatalf("a book that never ran has a run: %+v", run) } if book.ID != id { t.Fatalf("card: %+v", book) } started, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: id, OrderedChapters: 10, Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil) if err != nil { t.Fatal(err) } _, run, err = s.GetBook(ctx, "u1", id) if err != nil { t.Fatal(err) } // ⚠ A CHAPTER-SHAPED run, so the figure is present; nil here would mean «this run is not measured // in chapters» (runOrderedChapters), which is a different answer from a wrong number. if run == nil || run.ID != started.ID || run.OrderedChapters == nil || *run.OrderedChapters != 10 || run.VerifyBank { t.Fatalf("card run: %+v", run) } if run.PausedReason != "" || run.FinishedAt != nil { t.Errorf("a live run reports a pause or an end: %+v", run) } if _, _, err := s.GetBook(ctx, "u2", id); !errors.Is(err, ErrNoBook) { t.Errorf("another account read the card: %v", err) } } // What is LEFT of a book is what the read model knows, and since the order form it is read PER // CHAPTER, with a price, rather than as one count. // // ⚠ THE CARRIER MOVED AND THE GUARANTEE DID NOT. `BookRunContext.ChaptersLeft` was a single number // that clamped a ceiling SCALE; there is no scale, and a count without prices cannot quote an order // over chapters that differ. What replaced it is `ReadBookForOrder().Remaining` — the same fact, per // chapter, with what each is expected to cost. This test asks the same three questions of the new // carrier: a book nothing has touched offers all of itself, a delivered chapter leaves the offer, // and a stranger is answered as if the book did not exist. func TestWhatIsLeftOfABookIsWhatTheReadModelKnows(t *testing.T) { s, ctx := testDB(t) fundedAccount(t, s, ctx, "u1", "10") seedBook(t, s, ctx, "bk1", "u1", 3) priceBookRow(t, s, ctx, "bk1") // Three chapters, one unit each, nothing delivered. exec(t, s, ctx, `insert into chapters (id, book_id, number, units_total) values ('c1','bk1',1,1), ('c2','bk1',2,1), ('c3','bk1',3,1)`) exec(t, s, ctx, `insert into units (id, chapter_id, ordinal, source, target, state, expected_micro_usd, source_chars) values ('u1','c1',0,'s','','pending',50000,1000), ('u2','c2',0,'s','','pending',50000,1000), ('u3','c3',0,'s','','pending',50000,1000)`) got, err := s.ReadBookForOrder(ctx, "u1", "bk1") if err != nil { t.Fatal(err) } if !got.Priced || len(got.Remaining) != 3 { t.Fatalf("a book nothing has touched offers %d chapters (priced %v)", len(got.Remaining), got.Priced) } // One chapter delivered leaves the offer entirely. exec(t, s, ctx, `insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at) values ('bk1',1,0,'edit',true,false,now())`) got, err = s.ReadBookForOrder(ctx, "u1", "bk1") if err != nil { t.Fatal(err) } if len(got.Remaining) != 2 || got.Remaining[0].Number != 2 { t.Errorf("after one chapter was delivered the offer is %+v", got.Remaining) } if _, err := s.ReadBookForOrder(ctx, "u2", "bk1"); !errors.Is(err, ErrNoBook) { t.Errorf("another account read a book's price: %v", err) } // The run's own context still answers what a START is judged on, and ownership with it. rc, err := s.ReadBookForRun(ctx, "u1", "bk1") if err != nil || rc.HasLiveRun || rc.Workdir == "" || rc.ChapterCount != 3 { t.Fatalf("book for run: %+v (%v)", rc, err) } if _, err := s.ReadBookForRun(ctx, "u2", "bk1"); !errors.Is(err, ErrNoBook) { t.Errorf("another account read a book for a run: %v", err) } } // priceBookRow gives a book the projection an order is judged against. Written here rather than in // each test because an UNPRICED book is refused outright now, and every test of what is left of a // book would otherwise be testing that refusal. func priceBookRow(t *testing.T, s *Store, ctx context.Context, book string) { t.Helper() exec(t, s, ctx, `update books set expected_micro_usd = 150000, book_once_micro_usd = 0, step_max_micro_usd = 69828, source_chars = 3000, structure = 'detected' where id = '`+book+`'`) } // …and "finished" is the LAST pass the book actually gets HERE. A deployment whose pipeline has no // editor announces an edit denominator of zero, and counting the edit wave there left every chapter // unfinished forever: the scale was never clamped, so the service went on offering — and taking a // hold for — chapters it had already translated. // // Mutation caught: naming units_edit_done outright in finishedUnits. func TestADraftOnlyDeploymentStillClampsTheScale(t *testing.T) { s, ctx := testDB(t) now := fundedAccount(t, s, ctx, "u1", "10") seedBook(t, s, ctx, "bk1", "u1", 3) priceBookRow(t, s, ctx, "bk1") // Two chapters the draft wave finished, and an edit wave that never touched them. exec(t, s, ctx, `insert into chapters (id, book_id, number, units_total) values ('c1','bk1',1,1), ('c2','bk1',2,1), ('c3','bk1',3,1)`) exec(t, s, ctx, `insert into units (id, chapter_id, ordinal, source, target, state, expected_micro_usd, source_chars) values ('u1','c1',0,'s','','pending',50000,1000), ('u2','c2',0,'s','','pending',50000,1000), ('u3','c3',0,'s','','pending',50000,1000)`) exec(t, s, ctx, `insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at) values ('bk1',1,0,'draft',true,false,now()), ('bk1',2,0,'draft',true,false,now())`) got, err := s.ReadBookForOrder(ctx, "u1", "bk1") if err != nil { t.Fatal(err) } // No run has reported yet, so the pipeline's shape is unknown — and unknown must not read as done. if len(got.Remaining) != 3 { t.Fatalf("the offer is %d chapters before any run reported, want all three", len(got.Remaining)) } if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", OrderedChapters: 3, Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil); err != nil { t.Fatal(err) } // What the engine's first progress event settles on a pipeline with no editor (recordWaveShape). exec(t, s, ctx, `update books set edit_wave = false where id = 'bk1'`) got, err = s.ReadBookForOrder(ctx, "u1", "bk1") if err != nil { t.Fatal(err) } if len(got.Remaining) != 1 || got.Remaining[0].Number != 3 { t.Errorf("a draft-only deployment offers %+v, want only the chapter the draft has not finished", got.Remaining) } } // What `/usage` carries is a SHARE, and since 05.09 that is a statement about THIS question rather // than a prohibition: the blanket ban on money crossing the boundary was the owner's own and the // owner revoked it (D39.196 §2) — the balance goes out in dollars, on the ORDER FORM, where a sum has // an order beside it to be a price OF. The account screen asks «am I running low», and a percentage // is honest exactly as an alarm. // // The edge that matters is an account with no grants at all — zero out of zero is not "everything // available", and telling a user they have 100% of nothing is the one answer they cannot act on. func TestUsageIsAShareAndAnAccountWithNoGrantsIsExhausted(t *testing.T) { s, ctx := testDB(t) seedUser(t, s, ctx, "empty") u, err := s.ReadUsage(ctx, "empty") if err != nil { t.Fatal(err) } // …and an account that cannot spend anything IS halted, whether it ran out or never had anything: // the answer a client acts on is the same either way. if u.RemainingPercent != 0 || u.Spendable || u.PausedReason != PausedCreditExhausted { t.Errorf("an account with no grants: %+v", u) } now := fundedAccount(t, s, ctx, "u1", "10") seedBook(t, s, ctx, "bk1", "u1", 500) if u, err = s.ReadUsage(ctx, "u1"); err != nil || u.RemainingPercent != 100 { t.Fatalf("a fresh account: %+v (%v)", u, err) } run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", OrderedChapters: 100, Ceiling: money.MicroUSD(3_000_000), Now: now}, 0, nil) if err != nil { t.Fatal(err) } // A hold is a debit, so the share drops when it is TAKEN and not when it is spent. if u, err = s.ReadUsage(ctx, "u1"); err != nil || u.RemainingPercent != 70 { t.Fatalf("after a $3 hold on $10: %+v (%v)", u, err) } // ⚠ A RUN that stopped at the ceiling IT bought does NOT halt the account. The canon says so at // §AccountHaltReason, and the wire said the opposite: `remaining_percent: 97` beside // `halt_reason: credit_exhausted` — a user with money told they have none. exec(t, s, ctx, `update runs set paused_reason='credit_exhausted', status='paused' where id=$1`, run.ID) if u, err = s.ReadUsage(ctx, "u1"); err != nil || u.PausedReason != "" || u.RemainingPercent != 70 { t.Fatalf("a run at its own limit halted the account: %+v (%v)", u, err) } // The account is halted when the ACCOUNT has nothing left, and by nothing else. if _, err := s.Adjust(ctx, "u1", money.MicroUSD(-7_000_000), "test", "spend", "spent", now); err != nil { t.Fatal(err) } if u, err = s.ReadUsage(ctx, "u1"); err != nil || u.PausedReason != PausedCreditExhausted { t.Fatalf("an account with nothing left: %+v (%v)", u, err) } if _, err := s.ReadUsage(ctx, "nobody"); !errors.Is(err, ErrNoAccount) { t.Errorf("usage of a missing account gave %v, want ErrNoAccount", err) } } // Rounded DOWN, because the figure is what is LEFT: rounding 0.4% up to 1% tells a user they can // still start something. func TestTheRemainingShareRoundsDown(t *testing.T) { s, ctx := testDB(t) now := fundedAccount(t, s, ctx, "u1", "10") if _, err := s.Adjust(ctx, "u1", money.MicroUSD(-9_960_000), "test", "spend", "spent", now); err != nil { t.Fatal(err) } u, err := s.ReadUsage(ctx, "u1") if err != nil { t.Fatal(err) } if u.RemainingPercent != 0 { t.Errorf("0.4%% of the grant left reported as %d%%", u.RemainingPercent) } } // Rejecting a cursor that does not apply to this collection is the SERVER's duty (contract // §NextCursor): the token is opaque to the client, so the client cannot perform the check. Measured // before the fix: a token built from another account's library was accepted and answered with a // window of the caller's own books. func TestACursorFromAnotherLibraryIsRefused(t *testing.T) { s, ctx := testDB(t) now := fundedAccount(t, s, ctx, "u1", "10") fundedAccount(t, s, ctx, "u2", "10") for i := range 3 { at := now.Add(time.Duration(i) * time.Second) if _, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "mine", SourceLang: "zh", TargetLang: "ru", ChapterCount: 1, Workdir: "/srv/a", Now: at}); err != nil { t.Fatal(err) } if _, err := s.AddBook(ctx, NewBook{OwnerID: "u2", Title: "theirs", SourceLang: "zh", TargetLang: "ru", ChapterCount: 1, Workdir: "/srv/b", Now: at}); err != nil { t.Fatal(err) } } theirs, err := s.ListBooks(ctx, "u2", 1, "") if err != nil { t.Fatal(err) } if theirs.NextCursor == "" { t.Fatal("no cursor to carry across") } if _, err := s.ListBooks(ctx, "u1", 1, theirs.NextCursor); !errors.Is(err, ErrBadCursor) { t.Fatalf("another library's cursor gave %v, want ErrBadCursor", err) } // The account's own cursor still works, so the binding did not simply break pagination. mine, err := s.ListBooks(ctx, "u1", 1, "") if err != nil { t.Fatal(err) } if _, err := s.ListBooks(ctx, "u1", 1, mine.NextCursor); err != nil { t.Fatalf("the account's own cursor was refused: %v", err) } } // A pause the platform's OWN path caused is visible where a pause belongs — on the RUN, and through // it on the book's card. It ends the run in the same statement that pauses it, so a projection keyed // on "the run has not finished" answered null exactly when the reconciler, rather than a stream // event, was what stopped the work. // // ⚠ On the run and NOT on the account: this used to be asserted through `/usage`, which is how the // account came to be lit from a fact about one run (canon §AccountHaltReason). func TestAPauseTheReconcilerCausedIsVisibleOnTheRun(t *testing.T) { s, ctx := testDB(t) now := fundedAccount(t, s, ctx, "u1", "10") seedBook(t, s, ctx, "bk1", "u1", 500) run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", OrderedChapters: 10, Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil) if err != nil { t.Fatal(err) } if paused, err := s.PauseRun(ctx, run.ID, run.AttemptID, PausedCreditExhausted, now); err != nil || !paused { t.Fatalf("PauseRun = %v, %v", paused, err) } book, card, err := s.GetBook(ctx, "u1", "bk1") if err != nil { t.Fatal(err) } if card == nil || card.PausedReason != PausedCreditExhausted || book.Status != "paused" { t.Errorf("the card does not report the pause the reconciler caused: %+v %+v", book, card) } // …and the ACCOUNT is untouched by it: the money is still there. u, err := s.ReadUsage(ctx, "u1") if err != nil { t.Fatal(err) } if u.PausedReason != "" || !u.Spendable { t.Errorf("one run's pause halted the whole account: %+v", u) } } // "Exhausted" is a fact about the balance, not a consequence of rounding the share down: a small // remainder of a large grant floors to 0% while runs can still be started. func TestASmallRemainderOfALargeGrantIsStillSpendable(t *testing.T) { s, ctx := testDB(t) now := fundedAccount(t, s, ctx, "u1", "1000") if _, err := s.Adjust(ctx, "u1", money.MicroUSD(-991_000_000), "test", "spend", "spent", now); err != nil { t.Fatal(err) } u, err := s.ReadUsage(ctx, "u1") if err != nil { t.Fatal(err) } if u.RemainingPercent != 0 { t.Fatalf("share %d%%, want 0 (floored)", u.RemainingPercent) } if !u.Spendable { t.Error("an account with $9 left reported nothing spendable") } if _, err := s.Adjust(ctx, "u1", money.MicroUSD(-9_000_000), "test", "rest", "rest", now); err != nil { t.Fatal(err) } if u, err = s.ReadUsage(ctx, "u1"); err != nil || u.Spendable { t.Errorf("an empty account: %+v (%v)", u, err) } } // The gate an engine upgrade reads (orchestrator's addendum, 14.08). `tmctl migrate` moves a book's // project file to the new binary's schema, and the OLD binary cannot open it afterwards — so a book // is only safe to migrate when nothing is still pinned to that older build. Three facts say it is // not, and each of them alone is enough: // // - a LIVE run, whose engine is holding the file open right now; // - a RESUMABLE one, which waits to be continued by the build its attempt is pinned to (row 139) // — migrating under it would force the continuation onto a new binary, and re-paying for calls // the account has already bought is exactly what the pinning exists to prevent; // - an UNSETTLED hold, whose money is read with that same pinned build. Migrate first and the // figure can never be read again, and the hold stays reserved with no sweep able to close it. // // Pinned as a table because the query answers three independent facts in one pass, and the command // that reads it (`tmplatformctl books --migratable`) is a for-loop in a deploy note: a blocker that // silently stops blocking does not fail anything until an operator's upgrade has already run. // // Mutation caught: dropping any one of the three predicates from BooksForMigration. func TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList(t *testing.T) { s, ctx := testDB(t) now := fundedAccount(t, s, ctx, "u1", "100") start := func(t *testing.T, book string) StartedRun { t.Helper() seedBook(t, s, ctx, book, "u1", 500) run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, OrderedChapters: 10, Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil) if err != nil { t.Fatal(err) } return run } end := func(t *testing.T, run StartedRun, status, reason string) { t.Helper() closed, err := s.FinishRun(ctx, RunEnding{RunID: run.ID, AttemptID: run.AttemptID, Status: status, PausedReason: reason, ExitResult: "exit-code", Now: now}) if err != nil || !closed { t.Fatalf("FinishRun(%s) = %v, %v", status, closed, err) } } settle := func(t *testing.T, run StartedRun) { t.Helper() if err := s.Settle(ctx, ReservationKey(run.ID, 1), money.MicroUSD(100_000), now); err != nil { t.Fatal(err) } } // One book per fact, and one with none of them. settled := start(t, "bk-free") end(t, settled, "ready", "") settle(t, settled) live := start(t, "bk-live") // admitted and never finished // Its hold is closed by hand, which no live run does: a live run always holds money, so the two // facts co-occur naturally and one predicate would then be hiding behind the other. Each has to // carry its own weight — the query is consumed by a for-loop in a deploy note, where a blocker // that has quietly stopped blocking is invisible until an upgrade has already run. settle(t, live) // ⚠ A `paused` book is MIGRATABLE since P7, and the reason is the contract rather than a // relaxation: a run stopped at a limit cannot be continued by `resume` at all any more (409 // `ceiling_reached`), so nothing is pinned to an old build — the remedy is a new run on the // current one. A book stuck on the engine's daily ceiling used to block an upgrade for ever // (PD-217). paused := start(t, "bk-paused") end(t, paused, "paused", PausedCreditExhausted) settle(t, paused) holding := start(t, "bk-hold") end(t, holding, "ready", "") // finished, but its hold was never closed books, err := s.BooksForMigration(ctx) if err != nil { t.Fatal(err) } got := map[string]BookForMigration{} for _, b := range books { got[b.ID] = b } for _, tc := range []struct { book string migratable bool live bool resumable bool unsettled bool }{ {book: "bk-free", migratable: true}, {book: "bk-live", live: true}, {book: "bk-paused", migratable: true}, {book: "bk-hold", unsettled: true}, } { b, ok := got[tc.book] if !ok { t.Errorf("%s is not in the migration list at all", tc.book) continue } if b.Migratable() != tc.migratable { t.Errorf("%s: migratable = %v, want %v (live=%v resumable=%v unsettled=%v)", tc.book, b.Migratable(), tc.migratable, b.Live, b.Resumable, b.Unsettled) } if b.Live != tc.live || b.Resumable != tc.resumable || b.Unsettled != tc.unsettled { t.Errorf("%s: live=%v resumable=%v unsettled=%v, want %v/%v/%v", tc.book, b.Live, b.Resumable, b.Unsettled, tc.live, tc.resumable, tc.unsettled) } } // The other half of the same fact: the list the deploy note's loop actually consumes carries the // free book and nothing else. var safe []string for _, b := range books { if b.Migratable() { safe = append(safe, b.ID) } } if len(safe) != 2 || safe[0] != "bk-free" || safe[1] != "bk-paused" { t.Errorf("the migratable list is %v, want the free book and the paused one", safe) } // Every status a resume can continue from blocks, not just the one above: `stopped` and // `awaiting_bank` wait for the same pinned build. for _, status := range []string{"stopped", "awaiting_bank"} { book := "bk-" + status run := start(t, book) end(t, run, status, "") settle(t, run) books, err := s.BooksForMigration(ctx) if err != nil { t.Fatal(err) } for _, b := range books { if b.ID == book && b.Migratable() { t.Errorf("a %s run left its book migratable", status) } } } }