1494 lines
61 KiB
Go
1494 lines
61 KiB
Go
package books
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"log/slog"
|
||
"net/url"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
|
||
"textmachine/platform/internal/ingest"
|
||
"textmachine/platform/internal/jobs"
|
||
"textmachine/platform/internal/pgstore"
|
||
"textmachine/platform/internal/runner"
|
||
)
|
||
|
||
// Intake is a walk with three ends — parsed, rejected, or removed — and every one of them is reached
|
||
// by a DIFFERENT process from the one that started it. That is what these tests are about: the row,
|
||
// the directory and the file agree at each step, and no step is reachable only from the request that
|
||
// began it.
|
||
func intakeDB(t *testing.T) (*pgstore.Store, context.Context) {
|
||
t.Helper()
|
||
admin := os.Getenv("TM_PLATFORM_TEST_DSN")
|
||
if admin == "" {
|
||
t.Skip("TM_PLATFORM_TEST_DSN not set: the intake's statuses need a live Postgres")
|
||
}
|
||
ctx := t.Context()
|
||
var suffix [6]byte
|
||
if _, err := rand.Read(suffix[:]); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
name := "tm_books_test_" + hex.EncodeToString(suffix[:])
|
||
conn, err := pgx.Connect(ctx, admin)
|
||
if err != nil {
|
||
t.Fatalf("connect: %v", err)
|
||
}
|
||
if _, err := conn.Exec(ctx, "create database "+pgx.Identifier{name}.Sanitize()); err != nil {
|
||
conn.Close(ctx)
|
||
t.Skipf("cannot create a scratch database (%v): grant CREATEDB or point the DSN at one", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
c, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cancel()
|
||
_, _ = conn.Exec(c, "drop database if exists "+pgx.Identifier{name}.Sanitize()+" with (force)")
|
||
conn.Close(c)
|
||
})
|
||
u, err := url.Parse(admin)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
u.Path = "/" + name
|
||
if err := pgstore.Migrate(ctx, u.String()); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
s, err := pgstore.Open(ctx, u.String())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(s.Close)
|
||
if _, err := s.Pool().Exec(ctx, `insert into users (id, email) values ('u1','u1@example.org')`); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return s, ctx
|
||
}
|
||
|
||
// fakeEngine stands in for `tmctl manifest`. It models the two answers that lead to different ends —
|
||
// a manifest, and a process that RAN and refused — because a fake that can only succeed proves
|
||
// nothing about the branch that rejects a book.
|
||
type fakeEngine struct {
|
||
mu sync.Mutex
|
||
manifest ingest.Manifest
|
||
err error
|
||
calls int
|
||
// onManifest runs INSIDE the call, which is where the deadline the caller granted is visible.
|
||
onManifest func(ctx context.Context)
|
||
}
|
||
|
||
func (f *fakeEngine) Manifest(ctx context.Context, _, _ string) (ingest.Manifest, error) {
|
||
f.mu.Lock()
|
||
f.calls++
|
||
hook := f.onManifest
|
||
m, err := f.manifest, f.err
|
||
f.mu.Unlock()
|
||
if hook != nil {
|
||
hook(ctx)
|
||
}
|
||
return m, err
|
||
}
|
||
|
||
func (f *fakeEngine) set(m ingest.Manifest, err error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.manifest, f.err = m, err
|
||
}
|
||
|
||
func (f *fakeEngine) called() int {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
return f.calls
|
||
}
|
||
|
||
// refusal is what one of the engine's answers looks like from here: a process that RAN and exited
|
||
// with the code that names why. Produced by running a real command rather than by hand-building an
|
||
// ExitError, so the type AND the code the classifier keys on are the ones a real answer carries.
|
||
func refusal(t *testing.T, code int) error {
|
||
t.Helper()
|
||
err := exec.CommandContext(t.Context(), "/bin/sh", "-c", "exit "+strconv.Itoa(code)).Run()
|
||
if err == nil {
|
||
t.Fatalf("the refusal helper exited 0 for code %d", code)
|
||
}
|
||
return err
|
||
}
|
||
|
||
type fixture struct {
|
||
svc *Service
|
||
store *pgstore.Store
|
||
engine *fakeEngine
|
||
ctx context.Context
|
||
root string
|
||
now time.Time
|
||
}
|
||
|
||
// wholeManifest is a manifest that DESCRIBES ITSELF: every count agrees with what the document
|
||
// actually carries, and every chapter and pair has an identity.
|
||
//
|
||
// ⚠ It exists because the intake's floor became symmetric with the materialiser's (register row
|
||
// PD-367), and the fixtures were EXTENDED to satisfy it rather than the floor relaxed to admit them.
|
||
// The old fixture declared 500 chapters and carried none — the very document the materialiser has
|
||
// always refused and the intake accepted, founding a book with `chapter_count = 500` and an empty
|
||
// tree over which a run could be started and PAID FOR. A fixture that models an impossible document
|
||
// proves nothing about the possible ones.
|
||
func wholeManifest(chapters, unitsPerChapter int) ingest.Manifest {
|
||
m := ingest.Manifest{
|
||
Version: "tm-manifest-v2", ChaptersTotal: chapters, UnitsTotal: chapters * unitsPerChapter,
|
||
SourceSHA256: strings.Repeat("ab", 32), ChunkerVersion: "chunk-2026.07",
|
||
}
|
||
for i := 1; i <= chapters; i++ {
|
||
c := ingest.ManifestChapter{ID: "c" + strconv.Itoa(i), Number: i, UnitsTotal: unitsPerChapter}
|
||
for u := range unitsPerChapter {
|
||
c.Units = append(c.Units, ingest.ManifestUnit{
|
||
ID: "c" + strconv.Itoa(i) + ":cut:" + strconv.Itoa(u), FirstChunkIdx: u})
|
||
}
|
||
m.Chapters = append(m.Chapters, c)
|
||
}
|
||
return m
|
||
}
|
||
|
||
func newFixture(t *testing.T) *fixture {
|
||
t.Helper()
|
||
store, ctx := intakeDB(t)
|
||
root := t.TempDir()
|
||
eng := &fakeEngine{manifest: wholeManifest(500, 2)}
|
||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||
f := &fixture{store: store, engine: eng, ctx: ctx, root: root, now: now}
|
||
f.svc = &Service{
|
||
Store: store,
|
||
Engine: eng,
|
||
// A deployment declares what it can translate, and the boot now refuses to mount an intake
|
||
// without it — so the fixture declares it too, or it would model a configuration that cannot
|
||
// exist.
|
||
Cfg: Config{BooksDir: root, EngineBinary: "/opt/engine/2026.08.01/tmctl",
|
||
Pairs: []Pair{{Source: "zh", Target: "ru"}}},
|
||
Now: func() time.Time { return f.now },
|
||
}
|
||
return f
|
||
}
|
||
|
||
// An undeclared deployment refuses every upload rather than accepting it, and a declared pair is
|
||
// matched case-insensitively — a language tag is case-insensitive by BCP 47, so `zh-Hans` declared
|
||
// and `zh-hans` uploaded are the same pair.
|
||
//
|
||
// Mutation caught: the `len(Pairs) == 0 → true` escape hatch; comparing tags byte-for-byte.
|
||
func TestAnUndeclaredDeploymentRefusesAndADeclaredPairIgnoresCase(t *testing.T) {
|
||
f := newFixture(t)
|
||
f.svc.Cfg.Pairs = nil
|
||
if _, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
|
||
Filename: "a.txt", File: strings.NewReader("第一节\n")}); !errors.Is(err, ErrUnsupportedPair) {
|
||
t.Errorf("a deployment that declares nothing accepted a book: %v", err)
|
||
}
|
||
// ⚠ Only the SUBTAG varies: the canon's LangCode fixes the primary subtag as lower-case
|
||
// (`^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$`), so `RU` is malformed and refused a step earlier.
|
||
f.svc.Cfg.Pairs = []Pair{{Source: "zh-Hans", Target: "ru"}}
|
||
if _, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh-hans", TargetLang: "ru",
|
||
Filename: "b.txt", File: strings.NewReader("第一节\n")}); err != nil {
|
||
t.Errorf("a case variant of a declared tag was refused: %v", err)
|
||
}
|
||
}
|
||
|
||
// fakeReader records which books were asked for a reading surface.
|
||
type fakeReader struct {
|
||
refreshed []pgstore.OwedBook
|
||
// cuts is the manifest each RefreshCut was HANDED, which is how a test tells "the intake passed
|
||
// the cut it already had" from "the materializer read it again".
|
||
cuts []ingest.Manifest
|
||
claims []pgstore.OwedBook
|
||
// unclaimable models a debt the materializer's own sweep is already paying.
|
||
unclaimable bool
|
||
err error
|
||
}
|
||
|
||
func (f *fakeReader) Claim(_ context.Context, b pgstore.OwedBook) (pgstore.OwedBook, bool, error) {
|
||
f.claims = append(f.claims, b)
|
||
return b, !f.unclaimable, nil
|
||
}
|
||
|
||
func (f *fakeReader) RefreshCut(_ context.Context, b pgstore.OwedBook, cut ingest.Manifest) error {
|
||
f.cuts = append(f.cuts, cut)
|
||
f.refreshed = append(f.refreshed, b)
|
||
return f.err
|
||
}
|
||
|
||
// A parse leaves the book OWING a reading surface, recorded where the process that failed to
|
||
// materialize it cannot lose it. The materialization at the end of the intake is the one with no
|
||
// other retry: a failure there left the reader screen empty until some later run happened to finish
|
||
// (PD-276), and the queue that answered it lived in memory.
|
||
//
|
||
// Mutation caught: stamping the debt anywhere but the transaction that ends the parse.
|
||
func TestAParsedBookOwesAReadingSurfaceUntilOneIsMaterialized(t *testing.T) {
|
||
f := newFixture(t)
|
||
reader := &fakeReader{err: errors.New("the engine refused")}
|
||
f.svc.Reader = reader
|
||
b := f.accept(t, "roman.txt", "第一节\n\n第二节\n")
|
||
f.provision(t, b)
|
||
// Before the parse the book is being cut and owes nothing: a debt found here would be one this
|
||
// test cannot attribute to the parse.
|
||
if owed := f.owed(t); len(owed) != 0 {
|
||
t.Fatalf("a book still in intake already owes a surface: %+v", owed)
|
||
}
|
||
if err := f.svc.Parse(f.ctx, b.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// The intake handed the materializer the cut it had just read, rather than paying for a second
|
||
// re-chunk of the same source (PD-248).
|
||
if len(reader.cuts) != 1 || reader.cuts[0].ChaptersTotal != 500 {
|
||
t.Fatalf("the intake passed %d cuts to the materializer, want the one it had read", len(reader.cuts))
|
||
}
|
||
owed := f.owed(t)
|
||
if len(owed) != 1 || owed[0].ID != b.ID {
|
||
t.Fatalf("the materializer's queue holds %+v, want the book whose tree did not land", owed)
|
||
}
|
||
// ⚠ …and stamped BY THE PARSE'S OWN TRANSACTION, which is the property and not the debt's mere
|
||
// presence: a stamp written by a second statement afterwards leaves the same end state and a
|
||
// window where a book is parsed and owes nothing, and the debt column is the only retry there is.
|
||
// `xmin` is the transaction that last wrote the row, so the book and the frame the parse emitted
|
||
// carry one number exactly when one transaction wrote both.
|
||
f.sameTransaction(t, b.ID,
|
||
`select b.xmin::text from books b where b.id = $1`,
|
||
`select e.xmin::text from book_events e where e.book_id = $1 order by e.position desc limit 1`)
|
||
// …and it is the same debt the intake tried to pay, so the attempt that failed cannot discharge a
|
||
// boundary recorded after it.
|
||
if len(reader.refreshed) != 1 || !reader.refreshed[0].OwedAt.Equal(owed[0].OwedAt) {
|
||
t.Errorf("the intake was handed %+v, want the debt its own parse recorded", reader.refreshed)
|
||
}
|
||
}
|
||
|
||
// sameTransaction asserts that two rows were last written by ONE transaction. It is how a test says
|
||
// "these two facts land together or not at all" without being able to crash the process between them.
|
||
func (f *fixture) sameTransaction(t *testing.T, bookID, left, right string) {
|
||
t.Helper()
|
||
var a, b string
|
||
if err := f.store.Pool().QueryRow(f.ctx, left, bookID).Scan(&a); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := f.store.Pool().QueryRow(f.ctx, right, bookID).Scan(&b); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if a == "" || a != b {
|
||
t.Errorf("the two facts were written by different transactions (%s vs %s)", a, b)
|
||
}
|
||
}
|
||
|
||
func (f *fixture) owed(t *testing.T) []pgstore.OwedBook {
|
||
t.Helper()
|
||
owed, err := f.store.BooksOwedReadModel(f.ctx, 10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return owed
|
||
}
|
||
|
||
// accept runs one upload of the given bytes and returns the book.
|
||
func (f *fixture) accept(t *testing.T, filename, body string) pgstore.Book {
|
||
t.Helper()
|
||
b, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
|
||
Filename: filename, File: strings.NewReader(body)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return b
|
||
}
|
||
|
||
// provision puts an engine configuration in the book's directory. Who does this in production is the
|
||
// open question the intake stops at (ErrNotProvisioned); here it stands for the operator.
|
||
func (f *fixture) provision(t *testing.T, b pgstore.Book) string {
|
||
t.Helper()
|
||
dir := filepath.Join(f.root, b.ID)
|
||
if err := os.WriteFile(filepath.Join(dir, runner.ConfigFile), []byte("book_id: x\n"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return dir
|
||
}
|
||
|
||
func (f *fixture) libraryRevision(t *testing.T) int64 {
|
||
t.Helper()
|
||
lib, err := f.store.ListBooks(f.ctx, "u1", 50, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return lib.Revision
|
||
}
|
||
|
||
func (f *fixture) card(t *testing.T, id string) pgstore.Book {
|
||
t.Helper()
|
||
b, _, err := f.store.GetBook(f.ctx, "u1", id)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return b
|
||
}
|
||
|
||
// The whole walk, and the assertion is that each status is written by somebody: the row exists while
|
||
// the file arrives, the engine turns it into a chapter tree, and the library reads the result exactly
|
||
// as it reads a book the dev intake put there.
|
||
func TestAnUploadedBookWalksFromUploadingToNotStarted(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "蛊真人.txt", "первая глава\fвторая глава")
|
||
if book.Status != "parsing" {
|
||
t.Fatalf("the response says %q; the file is in, so the book is being parsed", book.Status)
|
||
}
|
||
if book.Title != "蛊真人" {
|
||
t.Fatalf("title %q, want the name the user gave the file", book.Title)
|
||
}
|
||
// Characters, not bytes: the contract's figure is a size in characters and this source is Cyrillic.
|
||
if book.CharacterCount != 25 {
|
||
t.Fatalf("character_count %d, want 25 runes", book.CharacterCount)
|
||
}
|
||
f.provision(t, book)
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := f.card(t, book.ID)
|
||
if got.Status != "not_started" || got.ChapterCount != 500 {
|
||
t.Fatalf("after parsing: %q with %d chapters, want not_started with 500", got.Status, got.ChapterCount)
|
||
}
|
||
// The manifest's identity is kept: it says WHICH cut of WHICH bytes produced that chapter count.
|
||
var sha []byte
|
||
var chunker string
|
||
if err := f.store.Pool().QueryRow(f.ctx,
|
||
`select source_sha256, chunker_version from books where id = $1`, book.ID).Scan(&sha, &chunker); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(sha) != 32 || chunker != "chunk-2026.07" {
|
||
t.Fatalf("the manifest's provenance was not recorded: sha %d bytes, chunker %q", len(sha), chunker)
|
||
}
|
||
}
|
||
|
||
// `uploading` is a state something can observe, and that is the whole reason the row is written
|
||
// before the body is read: a second reader of the library sees the book while its file is still on
|
||
// the wire.
|
||
func TestABookIsVisibleAsUploadingWhileItsFileIsStillArriving(t *testing.T) {
|
||
f := newFixture(t)
|
||
reading := make(chan struct{})
|
||
release := make(chan struct{})
|
||
body := &blockingReader{data: "the whole book", gate: reading, release: release}
|
||
done := make(chan error, 1)
|
||
go func() {
|
||
_, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
|
||
Filename: "book.txt", File: body})
|
||
done <- err
|
||
}()
|
||
<-reading
|
||
lib, err := f.store.ListBooks(f.ctx, "u1", 10, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(lib.Books) != 1 || lib.Books[0].Status != "uploading" {
|
||
t.Fatalf("the library shows %+v while the file is arriving, want one book in uploading", lib.Books)
|
||
}
|
||
close(release)
|
||
if err := <-done; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := f.card(t, lib.Books[0].ID); got.Status != "parsing" {
|
||
t.Fatalf("after the body arrived the book is %q, want parsing", got.Status)
|
||
}
|
||
}
|
||
|
||
// blockingReader hands over its first chunk and then waits, so a test can look at the world in the
|
||
// middle of an upload.
|
||
type blockingReader struct {
|
||
data string
|
||
gate chan struct{}
|
||
release chan struct{}
|
||
sent bool
|
||
}
|
||
|
||
func (b *blockingReader) Read(p []byte) (int, error) {
|
||
if !b.sent {
|
||
b.sent = true
|
||
n := copy(p, b.data)
|
||
close(b.gate)
|
||
<-b.release
|
||
return n, nil
|
||
}
|
||
return 0, io.EOF
|
||
}
|
||
|
||
// An upload that did not finish leaves NOTHING: not a row the user cannot delete — the contract has
|
||
// no handle for that — and not a directory nothing points at.
|
||
//
|
||
// The client going AWAY is the ordinary way to get here, so that is what the test does: the request's
|
||
// own context is cancelled before the body fails. Cleaning up on it would then clean up nothing.
|
||
func TestAnAbandonedUploadLeavesNeitherARowNorADirectory(t *testing.T) {
|
||
f := newFixture(t)
|
||
ctx, cancel := context.WithCancel(f.ctx)
|
||
defer cancel()
|
||
_, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
|
||
Filename: "book.txt", File: &failingReader{cancel: cancel}})
|
||
if err == nil {
|
||
t.Fatal("an upload whose body failed was accepted")
|
||
}
|
||
lib, err := f.store.ListBooks(f.ctx, "u1", 10, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(lib.Books) != 0 {
|
||
t.Fatalf("the library holds %+v after a failed upload", lib.Books)
|
||
}
|
||
entries, err := os.ReadDir(f.root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// Everything except the storage marker, which is the root's own and outlives every book: it is
|
||
// written by the first upload and never removed, because its absence is what tells the intake the
|
||
// volume is gone (FP5-10).
|
||
var left []string
|
||
for _, e := range entries {
|
||
if e.Name() != StorageMarker {
|
||
left = append(left, e.Name())
|
||
}
|
||
}
|
||
if len(left) != 0 {
|
||
t.Fatalf("the books root holds %v after a failed upload", left)
|
||
}
|
||
}
|
||
|
||
// failingReader is a client that goes away mid-body. gate/release, when set, let a test look at the
|
||
// world while the upload is still in flight.
|
||
type failingReader struct {
|
||
cancel context.CancelFunc
|
||
gate chan struct{}
|
||
release chan struct{}
|
||
}
|
||
|
||
func (f failingReader) Read([]byte) (int, error) {
|
||
if f.gate != nil {
|
||
close(f.gate)
|
||
<-f.release
|
||
}
|
||
if f.cancel != nil {
|
||
f.cancel()
|
||
}
|
||
return 0, errors.New("the client went away")
|
||
}
|
||
|
||
// A source the engine READ and found no book in is terminal, and its bytes go: nothing re-parses
|
||
// them, no path downloads them, and an authenticated route that writes to an operator's disk and
|
||
// never removes anything is a hole this one must not open.
|
||
//
|
||
// ⚠ Exit 11 and no other number. This is the ONLY code that reaches the destructive end of the walk
|
||
// (PD-196); the tests below hold the other side of that line.
|
||
func TestASourceTheEngineRefusesIsRejectedAndItsFileRemoved(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "not-a-book.pdf", "%PDF-1.7 …")
|
||
dir := f.provision(t, book)
|
||
f.engine.set(ingest.Manifest{}, refusal(t, ingest.ExitSourceUnreadable))
|
||
// ⚠ The FIRST refusal is still not terminal, and that is now a deliberate margin rather than a
|
||
// necessity: the engine's verdict is unambiguous, and the budget is kept because it costs an
|
||
// empty book five $0 calls and costs a mistaken verdict nothing (see parseAttempts).
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := f.card(t, book.ID); got.Status != "parsing" {
|
||
t.Fatalf("one refusal made the book %q; a single exit 1 must not be terminal", got.Status)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(dir, SourceName+".pdf")); err != nil {
|
||
t.Fatalf("the source was deleted on the first refusal: %v", err)
|
||
}
|
||
for range parseAttempts {
|
||
f.now = f.now.Add(ClaimGrace + time.Minute)
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
got := f.card(t, book.ID)
|
||
if got.Status != "rejected" {
|
||
t.Fatalf("a source the engine refused is %q, want rejected", got.Status)
|
||
}
|
||
if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) {
|
||
t.Fatalf("the rejected book's directory is still there: %v", err)
|
||
}
|
||
// The reason is the platform's own word and is kept for an operator — contract v0 has no field
|
||
// for it, so it is deliberately not on the wire.
|
||
var reason string
|
||
if err := f.store.Pool().QueryRow(f.ctx,
|
||
`select reject_reason from books where id = $1`, book.ID).Scan(&reason); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if reason != ReasonSourceUnreadable {
|
||
t.Fatalf("reject_reason %q, want %q", reason, ReasonSourceUnreadable)
|
||
}
|
||
}
|
||
|
||
// The other side of that line, and it is the whole of PD-196: every answer of the engine EXCEPT
|
||
// exit 11 leaves the user's upload on disk.
|
||
//
|
||
// Before the refusal band the engine had one number for all of them, so an operator's typo in a
|
||
// book.yaml, a lock held by their own tmctl and a source that genuinely is not a book arrived here
|
||
// as the same fact — and the only verdict this side could give it deleted the file. Each case below
|
||
// is a deployment or host condition, and none of them is the user's to pay for.
|
||
//
|
||
// Mutation caught: mapping anything but ingest.ExitSourceUnreadable onto ReasonSourceUnreadable.
|
||
func TestOnlyTheOneRefusalClassAboutTheTextEverCostsTheUpload(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
code int
|
||
// terminal says whether the budget can ever run out for this answer. Two of them never spend
|
||
// an attempt at all, so the book waits for a human instead of being rejected.
|
||
terminal bool
|
||
want string
|
||
}{
|
||
{"the configuration will not load", ingest.ExitConfigInvalid, false, ReasonNotConfigured},
|
||
{"another process holds the project", ingest.ExitProjectLocked, true, ReasonParserUnavailable},
|
||
{"a refusal class this build has no name for", ingest.ExitRefusedOther, true, ReasonParserUnavailable},
|
||
{"an ordinary unclassified failure", 1, true, ReasonParserUnavailable},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "book.txt", "первая глава")
|
||
dir := f.provision(t, book)
|
||
f.engine.set(ingest.Manifest{}, refusal(t, tc.code))
|
||
for range parseAttempts + 1 {
|
||
f.now = f.now.Add(ClaimGrace + time.Minute)
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
got := f.card(t, book.ID)
|
||
switch {
|
||
case tc.terminal && got.Status != "rejected":
|
||
t.Errorf("after %d answers the book is %q, want rejected", parseAttempts+1, got.Status)
|
||
case !tc.terminal && got.Status != "parsing":
|
||
t.Errorf("the book is %q; a deployment fault must never end an intake", got.Status)
|
||
}
|
||
// The assertion that matters, and it holds for BOTH ends: the bytes the user sent are
|
||
// still there.
|
||
if _, err := os.Stat(filepath.Join(dir, SourceName+".txt")); err != nil {
|
||
t.Fatalf("exit %d cost the user their upload: %v", tc.code, err)
|
||
}
|
||
if reason := intakeReason(refusal(t, tc.code)); reason != tc.want {
|
||
t.Errorf("exit %d classified as %q, want %q", tc.code, reason, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// A verdict about the book is only a verdict when the engine ANSWERED. A process the machine killed,
|
||
// and a binary that never started, say nothing about anyone's text.
|
||
func TestAnEngineThatDidNotAnswerIsNeverAVerdictAboutTheBook(t *testing.T) {
|
||
killed := exec.CommandContext(t.Context(), "/bin/sh", "-c", "kill -TERM $$").Run()
|
||
if killed == nil {
|
||
t.Fatal("the helper was not killed")
|
||
}
|
||
for _, err := range []error{killed, &exec.Error{Name: "tmctl", Err: os.ErrNotExist}, context.DeadlineExceeded} {
|
||
if got := intakeReason(err); got != ReasonParserUnavailable {
|
||
t.Errorf("%v classified as %q, want %q", err, got, ReasonParserUnavailable)
|
||
}
|
||
}
|
||
}
|
||
|
||
// An engine that cannot be RUN is this host's problem and not this book's. The parse is retried —
|
||
// and bounded, because a book that waits forever is the failure this must not trade for (PD-162).
|
||
func TestAnEngineThatCannotBeRunIsRetriedAndThenGivenUpOn(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "book.txt", "text")
|
||
f.provision(t, book)
|
||
f.engine.set(ingest.Manifest{}, &exec.Error{Name: "tmctl", Err: os.ErrNotExist})
|
||
// ⚠ The clock moves between attempts because the CLAIM is what spaces them: a failed parse keeps
|
||
// it, so the book is not claimable again until the grace passes. Without that the five attempts
|
||
// would burn in five ticks of the sweep — 75 seconds — instead of in the time this budget is
|
||
// meant to bound, and an engine outage of a couple of minutes would reject every book waiting.
|
||
for i := 1; i < parseAttempts; i++ {
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := f.card(t, book.ID); got.Status != "parsing" {
|
||
t.Fatalf("attempt %d left the book %q, want it still parsing", i, got.Status)
|
||
}
|
||
// The very next call, before the grace, must do NOTHING at all.
|
||
before := f.engine.called()
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if f.engine.called() != before {
|
||
t.Fatalf("attempt %d was retried before the grace passed", i)
|
||
}
|
||
f.now = f.now.Add(ClaimGrace + time.Minute)
|
||
}
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := f.card(t, book.ID)
|
||
if got.Status != "rejected" {
|
||
t.Fatalf("after %d attempts the book is %q, want rejected", parseAttempts, got.Status)
|
||
}
|
||
// The file STAYS: the fault was the deployment's, and deleting a user's upload because this host
|
||
// was misconfigured is not a decision to make on their behalf.
|
||
if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil {
|
||
t.Fatalf("the source of a book rejected for a deployment fault was removed: %v", err)
|
||
}
|
||
}
|
||
|
||
// The seam of the open question: a book directory with no engine configuration cannot be parsed, and
|
||
// the platform does not write that file (D39.110 §2b).
|
||
//
|
||
// ⚠ This test asserts the OPPOSITE of what it did before the cross-family review, and the reversal is
|
||
// deliberate. It used to pin "five attempts, then rejected" — which meant that on any deployment
|
||
// without an answer to the `book.yaml` question (that is, every deployment today) EVERY upload was
|
||
// destroyed within the hour, over a gap the user cannot see and an operator can fix in a second.
|
||
// A missing configuration is a DEPLOYMENT state, so the book waits in `parsing`, visibly, with its
|
||
// source intact, and one file dropped in is all it takes.
|
||
func TestABookWithNoEngineConfigurationWaitsRatherThanDies(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "book.txt", "text")
|
||
for range parseAttempts * 2 {
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
f.now = f.now.Add(ClaimGrace + time.Minute) // the claim spaces the retries
|
||
}
|
||
if f.engine.called() != 0 {
|
||
t.Fatalf("the engine was asked %d times about a book it has no configuration for", f.engine.called())
|
||
}
|
||
var status, reason string
|
||
if err := f.store.Pool().QueryRow(f.ctx,
|
||
`select status, reject_reason from books where id = $1`, book.ID).Scan(&status, &reason); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if status != "parsing" || reason != "" {
|
||
t.Fatalf("a book with no configuration is %q/%q; it must WAIT, not be destroyed", status, reason)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil {
|
||
t.Fatalf("the source of a book waiting for its configuration was removed: %v", err)
|
||
}
|
||
// And the moment somebody provisions it, the very next pass parses it.
|
||
f.provision(t, pgstore.Book{ID: book.ID})
|
||
f.now = f.now.Add(ClaimGrace + time.Minute)
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := f.card(t, book.ID); got.Status != "not_started" {
|
||
t.Fatalf("after provisioning the book is %q, want not_started", got.Status)
|
||
}
|
||
}
|
||
|
||
// Two parsers on one book would be two writers of the engine's project file. The claim is a
|
||
// compare-and-set, and losing that race is the ordinary case rather than a failure.
|
||
func TestOnlyOneOfTwoParsersOfOneBookAsksTheEngine(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "book.txt", "text")
|
||
f.provision(t, book)
|
||
var wg sync.WaitGroup
|
||
errs := make([]error, 8)
|
||
for i := range errs {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
errs[i] = f.svc.Parse(f.ctx, book.ID)
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
for i, err := range errs {
|
||
if err != nil {
|
||
t.Errorf("parser %d: %v", i, err)
|
||
}
|
||
}
|
||
if n := f.engine.called(); n != 1 {
|
||
t.Fatalf("the engine was asked %d times for one book, want exactly 1", n)
|
||
}
|
||
}
|
||
|
||
// The backstop: both halves of a stuck intake are reachable only from the sweep, because the process
|
||
// that could have finished them is the one that died.
|
||
func TestTheSweepFinishesAWalkNobodyElseCan(t *testing.T) {
|
||
f := newFixture(t)
|
||
stale := f.accept(t, "abandoned.txt", "half a file")
|
||
// This one never got past `uploading`: its request is gone.
|
||
if _, err := f.store.Pool().Exec(f.ctx,
|
||
`update books set status = 'uploading' where id = $1`, stale.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
waiting := f.accept(t, "waiting.txt", "text")
|
||
f.provision(t, waiting)
|
||
f.now = f.now.Add(2 * time.Hour) // past both graces
|
||
if err := f.svc.Sweep(f.ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
lib, err := f.store.ListBooks(f.ctx, "u1", 10, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(lib.Books) != 1 || lib.Books[0].ID != waiting.ID {
|
||
t.Fatalf("the library holds %+v, want only the book that finished uploading", lib.Books)
|
||
}
|
||
if got := f.card(t, waiting.ID); got.Status != "not_started" {
|
||
t.Fatalf("the waiting book is %q, want the sweep to have parsed it", got.Status)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(f.root, stale.ID)); !errors.Is(err, os.ErrNotExist) {
|
||
t.Fatalf("the abandoned upload's directory survived the sweep: %v", err)
|
||
}
|
||
}
|
||
|
||
// Adding a book must MOVE the library's revision. The contract asks a client to drop a read whose
|
||
// revision is not above what it applied, so a book that joins at a number the library already had is
|
||
// a book the screen that uploaded it never sees (register row PD-122).
|
||
func TestABookThatJoinsTheLibraryMovesItsRevision(t *testing.T) {
|
||
f := newFixture(t)
|
||
before, err := f.store.ListBooks(f.ctx, "u1", 10, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
first := f.accept(t, "one.txt", "text")
|
||
after, err := f.store.ListBooks(f.ctx, "u1", 10, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if after.Revision <= before.Revision {
|
||
t.Fatalf("the library revision stayed at %d after a book joined it", after.Revision)
|
||
}
|
||
f.provision(t, first)
|
||
if err := f.svc.Parse(f.ctx, first.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
parsed, err := f.store.ListBooks(f.ctx, "u1", 10, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if parsed.Revision <= after.Revision {
|
||
t.Fatalf("the library revision stayed at %d when a book's status changed", parsed.Revision)
|
||
}
|
||
}
|
||
|
||
// A book the DEV intake registered carries a workdir the operator chose — their own project
|
||
// directory, with their own source in it. No path here may remove one.
|
||
func TestADirectoryOutsideTheBooksRootIsNeverRemoved(t *testing.T) {
|
||
f := newFixture(t)
|
||
outside := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(outside, "irreplaceable.txt"), []byte("x"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
f.svc.removeDir(outside)
|
||
if _, err := os.Stat(filepath.Join(outside, "irreplaceable.txt")); err != nil {
|
||
t.Fatalf("a directory outside the books root was removed: %v", err)
|
||
}
|
||
for _, dir := range []string{f.root, filepath.Join(f.root, ".."), "", "/"} {
|
||
if f.svc.owns(dir) {
|
||
t.Errorf("owns(%q) is true; only paths BELOW the root are this service's", dir)
|
||
}
|
||
}
|
||
if !f.svc.owns(filepath.Join(f.root, "bk_1")) {
|
||
t.Error("a book directory under the root is not recognised as this service's")
|
||
}
|
||
}
|
||
|
||
// The two things the intake takes from a name it did not write.
|
||
func TestWhatTheUploadedFileNameIsAllowedToDecide(t *testing.T) {
|
||
titles := []struct{ in, want string }{
|
||
{"蛊真人.epub", "蛊真人"},
|
||
{"/etc/passwd", "passwd"},
|
||
{"../../secret.txt", "secret"},
|
||
{"", ""},
|
||
{"no-extension", "no-extension"},
|
||
{".hidden", ".hidden"},
|
||
{strings.Repeat("x", 400) + ".txt", strings.Repeat("x", MaxTitle)},
|
||
{"line\nbreak.txt", "linebreak"},
|
||
}
|
||
for _, tc := range titles {
|
||
if got := titleFrom(tc.in); got != tc.want {
|
||
t.Errorf("titleFrom(%q) = %q, want %q", tc.in, got, tc.want)
|
||
}
|
||
}
|
||
exts := []struct{ in, want string }{
|
||
{"book.epub", ".epub"},
|
||
{"BOOK.EPUB", ".epub"},
|
||
{"book.txt", ".txt"},
|
||
{"book", ".txt"},
|
||
{"book.", ".txt"},
|
||
{"book.tar.gz", ".gz"},
|
||
{"evil.sh;rm -rf", ".txt"},
|
||
{"evil../../x", ".txt"},
|
||
{"book.verylongextension", ".txt"},
|
||
}
|
||
for _, tc := range exts {
|
||
if got := extensionOf(tc.in); got != tc.want {
|
||
t.Errorf("extensionOf(%q) = %q, want %q", tc.in, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// The languages are CODES and they enter here from a browser.
|
||
func TestALanguageThatIsNotACodeIsRefused(t *testing.T) {
|
||
f := newFixture(t)
|
||
for _, bad := range []string{"", "Chinese", "zh_CN", "ZH", "../zh", "z"} {
|
||
_, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: bad, TargetLang: "ru",
|
||
Filename: "b.txt", File: strings.NewReader("x")})
|
||
if !errors.Is(err, ErrBadIntake) {
|
||
t.Errorf("source_lang %q: %v, want ErrBadIntake", bad, err)
|
||
}
|
||
}
|
||
if entries, err := os.ReadDir(f.root); err != nil || len(entries) != 0 {
|
||
t.Fatalf("a refused intake left %v in the books root (%v)", entries, err)
|
||
}
|
||
}
|
||
|
||
// The engine dispatches its reader by EXTENSION (backend/internal/chunk/ingest.go: `.epub` → the
|
||
// epub reader, anything else → plain text), so the extension has to survive intake or an EPUB is
|
||
// read as text and the book comes out as one chapter of markup.
|
||
func TestTheUploadedFilesExtensionSurvivesIntake(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "蛊真人.EPUB", "PK\x03\x04 not really an epub")
|
||
if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".epub")); err != nil {
|
||
t.Fatalf("the source was not written as source.epub: %v", err)
|
||
}
|
||
}
|
||
|
||
// The zone's log discipline (ENGINEERING_STANDARDS §Наблюдаемость, PD-3): an INFO line does not
|
||
// identify a user's library. The intake writes several, so this asserts the whole walk at once.
|
||
func TestNoBookIdentifierReachesAnInfoLine(t *testing.T) {
|
||
f := newFixture(t)
|
||
var buf bytes.Buffer
|
||
f.svc.Log = slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||
book := f.accept(t, "book.txt", "text")
|
||
f.provision(t, book)
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
f.now = f.now.Add(2 * time.Hour)
|
||
if err := f.svc.Sweep(f.ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
||
if line == "" {
|
||
continue
|
||
}
|
||
var rec map[string]any
|
||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rec["level"] != "INFO" && rec["level"] != "WARN" {
|
||
continue // an ERROR may carry the path an operator needs — the open class of PD-139
|
||
}
|
||
if strings.Contains(line, book.ID) {
|
||
t.Errorf("a book id reached a %s line: %s", rec["level"], line)
|
||
}
|
||
}
|
||
}
|
||
|
||
// The library's revision may never go BACKWARDS, and an abandoned upload is the one deletion this
|
||
// zone has: the book that joined at the top of the account's scale is removed when its request goes
|
||
// away, and the maximum would fall with it. A client that had applied the higher number must then, by
|
||
// the contract, drop every later read of the library — the screen keeps rendering a book that is gone.
|
||
func TestCancellingAnUploadDoesNotWindTheLibraryBack(t *testing.T) {
|
||
f := newFixture(t)
|
||
kept := f.accept(t, "kept.txt", "text")
|
||
f.provision(t, kept)
|
||
before, err := f.store.ListBooks(f.ctx, "u1", 10, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// The number that matters is the one a CLIENT could have seen — the library WHILE the doomed
|
||
// upload was in it, not the library before it arrived. Read from inside the upload, which is
|
||
// exactly where a second tab would read it.
|
||
reading, release := make(chan struct{}), make(chan struct{})
|
||
ctx, cancel := context.WithCancel(f.ctx)
|
||
defer cancel()
|
||
done := make(chan error, 1)
|
||
go func() {
|
||
_, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
|
||
Filename: "abandoned.txt",
|
||
File: &failingReader{cancel: cancel, gate: reading, release: release}})
|
||
done <- err
|
||
}()
|
||
<-reading
|
||
during, err := f.store.ListBooks(f.ctx, "u1", 10, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if during.Revision <= before.Revision {
|
||
t.Fatalf("the upload joined the library at revision %d, which is not above %d",
|
||
during.Revision, before.Revision)
|
||
}
|
||
close(release)
|
||
if err := <-done; err == nil {
|
||
t.Fatal("an upload whose body failed was accepted")
|
||
}
|
||
after, err := f.store.ListBooks(f.ctx, "u1", 10, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if after.Revision < during.Revision {
|
||
t.Fatalf("the library revision went back from %d to %d when the upload was cancelled",
|
||
during.Revision, after.Revision)
|
||
}
|
||
if len(after.Books) != 1 || after.Books[0].ID != kept.ID {
|
||
t.Fatalf("the library holds %+v, want only the book that stayed", after.Books)
|
||
}
|
||
}
|
||
|
||
// The sweep decides from a snapshot too, and its cure is the same: the directory of a book goes only
|
||
// when the ROW went with it. A request that finished while the sweep was looking moves the book to
|
||
// `parsing`, DeleteUpload's status guard then refuses — and removing the directory anyway would
|
||
// delete the source of a live book, leaving one that can only end rejected and an hour of somebody's
|
||
// upload gone without a word.
|
||
func TestTheSweepNeverRemovesTheSourceOfABookItCouldNotDelete(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "book.txt", "text") // already `parsing`: its upload finished
|
||
f.svc.abandon(f.ctx, book.ID, filepath.Join(f.root, book.ID))
|
||
if got := f.card(t, book.ID); got.Status != "parsing" {
|
||
t.Fatalf("the row is %q, want the parsing book it became", got.Status)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil {
|
||
t.Fatalf("the source of a book that was NOT deleted was removed: %v", err)
|
||
}
|
||
}
|
||
|
||
// The two constants are a PAIR and neither package can see the other's reasoning, so the pair is
|
||
// asserted here. A claim grace shorter than the queue's job timeout means the backstop sweep takes a
|
||
// parse away from the worker that is still running it: both then meet on one project directory, the
|
||
// loser dies on the engine's exclusive lock with exit 1 — and exit 1 is how the engine says "I cannot
|
||
// cut this source".
|
||
func TestTheClaimGraceOutlivesTheQueuesJobTimeout(t *testing.T) {
|
||
if ClaimGrace <= jobs.JobTimeout {
|
||
t.Fatalf("ClaimGrace %s does not outlive the queue's job timeout %s: the sweep would steal a claim from a running parse",
|
||
ClaimGrace, jobs.JobTimeout)
|
||
}
|
||
}
|
||
|
||
// Every byte is in and the client hung up while waiting for the 201 — a phone that went to sleep, a
|
||
// tab that was closed. The book must still be theirs: the row is what makes it findable at all, and
|
||
// throwing away a completed upload because nobody is left to read the answer costs the user the whole
|
||
// file and the whole wait.
|
||
func TestAnUploadSurvivesAClientThatHangsUpAfterTheLastByte(t *testing.T) {
|
||
f := newFixture(t)
|
||
ctx, cancel := context.WithCancel(f.ctx)
|
||
book, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
|
||
Filename: "book.txt", File: &hangUpReader{data: "первая глава", cancel: cancel}})
|
||
if err != nil {
|
||
t.Fatalf("an upload whose client left after the last byte was dropped: %v", err)
|
||
}
|
||
if book.Status != "parsing" {
|
||
t.Fatalf("the book is %q, want parsing", book.Status)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil {
|
||
t.Fatalf("the received source is gone: %v", err)
|
||
}
|
||
}
|
||
|
||
// hangUpReader delivers the whole body and then cancels the request's context, which is what a client
|
||
// that walks away after sending everything looks like from here.
|
||
type hangUpReader struct {
|
||
data string
|
||
cancel context.CancelFunc
|
||
sent bool
|
||
}
|
||
|
||
func (h *hangUpReader) Read(p []byte) (int, error) {
|
||
if !h.sent {
|
||
h.sent = true
|
||
return copy(p, h.data), nil
|
||
}
|
||
h.cancel()
|
||
return 0, io.EOF
|
||
}
|
||
|
||
// FP5-1 (acceptance, HIGH). A library revision must identify ONE state of the library. The floor a
|
||
// cancelled upload raises is only half the rule: if the next book joins from the maximum alone it
|
||
// lands at or below that floor, and the same number then answers for three different libraries —
|
||
// before the cancelled upload, during it, and after the next one arrived.
|
||
func TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision(t *testing.T) {
|
||
f := newFixture(t)
|
||
first := f.accept(t, "kept.txt", "text")
|
||
f.provision(t, first)
|
||
before := f.libraryRevision(t)
|
||
|
||
ctx, cancel := context.WithCancel(f.ctx)
|
||
defer cancel()
|
||
if _, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
|
||
Filename: "abandoned.txt", File: &failingReader{cancel: cancel}}); err == nil {
|
||
t.Fatal("an upload whose body failed was accepted")
|
||
}
|
||
afterCancel := f.libraryRevision(t)
|
||
if afterCancel < before {
|
||
t.Fatalf("the revision went back after a cancelled upload: %d → %d", before, afterCancel)
|
||
}
|
||
next := f.accept(t, "next.txt", "text")
|
||
afterNext := f.libraryRevision(t)
|
||
if afterNext <= afterCancel {
|
||
t.Fatalf("a book that joined after a cancelled upload left the revision at %d (was %d): the same number now answers for two different libraries",
|
||
afterNext, afterCancel)
|
||
}
|
||
// And the book itself carries a revision above the floor, so its own card is not stale on arrival.
|
||
var rev int64
|
||
if err := f.store.Pool().QueryRow(f.ctx, `select revision from books where id = $1`, next.ID).Scan(&rev); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rev <= afterCancel {
|
||
t.Fatalf("the new book joined at revision %d, at or below the floor %d", rev, afterCancel)
|
||
}
|
||
}
|
||
|
||
// FP5-4 (acceptance). Waiting for a configuration must not bring DELETION closer: every claim counted
|
||
// against the budget, so a book that waited out five graces had its budget spent before the engine
|
||
// was asked once — and the first answer, which the engine gives as exit 1 for a typo in `book.yaml`
|
||
// as much as for an unreadable file, was terminal on arrival and took the source with it.
|
||
func TestWaitingForAConfigurationDoesNotBringDeletionCloser(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "book.txt", "text")
|
||
for range parseAttempts * 3 {
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
f.now = f.now.Add(ClaimGrace + time.Minute)
|
||
}
|
||
var attempts int
|
||
if err := f.store.Pool().QueryRow(f.ctx,
|
||
`select parse_attempts from books where id = $1`, book.ID).Scan(&attempts); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if attempts != 0 {
|
||
t.Fatalf("waiting for a configuration spent %d attempts of the budget", attempts)
|
||
}
|
||
// Now it is provisioned and the engine refuses the source once: with the budget intact, that first
|
||
// answer must NOT be terminal and must NOT delete anything.
|
||
f.provision(t, pgstore.Book{ID: book.ID})
|
||
f.engine.set(ingest.Manifest{}, refusal(t, ingest.ExitSourceUnreadable))
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := f.card(t, book.ID); got.Status != "parsing" {
|
||
t.Fatalf("the first engine refusal after a long wait made the book %q", got.Status)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil {
|
||
t.Fatalf("the source was deleted on the first refusal after a wait: %v", err)
|
||
}
|
||
}
|
||
|
||
// FP5-3 (acceptance). The rejection path removes the directory BEFORE it writes the row, so a crash
|
||
// between the two leaves a book in `parsing` with nothing on disk. That must be terminal — otherwise
|
||
// the missing directory reads as "not configured", which is the one reason that never ends, and the
|
||
// book waits for a configuration nobody can put anywhere.
|
||
func TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "book.txt", "text")
|
||
f.provision(t, book)
|
||
if err := os.RemoveAll(filepath.Join(f.root, book.ID)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var status, reason string
|
||
if err := f.store.Pool().QueryRow(f.ctx,
|
||
`select status, reject_reason from books where id = $1`, book.ID).Scan(&status, &reason); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if status != "rejected" || reason != ReasonSourceUnreadable {
|
||
t.Fatalf("a book with no directory is %q/%q, want rejected/%s", status, reason, ReasonSourceUnreadable)
|
||
}
|
||
if f.engine.called() != 0 {
|
||
t.Fatalf("the engine was asked %d times about a book with no directory", f.engine.called())
|
||
}
|
||
}
|
||
|
||
// FP5-5 (acceptance): the backstop's re-drive of a parse is the SAME engine call the queue gives
|
||
// fifteen minutes. Under the run sweep's two-minute pass it was killed by the deadline, the kill was
|
||
// read as a host that cannot run the engine, and the attempt was spent — every pass, until the book
|
||
// was rejected for being large.
|
||
func TestOneBookInTheSweepGetsABudgetAParseCanLiveIn(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "book.txt", "text")
|
||
f.provision(t, book)
|
||
// The engine takes longer than a second and reports the deadline it was given.
|
||
var granted time.Duration
|
||
f.engine.onManifest = func(ctx context.Context) {
|
||
if dl, ok := ctx.Deadline(); ok {
|
||
granted = time.Until(dl)
|
||
}
|
||
}
|
||
f.now = f.now.Add(2 * time.Hour) // past the graces, so the sweep picks the book up
|
||
if err := f.svc.Sweep(f.ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// Not an equality: the deadline is read a few microseconds after it was granted. What matters is
|
||
// that a parse gets the same ORDER of time from the sweep as it does from the queue, rather than
|
||
// the two minutes the run pass runs on.
|
||
if granted < jobs.JobTimeout-time.Minute {
|
||
t.Fatalf("the sweep gave one book's parse %s; the same call gets %s from the queue",
|
||
granted, jobs.JobTimeout)
|
||
}
|
||
}
|
||
|
||
// Cross-family review of the acceptance dofix (H1): the storage ROOT being gone — an unmounted
|
||
// volume, a deployment pointed at a path that is not there yet — looks exactly like one book's
|
||
// directory being gone, and the two mean opposite things. Read as the second, ONE sweep rejects
|
||
// every book in intake with "the source cannot be read": a reason that blames the user's file, is
|
||
// terminal by design, and has no way back.
|
||
func TestAVanishedStorageRootIsNotEveryBooksFault(t *testing.T) {
|
||
f := newFixture(t)
|
||
first := f.accept(t, "one.txt", "text")
|
||
second := f.accept(t, "two.txt", "text")
|
||
f.provision(t, first)
|
||
f.provision(t, second)
|
||
if err := os.RemoveAll(f.root); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// Well past the budget: waiting for a host to come back must never bring a rejection closer.
|
||
for range parseAttempts * 2 {
|
||
for _, b := range []pgstore.Book{first, second} {
|
||
if err := f.svc.Parse(f.ctx, b.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
f.now = f.now.Add(ClaimGrace + time.Minute)
|
||
}
|
||
if f.engine.called() != 0 {
|
||
t.Fatalf("the engine was asked %d times while the storage root was gone", f.engine.called())
|
||
}
|
||
for _, b := range []pgstore.Book{first, second} {
|
||
var status, reason string
|
||
var attempts int
|
||
if err := f.store.Pool().QueryRow(f.ctx,
|
||
`select status, reject_reason, parse_attempts from books where id = $1`, b.ID).
|
||
Scan(&status, &reason, &attempts); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if status != "parsing" || reason != "" {
|
||
t.Fatalf("book %s is %q/%q after the storage root vanished; the host is at fault, not the book",
|
||
b.ID, status, reason)
|
||
}
|
||
if attempts != 0 {
|
||
t.Fatalf("book %s spent %d attempts on a host that could not see its own storage: the first "+
|
||
"real answer after this would be terminal on arrival", b.ID, attempts)
|
||
}
|
||
}
|
||
// And when the volume comes back, the very next pass parses the book.
|
||
if err := os.MkdirAll(filepath.Join(f.root, first.ID), 0o700); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
f.provision(t, first)
|
||
if err := f.svc.Parse(f.ctx, first.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := f.card(t, first.ID); got.Status != "not_started" {
|
||
t.Fatalf("after the storage came back the book is %q, want not_started", got.Status)
|
||
}
|
||
}
|
||
|
||
// Cross-family review of the acceptance dofix (H2): the floor under the library's revision made the
|
||
// STATUS walk invisible. A book uploaded before somebody else's upload was cancelled carries a
|
||
// counter below that floor, so `uploading → parsing → not_started` — three increments of its own
|
||
// number — left `greatest(max, floor)` exactly where it was, and a client obeying the contract drops
|
||
// every read that is not above what it applied. The book would sit on the screen as "arriving" until
|
||
// an unrelated event moved the library's number.
|
||
func TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "book.txt", "text")
|
||
f.provision(t, book)
|
||
|
||
// A cancelled upload raises the floor above the book's own counter.
|
||
ctx, cancel := context.WithCancel(f.ctx)
|
||
defer cancel()
|
||
if _, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru",
|
||
Filename: "abandoned.txt", File: &failingReader{cancel: cancel}}); err == nil {
|
||
t.Fatal("an upload whose body failed was accepted")
|
||
}
|
||
floor := f.libraryRevision(t)
|
||
var own int64
|
||
if err := f.store.Pool().QueryRow(f.ctx,
|
||
`select revision from books where id = $1`, book.ID).Scan(&own); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if own > floor {
|
||
t.Fatalf("the fixture does not reproduce the case: the book is at %d, above the floor %d", own, floor)
|
||
}
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := f.card(t, book.ID); got.Status != "not_started" {
|
||
t.Fatalf("the book is %q, want not_started", got.Status)
|
||
}
|
||
if got := f.libraryRevision(t); got <= floor {
|
||
t.Fatalf("the book finished parsing and the library still answers %d (was %d): the client drops "+
|
||
"that read and never sees the status", got, floor)
|
||
}
|
||
}
|
||
|
||
// Re-check of the dofix (FP5-10): the SHAPE the first guard missed. A volume mounted at BooksDir
|
||
// leaves an empty directory behind when it is unmounted — the mountpoint — and the boot recreates
|
||
// that directory anyway, so "does the root exist" answers yes about storage that is gone. Every book
|
||
// in intake would then be rejected as unreadable and its source deleted. The marker is what tells the
|
||
// two apart: it is written by an upload and cannot be forged by an unmount or by MkdirAll.
|
||
func TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "one.txt", "text")
|
||
f.provision(t, book)
|
||
// The upload is what writes the marker, and nothing else does — asserted here rather than assumed,
|
||
// because a marker that is never written makes every missing directory look like an unmount and
|
||
// the crash window of the rejection path (FP5-3) would then wait for ever instead of closing.
|
||
if _, err := os.Stat(filepath.Join(f.root, StorageMarker)); err != nil {
|
||
t.Fatalf("an accepted upload left no storage marker: %v", err)
|
||
}
|
||
// Exactly what an unmount leaves: the root is there, everything under it is not — including the
|
||
// marker, which lived on the volume.
|
||
if err := os.RemoveAll(f.root); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.MkdirAll(f.root, 0o750); err != nil { // the boot's own MkdirAll, or the mountpoint
|
||
t.Fatal(err)
|
||
}
|
||
for range parseAttempts * 2 {
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
f.now = f.now.Add(ClaimGrace + time.Minute)
|
||
}
|
||
if f.engine.called() != 0 {
|
||
t.Fatalf("the engine was asked %d times about storage that is not mounted", f.engine.called())
|
||
}
|
||
var status, reason string
|
||
var attempts int
|
||
if err := f.store.Pool().QueryRow(f.ctx,
|
||
`select status, reject_reason, parse_attempts from books where id = $1`, book.ID).
|
||
Scan(&status, &reason, &attempts); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if status != "parsing" || reason != "" {
|
||
t.Fatalf("an unmounted volume left the book %q/%q: the storage is the host's problem, not the book's",
|
||
status, reason)
|
||
}
|
||
if attempts != 0 {
|
||
t.Fatalf("the book spent %d attempts while its storage was unmounted", attempts)
|
||
}
|
||
// And the marker is not something the platform re-creates behind an operator's back: only an
|
||
// upload writes it, so the volume coming back is what restores the answer.
|
||
if _, err := os.Stat(filepath.Join(f.root, StorageMarker)); err == nil {
|
||
t.Fatal("the marker was re-created while the volume was away: it can no longer tell mounted from not")
|
||
}
|
||
}
|
||
|
||
// Re-check of the dofix (tail в): the rest of the same defect. A budget the BOOK gets is not a budget
|
||
// the PASS still has — the second book of a pass inherited whatever the first one left, so a slow
|
||
// first parse handed the second a stub of a deadline, the engine call died on it, and the attempt was
|
||
// spent on a timeout that says nothing about the book. A pass that cannot give a book its whole
|
||
// budget must not start it: the claim is taken inside Parse, so a book left for the next tick has
|
||
// spent nothing.
|
||
func TestAPassTooShortForAParseStartsNoneAtAll(t *testing.T) {
|
||
f := newFixture(t)
|
||
first := f.accept(t, "one.txt", "text")
|
||
second := f.accept(t, "two.txt", "text")
|
||
f.provision(t, first)
|
||
f.provision(t, second)
|
||
f.now = f.now.Add(2 * time.Hour) // past the graces, so the sweep picks both up
|
||
// What is left of a pass whose first book was slow.
|
||
ctx, cancel := context.WithTimeout(f.ctx, time.Minute)
|
||
defer cancel()
|
||
if err := f.svc.Sweep(ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if f.engine.called() != 0 {
|
||
t.Fatalf("the engine was called %d times on a pass shorter than a parse", f.engine.called())
|
||
}
|
||
for _, b := range []pgstore.Book{first, second} {
|
||
var attempts int
|
||
var status string
|
||
if err := f.store.Pool().QueryRow(f.ctx,
|
||
`select parse_attempts, status from books where id = $1`, b.ID).Scan(&attempts, &status); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if attempts != 0 || status != "parsing" {
|
||
t.Fatalf("book %s spent %d attempts and is %q after a pass that had no time to parse it",
|
||
b.ID, attempts, status)
|
||
}
|
||
}
|
||
}
|
||
|
||
// THE PD-367 DOCUMENT: counts that describe a book, and no book. `{chapters_total: 120,
|
||
// units_total: 400}` with an empty chapter list is exactly what the MATERIALISER has always refused
|
||
// — and the intake accepted it, founded a book with `chapter_count = 120` over an empty tree, and let
|
||
// a run be STARTED AND PAID FOR against a count nothing could ever fill.
|
||
//
|
||
// The floor is symmetric now, and symmetric is the whole property: one document gets ONE answer from
|
||
// both ends of the intake. Its class is the seam's — unknown or inconsistent is non-destructive and
|
||
// loud — so the upload survives and the book is never founded.
|
||
//
|
||
// ⚠ This is a COMMISSIONED change to a pinned intake contract (P12 §3.8), not a test bent to a green:
|
||
// the intake's own battery rode documents with no chapter list, and those fixtures were EXTENDED
|
||
// (`wholeManifest`) rather than the floor relaxed to admit them.
|
||
//
|
||
// Mutation caught: moving the floor below the `ChaptersTotal < 1` branches, or dropping Whole() from
|
||
// Readable.
|
||
func TestTheIntakeRefusesTheDocumentItsOwnMaterialiserWouldReject(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "book.txt", "первая глава")
|
||
dir := f.provision(t, book)
|
||
// Counts that describe 120 chapters and 400 pairs; a document carrying neither.
|
||
f.engine.set(ingest.Manifest{Version: "tm-manifest-v2", ChaptersTotal: 120, UnitsTotal: 400,
|
||
SourceSHA256: strings.Repeat("ab", 32), ChunkerVersion: "chunk-2026.07"}, nil)
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := f.card(t, book.ID)
|
||
if got.Status == "not_started" {
|
||
t.Fatalf("the intake founded a book on a document its own materialiser refuses: status %q,"+
|
||
" chapter_count %d — a run over it can be started and PAID FOR against a count nothing fills",
|
||
got.Status, got.ChapterCount)
|
||
}
|
||
if got.ChapterCount != 0 {
|
||
t.Errorf("chapter_count was written as %d from a document that carries no chapters", got.ChapterCount)
|
||
}
|
||
// NON-DESTRUCTIVE, through the whole budget: a document this build cannot read says nothing about
|
||
// the user's text.
|
||
for range parseAttempts + 1 {
|
||
f.now = f.now.Add(ClaimGrace + time.Minute)
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if got := f.card(t, book.ID); got.Status != "rejected" {
|
||
t.Errorf("the book is %q after its whole budget; the budget still bounds a broken deployment", got.Status)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(dir, SourceName+".txt")); err != nil {
|
||
t.Fatalf("a document this build could not read cost the user their upload: %v", err)
|
||
}
|
||
}
|
||
|
||
// PD-213, the LATENT MINE: an engine whose manifest shape moved must never look like a book with
|
||
// nothing in it.
|
||
//
|
||
// The mechanism, and it is one field rename away: `DecodeManifest` does not gate the version,
|
||
// `json.Unmarshal` ignores unknown fields and leaves missing ones zero — so a v3 manifest that
|
||
// renamed `chapters_total` decodes into a valid Manifest reading zero chapters and zero units. Zero
|
||
// and zero is exactly what the intake reads as «the engine read the source and there is no book in
|
||
// it», which is the ONE verdict that DELETES the user's file after five attempts. The engine would
|
||
// have parsed the book perfectly.
|
||
//
|
||
// The version gate makes that unreachable by turning the same document into `parser_unavailable`,
|
||
// which keeps the file. It is a SHAPE gate, not a value pin — the counts and identities below it are
|
||
// stable, and pinning values everywhere would make every engine release a platform release.
|
||
//
|
||
// Mutation caught: dropping the version check from Readable, which restores the deletion path.
|
||
func TestAManifestShapeThisBuildDoesNotKnowNeverDeletesTheUpload(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "book.txt", "первая глава")
|
||
dir := f.provision(t, book)
|
||
// The engine of tomorrow: a version this build has never heard of, and — because the keys moved
|
||
// under the allowlist — every count decoding to zero. Indistinguishable from an empty book to a
|
||
// reader that does not ask which shape it is holding.
|
||
f.engine.set(ingest.Manifest{Version: "tm-manifest-v3"}, nil)
|
||
for range parseAttempts + 1 {
|
||
f.now = f.now.Add(ClaimGrace + time.Minute)
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if got := f.card(t, book.ID); got.Status != "rejected" {
|
||
t.Errorf("the book is %q; the budget still bounds a deployment this build cannot read", got.Status)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(dir, SourceName+".txt")); err != nil {
|
||
t.Fatalf("an engine upgrade deleted the user's upload: %v", err)
|
||
}
|
||
// And the KNOWN shape, whose counts genuinely agree on nothing, still ends the way it must: that
|
||
// verdict is a statement about the user's text, and it is only reachable past the gate.
|
||
empty := f.accept(t, "empty.txt", " ")
|
||
emptyDir := f.provision(t, empty)
|
||
f.engine.set(ingest.Manifest{Version: ingest.KnownManifestVersion}, nil)
|
||
for range parseAttempts + 1 {
|
||
f.now = f.now.Add(ClaimGrace + time.Minute)
|
||
if err := f.svc.Parse(f.ctx, empty.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if _, err := os.Stat(emptyDir); !errors.Is(err, os.ErrNotExist) {
|
||
t.Errorf("a source with no book in it kept its directory: %v", err)
|
||
}
|
||
}
|
||
|
||
// A manifest that counts no chapters and still counts UNITS is not an empty book — it is a document
|
||
// this build is reading wrong, most likely because a field moved under it. The difference decides
|
||
// whether the user's upload is deleted, so the two must not share a verdict.
|
||
//
|
||
// ⚠ Its FIRST case now dies one line earlier than it used to, and the test is kept rather than
|
||
// retired because the property it states is unchanged: `tm-manifest-v9` is refused by the version
|
||
// gate `Readable` added (PD-213) before the count branches are reached at all. The count branch it
|
||
// was written against is still there and still reachable — by a KNOWN shape whose counts contradict
|
||
// each other — which is what the second half of this test exercises. The sentence that used to close
|
||
// this comment ("the engine's manifest is not version-gated on purpose, which leaves exactly this
|
||
// gap") describes the state before P12: the gate exists now, and it is a SHAPE gate rather than the
|
||
// value pin that sentence was arguing against.
|
||
//
|
||
// Mutation caught: reading `ChaptersTotal < 1` alone as the source being unreadable.
|
||
func TestAManifestThatContradictsItselfNeverCostsTheUpload(t *testing.T) {
|
||
f := newFixture(t)
|
||
book := f.accept(t, "book.txt", "первая глава")
|
||
dir := f.provision(t, book)
|
||
// No chapters, yet units and chunks were cut: no book produces this and no engine reports it.
|
||
f.engine.set(ingest.Manifest{Version: "tm-manifest-v9", UnitsTotal: 4402}, nil)
|
||
for range parseAttempts + 1 {
|
||
f.now = f.now.Add(ClaimGrace + time.Minute)
|
||
if err := f.svc.Parse(f.ctx, book.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if got := f.card(t, book.ID); got.Status != "rejected" {
|
||
t.Errorf("the book is %q; the budget still bounds an engine this platform cannot read", got.Status)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(dir, SourceName+".txt")); err != nil {
|
||
t.Fatalf("a manifest this build could not read cost the user their upload: %v", err)
|
||
}
|
||
// And the genuinely empty book — every counter agreeing on nothing — still ends the way it must.
|
||
empty := f.accept(t, "empty.txt", " ")
|
||
emptyDir := f.provision(t, empty)
|
||
f.engine.set(ingest.Manifest{Version: "tm-manifest-v2"}, nil)
|
||
for range parseAttempts + 1 {
|
||
f.now = f.now.Add(ClaimGrace + time.Minute)
|
||
if err := f.svc.Parse(f.ctx, empty.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if got := f.card(t, empty.ID); got.Status != "rejected" {
|
||
t.Fatalf("a source with no book in it is %q, want rejected", got.Status)
|
||
}
|
||
if _, err := os.Stat(emptyDir); !errors.Is(err, os.ErrNotExist) {
|
||
t.Errorf("the empty book kept its directory: %v", err)
|
||
}
|
||
}
|
||
|
||
// The intake CLAIMS the debt it just recorded before it materializes, and stands down when somebody
|
||
// else holds it. The debt is visible to the materializer's own sweep the moment the parse commits,
|
||
// and the two would otherwise read one book's source at the same time.
|
||
//
|
||
// Mutation caught: refreshing without claiming; refreshing anyway when the claim was refused.
|
||
func TestTheIntakeClaimsItsOwnDebtBeforeMaterializing(t *testing.T) {
|
||
f := newFixture(t)
|
||
reader := &fakeReader{}
|
||
f.svc.Reader = reader
|
||
b := f.accept(t, "roman.txt", "第一节\n\n第二节\n")
|
||
f.provision(t, b)
|
||
if err := f.svc.Parse(f.ctx, b.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(reader.claims) != 1 || reader.claims[0].ID != b.ID {
|
||
t.Fatalf("the intake claimed %+v, want the debt its own parse recorded", reader.claims)
|
||
}
|
||
if len(reader.refreshed) != 1 {
|
||
t.Fatalf("a claimed debt was not materialized: %+v", reader.refreshed)
|
||
}
|
||
// …and a debt somebody else is already paying is left alone.
|
||
second := newFixture(t)
|
||
held := &fakeReader{unclaimable: true}
|
||
second.svc.Reader = held
|
||
b2 := second.accept(t, "roman.txt", "第一节\n\n第二节\n")
|
||
second.provision(t, b2)
|
||
if err := second.svc.Parse(second.ctx, b2.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(held.claims) != 1 || len(held.refreshed) != 0 {
|
||
t.Errorf("the intake materialized a debt it does not hold: claims=%d refreshes=%d",
|
||
len(held.claims), len(held.refreshed))
|
||
}
|
||
}
|