package stacks import ( "errors" "os" "path/filepath" "strings" "testing" ) // ── A / A2 — the probe target (R-630) ──────────────────────────────────────────────────────────── // // RED-PROOF: revert findProbeContainerMeta to "exact, else FIRST prefix, else empty" and // TestProbeTarget_ImmichPrefixIsAmbiguous and TestProbeTarget_ExplicitContainer both fail. func running(names ...string) []ContainerInfo { out := make([]ContainerInfo, 0, len(names)) for _, n := range names { out = append(out, ContainerInfo{Name: n, State: StateRunning}) } return out } func TestProbeTarget_ExactNameWins(t *testing.T) { got, cand := findProbeContainerMeta("romm", nil, running("romm-db", "romm", "romm-redis")) if got != "romm" { t.Fatalf("exact match must win: got %q (candidates %v)", got, cand) } } func TestProbeTarget_ExplicitContainer(t *testing.T) { // paperless-ngx: no container is called `paperless-ngx`, and none even has it as a prefix. cs := running("paperless-webserver", "paperless-postgres", "paperless-redis") if got, _ := findProbeContainerMeta("paperless-ngx", nil, cs); got != "" { t.Fatalf("without the explicit field there is nothing to probe, got %q", got) } meta := &Metadata{HealthCheck: &HealthCheckConfig{ Container: "paperless-webserver", Checks: []HealthCheckItem{{Type: "http", Port: 8000}}, }} got, _ := findProbeContainerMeta("paperless-ngx", meta, cs) if got != "paperless-webserver" { t.Fatalf("the explicit container must be used: got %q", got) } } func TestProbeTarget_ImmichPrefixIsAmbiguous(t *testing.T) { // Four `immich-*` containers and no exact match. The OLD rule returned the first one — which is // whichever the container list happened to yield. Ambiguity must resolve to "nothing", with the // candidates named, not to a guess. cs := running("immich-server", "immich-machine-learning", "immich-postgres", "immich-redis") got, cand := findProbeContainerMeta("immich", nil, cs) if got != "" { t.Fatalf("four candidates must not be resolved by guessing: got %q", got) } if len(cand) != 4 { t.Fatalf("the candidates must come back so the log can name them: %v", cand) } meta := &Metadata{HealthCheck: &HealthCheckConfig{Container: "immich-server"}} if got, _ := findProbeContainerMeta("immich", meta, cs); got != "immich-server" { t.Fatalf("the explicit field must settle it: got %q", got) } } func TestProbeTarget_UniquePrefixStillWorks(t *testing.T) { // One running candidate is not ambiguous — the fallback stays useful. got, _ := findProbeContainerMeta("gitea", nil, running("gitea-server")) if got != "gitea-server" { t.Fatalf("a single prefix match must still resolve: got %q", got) } } func TestProbeTarget_StoppedContainersAreNotProbeTargets(t *testing.T) { cs := []ContainerInfo{{Name: "wger", State: StateStopped}} if got, _ := findProbeContainerMeta("wger", nil, cs); got != "" { t.Fatalf("a stopped container is not probeable: got %q", got) } } // ── A — the settle reason (R-630) ──────────────────────────────────────────────────────────────── // // RED-PROOF: this is the sentence that distinguishes "declares no check" from "declares one that // resolves to nothing". Before v0.262.0 the second case never reached a settle at all — it looped // until the health timeout and the app was STOPPED. func TestSettleReason_NamesWhyTheProbeWasNotUsed(t *testing.T) { none := settleReason(Metadata{}) if !strings.Contains(none, "no health check declared") { t.Fatalf("an app with no check must say so: %q", none) } declared := settleReason(Metadata{HealthCheck: &HealthCheckConfig{ Checks: []HealthCheckItem{{Type: "http", Port: 80}}, }}) if !strings.Contains(declared, "no probe container") { t.Fatalf("an app whose check resolves to nothing must say THAT, not the other thing: %q", declared) } if none == declared { t.Fatal("the two reasons must be distinguishable in the journal without reading the log") } } // ── C — remove accepts the half-state (R-634) ──────────────────────────────────────────────────── // // RED-PROOF: delete the halfStateEvidence branch in RemoveStack and this fails — which is exactly // the state three apps were measured in, with no button that worked. func TestHalfStateEvidence(t *testing.T) { m := &Manager{} if ok, _ := m.halfStateEvidence("ghost", &Stack{}); ok { t.Fatal("nothing on disk and no containers is genuinely 'not deployed'") } ok, why := m.halfStateEvidence("outline", &Stack{Containers: running("outline", "outline-postgres")}) if !ok || !strings.Contains(why, "container") { t.Fatalf("containers exist: the household must be able to remove them (%v, %q)", ok, why) } dir := t.TempDir() compose := filepath.Join(dir, "docker-compose.yml") if err := os.WriteFile(compose, []byte("services: {}\n"), 0o644); err != nil { t.Fatal(err) } ok, why = m.halfStateEvidence("sparkyfitness", &Stack{ComposePath: compose}) if !ok || !strings.Contains(why, "compose file") { t.Fatalf("a compose file on disk is something to remove (%v, %q)", ok, why) } if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: x\n"), 0o644); err != nil { t.Fatal(err) } ok, why = m.halfStateEvidence("sparkyfitness", &Stack{ComposePath: compose}) if !ok || !strings.Contains(why, "app.yaml") { t.Fatalf("this is sparkyfitness's exact measured shape (%v, %q)", ok, why) } } // ── F — a remove clears the update record (R-614) ──────────────────────────────────────────────── // // RED-PROOF: drop the ClearUpdateState call from RemoveStack and a redeploy inherits „Frissítve". func TestClearUpdateState(t *testing.T) { m := &Manager{stacks: map[string]*Stack{ "komga": { Updating: true, UpdatePhase: "failed", UpdatePhaseLabel: "A frissítés nem sikerült", UpdateError: "held", updateHeld: true, HealthProbe: &HealthProbeResult{Healthy: false}, }, }} m.ClearUpdateState("komga") s := m.stacks["komga"] if s.Updating || s.UpdatePhase != "" || s.UpdatePhaseLabel != "" || s.UpdateError != "" || s.updateHeld || s.HealthProbe != nil { t.Fatalf("every field the next install could inherit must be cleared: %+v", s) } m.ClearUpdateState("does-not-exist") // must not panic } // ── B — the busy refusal is a CONFLICT, by type (R-633) ────────────────────────────────────────── // // RED-PROOF: return `util.MsgError(KeyRemoveBusy)` instead of `&RemoveBusyError{}` and this fails — // which is what the first live run did, answering **500** while refusing correctly. The status // mapping greps the error TEXT for "not deployed"/"still running"; the busy sentence has neither. func TestRemoveBusyError_IsTypedAndCarriesBothForms(t *testing.T) { var err error = &RemoveBusyError{Why: "a backup or restore is running (single-flight held)"} var busy *RemoveBusyError if !errors.As(err, &busy) { t.Fatal("the API must be able to recognise this by TYPE, not by matching prose") } if busy.Why == "" { t.Error("the guard's own words must survive for the log") } if err.Error() != MsgRemoveBusyHU { t.Errorf("the default-language bytes must be the bundle's Hungarian, got %q", err.Error()) } if busy.Key() != KeyRemoveBusy { t.Errorf("the key must travel so the API can localise it, got %q", busy.Key()) } // The whole point: none of the words the status mapping greps for appear in this sentence, so a // text-matching handler WOULD answer 500. This assertion is why the type exists. for _, w := range []string{"not deployed", "still running", "not found", "protected"} { if strings.Contains(err.Error(), w) { t.Errorf("the sentence happens to contain %q — the text mapping would catch it and this "+ "test would stop proving anything", w) } } }