textmachine/platform/internal/pgstore/books_test.go

301 lines
11 KiB
Go

package pgstore
import (
"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)
}
if b.Progress.DraftTotal != 0 || b.Progress.ETASeconds != nil {
t.Errorf("a book that never ran carries progress: %+v", b.Progress)
}
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, CeilingChapters: 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)
}
if run == nil || run.ID != started.ID || run.CeilingChapters != 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)
}
}
// The scale is clamped by what is LEFT of the book as well as by money, and "left" is what the read
// model knows: with no chapter rows materialized yet (the manifest is engine work, row 100) a book
// that never ran answers with its whole length.
func TestWhatIsLeftOfABookIsWhatTheReadModelKnows(t *testing.T) {
s, ctx := testDB(t)
fundedAccount(t, s, ctx, "u1", "10")
seedBook(t, s, ctx, "bk1", "u1", 10)
got, err := s.ReadBookForRun(ctx, "u1", "bk1")
if err != nil {
t.Fatal(err)
}
if got.ChaptersLeft != 10 || got.HasLiveRun || got.Workdir == "" {
t.Fatalf("book for run: %+v", got)
}
// Two chapters materialized and finished.
exec(t, s, ctx, `insert into chapters (id, book_id, number, units_total, units_done) values
('c1','bk1',1,3,3), ('c2','bk1',2,2,2), ('c3','bk1',3,4,1)`)
got, err = s.ReadBookForRun(ctx, "u1", "bk1")
if err != nil {
t.Fatal(err)
}
if got.ChaptersLeft != 8 {
t.Errorf("chapters left %d, want 8", got.ChaptersLeft)
}
if _, err := s.ReadBookForRun(ctx, "u2", "bk1"); !errors.Is(err, ErrNoBook) {
t.Errorf("another account read a book for a run: %v", err)
}
}
// Money SUMS never cross the boundary: what /usage carries is a share (D39.84, contract §Usage).
// 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)
}
if u.RemainingPercent != 0 || u.PausedReason != "" {
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", CeilingChapters: 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 halted on the ceiling puts the ACCOUNT in a halted state, which is what the settings
// screen reads.
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 != PausedCreditExhausted {
t.Fatalf("with a halted run: %+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)
}
}
// The halted state has to survive the platform's OWN pause path, which ends the run in the same
// statement that pauses it. A condition on "the run has not finished" answered null exactly when the
// reconciler — rather than a stream event — was what paused the account.
func TestTheAccountReportsAPauseTheReconcilerCaused(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", CeilingChapters: 10,
Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil)
if err != nil {
t.Fatal(err)
}
if err := s.PauseRun(ctx, run.ID, run.AttemptID, PausedCreditExhausted, now); err != nil {
t.Fatal(err)
}
u, err := s.ReadUsage(ctx, "u1")
if err != nil {
t.Fatal(err)
}
if u.PausedReason != PausedCreditExhausted {
t.Errorf("the account does not report the pause the reconciler caused: %+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)
}
}