textmachine/backend/internal/config/decisionpaths_test.go

190 lines
7.9 KiB
Go
Raw Permalink 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 config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// decisionpaths_test.go: the two owner-decision paths are a CONVENTION and only a convention
// (D39.156 п.3, hardened by fix-pack-2): `<book_id>.mined-delta.yaml` / `<book_id>.mined-rejects.yaml`
// beside book.yaml, a pure function of the config's location. Nobody declares them, nobody creates the
// files in advance, and — since the keys are retired — no two books can point their decisions at one
// file, which is what made the cross-book write race reachable.
//
// ⚠ The declared-path era's tests lived here; their subject was REMOVED by fix-pack-2 §4.9(б) (the keys
// are refused, not honoured), so the tests were rewritten to pin the removal itself.
func writeBook(t *testing.T, dir, extra string) string {
t.Helper()
src := filepath.Join(dir, "source.txt")
if err := os.WriteFile(src, []byte("текст"), 0o644); err != nil {
t.Fatal(err)
}
body := `book_id: decide-book
title: Т
source_lang: zh
target_lang: ru
pipeline: pipeline.yaml
models: models.yaml
source_file: source.txt
ceilings: { book_usd: 1.0 }
` + extra
p := filepath.Join(dir, "book.yaml")
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func TestDecisionPathsAreAPureFunctionOfTheConvention(t *testing.T) {
dir := t.TempDir()
b, err := LoadBook(writeBook(t, dir, ""))
if err != nil {
t.Fatalf("a book that declares no decision keys must load: %v", err)
}
// Same convention project_db uses, one directory and one book_id prefix — and NOT hung off
// project_db, which may point outside the book's directory entirely.
if want := filepath.Join(dir, "decide-book"+MinedDeltaSuffix); b.MinedDelta != want {
t.Errorf("mined_delta = %q, want %q", b.MinedDelta, want)
}
if want := filepath.Join(dir, "decide-book"+MinedRejectsSuffix); b.MinedRejects != want {
t.Errorf("mined_rejects = %q, want %q", b.MinedRejects, want)
}
// The files do NOT have to exist: an absent file is "nobody has decided anything yet", the same
// standing an absent project database has before the first run.
if _, err := os.Stat(b.MinedDelta); err == nil {
t.Error("loading a book must not CREATE the decision files")
}
}
// TestRetiredDecisionPathKeysAreRefused pins §4.9(б): a config written for the declarable era fails
// NAMING THE CURE, whether or not the declared file exists — silently honouring the convention instead
// of the declared path would send the run's decisions to a different file than the operator believes.
func TestRetiredDecisionPathKeysAreRefused(t *testing.T) {
for _, tc := range []struct {
key string
exists bool
}{
{"mined_delta", true}, {"mined_delta", false},
{"mined_rejects", true}, {"mined_rejects", false},
} {
dir := t.TempDir()
if tc.exists {
if err := os.WriteFile(filepath.Join(dir, "my.yaml"), []byte("terms: []\n"), 0o644); err != nil {
t.Fatal(err)
}
}
_, err := LoadBook(writeBook(t, dir, tc.key+": my.yaml\n"))
if err == nil {
t.Fatalf("%s (file exists=%v) must be refused as retired", tc.key, tc.exists)
}
for _, want := range []string{"retired", "convention", tc.key} {
if !strings.Contains(err.Error(), want) {
t.Errorf("%s: the refusal must carry %q (the cure, not a bare decode error): %v", tc.key, want, err)
}
}
}
// The `adult: false` discipline: an EMPTY declared value asked for the convention, which is what it
// gets — refusing it would break configs for nothing.
if _, err := LoadBook(writeBook(t, t.TempDir(), "mined_delta: \"\"\n")); err != nil {
t.Fatalf("an empty retired key must still load: %v", err)
}
}
// --- the dofix pack ---------------------------------------------------------------------------------
func TestTheDecisionFilesTravelWithTheBookAndNotWithTheDatabase(t *testing.T) {
// The rule the convention rests on. They are the USER's decisions: a backup or an export of the
// book's directory has to contain them, and `project_db` may point anywhere at all — a scratch disk,
// a tmpfs, a directory the operator wipes between runs.
dir := t.TempDir()
db := filepath.Join(t.TempDir(), "state") // a DIFFERENT tree entirely
if err := os.MkdirAll(db, 0o755); err != nil {
t.Fatal(err)
}
p := writeBook(t, dir, "project_db: "+filepath.Join(db, "decide-book.db")+"\n")
b, err := LoadBook(p)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ what, got, want string }{
{"mined_delta", b.MinedDelta, filepath.Join(dir, "decide-book"+MinedDeltaSuffix)},
{"mined_rejects", b.MinedRejects, filepath.Join(dir, "decide-book"+MinedRejectsSuffix)},
} {
if tc.got != tc.want {
t.Errorf("%s resolved to %q, want it beside the BOOK at %q", tc.what, tc.got, tc.want)
}
if filepath.Dir(tc.got) == db {
t.Errorf("%s followed project_db out of the book's directory", tc.what)
}
}
}
func TestDeclaredKeysCannotLandOnTheDecisionFiles(t *testing.T) {
// The blast radius the same-path guard still covers now that the decision keys are gone: the two
// remaining declarable paths could still be pointed AT a conventional decision file, and this door
// would then write YAML over the book's sqlite store — or read the seed and the delta as one file.
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "decide-book"+MinedDeltaSuffix), nil, 0o644); err != nil {
t.Fatal(err)
}
p := writeBook(t, dir, "project_db: decide-book"+MinedDeltaSuffix+"\n")
if _, err := LoadBook(p); err == nil || !strings.Contains(err.Error(), "project_db") {
t.Fatalf("project_db on the conventional mined_delta path must be refused, got %v", err)
}
// …and a book that names its paths normally is untouched.
if _, err := LoadBook(writeBook(t, t.TempDir(), "")); err != nil {
t.Fatalf("an ordinary book must still load: %v", err)
}
}
// TestAbsolutePathsAreCleanedBeforeTheSamePathGuard pins §4.9(а): the guard compares STRINGS, and an
// uncleaned absolute path (`<dir>/./x`) used to be a second spelling of a guarded path — the acceptance
// walked through exactly that hole and reproduced the original data-loss blocker whole.
func TestAbsolutePathsAreCleanedBeforeTheSamePathGuard(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "decide-book"+MinedDeltaSuffix)
if err := os.WriteFile(target, nil, 0o644); err != nil {
t.Fatal(err)
}
dotted := dir + string(filepath.Separator) + "." + string(filepath.Separator) + "decide-book" + MinedDeltaSuffix
if dotted == target {
t.Fatalf("fixture is vacuous: %q already clean", dotted)
}
p := writeBook(t, dir, "project_db: "+dotted+"\n")
b, err := LoadBook(p)
if err == nil {
t.Fatalf("an uncleaned absolute project_db on the mined_delta path must be refused; loaded with project_db=%q", b.ProjectDB)
}
if !strings.Contains(err.Error(), "project_db") {
t.Errorf("the refusal must name project_db: %v", err)
}
}
func TestBookIDIsRefusedWhenItIsNotAPathElement(t *testing.T) {
// book_id is a FILE NAME: four of the engine's files are named after it, and two of them now carry the
// owner's decisions. `../x` puts all four outside the book's directory — somewhere no backup of the
// book contains and no export of it carries. The class is older than this pack (`<book_id>.db` has
// always been built this way); what the pack changed is that it went from one file to four.
for _, id := range []string{"../escape", "a/b", "..", "."} {
dir := t.TempDir()
p := writeBook(t, dir, "")
raw, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
body := strings.Replace(string(raw), "book_id: decide-book", "book_id: "+id, 1)
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadBook(p); err == nil {
t.Errorf("book_id %q names a path, not a file, and must be refused", id)
}
}
// An ordinary slug stays legitimate — every book_id in the repository and on the stand is one.
if _, err := LoadBook(writeBook(t, t.TempDir(), "")); err != nil {
t.Fatalf("an ordinary book_id must still load: %v", err)
}
}