247 lines
9.9 KiB
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)
|
|
}
|
|
}
|