textmachine/platform/internal/books/books_test.go

1494 lines
61 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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