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) } }