Files
felhom-controller/controller/internal/api/lifecycle_gate_wiring_test.go
T
admin 5270bad76e
gates / gates (push) Successful in 23s
v0.252.0 — the sentences the program builds follow the language (R-557 slice 2 release A)
Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still
Hungarian literals in Go, so an English household clicked an English button and was
answered in Hungarian. 226 of them move into the bundle here.

A flash was the hard part: it travels inside the redirect URL and is rendered by a
DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by
an older controller carries prose and is shown verbatim — never a raw key, never dropped.

Also converted: page data and view-model text, the internal/api JSON answers, the alert
banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at
display, and the four page titles built around an app name (R-566 closed).

Hungarian is byte-identical, and that is measured rather than read:
scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses
a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict.
Its own first version filtered the capture through an ASCII-Hungarian word list and missed
seven real literals — the R-565 class. The filter is gone.

Nothing on the wire moved, and wire goldens now hold it there: the report's health
warnings and every notify event message stay Hungarian, because the hub MAILS the
controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 10:15:56 +02:00

116 lines
3.9 KiB
Go

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)
}
}