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