Files
felhom-controller/controller/cmd/controller/r521_drive_lost_test.go
T
admin 800b32ceec R-521: an app a lost drive stopped no longer mails app_start_failed one by one
One unplugged drive sent storage_disconnected plus an app_start_failed per app on it (five operator
mails). The dead-app job now hands the notifier the apps of a disconnected drive (its recorded
StoppedStacks plus apps whose HDD_PATH is that drive) as not down; the storage event already names
them. The dashboard's dead list is unchanged. The hub-side cooldown (F6/F7) and a household mail
for a lost drive are not in this change.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00

95 lines
3.3 KiB
Go

package main
import (
"go/ast"
"io"
"log"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
type fakeHDD struct {
sts []stacks.Stack
hdd map[string]string
}
func (f fakeHDD) GetStacks() []stacks.Stack { return f.sts }
func (f fakeHDD) LoadAppConfigByName(n string) *stacks.AppConfig {
return &stacks.AppConfig{Env: map[string]string{"HDD_PATH": f.hdd[n]}}
}
// R-521: one unplugged drive mailed the operator five times — storage_disconnected plus an
// app_start_failed per app on it. The consequence asserted: the NOTIFIER is told the drive's apps are
// not down (no per-app mail), while the dashboard's dead list still carries them, and an app on
// another drive that is genuinely down still alarms.
func TestR521_DriveLostAppsDoNotMailOneByOne(t *testing.T) {
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
for _, p := range []string{"/mnt/felhom-drives/usb", "/mnt/felhom-drives/other"} {
if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: p, Schedulable: true}); err != nil {
t.Fatal(err)
}
}
if err := sett.SetDisconnected("/mnt/felhom-drives/usb", true, []string{"immich"}); err != nil {
t.Fatal(err)
}
sts := []stacks.Stack{
stack("immich", stacks.StateExited, true, false), // recorded by the disconnect
stack("paperless", stacks.StateExited, true, false), // on the lost drive by HDD_PATH
stack("romm", stacks.StateExited, true, false), // another drive, genuinely broken
}
for i := range sts {
sts[i].AppConfig = &stacks.AppConfig{DesiredState: stacks.DesiredStateRunning}
}
mgr := fakeHDD{sts: sts, hdd: map[string]string{"immich": "/mnt/felhom-drives/usb", "paperless": "/mnt/felhom-drives/usb", "romm": "/mnt/felhom-drives/other"}}
dead, states := classifyRunStates(sts, nil, nil, time.Now())
notified := downByName(maskDriveLost(states, driveLostApps(sett, mgr)))
if notified["immich"] || notified["paperless"] {
t.Fatalf("R-521: an app stopped by the lost drive is still reported down to the notifier: %v", notified)
}
if !notified["romm"] {
t.Fatal("a genuinely broken app on another drive stopped alarming — over-correction")
}
if dn := deadNames(dead); !dn["immich"] || !dn["paperless"] {
t.Fatalf("the dashboard's dead list must still carry the drive's apps: %v", dn)
}
// Reconnected: nothing is masked any more.
if err := sett.SetDisconnected("/mnt/felhom-drives/usb", false, nil); err != nil {
t.Fatal(err)
}
if got := driveLostApps(sett, mgr); len(got) != 0 {
t.Fatalf("with no drive disconnected nothing may be masked, got %v", got)
}
}
// The wiring: the dead-app job hands the notifier the masked states.
func TestR521_DeadAppJobMasksDriveLost(t *testing.T) {
found := false
ast.Inspect(mainBody(t), func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "NotifyAppStartFailures" || len(call.Args) != 1 {
return true
}
if inner, ok := call.Args[0].(*ast.CallExpr); ok {
if id, ok := inner.Fun.(*ast.Ident); ok && id.Name == "maskDriveLost" {
found = true
}
}
return true
})
if !found {
t.Fatal("R-521: main calls NotifyAppStartFailures without maskDriveLost")
}
}