package backup import ( "context" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // Decision 50 / R-95, measured 2026-09-30: over the quota the box deletes NOTHING beyond the ruled retention. // The consequence asserted: the forget an over-quota run executes carries EXACTLY the retention arguments of a // normal run — no stricter keep, no --keep-last, no extra forget. (If someone "helpfully" made the over-quota // path prune harder to get the household back under quota, it would delete history nobody chose to delete.) // COMPANION RED-PROOF: change offboxPruneOnly's "--keep-daily", "7" to "1" → this test fails with the two // argument lists printed. func TestDecision50_OverQuotaDeletesNothingExtra(t *testing.T) { forgetArgs := func(over bool) []string { m, sett := newOffboxManager(t) _ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.QuotaGB = 50 if over { o.RepoSizeBytes = 51 << 30 } else { o.RepoSizeBytes = 1 << 30 } }) var got []string n := 0 m.SetOffboxSizer(func(string) int64 { return 0 }) m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { switch { case contains(args, "cat") && contains(args, "config"): return []byte(`{}`), nil case contains(args, "forget"): n++ for i, a := range args { if a == "forget" { got = append([]string{}, args[i:]...) } } case contains(args, "stats"): return []byte(`{"total_size":123}`), nil case contains(args, "snapshots"): return []byte(`[]`), nil } return nil, nil }) _ = m.RunOffboxBackup(context.Background()) if n != 1 { t.Fatalf("over=%v: expected exactly one forget, got %d", over, n) } return got } normal, over := forgetArgs(false), forgetArgs(true) if strings.Join(normal, " ") != strings.Join(over, " ") { t.Fatalf("the over-quota forget differs from the normal retention — it would delete history nobody chose to delete:\n normal: %v\n over: %v", normal, over) } } // The size rule: sum against the quota, largest first; no quota → always fits. // COMPANION RED-PROOF: compare with < instead of <= (or drop the QuotaBytes<=0 arm) → a case fails. func TestDecision50_EstimateOffsiteFit(t *testing.T) { sizes := map[string]int64{"immich": 80 << 30, "nextcloud": 30 << 30, "bookstack": 1 << 20} sizeOf := func(s string) int64 { return sizes[s] } now := time.Now() f := estimateOffsiteFit([]string{"bookstack", "immich", "nextcloud"}, sizeOf, 100, now) if f.Fits { t.Fatalf("110 GiB into 100 GiB reported as fitting: %+v", f) } if f.Apps[0].Stack != "immich" || f.Apps[1].Stack != "nextcloud" { t.Fatalf("largest first expected: %+v", f.Apps) } if g := estimateOffsiteFit([]string{"immich"}, func(string) int64 { return 100 << 30 }, 100, now); !g.Fits { t.Fatal("exactly the quota must fit") } if g := estimateOffsiteFit([]string{"immich"}, sizeOf, 0, now); !g.Fits { t.Fatal("no quota (own NAS / dedicated box) must always fit") } }