6be6c53e29
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
82 lines
3.0 KiB
Go
82 lines
3.0 KiB
Go
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")
|
|
}
|
|
}
|