package pgstore import ( "errors" "testing" "textmachine/platform/internal/money" ) // The rename is scoped by OWNER and moves the revision, which are the two properties a client and a // stranger respectively depend on. // // Mutation caught: dropping `owner_id` from the WHERE (anyone renames anyone's book); leaving the // revision alone (a client obeying the contract drops the read that carried the new name, so the // screen never shows it); reading the card outside the transaction (the card comes back labelled with // a number older than the figures on it). func TestRenamingABookIsOwnerScopedAndMovesTheRevision(t *testing.T) { s, ctx := testDB(t) seedUser(t, s, ctx, "u_owner") seedUser(t, s, ctx, "u_stranger") seedBook(t, s, ctx, "bk_1", "u_owner", 10) beforeBook, _, err := s.GetBook(ctx, "u_owner", "bk_1") if err != nil { t.Fatal(err) } after, _, err := s.RenameBook(ctx, "u_owner", "bk_1", "Мастер Гу") if err != nil { t.Fatal(err) } if after.Title != "Мастер Гу" { t.Errorf("the card came back with %q", after.Title) } if after.Revision <= beforeBook.Revision { t.Errorf("revision %d did not rise above %d: a conforming client drops this read", after.Revision, beforeBook.Revision) } // The store, not just the answer. reread, _, err := s.GetBook(ctx, "u_owner", "bk_1") if err != nil || reread.Title != "Мастер Гу" || reread.Revision != after.Revision { t.Errorf("the rename did not land: %+v (%v)", reread, err) } // ⚠ AND THE PART THAT SEPARATES THE TWO FORMULAS. On a fresh book the account floor and the // book's own counter are both 0, so `greatest(max, floor) + 1` and a plain `revision + 1` give // the same answer and the test proves nothing about which one is written. Push the ACCOUNT's // floor above this book's counter — the state a cancelled upload leaves — and only the correct // formula clears it. A client obeying the contract drops any read not ABOVE what it applied, so // the wrong one here is a rename the screen never shows. exec(t, s, ctx, `update users set library_revision = $1 where id = 'u_owner'`, after.Revision+500) cleared, _, err := s.RenameBook(ctx, "u_owner", "bk_1", "Мастер Гу, второе имя") if err != nil { t.Fatal(err) } if cleared.Revision <= after.Revision+500 { t.Errorf("revision %d did not clear the account's floor %d: a plain `revision + 1` would look exactly like this", cleared.Revision, after.Revision+500) } // A stranger gets the same answer as for a book that is not there, and writes nothing. if _, _, err := s.RenameBook(ctx, "u_stranger", "bk_1", "mine now"); !errors.Is(err, ErrNoBook) { t.Errorf("a stranger's rename answered %v, want ErrNoBook", err) } if _, _, err := s.RenameBook(ctx, "u_owner", "bk_missing", "x"); !errors.Is(err, ErrNoBook) { t.Errorf("a missing book answered %v, want ErrNoBook", err) } still, _, err := s.GetBook(ctx, "u_owner", "bk_1") if err != nil || still.Title != "Мастер Гу, второе имя" { t.Errorf("a refused rename changed the book anyway: %+v (%v)", still, err) } } // ⚠ A rename is accepted while a run is LIVE, which the canon states outright («Accepted while a run // is live: a rename touches nothing a run reads»). Pinned because the tempting defensive move — a // 409 like `deleteBook` has — would be wrong: nothing a run reads is in this row, and a user waiting // hours for a translation is exactly the user who notices the name is wrong. func TestARenameIsAcceptedWhileARunIsLive(t *testing.T) { s, ctx := testDB(t) now := fundedAccount(t, s, ctx, "u_owner", "25") seedBook(t, s, ctx, "bk_1", "u_owner", 10) if _, err := s.StartRun(ctx, StartRunInput{UserID: "u_owner", BookID: "bk_1", OrderedChapters: 2, Ceiling: money.MicroUSD(1_000_000), Now: now}, 0, nil); err != nil { t.Fatal(err) } after, run, err := s.RenameBook(ctx, "u_owner", "bk_1", "Переименовано на ходу") if err != nil { t.Fatalf("a rename during a live run was refused: %v", err) } if after.Title != "Переименовано на ходу" { t.Errorf("title: %q", after.Title) } // The run comes back beside the card and carries the BOOK's revision, like every other path that // hands one out (§Revision: one counter per book). if run == nil { t.Fatal("the live run vanished from the answer") } if run.Revision != after.Revision { t.Errorf("the run carries revision %d and the card %d", run.Revision, after.Revision) } } // The backup listing is EVERY book with its directory, in a stable order. A filter here — on status, // on a run having happened — is how a book somebody paid for comes to be left out of the copy. func TestTheBackupListingHoldsEveryBookAndItsDirectory(t *testing.T) { s, ctx := testDB(t) seedUser(t, s, ctx, "u_owner") seedBook(t, s, ctx, "bk_1", "u_owner", 10) seedBook(t, s, ctx, "bk_2", "u_owner", 3) exec(t, s, ctx, `update books set status = 'rejected' where id = 'bk_2'`) got, err := s.BooksForBackup(ctx) if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("the listing holds %d books, want both — including the rejected one, whose "+ "directory may still be there: %+v", len(got), got) } for _, b := range got { if b.Workdir == "" { t.Errorf("%s has no directory, so the backup would not know where to look", b.ID) } } }