Files
felhom-controller/controller/internal/aftercrash/aftercrash_test.go
T
admin 8d862e4e69
gates / gates (push) Successful in 52s
v0.311.0: after a power cut, an app repairs the files the cut left empty; the household sees lost photos (R-1007)
New template block after_unclean_boot (decision 236): once per controller start that followed an
UNCLEAN host boot (crashboot's own verdict, after the 15-minute crash grace; a normal or unknown boot
never runs it), each app with the block rebuilds ONLY its zero-byte files (scan: regular files, no
symlinks, its own filesystem; ids appended to the app's own command in batches; one retry, then
reported) and its own lost-file check is read. The household sees lost files (name, date taken) on
the app page until dismissed; the operator gets app_repaired_after_crash with counts only. Skips,
with the reason, an unproven image, an unknown key, an app not running. MinAgent 0.155.0 unchanged.
Tests red-proofed (14 guards). One new parity fixture (the new card only).

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-14 16:47:13 +02:00

74 lines
2.9 KiB
Go

package aftercrash
import (
"context"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-1007 (decision 236): the repair runs ONLY after an unclean host boot that THIS start followed, only after the
// crash grace, and only once. A normal boot and an unknown fact never run it. The verdict is crashboot's own (the
// real Grace with a fake probe), so these tests assert the consequence: was the app's block run?
var start = time.Date(2026, 10, 14, 12, 8, 0, 0, time.UTC)
func probe(f crashboot.Fact, err error) crashboot.Probe {
return func(context.Context) (crashboot.Fact, error) { return f, err }
}
func runner(p crashboot.Probe) (*Runner, *[]string) {
var ran []string
g := crashboot.New(start, p, nil)
r := New(g, func() []string { return []string{"immich"} },
func(name string, _ time.Time) stacks.AfterUncleanBootResult {
ran = append(ran, name)
return stacks.AfterUncleanBootResult{}
},
func(string, time.Time, stacks.AfterUncleanBootResult) {}, nil)
return r, &ran
}
// RED-PROOF: drop the `now.Sub(r.Start) < wait` check → the run happens at +1 min → fails.
// RED-PROOF: drop `r.done = true` before the run → a second tick runs it again → fails.
func TestAfterCrash_RunsOnceAfterTheGraceOnAnUncleanBootThisStartFollowed(t *testing.T) {
r, ran := runner(probe(crashboot.Fact{Known: true, Unclean: true, BootAt: start.Add(-40 * time.Second)}, nil))
for _, at := range []time.Duration{0, time.Minute, 14 * time.Minute} {
r.Tick(start.Add(at))
}
if len(*ran) != 0 {
t.Fatalf("ran inside the crash grace: %v", *ran)
}
r.Tick(start.Add(crashboot.CrashGrace + time.Second))
r.Tick(start.Add(crashboot.CrashGrace + time.Minute))
if len(*ran) != 1 {
t.Fatalf("ran %d time(s), want exactly once after the grace", len(*ran))
}
}
// RED-PROOF: in Tick, treat `!crash` like a crash (drop the early return) → a normal boot runs the repair → fails.
func TestAfterCrash_NeverOnANormalAnUnknownOrAnOldUncleanBoot(t *testing.T) {
cases := map[string]crashboot.Probe{
"normal boot": probe(crashboot.Fact{Known: true, Unclean: false, BootAt: start.Add(-time.Minute)}, nil),
"unknown (no crash guard)": probe(crashboot.Fact{}, nil),
"unknown (agent unreachable)": probe(crashboot.Fact{}, context.DeadlineExceeded),
"no agent at all": nil,
"an unclean boot days ago": probe(crashboot.Fact{Known: true, Unclean: true, BootAt: start.Add(-72 * time.Hour)}, nil),
"an unclean boot with no time": probe(crashboot.Fact{Known: true, Unclean: true}, nil),
}
for name, p := range cases {
r, ran := runner(p)
for at := time.Duration(0); at <= 2*crashboot.CrashGrace; at += 30 * time.Second {
r.Tick(start.Add(at))
}
if len(*ran) != 0 {
t.Errorf("%s: the repair ran", name)
}
if !r.Done() {
t.Errorf("%s: the decision was never taken", name)
}
}
}