Files
felhom-controller/controller/internal/notify/deadapp_test.go
T
admin bef39598d0
gates / gates (push) Successful in 24s
v0.256.0: the box sends its own sentence in the household's language (R-558 Part B)
MinAgent: 0.131.0 (unchanged). Needs hub v0.118.0+, which shipped first and
tolerates a box that sends none of this - every box in the fleet is that box
until this release reaches it.

The hub writes a household's e-mails in their language now, but about a third
of those mails carry a sentence the BOX composed, naming a drive, an app or a
number. The hub cannot translate one. So the box sends it twice.

- message_customer on POST /api/v1/event, omitempty. A HUNGARIAN household
  sends nothing extra at all, so its payload stays byte-for-byte what every box
  sends today and the hub's fallback path keeps being the one production
  exercises rather than a branch nobody takes.
- 19 producers render both sentences from ONE bundle key. `message` stays
  Hungarian always: it is what the operator is mailed and what the hub logs.
- customer.language bootstraps a new box - stored choice, then config, then
  Hungarian. The config value is NEVER written into settings.json: that would
  record a choice the household never made.

The Hungarian did not move, measured twice: the wire golden from the slice-2
base commit, and the Go parity gate over all 19 new keys.

Three guards had to learn the change and one caught me: the test seam now
carries the new field; the R-329 severity register reported two dynamic sites
as no longer existing the moment they moved off PushEvent (the walk now checks
36 severity literals, up from 20); and TestConfigLanguageIsWiredInMain reads
main.go, because cmd/ is gitignored and ripgrep does not.

A mistake, named: the first pass dropped displayName from three producers,
which would have mailed customers "Alkalmazás telepítve: %!s(MISSING)". Caught
reading the diff; now pinned by a test that refuses %!/MISSING/%s/%d in either
language.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 16:54:20 +02:00

106 lines
4.3 KiB
Go

package notify
import (
"io"
"log"
"testing"
)
// fix-3: app_start_failed fires ONCE per running→down transition. down→down cycles are silent (the
// anti-spam guarantee); down→running clears the tracker so a later re-failure re-notifies.
func TestNotifyAppStartFailures_OneEventPerTransition(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
var events []string
n.pushFn = func(eventType, _, msg, _ string, _ interface{}) {
if eventType == "app_start_failed" {
events = append(events, msg)
}
}
up := []AppRunState{{Name: "radarr", DisplayName: "Radarr", Down: false}}
down := []AppRunState{{Name: "radarr", DisplayName: "Radarr", Down: true}}
// running → down: exactly one event.
n.NotifyAppStartFailures(up)
n.NotifyAppStartFailures(down)
if len(events) != 1 {
t.Fatalf("running→down must fire exactly one event, got %d: %v", len(events), events)
}
// down → down (two more cycles): SILENT (companion: drop the n.appDown tracking → fires each cycle → fail).
n.NotifyAppStartFailures(down)
n.NotifyAppStartFailures(down)
if len(events) != 1 {
t.Fatalf("down→down must be silent, got %d events: %v", len(events), events)
}
// down → running → down: a fresh transition re-notifies.
n.NotifyAppStartFailures(up)
n.NotifyAppStartFailures(down)
if len(events) != 2 {
t.Fatalf("a fresh down transition must re-notify, got %d: %v", len(events), events)
}
}
// An app that is down on the FIRST evaluation (the F11 dead-at-boot case, after the boot grace) still
// fires — it is a running→down transition from the tracker's empty initial state.
func TestNotifyAppStartFailures_FirstSeenDownFires(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
var count int
n.pushFn = func(eventType, _, _, _ string, _ interface{}) {
if eventType == "app_start_failed" {
count++
}
}
n.NotifyAppStartFailures([]AppRunState{{Name: "jellyfin", DisplayName: "Jellyfin", Down: true}})
if count != 1 {
t.Fatalf("a first-seen dead app (dead-at-boot) must fire once, got %d", count)
}
}
// Group C (Scenario C, v0.164.0) — stop → start → crash stays correct. The stop is reported as
// Down=false (classifyRunStates suppresses StateStopped — proven in package main's
// TestClassifyRunStates_StoppedIsSuppressed), so it fires NOTHING and leaves the tracker clean; the
// later crash (StateExited → Down=true) is then a clean false→true transition → exactly ONE event.
//
// COMPANION red-proof: flip the stop cycle to Down=true (as bare IsDownState would report before the
// suppression) → the stop becomes a running→down transition and fires here, so the "zero after the
// stop" assertion below fails. (Verified by hand-editing the fixture.)
func TestNotifyAppStartFailures_StopStartCrashSequence(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
var count int
n.pushFn = func(eventType, _, _, _ string, _ interface{}) {
if eventType == "app_start_failed" {
count++
}
}
app := "immich"
stopped := []AppRunState{{Name: app, DisplayName: "Immich", Down: false}} // user stop: StateStopped ⇒ Down=false
running := []AppRunState{{Name: app, DisplayName: "Immich", Down: false}} // started again
exited := []AppRunState{{Name: app, DisplayName: "Immich", Down: true}} // then it crashes: StateExited
// The deliberate stop must be SILENT — the whole point of the suppression.
n.NotifyAppStartFailures(stopped)
if count != 0 {
t.Fatalf("a deliberate stop must fire no event, got %d", count)
}
// Start, then crash → exactly ONE app_start_failed for the final false→true transition.
n.NotifyAppStartFailures(running)
n.NotifyAppStartFailures(exited)
if count != 1 {
t.Fatalf("stop→start→crash must fire exactly one event for the crash, got %d", count)
}
}
// A deployed app that is up never fires.
func TestNotifyAppStartFailures_HealthyNeverFires(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
var count int
n.pushFn = func(string, string, string, string, interface{}) { count++ }
n.NotifyAppStartFailures([]AppRunState{{Name: "radarr", Down: false}})
n.NotifyAppStartFailures([]AppRunState{{Name: "radarr", Down: false}})
if count != 0 {
t.Fatalf("a healthy app must never fire, got %d", count)
}
}