448 lines
21 KiB
Go
448 lines
21 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"textmachine/backend/internal/membank"
|
|
)
|
|
|
|
// keysfile_test.go: `--keys-file` (backlog row 211) and the `bank-apply` half of the CLI surface.
|
|
//
|
|
// Row 211 is a dev/prod PARITY trap, which is why the pins are about who may pass the flag and in what
|
|
// order it is read rather than about a provider call. On the SaaS path the engine's unit gets exactly one
|
|
// environment variable and no key file is written anywhere, while the dev supervisor spawns the engine as
|
|
// an ordinary child that inherits the platform's own environment — so every gate ran green on a path that
|
|
// had keys while production starved. Nothing here spends a cent: the values are fake by construction and
|
|
// the assertions are about the CHAIN, not about a call.
|
|
|
|
// setEnvKey sets a variable for the duration of one test.
|
|
func setEnvKey(t *testing.T, k, v string) {
|
|
t.Helper()
|
|
t.Setenv(k, v)
|
|
}
|
|
|
|
func writeKeys(t *testing.T, dir, name, body string) string {
|
|
t.Helper()
|
|
p := filepath.Join(dir, name)
|
|
writeCLIFile(t, p, body)
|
|
return p
|
|
}
|
|
|
|
// TestKeysFileWinsOverTheConventionalEnv pins the ORDER of the chain: the deployment's file is loaded
|
|
// FIRST and therefore wins, because no link overrides a variable that is already set. Explicit beats
|
|
// convention, and a stand book that passes no flag keeps exactly the behaviour it had.
|
|
func TestKeysFileWinsOverTheConventionalEnv(t *testing.T) {
|
|
dir := t.TempDir()
|
|
deploy := writeKeys(t, dir, "deploy.env", "FAKE_PROVIDER_KEY=from-deploy\nDEPLOY_ONLY=yes\n")
|
|
bookEnv := writeKeys(t, dir, ".env", "FAKE_PROVIDER_KEY=from-book-dir\nBOOK_ONLY=yes\n")
|
|
|
|
setEnvKey(t, "FAKE_PROVIDER_KEY", "")
|
|
setEnvKey(t, "DEPLOY_ONLY", "")
|
|
setEnvKey(t, "BOOK_ONLY", "")
|
|
|
|
var warn bytes.Buffer
|
|
if err := loadKeysFile(deploy, &warn); err != nil {
|
|
t.Fatalf("a readable key file must load: %v", err)
|
|
}
|
|
loadDotEnv(bookEnv, &warn)
|
|
|
|
if got := os.Getenv("FAKE_PROVIDER_KEY"); got != "from-deploy" {
|
|
t.Errorf("FAKE_PROVIDER_KEY = %q, want the DEPLOYMENT's value — the explicit file is first in the chain", got)
|
|
}
|
|
// …and the conventional file is still read for everything the deployment did not name, so a book
|
|
// directory carrying an extra variable is not silently ignored.
|
|
if got := os.Getenv("BOOK_ONLY"); got != "yes" {
|
|
t.Errorf("BOOK_ONLY = %q: the conventional .env must still load its own keys", got)
|
|
}
|
|
if got := os.Getenv("DEPLOY_ONLY"); got != "yes" {
|
|
t.Errorf("DEPLOY_ONLY = %q", got)
|
|
}
|
|
}
|
|
|
|
// TestKeysFileFailsLoud: a file the caller NAMED and that cannot be read is a deployment fault. The
|
|
// alternative is a paid run that starts, calls a provider and gets 401 — the failure row 211 describes.
|
|
func TestKeysFileFailsLoud(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := loadKeysFile(filepath.Join(dir, "not-there.env"), &bytes.Buffer{}); err == nil {
|
|
t.Error("an absent named key file must be an error, never a silent skip")
|
|
}
|
|
empty := writeKeys(t, dir, "empty.env", "# only a comment\n")
|
|
if err := loadKeysFile(empty, &bytes.Buffer{}); err == nil {
|
|
t.Error("a named key file with no KEY=VALUE line is the same fault one step further along")
|
|
}
|
|
}
|
|
|
|
// TestOnlyTranslateAcceptsKeys is the mutation pin for "a reading verb demands keys" (D20.4). The $0
|
|
// projections must not merely tolerate the flag: accepting it teaches a caller to pass it everywhere, and
|
|
// the next reader of that wiring concludes the read commands need provider keys.
|
|
func TestOnlyTranslateAcceptsKeys(t *testing.T) {
|
|
for _, cmd := range []string{"status", "report", "export", "manifest", "redrive", "backup", "migrate", "seed-lint", "bank-apply"} {
|
|
_, err := parseInvocation([]string{cmd, "--config", "book.yaml", "--keys-file", "k.env"}, &bytes.Buffer{})
|
|
if err == nil {
|
|
t.Errorf("%s accepted --keys-file", cmd)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), "--keys-file") {
|
|
t.Errorf("%s: the refusal must name the flag, got: %v", cmd, err)
|
|
}
|
|
}
|
|
inv, err := parseInvocation([]string{"translate", "--config", "book.yaml", "--keys-file", "k.env"}, &bytes.Buffer{})
|
|
if err != nil || inv.keysFile != "k.env" {
|
|
t.Fatalf("translate must take the flag: inv=%+v err=%v", inv, err)
|
|
}
|
|
}
|
|
|
|
// TestOnlyBankApplyAcceptsDecisions is the same rule as TestOnlyTranslateAcceptsKeys for the pack's own
|
|
// new flag, and the silent version of THIS one costs money: every flag of this CLI lives in one FlagSet,
|
|
// so `tmctl translate --decisions d.json` parsed cleanly and ran a PAID translation while its caller
|
|
// believed it was applying a user's bank corrections. Found by re-checking the pack's own scoping after
|
|
// the reviews — the keys flag had the guard from the start and the decisions flag did not.
|
|
func TestOnlyBankApplyAcceptsDecisions(t *testing.T) {
|
|
for _, cmd := range []string{"translate", "status", "report", "export", "redrive", "manifest", "backup", "migrate"} {
|
|
_, err := parseInvocation([]string{cmd, "--config", "book.yaml", "--decisions", "d.json"}, &bytes.Buffer{})
|
|
if err == nil {
|
|
t.Errorf("%s accepted --decisions and would have done something other than apply decisions", cmd)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), "--decisions") {
|
|
t.Errorf("%s: the refusal must name the flag, got: %v", cmd, err)
|
|
}
|
|
}
|
|
inv, err := parseInvocation([]string{"bank-apply", "--config", "book.yaml", "--decisions", "d.json"}, &bytes.Buffer{})
|
|
if err != nil || inv.decisionsPath != "d.json" {
|
|
t.Fatalf("bank-apply must take the flag: inv=%+v err=%v", inv, err)
|
|
}
|
|
}
|
|
|
|
// TestBankApplySurvivesABrokenModelsFile pins the reason the verb is a package-level function rather
|
|
// than a Runner method: a $0 decisions verb must not stop working because of a file its decisions have
|
|
// nothing to do with. Asserted rather than assumed — it was a claim in a comment until this test.
|
|
func TestBankApplySurvivesABrokenModelsFile(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("builds and runs the binary")
|
|
}
|
|
bin := buildTmctl(t)
|
|
bookPath := setupCLIProject(t, "http://127.0.0.1:1")
|
|
dir := filepath.Dir(bookPath)
|
|
if err := os.Remove(filepath.Join(dir, "models.yaml")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := json.Marshal(membank.DecisionsDoc{Version: membank.DecisionsVersion, BookID: "cli-book",
|
|
Decisions: []membank.Decision{{Action: membank.ActionApprove, Src: "図書館", Dst: "библиотека"}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
doc := filepath.Join(dir, "decisions.json")
|
|
writeCLIFile(t, doc, string(body))
|
|
|
|
out, err := exec.Command(bin, "bank-apply", "--config", bookPath, "--decisions", doc).CombinedOutput()
|
|
if got := exitCodeOf(t, err); got != 0 {
|
|
t.Fatalf("a decisions verb must not depend on models.yaml: exit %d\n%s", got, out)
|
|
}
|
|
// …and the comparison that gives the claim its force: a command that DOES build a Runner refuses the
|
|
// same book, so the test is not passing for want of a gate.
|
|
_, serr := exec.Command(bin, "status", "--config", bookPath, "--json").CombinedOutput()
|
|
if got := exitCodeOf(t, serr); got == 0 {
|
|
t.Fatal("status was expected to refuse this book; the comparison proves nothing if it does not")
|
|
}
|
|
}
|
|
|
|
// TestAnUnreadableKeysFileRefusesWithTheConfigClass drives the REAL binary: the shell contract is only a
|
|
// contract at the shell. Exit 10 is "this deployment will not run and a human fixes it", which is exactly
|
|
// what a named-but-absent key file is.
|
|
func TestAnUnreadableKeysFileRefusesWithTheConfigClass(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("builds and runs the binary")
|
|
}
|
|
bin := buildTmctl(t)
|
|
bookPath := setupCLIProject(t, "http://127.0.0.1:1")
|
|
missing := filepath.Join(filepath.Dir(bookPath), "no-such-keys.env")
|
|
out, err := exec.Command(bin, "translate", "--config", bookPath, "--keys-file", missing).CombinedOutput()
|
|
if got := exitCodeOf(t, err); got != exitConfigInvalid {
|
|
t.Fatalf("exit %d, want %d\n%s", got, exitConfigInvalid, out)
|
|
}
|
|
if !strings.Contains(string(out), "--keys-file") {
|
|
t.Errorf("the operator must be told which file: %s", out)
|
|
}
|
|
}
|
|
|
|
// TestBankApplyIsAZeroDollarVerbAtTheShell walks the whole command through the real binary, on a book
|
|
// whose provider is unreachable — if the verb touched a provider or demanded a key, this is where it
|
|
// would show. It also pins the projection: `--dry-run` writes nothing.
|
|
func TestBankApplyIsAZeroDollarVerbAtTheShell(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("builds and runs the binary")
|
|
}
|
|
bin := buildTmctl(t)
|
|
bookPath := setupCLIProject(t, "http://127.0.0.1:1")
|
|
dir := filepath.Dir(bookPath)
|
|
body, err := json.Marshal(membank.DecisionsDoc{Version: membank.DecisionsVersion, BookID: "cli-book",
|
|
Decisions: []membank.Decision{{Action: membank.ActionApprove, Src: "図書館", Dst: "библиотека"}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
doc := filepath.Join(dir, "decisions.json")
|
|
writeCLIFile(t, doc, string(body))
|
|
delta := filepath.Join(dir, "cli-book.mined-delta.yaml")
|
|
|
|
out, err := exec.Command(bin, "bank-apply", "--config", bookPath, "--decisions", doc, "--dry-run").CombinedOutput()
|
|
if got := exitCodeOf(t, err); got != 0 {
|
|
t.Fatalf("projection exit %d\n%s", got, out)
|
|
}
|
|
var rep struct {
|
|
Version string `json:"report_version"`
|
|
Mode string `json:"mode"`
|
|
Depth string `json:"depth"`
|
|
Changed bool `json:"changed"`
|
|
}
|
|
if jerr := json.Unmarshal(out, &rep); jerr != nil {
|
|
t.Fatalf("the report must be the whole of stdout and must parse: %v\n%s", jerr, out)
|
|
}
|
|
if rep.Version != membank.DecisionsReportVersion || rep.Mode != "projection" || rep.Depth != membank.DecisionDepth || !rep.Changed {
|
|
t.Fatalf("report = %+v", rep)
|
|
}
|
|
if _, serr := os.Stat(delta); serr == nil {
|
|
t.Fatal("the projection wrote the delta")
|
|
}
|
|
|
|
if out, err = exec.Command(bin, "bank-apply", "--config", bookPath, "--decisions", doc).CombinedOutput(); err != nil {
|
|
t.Fatalf("apply: %v\n%s", err, out)
|
|
}
|
|
raw, rerr := os.ReadFile(delta)
|
|
if rerr != nil || !strings.Contains(string(raw), "status: approved") {
|
|
t.Fatalf("delta after apply: err=%v\n%s", rerr, raw)
|
|
}
|
|
}
|
|
|
|
// TestTheKeysFileActuallyReachesTheProvider is the strongest proof available for $0 (promt §4.7): a
|
|
// project whose provider DECLARES an api_key_env, a fake HTTP provider that asserts the Authorization
|
|
// header, and the key delivered ONLY through --keys-file.
|
|
//
|
|
// Two halves, and the first is what makes the second mean anything: without the flag the run is refused
|
|
// at config validation naming the missing env, so the pass with it cannot be an accident of an inherited
|
|
// environment — which is the exact dev/prod parity trap row 211 records.
|
|
//
|
|
// ⚠ The key is FAKE and the provider is an httptest server. Nothing here proves anything about a REAL
|
|
// provider call; the runtime verdict on the paid path stays PLAUSIBLE.
|
|
func TestTheKeysFileActuallyReachesTheProvider(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("builds and runs the binary")
|
|
}
|
|
bin := buildTmctl(t)
|
|
const envName = "TM_FAKE_PROVIDER_KEY"
|
|
seen := make(chan string, 8)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
select {
|
|
case seen <- r.Header.Get("Authorization"):
|
|
default:
|
|
}
|
|
fmt.Fprint(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":"ПЕРЕВОД"},"finish_reason":"stop"}],
|
|
"usage":{"prompt_tokens":10,"completion_tokens":5}}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
bookPath := setupCLIProject(t, srv.URL)
|
|
dir := filepath.Dir(bookPath)
|
|
// Re-declare the provider WITH a key env. Everything else is the fixture's own models.yaml.
|
|
writeCLIFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
|
|
prices_checked: %q
|
|
default_model: fake-model
|
|
providers:
|
|
fake:
|
|
kind: openai
|
|
base_url: %q
|
|
api_key_env: %s
|
|
timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 }
|
|
models:
|
|
fake-model:
|
|
provider: fake
|
|
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
|
|
`, time.Now().UTC().Format("2006-01-02"), srv.URL, envName))
|
|
|
|
// The engine must not inherit the key from the test process, or the assertion below proves nothing.
|
|
env := append(os.Environ(), envName+"=")
|
|
|
|
bare := exec.Command(bin, "translate", "--config", bookPath)
|
|
bare.Env = env
|
|
out, err := bare.CombinedOutput()
|
|
if got := exitCodeOf(t, err); got != exitConfigInvalid {
|
|
t.Fatalf("without a key the run must be refused as a config: exit %d\n%s", got, out)
|
|
}
|
|
if !strings.Contains(string(out), envName) {
|
|
t.Fatalf("the refusal must name the missing env: %s", out)
|
|
}
|
|
|
|
keys := writeKeys(t, dir, "deploy.env", envName+"=fake-deploy-key\n")
|
|
withKeys := exec.Command(bin, "translate", "--config", bookPath, "--keys-file", keys)
|
|
withKeys.Env = env
|
|
if out, err = withKeys.CombinedOutput(); err != nil {
|
|
t.Fatalf("the run must start once the deployment names its key file: %v\n%s", err, out)
|
|
}
|
|
select {
|
|
case auth := <-seen:
|
|
if auth != "Bearer fake-deploy-key" {
|
|
t.Fatalf("Authorization = %q — the key from --keys-file did not reach the wire", auth)
|
|
}
|
|
default:
|
|
t.Fatal("the provider was never called, so nothing was proved about the key")
|
|
}
|
|
}
|
|
|
|
// TestARejectedDecisionSetHasItsOwnNumberInTheBand drives the real binary for the new class.
|
|
//
|
|
// Its own number and not 10, on the axis a caller ACTS on: the platform's reader files 10 as a
|
|
// deployment fault and exempts it from a book's attempt budget as "a condition every book on the host
|
|
// shares" (platform/internal/ingest/exit.go DeploymentFault) — while a rejected decision set is one
|
|
// user's document on one book, with retry futile until they re-decide.
|
|
func TestARejectedDecisionSetHasItsOwnNumberInTheBand(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("builds and runs the binary")
|
|
}
|
|
bin := buildTmctl(t)
|
|
bookPath := setupCLIProject(t, "http://127.0.0.1:1")
|
|
dir := filepath.Dir(bookPath)
|
|
body, err := json.Marshal(membank.DecisionsDoc{Version: membank.DecisionsVersion, BookID: "cli-book",
|
|
Decisions: []membank.Decision{{Action: membank.ActionApprove, Src: "図書館"}}}) // no dst
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
doc := filepath.Join(dir, "bad.json")
|
|
writeCLIFile(t, doc, string(body))
|
|
out, err := exec.Command(bin, "bank-apply", "--config", bookPath, "--decisions", doc).CombinedOutput()
|
|
if got := exitCodeOf(t, err); got != exitDecisionsRejected {
|
|
t.Fatalf("exit %d, want %d\n%s", got, exitDecisionsRejected, out)
|
|
}
|
|
if exitDecisionsRejected < refusalFirst || exitDecisionsRejected > refusalLast {
|
|
t.Fatalf("the new class must live INSIDE the band [%d,%d] every consumer keys on", refusalFirst, refusalLast)
|
|
}
|
|
// The report is the product of a refused call and must still be on stdout, whole.
|
|
var rep struct {
|
|
Rejected []struct {
|
|
Reason string `json:"reason"`
|
|
} `json:"rejected"`
|
|
}
|
|
if jerr := json.Unmarshal(out[:strings.LastIndex(string(out), "}")+1], &rep); jerr != nil || len(rep.Rejected) != 1 {
|
|
t.Fatalf("a refusal must still print the report naming each rejection: %v\n%s", jerr, out)
|
|
}
|
|
|
|
// A document that never parsed AS a decisions document stays the CONFIG class: the caller's wiring
|
|
// is what is wrong there, not their decisions.
|
|
junk := filepath.Join(dir, "junk.json")
|
|
writeCLIFile(t, junk, `{"decisions_version":"tm-bank-decisions-v99"}`)
|
|
_, err = exec.Command(bin, "bank-apply", "--config", bookPath, "--decisions", junk).CombinedOutput()
|
|
if got := exitCodeOf(t, err); got != exitConfigInvalid {
|
|
t.Fatalf("an undecodable document: exit %d, want %d", got, exitConfigInvalid)
|
|
}
|
|
}
|
|
|
|
// TestBankApplyNeedsItsDocument: defaulting the decision document to some conventional path would let an
|
|
// argument-less invocation mutate a book's canon.
|
|
func TestBankApplyNeedsItsDocument(t *testing.T) {
|
|
if _, err := parseInvocation([]string{"bank-apply", "--config", "book.yaml"}, &bytes.Buffer{}); err == nil {
|
|
t.Fatal("bank-apply without --decisions must be refused")
|
|
}
|
|
}
|
|
|
|
// --- the dofix pack ---------------------------------------------------------------------------------
|
|
|
|
func TestAFlagIsRefusedForBEINGTHERE_NotForCarryingAValue(t *testing.T) {
|
|
// The money hole that survived in one spelling. Both guards asked whether the VALUE was non-empty, so
|
|
// `--decisions=` slipped past and `tmctl translate --decisions=` went down the PAID path exactly as if
|
|
// the flag were absent. The live trigger is not a typist: it is `--decisions=$DOC` in a unit file or a
|
|
// deploy script with the variable unset, which is the ordinary way an empty value is produced.
|
|
//
|
|
// The technique is the only one that answers the question: fs.Visit reports the flags actually PASSED.
|
|
// fs.Lookup returns the DECLARED flag whether or not anybody passed it, so it cannot tell these apart.
|
|
for _, form := range [][]string{
|
|
{"translate", "--config", "b.yaml", "--decisions="},
|
|
{"translate", "--config", "b.yaml", "--decisions", ""},
|
|
{"redrive", "--config", "b.yaml", "--keys-file="},
|
|
{"status", "--config", "b.yaml", "--keys-file", ""},
|
|
} {
|
|
if _, err := parseInvocation(form, &bytes.Buffer{}); err == nil {
|
|
t.Errorf("%v was accepted — an empty value on a flag the command does not act on is still the flag", form)
|
|
}
|
|
}
|
|
// The same rule for the command that DOES take the flag: an empty value there is an unset variable
|
|
// too, and «apply nothing» is not a thing this verb does.
|
|
if _, err := parseInvocation([]string{"bank-apply", "--config", "b.yaml", "--decisions="}, &bytes.Buffer{}); err == nil {
|
|
t.Error("bank-apply accepted an empty --decisions")
|
|
}
|
|
if _, err := parseInvocation([]string{"translate", "--config", "b.yaml", "--keys-file="}, &bytes.Buffer{}); err == nil {
|
|
t.Error("translate accepted an empty --keys-file and would fall back to the conventional .env")
|
|
}
|
|
// …and an ABSENT flag is untouched, which is what keeps every ordinary invocation working.
|
|
if _, err := parseInvocation([]string{"translate", "--config", "b.yaml"}, &bytes.Buffer{}); err != nil {
|
|
t.Errorf("an absent flag must change nothing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTheKeysFileRefusalDescribesEveryoneItTurnsAway(t *testing.T) {
|
|
// The branch rejects EIGHT commands and its reason described three of them. An operator who ran
|
|
// `redrive --keys-file=...` was told that «the $0 read commands must not demand provider keys» — about
|
|
// a command that is the paid re-attack path, and that has a key channel, just not this one. A refusal
|
|
// whose reason does not describe the reader teaches them the wrong thing about the system.
|
|
//
|
|
// Deliberately NOT decided here: whether `redrive` should get the flag. That fork is a ping; this is
|
|
// only about the sentence being true of everyone who reads it.
|
|
// Enumerated from dispatchCommands, NOT from a list written here. A hand list is the same weakness
|
|
// §4.11(в) was raised against, and it reproduced immediately: the first version of this test named
|
|
// four commands and missed `bank-apply` — the verb this pack itself added — which the refusal then
|
|
// did not describe either.
|
|
for _, cmd := range dispatchCommands {
|
|
if cmd == "translate" {
|
|
continue
|
|
}
|
|
_, err := parseInvocation([]string{cmd, "--config", "b.yaml", "--keys-file", "k.env"}, &bytes.Buffer{})
|
|
if err == nil {
|
|
t.Fatalf("%s must be refused the flag", cmd)
|
|
}
|
|
// ⚠ The name is stripped from the «not by %q» clause FIRST. Without that this assertion is
|
|
// vacuous: every refusal quotes the command it is refusing, so `strings.Contains` was satisfied by
|
|
// the accusation rather than by the explanation — and it passed with `bank-apply` deleted from the
|
|
// reason. Caught by planting exactly that.
|
|
rest := strings.Replace(err.Error(), fmt.Sprintf("%q", cmd), "", 1)
|
|
if !strings.Contains(rest, cmd) {
|
|
t.Errorf("the reason given to `%s` names it only to refuse it, and then describes somebody else: %v", cmd, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDryRunIsRefusedWhereNothingProjects closes the third spelling of the same money hole, found by
|
|
// the fix2 review workflow: `tmctl translate --dry-run` parsed cleanly and ran a PAID translation while
|
|
// its caller believed it was asking for a $0 projection. The rule is this file's own: a flag a command
|
|
// does not act on is refused, never ignored — and this flag's silent version is the most expensive,
|
|
// because it inverts the one promise (--dry-run = spends nothing) the caller is relying on.
|
|
func TestDryRunIsRefusedWhereNothingProjects(t *testing.T) {
|
|
for _, form := range [][]string{
|
|
{"translate", "--config", "b.yaml", "--dry-run"},
|
|
{"status", "--config", "b.yaml", "--dry-run"},
|
|
{"export", "--config", "b.yaml", "--dry-run"},
|
|
{"migrate", "--config", "b.yaml", "--dry-run"},
|
|
} {
|
|
if _, err := parseInvocation(form, &bytes.Buffer{}); err == nil {
|
|
t.Errorf("%v was accepted — the caller believes this call is free, and it is not", form)
|
|
} else if !strings.Contains(err.Error(), "dry-run") {
|
|
t.Errorf("%v: the refusal must name the flag: %v", form, err)
|
|
}
|
|
}
|
|
// The two commands that DO project keep the flag.
|
|
for _, form := range [][]string{
|
|
{"bank-apply", "--config", "b.yaml", "--decisions", "d.json", "--dry-run"},
|
|
{"redrive", "--config", "b.yaml", "--chapter", "1", "--chunk", "0", "--reason", "x", "--dry-run"},
|
|
} {
|
|
if _, err := parseInvocation(form, &bytes.Buffer{}); err != nil {
|
|
t.Errorf("%v must keep its projection mode: %v", form, err)
|
|
}
|
|
}
|
|
}
|