textmachine/backend/internal/pipeline/buildhonesty_test.go

116 lines
5.1 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 pipeline
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/obs"
)
// buildhonesty_test.go: the two disclosure defects the cold run of 31.08 found in `build`'s cleanup loop.
//
// They are one loop and one law: an act nobody asked for was performed in silence (§2.1), and a failure
// AFTER an irreversible write threw away the report of that write (§2.5). Both were observed on live data —
// (a) `build --format epub` deleting a neighbouring .book.txt at exit 0 with no line anywhere; (b) derived
// from code, and named in the run report as NOT reproduced, which is why it gets a fixture here.
// buildTwoFormats translates a small book and builds BOTH formats, returning the project directory.
func buildTwoFormats(t *testing.T, srvURL string) (dir string, r *Runner) {
t.Helper()
bookPath := setupProjectOpts(t, srvURL, projectOpts{source: "ГЛАВАА\fГЛАВАБ", regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r = newRunner(t, bookPath)
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
if _, err := r.BuildBook(BuildOptions{}); err != nil {
t.Fatalf("the two-format build must succeed: %v", err)
}
return filepath.Dir(bookPath), r
}
// TestBuildNamesTheFileItDeletes is A3(б) as it actually is — not the `book_files` map, which turned out
// to be a documented contract on BOTH sides («a PLACE, not a presence») and no defect at all, but the
// SILENT DELETION next to it. os.Remove returning nil means a file was there and is now gone; the loop
// knew and said nothing.
//
// Mutation this catches: collapse the switch back to `if err != nil && !errors.Is(err, fs.ErrNotExist)`
// and the removal stops being recorded → both assertions fire.
func TestBuildNamesTheFileItDeletes(t *testing.T) {
srv := newJSONProvider(&reqRec{}, multiLineEdit)
defer srv.Close()
dir, r := buildTwoFormats(t, srv.URL)
defer r.Close()
txt := filepath.Join(dir, "test-book.db.book.txt")
if _, err := os.Stat(txt); err != nil {
t.Fatalf("premise: the two-format build must leave a .txt beside the database: %v", err)
}
rep, err := r.BuildBook(BuildOptions{Formats: []string{"epub"}})
if err != nil {
t.Fatalf("an epub-only build is a normal build: %v", err)
}
if _, err := os.Stat(txt); !os.IsNotExist(err) {
t.Fatalf("premise: the unrequested format is removed — that behaviour is correct and is not what "+
"this test challenges; got %v", err)
}
if len(rep.RemovedFiles) != 1 || !strings.HasSuffix(rep.RemovedFiles[0], "test-book.db.book.txt") {
t.Fatalf("a file the caller did not ask to delete was deleted, and the report must NAME it: %+v",
rep.RemovedFiles)
}
if len(rep.StaleCopies) != 0 {
t.Fatalf("nothing failed to be removed here: %+v", rep.StaleCopies)
}
}
// TestACleanupFailureDoesNotEraseTheBuildReport is A3(а), the branch the cold run derived from code and
// could not reach. The files are committed BEFORE the cleanup runs, so a cleanup failure must not be able
// to turn a written book into «infra failure, nothing written» — which is exactly what a bare error does,
// because the exit mapper sends anything unclassified to 1 and the band's reader writes the run off.
//
// The failure is produced honestly rather than mocked: a non-empty DIRECTORY at the path os.Remove is
// about to take. os.Remove cannot remove it, and the error is neither nil nor fs.ErrNotExist.
//
// Mutation this catches: return the error from the loop again (`return nil, fmt.Errorf(...)`) and the
// build fails → the first assertion fires with the files sitting on disk.
func TestACleanupFailureDoesNotEraseTheBuildReport(t *testing.T) {
srv := newJSONProvider(&reqRec{}, multiLineEdit)
defer srv.Close()
dir, r := buildTwoFormats(t, srv.URL)
defer r.Close()
txt := filepath.Join(dir, "test-book.db.book.txt")
if err := os.Remove(txt); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(txt, "occupied"), 0o755); err != nil {
t.Fatal(err)
}
rep, err := r.BuildBook(BuildOptions{Formats: []string{"epub"}})
if err != nil {
t.Fatalf("the book WAS written before this cleanup ran, so a housekeeping failure must not report "+
"the build as failed — a bare error here maps to exit 1 and reads as «nothing was written»: %v", err)
}
if rep == nil {
t.Fatal("the report must survive: it is the only record of what landed on disk")
}
epub := filepath.Join(dir, "test-book.db.book.epub")
if got, ok := rep.Files["epub"]; !ok || !strings.HasSuffix(got, "test-book.db.book.epub") {
t.Fatalf("the report must still name the file it wrote: %+v", rep.Files)
}
if _, err := os.Stat(epub); err != nil {
t.Fatalf("premise: the epub really is on disk: %v", err)
}
if len(rep.StaleCopies) != 1 || !strings.HasSuffix(rep.StaleCopies[0], "test-book.db.book.txt") {
t.Fatalf("the copy that could NOT be removed is stale and does not belong to this build — silence "+
"about it would leave a file the envelope presents as current: %+v", rep.StaleCopies)
}
if len(rep.RemovedFiles) != 0 {
t.Fatalf("nothing was actually removed: %+v", rep.RemovedFiles)
}
}