package stacks import ( "os" "path/filepath" "sync" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) // v0.264.0 — the update's outcome is TOLD (`09` §3 decision 15): one event per undone update, one per // held update, handed to the sink main.go wires to the notifier. And R-646 / R-606 on the stacks side. type eventRec struct { mu sync.Mutex evs []UpdateEvent } func (r *eventRec) sink(ev UpdateEvent) { r.mu.Lock(); r.evs = append(r.evs, ev); r.mu.Unlock() } func (r *eventRec) list() []UpdateEvent { r.mu.Lock() defer r.mu.Unlock() return append([]UpdateEvent(nil), r.evs...) } // COMPANION RED-PROOF (REPORT.md): delete the emitUpdateEvent call from tryUndo — no event is sent, // and this test fails on the count. func TestUpdateEvents_AnUndoneUpdateIsToldOnce(t *testing.T) { m, _, _, _, _ := newUndoManager(t) rec := &eventRec{} m.SetUpdateEventSink(rec.sink) if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } if st := waitUpdateDone(t, m, "nextcloud"); st.UpdatePhase != UpdatePhaseUndone { t.Fatalf("setup: phase %q", st.UpdatePhase) } evs := rec.list() if len(evs) != 1 || evs[0].Kind != UpdateEventUndone || evs[0].App != "nextcloud" { t.Fatalf("an undone update must produce exactly ONE %s event, got %+v", UpdateEventUndone, evs) } if evs[0].From["web"] != "nextcloud:31.0.14-apache" || evs[0].To["web"] != "nextcloud:34.0.1-apache" { t.Errorf("the event must name the step (from → to), got %+v → %+v", evs[0].From, evs[0].To) } } // COMPANION RED-PROOF (REPORT.md): delete the emitUpdateEvent call from failAndHold — a held update // sends nothing, and this test fails on the count. func TestUpdateEvents_AHeldUpdateIsToldOnce(t *testing.T) { m, _, _, _, fc := newUndoManager(t) fc.cutOff = true // the undo fails → HOLD rec := &eventRec{} m.SetUpdateEventSink(rec.sink) if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } waitUpdateDone(t, m, "nextcloud") evs := rec.list() if len(evs) != 1 || evs[0].Kind != UpdateEventHeld || !evs[0].HoldRecorded { t.Fatalf("a held update must produce exactly ONE recorded %s event, got %+v", UpdateEventHeld, evs) } if evs[0].CopyTier != UpdateTierSecondDrive || evs[0].CopyDate.IsZero() { t.Errorf("the held event must name the copy the hold points at, got tier %d date %v", evs[0].CopyTier, evs[0].CopyDate) } } // A hold that could not be SAVED still tells the operator — that is exactly what they must hear. func TestUpdateEvents_AnUnsavedHoldIsStillTold(t *testing.T) { m, _, g, _, fc := newUndoManager(t) fc.cutOff = true g.holdErr = os.ErrPermission rec := &eventRec{} m.SetUpdateEventSink(rec.sink) if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } st := waitUpdateDone(t, m, "nextcloud") evs := rec.list() if len(evs) != 1 || evs[0].Kind != UpdateEventHeld || evs[0].HoldRecorded { t.Fatalf("an unsaved hold must still send ONE held event, marked unrecorded; got %+v", evs) } if st.UpdateError != MsgUpdateHoldUnsaved || st.UpdateErrorKey != "update.error.hold_unsaved" { t.Errorf("err=%q key=%q", st.UpdateError, st.UpdateErrorKey) } } // R-646. COMPANION RED-PROOF (REPORT.md): make BackfillAppliedMeta skip the storeAppliedMeta call — // the current app then has no record and this test fails. func TestR646_BackfillRecordsOnlyAppsCurrentWithTheCatalog(t *testing.T) { m, dir, _, _, _ := newUndoManager(t) // nextcloud: pinned 31, catalog 34 → Behind if err := os.RemoveAll(filepath.Join(dir, appliedMetaDir)); err != nil { t.Fatal(err) } // A second app, CURRENT: installed == catalog. cur := filepath.Join(m.cfg.Paths.StacksDir, "vikunja") if err := os.MkdirAll(cur, 0o755); err != nil { t.Fatal(err) } mustWrite(t, filepath.Join(cur, "docker-compose.yml"), "services:\n web:\n image: vikunja/vikunja:2.3.0\n") mustWrite(t, filepath.Join(cur, ".felhom.yml"), undoMetaOld) mustWrite(t, filepath.Join(cur, "app.yaml"), "deployed: true\nenv: {}\npinned_images:\n web: vikunja/vikunja:2.3.0\n") m.mu.Lock() m.stacks["vikunja"] = &Stack{Name: "vikunja", Deployed: true, ComposePath: filepath.Join(cur, "docker-compose.yml"), AppConfig: LoadAppConfig(cur), CatalogImages: map[string]string{"web": "vikunja/vikunja:2.3.0"}} m.stacks["vikunja"].AppConfig.InstalledImages = map[string]InstalledImage{"web": {Ref: "vikunja/vikunja:2.3.0"}} m.stacks["nextcloud"].CatalogImages = map[string]string{"web": "nextcloud:34.0.1-apache"} m.stacks["nextcloud"].AppConfig.InstalledImages = map[string]InstalledImage{"web": {Ref: "nextcloud:31.0.14-apache"}} m.mu.Unlock() recorded, skipped := m.BackfillAppliedMeta() if len(recorded) != 1 || recorded[0] != "vikunja" || len(skipped) != 1 || skipped[0] != "nextcloud" { t.Fatalf("recorded=%v skipped=%v — want the current app recorded and the behind app skipped by name", recorded, skipped) } if _, err := os.Stat(filepath.Join(cur, appliedMetaDir, ".felhom.yml")); err != nil { t.Errorf("the current app must have its record: %v", err) } if _, err := os.Stat(filepath.Join(dir, appliedMetaDir, ".felhom.yml")); err == nil { t.Error("a BEHIND app must get no record — its pinned version's file is gone, and guessing it is worse") } // Idempotent, and never overwrites. mustWrite(t, filepath.Join(cur, appliedMetaDir, ".felhom.yml"), "marker: kept\n") if rec2, _ := m.BackfillAppliedMeta(); len(rec2) != 0 { t.Errorf("a second pass must record nothing, recorded %v", rec2) } if b, _ := os.ReadFile(filepath.Join(cur, appliedMetaDir, ".felhom.yml")); string(b) != "marker: kept\n" { t.Errorf("an existing record must never be overwritten, got %q", b) } } // R-606 — the Hungarian is byte-identical to the literals the keys replaced, and the other language is // really the other language. func TestR606_UpdateSentencesHungarianUnchangedAndTranslated(t *testing.T) { for key, lit := range map[string]string{ "update.error.interrupted": MsgUpdateInterrupted, "update.error.pull_failed": MsgUpdatePullFailed, "update.error.pin_failed": MsgUpdatePinFailed, "update.error.journal_failed": MsgUpdateJournalFailed, "update.error.backup_no_unit": MsgUpdateBackupNoUnit, "update.error.hold_unsaved": MsgUpdateHoldUnsaved, "update.error.no_guards": MsgUpdateNoGuards, "update.error.backup_failed": MsgUpdateBackupFailFmt, "update.error.dump_failed": MsgUpdateDumpFailFmt, "update.refusal.busy": MsgUpdateBusy, "update.refusal.disk": MsgUpdateDiskFmt, "update.refusal.no_backup": MsgUpdateNoBackupFmt, } { b, _ := i18n.Shared() if got := b.Msg(i18n.Default, key); got != lit { t.Errorf("%s: Hungarian changed:\n got %q\nwant %q", key, got, lit) } if en := b.Msg("en", key); en == "" || en == lit { t.Errorf("%s: no English", key) } } for ph, lbl := range updatePhaseLabels { if got := UpdatePhaseLabelIn(i18n.Default, ph); got != lbl { t.Errorf("phase %s: Hungarian label %q, want %q", ph, got, lbl) } if en := UpdatePhaseLabelIn("en", ph); en == lbl { t.Errorf("phase %s: no English label", ph) } } } // R-606 — a stored update sentence renders in the reader's language; an old stored one (no key) renders // as stored, never empty. // // COMPANION RED-PROOF (REPORT.md): make UpdateErrorIn return s.UpdateError always — the English // assertion fails. func TestR606_UpdateErrorRendersInTheReadersLanguage(t *testing.T) { m, _, _, _, _ := newUndoManager(t) m.finishUpdateKey("nextcloud", UpdatePhaseFailed, "update.error.backup_failed", "", "disk full") st, _ := m.GetStack("nextcloud") if st.UpdateError != util.Text(i18n.Default, "update.error.backup_failed", "disk full") { t.Errorf("UpdateError must stay the Hungarian, got %q", st.UpdateError) } en := st.UpdateErrorIn("en") if en != "The update did not start, because the backup before the update did not succeed: disk full. The app keeps running unchanged." { t.Errorf("English = %q", en) } old := Stack{UpdateError: "egy régi, kulcs nélküli mondat"} if old.UpdateErrorIn("en") != "egy régi, kulcs nélküli mondat" { t.Error("a sentence stored by an older version must render as stored, never as an empty line") } }