textmachine/backend/internal/pipeline/status_rebill_projection_test.go

77 lines
2.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"
"fmt"
"strings"
"testing"
"textmachine/backend/internal/obs"
)
// status_rebill_projection_test.go: `tmctl status` prices the drift (spec D15.2 §9, taken with the
// pre-run hygiene pack 25.07). The boolean ConfigDrift told the operator that the config moved but not
// whether continuing costs a cent or the whole book — and the number that answers that already existed
// on the refusal path. The claim under test is that status quotes THE SAME number the consent gate
// refuses on: two definitions of one quantity is the exact bug projectBookUSD was extracted to remove.
// TestStatusPricesTheDriftWithTheGatesOwnNumber runs a book, drifts both waves, and asserts that the
// read-only status projection equals what was actually billed AND what the gate names when it refuses.
func TestStatusPricesTheDriftWithTheGatesOwnNumber(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
committed, _, err := r1.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
r1.Close()
// Before any drift the projection must be SILENT: everything resumes at $0, so a status that
// reported a re-payment here would be crying wolf on every healthy book.
rClean := newRunner(t, bookPath)
cleanRep, err := rClean.Status(ctx)
rClean.Close()
if err != nil {
t.Fatal(err)
}
if cleanRep.RebillUnits != 0 || cleanRep.RebillUSD != 0 {
t.Fatalf("an undrifted book must project no re-payment, got %d units / $%v",
cleanRep.RebillUnits, cleanRep.RebillUSD)
}
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
rep, err := r2.Status(ctx)
if err != nil {
t.Fatal(err)
}
if !rep.ConfigDrift {
t.Fatal("setup: the drift itself was not detected")
}
if rep.RebillUnits != 2 { // 1 chunk × (draft + edit)
t.Errorf("rebill_units = %d, want 2 (both stages of the single chunk)", rep.RebillUnits)
}
// Every billed row is superseded here, so the projection is the whole spend of run 1.
if diff := rep.RebillUSD - committed; diff > 1e-9 || diff < -1e-9 {
t.Errorf("rebill_usd = %v, want the whole billed amount %v", rep.RebillUSD, committed)
}
// The same number, from the enforcement side: the gate's refusal must quote what status showed.
err = r2.checkRebillConsent(ctx, chunksOf(t, r2))
if err == nil {
t.Fatal("the gate must refuse a fully-superseded book without consent")
}
if want := fmt.Sprintf("~$%.6f", rep.RebillUSD); !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not quote the projection %s:\n%v", want, err)
}
}