package stacks import ( "context" "errors" "testing" "time" ) // R-480 — the failed update's sentence goes when the hold it announced is lifted, or the app is gone. func heldFailedUpdate(t *testing.T) (*Manager, *fakeGuards) { t.Helper() m, _, g, _ := newSlice4Manager(t) m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { return false, "crash loop" } if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } st := waitUpdateDone(t, m, "nextcloud") if st.UpdatePhase != UpdatePhaseFailed || st.UpdateError != "HELD-SENTENCE" { t.Fatalf("fixture: a held failure must show the hold sentence while held; phase=%q err=%q", st.UpdatePhase, st.UpdateError) } return m, g } func TestR480_TheFailureSentenceGoesWhenItsHoldIsLifted(t *testing.T) { m, g := heldFailedUpdate(t) g.mu.Lock() g.held, g.holdWhy = false, "" // a successful restore cleared the hold g.mu.Unlock() if st, _ := m.GetStack("nextcloud"); st.UpdateError != "" || st.UpdatePhase != "" { t.Errorf("GetStack after the hold is lifted: phase=%q err=%q — the card would say a running app is stopped", st.UpdatePhase, st.UpdateError) } for _, st := range m.GetStacks() { if st.Name == "nextcloud" && st.UpdateError != "" { t.Errorf("GetStacks after the hold is lifted still carries %q", st.UpdateError) } } } func TestR480_ARemovedAppShowsNoUpdateOutcome(t *testing.T) { m, _ := heldFailedUpdate(t) m.mu.Lock() m.stacks["nextcloud"].Deployed = false m.mu.Unlock() if st, _ := m.GetStack("nextcloud"); st.UpdateError != "" { t.Errorf("a removed app must not carry the held update's sentence, got %q", st.UpdateError) } } func TestR480_AFailureThatHeldNothingKeepsItsSentence(t *testing.T) { m, _, _, c := newSlice4Manager(t) c.fail["pull"] = errors.New("manifest unknown") if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } st := waitUpdateDone(t, m, "nextcloud") if st.UpdateError != MsgUpdatePullFailed || st.UpdatePhase != UpdatePhaseFailed { t.Errorf("a pull failure held nothing and its sentence is still true; phase=%q err=%q", st.UpdatePhase, st.UpdateError) } } // R-478 — a copy older than THIS install's deploy does not carry the update. func runWithDeployedAt(t *testing.T, deployedAgo time.Duration) *fakeGuards { t.Helper() m, _, g, _ := newSlice4Manager(t) m.mu.Lock() if m.stacks["nextcloud"].AppConfig == nil { m.stacks["nextcloud"].AppConfig = &AppConfig{} } m.stacks["nextcloud"].AppConfig.DeployedAt = slice4T0.Add(-deployedAgo).Format(time.RFC3339) m.mu.Unlock() g.points = []UpdateRestorePoint{{Tier: UpdateTierLocal, ProvenAt: slice4T0.Add(-2 * time.Hour)}} g.pointsAfterBackup = []UpdateRestorePoint{{Tier: UpdateTierLocal, ProvenAt: slice4T0.Add(-time.Minute)}} if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } if st := waitUpdateDone(t, m, "nextcloud"); st.UpdatePhase != UpdatePhaseDone { t.Fatalf("phase=%q err=%q", st.UpdatePhase, st.UpdateError) } return g } func TestR478_ACopyOlderThanThisInstallDoesNotCount(t *testing.T) { if g := runWithDeployedAt(t, 10*time.Minute); !hasGuardCall(g, "BackupNow") { t.Errorf("a 2-hour-old unit under a 10-minute-old install belongs to the previous install — back up first; calls=%v", g.callList()) } // Control: the same unit under a 3-hour-old install is this install's own copy. if g := runWithDeployedAt(t, 3*time.Hour); hasGuardCall(g, "BackupNow") { t.Errorf("control: a copy newer than the deploy must carry the update with no backup; calls=%v", g.callList()) } }