textmachine/backend/internal/pipeline/rebillsource_test.go

179 lines
7.7 KiB
Go
Raw 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 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)
}
}