textmachine/platform/internal/pgstore/rename_test.go

124 lines
5.2 KiB
Go

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