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