package api import ( "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" "go/ast" "go/parser" "go/token" "strings" "testing" ) // TestDeployStackWiresTheLifecycleGate — the seam-discipline test (§9 rule 6). // // The lifecycle predicate is unit-tested in internal/stacks, and a test there passes whether or not // deployStack ever calls it. Three inert-seam defects shipped fully-green in three days (controller // v0.154.0, agent v0.91.0, agent v0.92.0's missing sudoers grant), all this exact shape: correct // component, absent caller. So the CALLER is asserted here, from source. // // It walks the AST rather than doing strings.Contains on the file, because a commented-out call // still contains the string — the lesson recorded in PROMPT-TEMPLATE §10. // // It also asserts ORDER: the gate must precede the DeployStack call, or it is not fail-closed. func TestDeployStackWiresTheLifecycleGate(t *testing.T) { fset := token.NewFileSet() f, err := parser.ParseFile(fset, "router.go", nil, 0) // comments dropped: a commented call is not a call if err != nil { t.Fatalf("parse router.go: %v", err) } var fn *ast.FuncDecl ast.Inspect(f, func(n ast.Node) bool { if d, ok := n.(*ast.FuncDecl); ok && d.Name.Name == "deployStack" { fn = d return false } return true }) if fn == nil { t.Fatal("deployStack not found in router.go — did it move? the gate's wiring is now unasserted") } canInstallPos, deployPos := -1, -1 ast.Inspect(fn, func(n ast.Node) bool { call, ok := n.(*ast.CallExpr) if !ok { return true } sel, ok := call.Fun.(*ast.SelectorExpr) if !ok { return true } off := fset.Position(call.Pos()).Offset switch sel.Sel.Name { case "CanInstall": if canInstallPos == -1 { canInstallPos = off } case "DeployStack": if deployPos == -1 { deployPos = off } } return true }) if canInstallPos == -1 { t.Fatal("deployStack never calls Meta.CanInstall() — the lifecycle gate is INERT: " + "a withdrawn app is hidden from the catalog page but still installable by direct POST") } if deployPos == -1 { t.Fatal("deployStack no longer calls DeployStack — this test's ordering assertion is meaningless") } if canInstallPos > deployPos { t.Fatalf("the lifecycle gate (offset %d) runs AFTER DeployStack (offset %d) — a gate that "+ "fires after the mutation is not fail-closed", canInstallPos, deployPos) } } // TestLifecycleRefusalMessageIsCustomerFacingHungarian: the refusal text reaches the customer via // showAlert(), so it must be the sentence the spec ruled, not a Go error string. // // v0.252.0 (R-557) moved the sentence into the message bundle, so router.go now names a KEY. The // ruling is unchanged and still checkable in one step more: router.go must name that key, and the // key must still carry the ruled sentence, byte for byte. Both halves matter — a key with no handler // is dead copy, and a handler naming a key whose text was reworded is the drift this test exists to // catch. scripts/i18n_go_parity.py proves the same equality against the base commit independently. func TestLifecycleRefusalMessageIsCustomerFacingHungarian(t *testing.T) { fset := token.NewFileSet() f, err := parser.ParseFile(fset, "router.go", nil, 0) if err != nil { t.Fatal(err) } const key = "api.deploy.not_installable" const want = "Ez az alkalmazás jelenleg nem telepíthető." found := false ast.Inspect(f, func(n ast.Node) bool { if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, key) { found = true } return true }) if !found { t.Fatalf("router.go no longer names %q -- the ruled refusal has no handler", key) } b, err := i18n.Load() if err != nil { t.Fatal(err) } if got := b.Msg(i18n.Default, key); got != want { t.Fatalf("the ruled refusal was reworded\n hu.json %s = %q\n ruled %q", key, got, want) } if en := b.Msg("en", key); en == "" || en == want { t.Errorf("%s has no English of its own: %q", key, en) } }