Files
admin 6be6c53e29
gates / gates (push) Successful in 25s
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)
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
2026-09-30 10:31:55 +02:00

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