package pipeline import ( "context" "os" "path/filepath" "strings" "testing" "textmachine/backend/internal/obs" ) // rebillsource_test.go: backlog row 238 — the re-payment projection was blind to an in-place SOURCE edit. // // The cold run of 31.08 did not run this probe on purpose (editing the source would have closed its paid // half forever), so the defect stood as a code reading. This is that reading turned into a fixture. // // The shape: the source file is edited while the CONFIG is not. Nothing the snapshot folds moves — the // source is deliberately outside it — so every stored row keeps its snapshot id and the projection's // same-snapshot branch used to wave them through as «$0 resume». The run itself does not: its resume // fast-path compares the rendered CONTENT hash and re-buys the unit. Money was silent about work it was // about to do. // TestTheProjectionSeesAnInPlaceSourceEdit is the landing. // // Mutation this catches: restore the bare `if cs.SnapshotID == cur { continue }` and RebillUnits drops to // zero while `translate` still charges — the exact defect, and the assertion names it. func TestTheProjectionSeesAnInPlaceSourceEdit(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ГЛАВАА\fГЛАВАБ", regenerate: 0}) dir := filepath.Dir(bookPath) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) r1 := newRunner(t, bookPath) if _, err := r1.TranslateBook(ctx); err != nil { t.Fatal(err) } // The book must be a $0 resume BEFORE the edit — otherwise the assertion after it proves nothing. before, err := r1.Status(ctx) if err != nil { t.Fatal(err) } if before.RebillUnits != 0 { t.Fatalf("premise: an untouched book re-pays nothing, got %d units / $%.6f", before.RebillUnits, before.RebillUSD) } callsBefore := rec.count() r1.Close() // The SOURCE changes and nothing else does. No config edit, no bank edit, no prompt edit — so no // snapshot moves, which is the whole premise of row 238. if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte("ГЛАВАА ПРАВЛЕНА\fГЛАВАБ"), 0o644); err != nil { t.Fatal(err) } r2 := newRunner(t, bookPath) after, err := r2.Status(ctx) if err != nil { t.Fatal(err) } if after.ConfigDrift { t.Fatalf("premise: a source edit moves NO snapshot — if it did, the old projection would already "+ "have caught it and this test would be about something else; basis=%q", after.ConfigDriftBasis) } if after.RebillUnits == 0 { t.Fatal("the source moved under already-billed rows, so `translate` will re-buy them — a projection " + "that reports zero here tells an operator the next run is free and then charges him (row 238)") } r2.Close() // And the projection must be TRUE, not merely non-zero: the run really does pay again. r3 := newRunner(t, bookPath) defer r3.Close() r3.AcceptRebill = RebillConsent{Given: true} if _, err := r3.TranslateBook(ctx); err != nil { t.Fatal(err) } if rec.count() == callsBefore { t.Fatal("premise: the edited source really must cost provider calls; if it did not, the projection " + "would be right to report zero") } } // TestAnUntouchedSourceStillCostsNoReChunk keeps the fix from being paid for by every status read: the // content reproduction is expensive (it re-chunks the source), and the whole reason the projection took // `withText` as a callback is that a status read almost never needs it. The cheap probe is what decides. // // Mutation this catches: make sourceMovedUnderTheRows return true unconditionally and this test still // passes on correctness — so it asserts the PROBE instead, which is the thing that must not rot. func TestAnUntouchedSourceStillCostsNoReChunk(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ГЛАВАА\fГЛАВАБ", regenerate: 0}) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) w := newRunner(t, bookPath) if _, err := w.TranslateBook(ctx); err != nil { t.Fatal(err) } w.Close() // ⚠ A FRESH RUNNER, deliberately: this is the READ path, and a status read is always a new process. // Re-using the runner that just wrote the book would ask a probe that was frozen before the manifest // existed — correct for that runner (it was about to create the sidecar) and meaningless as a model of // a status read. The first version of this test made exactly that mistake. rd := newRunner(t, bookPath) moved := rd.sourceMovedUnderTheRows() rd.Close() // one flock at a time: the reader is closed before the next one opens if moved { t.Fatal("a book whose source has not been touched since its own run must validate its stored " + "manifest — otherwise every status read pays for a re-chunk it does not need") } dir := filepath.Dir(bookPath) if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte("ДРУГОЕ\fСОВСЕМ"), 0o644); err != nil { t.Fatal(err) } after := newRunner(t, bookPath) defer after.Close() if !after.sourceMovedUnderTheRows() { t.Fatal("an edited source must invalidate the stored manifest — that is the signal the projection " + "buys its expensive answer with") } } // TestTheCONSENTGateSeesAnInPlaceSourceEdit is the half the first version of this landing MISSED, and the // acceptance caught it: the projection was fixed on the READ path and left broken on the MONEY path. // // `status` never rewrites the manifest, so a probe asking «is the stored manifest still valid» answers // correctly there. `translate` persists the manifest BEFORE the consent gate runs (bookrun.go, the // manifest is written where the split every paid byte is addressed against was just computed) — so by the // time the gate asks, the sidecar already describes the NEW source and the probe answers «nothing moved». // The expensive content check was then skipped on the one surface where money is actually authorised. // // This asserts the gate itself: an edited source must make `translate` REFUSE without --accept-rebill. func TestTheCONSENTGateSeesAnInPlaceSourceEdit(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() // A consent threshold low enough that any re-payment at all has to be consented to. bookPath := setupProjectOpts(t, srv.URL, projectOpts{ source: "ГЛАВАА\fГЛАВАБ", regenerate: 0, rebillConsentUSD: 0.000001, }) dir := filepath.Dir(bookPath) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) r1 := newRunner(t, bookPath) if _, err := r1.TranslateBook(ctx); err != nil { t.Fatal(err) } r1.Close() // A re-run with NOTHING changed must not ask for consent — otherwise the assertion below would pass // for the wrong reason. r2 := newRunner(t, bookPath) if _, err := r2.TranslateBook(ctx); err != nil { t.Fatalf("an untouched book resumes for $0 and needs no consent: %v", err) } r2.Close() if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte("ГЛАВАА ПРАВЛЕНА\fГЛАВАБ"), 0o644); err != nil { t.Fatal(err) } r3 := newRunner(t, bookPath) defer r3.Close() _, err := r3.TranslateBook(ctx) // no --accept-rebill if err == nil { t.Fatal("the source moved under already-billed rows, so this run RE-BUYS them — and Р6 requires " + "consent to a concrete spend. A run that proceeds silently here charges for work the operator " + "was told was free (row 238), and the read-path projection being right does not help: the money " + "is authorised on THIS path") } if !strings.Contains(err.Error(), "RE-PAY") { t.Fatalf("the refusal must be the re-payment consent gate, not something else: %v", err) } }