Files
felhom-controller/controller/internal/stacks/update_events_test.go
T
admin bc278944a3
gates / gates (push) Successful in 25s
controller v0.264.0: the household is told when an update is undone or held, in its language
app_update_undone / app_update_held events (09 decision 15), on by
default and seeded once on existing boxes; R-606 update sentences as
key+args rendered per reader; R-646 startup applied-meta backfill for
apps current with the catalog; R-620 a disabled notifier WARNs once per
event type. Needs hub v0.120.0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 13:51:21 +02:00

189 lines
8.1 KiB
Go

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