textmachine/backend/internal/pipeline/statusartifacts_test.go

247 lines
9.9 KiB
Go

package pipeline
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/obs"
)
// statusartifacts_test.go: backlog row 213 and 17-seam-inbound-law п.3 — the paths the engine REPORTS
// instead of letting another zone derive, and the versioned envelope every JSON output carries.
//
// The defect row 213 records is not hypothetical: the platform parses book.yaml for two keys and, when
// `project_db` is absent (which is what the operator template ships), computes `<workdir>/<book_id>.db`
// itself — a byte-for-byte copy of config.LoadBook's convention — then appends its own copy of the bank
// read-out's `.bank.json` suffix. Changing either default here would have quietly pointed another zone's
// read at a file that does not exist.
func TestStatusReportsTheEngineOwnedPaths(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
dir := filepath.Dir(bookPath)
r := newRunner(t, bookPath)
defer r.Close()
rep, err := r.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if rep.Version != statusVersion {
t.Errorf("status_version = %q, want %q — the bank read-out and the manifest both carry one and this was the gap", rep.Version, statusVersion)
}
want := map[string]string{
"project_db": filepath.Join(dir, "test-book.db"),
"bank_export": filepath.Join(dir, "test-book.db.bank.json"),
"mined_delta": filepath.Join(dir, "test-book.mined-delta.yaml"),
"mined_rejects": filepath.Join(dir, "test-book.mined-rejects.yaml"),
}
got := map[string]string{
"project_db": rep.Artifacts.ProjectDB,
"bank_export": rep.Artifacts.BankExport,
"mined_delta": rep.Artifacts.MinedDelta,
"mined_rejects": rep.Artifacts.MinedRejects,
}
for k, w := range want {
if got[k] != w {
t.Errorf("artifacts.%s = %q, want %q", k, got[k], w)
}
if !filepath.IsAbs(got[k]) {
t.Errorf("artifacts.%s must be absolute — the consumer reads it from another working directory: %q", k, got[k])
}
}
// The suffix stays what it is. Renaming the bank read-out to a fixed name beside events.jsonl is a
// BREAKING change and its window is the chapter-structure re-cut (backlog row 161), not this one.
if rep.Artifacts.BankExport != r.bankExportPath() {
t.Errorf("the reported path must be the one the engine actually writes: %q vs %q", rep.Artifacts.BankExport, r.bankExportPath())
}
}
// TestStatusJSONCarriesTheEnvelopeAdditively: the platform allowlists the fields it materializes and
// ignores the rest, so a new field is safe — but only if it is genuinely ADDED and nothing existing moved.
func TestStatusJSONCarriesTheEnvelopeAdditively(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
r := newRunner(t, bookPath)
defer r.Close()
rep, err := r.Status(context.Background())
if err != nil {
t.Fatal(err)
}
body, err := json.Marshal(rep)
if err != nil {
t.Fatal(err)
}
var doc map[string]json.RawMessage
if err := json.Unmarshal(body, &doc); err != nil {
t.Fatal(err)
}
for _, k := range []string{"status_version", "artifacts", "book_id", "total_units", "committed_usd", "reserved_usd", "progress", "chapters"} {
if _, ok := doc[k]; !ok {
t.Errorf("status --json lost the field %q", k)
}
}
}
// TestExportJSONCarriesItsVersion: the law's п.3 is general ("every JSON output of the engine carries a
// versioned envelope") and names `status --json` because that is where the gap was noticed. The export
// was the second surface without one.
func TestExportJSONCarriesItsVersion(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
r := newRunner(t, bookPath)
defer r.Close()
exp, err := r.Export(false)
if err != nil {
t.Fatal(err)
}
if exp.Version != exportVersion {
t.Errorf("export_version = %q, want %q", exp.Version, exportVersion)
}
}
// --- the dofix pack ---------------------------------------------------------------------------------
func TestTheReportedPathsAreAbsoluteEvenWhenTheConfigIsNOT(t *testing.T) {
// The existing pin checks filepath.IsAbs, but its fixture hands the engine an ABSOLUTE --config, so
// every path is already absolute before absPath sees it and removing absPath changed nothing. Measured:
// with a relative --config the call is load-bearing, and without it a consumer running in its own
// working directory — which is what the platform is — resolves the path against a different directory
// and reads a different file, or none.
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
dir := filepath.Dir(bookPath)
t.Chdir(dir)
r := newRunner(t, filepath.Base(bookPath)) // RELATIVE, the way an operator types it
defer r.Close()
rep, err := r.Status(context.Background())
if err != nil {
t.Fatal(err)
}
for name, got := range map[string]string{
"project_db": rep.Artifacts.ProjectDB, "bank_export": rep.Artifacts.BankExport,
"mined_delta": rep.Artifacts.MinedDelta, "mined_rejects": rep.Artifacts.MinedRejects,
} {
if !filepath.IsAbs(got) {
t.Errorf("artifacts.%s = %q — a consumer in another working directory reads the wrong file", name, got)
}
}
}
func TestTheManifestCarriesTheArtifactEnvelope(t *testing.T) {
// Where the envelope has to LAND. `status --json` publishes it, and the platform's read-model does not
// call status at all — its engine interface is {Manifest, Export}, and the one place it needs an
// engine-owned path it re-derives `<workdir>/<book_id>.db` itself, which is the duplicate unified
// backlog row 213 records. An envelope on a surface nobody reads removes nothing.
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
dir := filepath.Dir(bookPath)
r := newRunner(t, bookPath)
defer r.Close()
m, err := r.BuildAndPersistManifest()
if err != nil {
t.Fatal(err)
}
want := map[string]string{
"project_db": filepath.Join(dir, "test-book.db"),
"bank_export": filepath.Join(dir, "test-book.db.bank.json"),
"mined_delta": filepath.Join(dir, "test-book.mined-delta.yaml"),
"mined_rejects": filepath.Join(dir, "test-book.mined-rejects.yaml"),
}
got := map[string]string{
"project_db": m.Artifacts.ProjectDB, "bank_export": m.Artifacts.BankExport,
"mined_delta": m.Artifacts.MinedDelta, "mined_rejects": m.Artifacts.MinedRejects,
}
for k, w := range want {
if got[k] != w {
t.Errorf("manifest artifacts.%s = %q, want %q", k, got[k], w)
}
}
// It is in the DOCUMENT a consumer parses, not only in the Go struct.
body, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(body, &doc); err != nil {
t.Fatal(err)
}
art, ok := doc["artifacts"].(map[string]any)
if !ok || art["mined_delta"] != want["mined_delta"] {
t.Fatalf("the emitted manifest must carry the envelope: %v", doc["artifacts"])
}
// DERIVED on every read, never trusted from the file: a path stored in a sidecar that travels with
// the book directory is wrong the first time the directory moves. A stored manifest with a stale
// envelope must come back with the current one.
raw, err := os.ReadFile(r.manifestPath())
if err != nil {
t.Fatal(err)
}
stale := strings.Replace(string(raw), want["mined_delta"], "/gone/elsewhere.yaml", 1)
if stale == string(raw) {
t.Fatal("test premise broken: the stored manifest does not carry the path")
}
writeFile(t, r.manifestPath(), stale)
if loaded := r.loadManifest(); loaded == nil {
t.Fatal("the doctored manifest must still be structurally valid (only the path was changed)")
} else if loaded.Artifacts.MinedDelta != want["mined_delta"] {
t.Fatalf("a stored path must be OVERWRITTEN with the current truth, got %q", loaded.Artifacts.MinedDelta)
}
}
// TestTheBankExportCarriesItsFreshnessAnchor pins fix3 §4.6: the export's write discipline is
// loud-but-not-fatal, so the file on disk may be a previous boundary's projection — and «may be stale»
// used to live only in a log line. The document itself now names the boundary that produced it and the
// run identity a journal reader can compare against; a later boundary moves the anchor.
func TestTheBankExportCarriesItsFreshnessAnchor(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
r := newRunner(t, setupProject(t, srv.URL))
defer r.Close()
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: "fx3-freshness-trace"})
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
var exp BankExport
mustParseJSON(t, r.bankExportPath(), &exp)
if exp.AsOf != "run-finished" {
t.Fatalf("the last boundary of a completed run is run-finished, got %q", exp.AsOf)
}
if exp.RunID != "fx3-freshness-trace" {
t.Fatalf("the anchor must carry the run identity the journal streams, got %q", exp.RunID)
}
// A later boundary REPLACES the anchor — that is the whole point: two projections are tellable apart.
ctx2 := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: "fx3-second-run"})
r.exportBank(ctx2, "redrive/re-seeded")
mustParseJSON(t, r.bankExportPath(), &exp)
if exp.AsOf != "redrive/re-seeded" || exp.RunID != "fx3-freshness-trace" {
// The emitter is authoritative once the journal is open: the SAME runner re-exporting still
// belongs to its own stream, whatever a caller stamped on the context.
t.Fatalf("anchor after a later boundary: as_of=%q run_id=%q", exp.AsOf, exp.RunID)
}
// …and a runner with no journal and no traced context says «unknown», never «current».
r2 := newRunner(t, setupProject(t, srv.URL))
defer r2.Close()
r2.exportBank(context.Background(), "run-start/seeded")
mustParseJSON(t, r2.bankExportPath(), &exp)
if exp.RunID != "" || exp.AsOf != "run-start/seeded" {
t.Fatalf("no run identity must export as empty, got as_of=%q run_id=%q", exp.AsOf, exp.RunID)
}
}