v0.283.0: apps go off-site by themselves (decision 50) with a size warning; a Stop holds during a backup (R-721); page slips (R-724/R-725)
gates / gates (push) Successful in 25s
gates / gates (push) Successful in 25s
Red-proofs RP31-RP38. MinAgent 0.131.0 (unchanged). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user