package stacks import ( "os" "path/filepath" "runtime" "testing" ) // withFakeComposeExit is withFakeCompose with a chosen exit code, so the FAILING deploy can be // driven through the same process boundary as the succeeding one. func withFakeComposeExit(t *testing.T, m *Manager, code int) { t.Helper() if runtime.GOOS != "linux" { t.Skip("the stub compose binary is a shell script") } bin := t.TempDir() script := "#!/bin/sh\nexit " + string(rune('0'+code)) + "\n" if err := os.WriteFile(filepath.Join(bin, "docker-compose"), []byte(script), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) m.composeCmd = "docker-compose" m.execFn = func(string, ...string) (string, error) { return "", nil } } // R-536 — „Alkalmazás telepítve" must be said at the END of a deploy, and a deploy that ends badly // must say THAT rather than nothing. // // The defect this pins, measured live on 2026-09-16: the deploy of `mealie` was accepted at // 12:31:36 CEST and the hub logged „Alkalmazás telepítve: Mealie" in the same second; the controller // was killed five seconds later, and after the agent restarted it the stack read // `not_deployed / deployed=false / deploying=false`. Nothing ever corrected the event. // // Red-proof: remove the deployDoneHook call from the success path → the "installed" case fails; // remove it from the failure branch → the "failed" case fails. func TestDeployDoneHook_FiresAtTheEndAndSaysWhichEndItWas(t *testing.T) { t.Run("a deploy that succeeds reports installed", func(t *testing.T) { m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", "deployed: true\nenv: {}\n") withFakeCompose(t, m) var gotName, gotDetail string var gotOK, fired bool m.SetDeployDoneHook(func(name string, ok bool, detail string) { fired, gotName, gotOK, gotDetail = true, name, ok, detail }) m.runComposeDeploy("bookstack", dir, map[string]string{}, &AppConfig{Deployed: true}) if !fired { t.Fatal("the deploy ended and nothing was told about it") } if gotName != "bookstack" || !gotOK { t.Fatalf("a successful deploy must report ok for its own app: name=%q ok=%v detail=%q", gotName, gotOK, gotDetail) } }) t.Run("a deploy that fails says so", func(t *testing.T) { m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", "deployed: true\nenv: {}\n") withFakeComposeExit(t, m, 1) var gotOK, fired bool var gotDetail string m.SetDeployDoneHook(func(_ string, ok bool, detail string) { fired, gotOK, gotDetail = true, ok, detail }) m.runComposeDeploy("bookstack", dir, map[string]string{}, &AppConfig{Deployed: true}) if !fired { t.Fatal("a failed deploy must be reported, not be silence — silence is what left a completed-install record for an app that never ran") } if gotOK { t.Fatalf("a failed deploy must not report ok (detail=%q)", gotDetail) } // And the durable record must read not-deployed, which is the fact every surface reads. if cfg := LoadAppConfig(dir); cfg != nil && cfg.Deployed { t.Fatal("a failed deploy must leave the durable record NOT deployed") } }) }