494 lines
19 KiB
Go
494 lines
19 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)
|
|
}
|
|
// 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, 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_edit_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)
|
|
}
|
|
}
|
|
|
|
// …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", 10)
|
|
// 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, units_draft_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)
|
|
}
|
|
// No run has reported yet, so the pipeline's shape is unknown — and unknown must not read as done.
|
|
if got.ChaptersLeft != 10 {
|
|
t.Fatalf("chapters left %d before any run reported, want the whole book", got.ChaptersLeft)
|
|
}
|
|
if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", CeilingChapters: 10,
|
|
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.ReadBookForRun(ctx, "u1", "bk1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.ChaptersLeft != 8 {
|
|
t.Errorf("chapters left %d on a draft-only deployment, want the two the draft finished taken off", got.ChaptersLeft)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
// …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", 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 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", CeilingChapters: 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,
|
|
CeilingChapters: 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)
|
|
}
|
|
}
|
|
}
|
|
}
|