Files
felhom-controller/controller/internal/notify/message_customer_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

170 lines
6.9 KiB
Go

package notify
import (
"io"
"log"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Slice 3 Part B (R-558) — the box sends its own sentence twice, and the Hungarian one never moves.
//
// The hub cannot translate a sentence the box composed: it arrives as finished text naming a drive,
// an app or a number. These tests are the whole of the controller's half of that.
func notifierInLang(t *testing.T, lang string) *Notifier {
t.Helper()
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if lang != "" {
if err := sett.SetLanguage(lang); err != nil {
t.Fatal(err)
}
}
return &Notifier{settings: sett, logger: log.New(io.Discard, "", 0)}
}
// converted lists every producer this release converted to a bundle key, with a call that exercises
// it. It is the table the two tests below share, so a producer cannot be covered by one and missed
// by the other.
func convertedProducers() []struct {
name string
call func(*Notifier)
} {
return []struct {
name string
call func(*Notifier)
}{
// NotifyHealthChange compares against the PREVIOUS status it saw, so each case primes it
// with a first call (which records and returns) and then transitions.
{"health_critical", func(n *Notifier) {
n.enabled = true
n.NotifyHealthChange("ok", nil, nil)
n.NotifyHealthChange("fail", nil, nil)
}},
{"health_degraded", func(n *Notifier) {
n.enabled = true
n.NotifyHealthChange("ok", nil, nil)
n.NotifyHealthChange("warn", nil, nil)
}},
{"health_recovered", func(n *Notifier) {
n.enabled = true
n.NotifyHealthChange("fail", nil, nil)
n.NotifyHealthChange("ok", nil, nil)
}},
{"db_dump_completed", func(n *Notifier) { n.NotifyDBDumpCompleted(DBDumpDetails{}) }},
{"controller_updated", func(n *Notifier) { n.NotifyControllerUpdated("0.255.0", "0.256.0", true) }},
{"controller_update_failed", func(n *Notifier) { n.NotifyControllerUpdated("0.255.0", "0.256.0", false) }},
{"controller_started", func(n *Notifier) { n.NotifyControllerStarted("0.256.0", nil) }},
{"storage_disconnected", func(n *Notifier) { n.NotifyStorageDisconnected("Kulso HDD", nil) }},
{"storage_reconnected", func(n *Notifier) { n.NotifyStorageReconnected("Kulso HDD") }},
{"backup_target_absent", func(n *Notifier) { n.NotifyBackupTargetAbsent("Kulso HDD", "/mnt/hdd_1") }},
{"backup_target_restored", func(n *Notifier) { n.NotifyBackupTargetRestored("Kulso HDD", "/mnt/hdd_1") }},
{"app_deployed", func(n *Notifier) { n.NotifyAppDeployed("privatebin", "PrivateBin") }},
{"app_deploy_started", func(n *Notifier) { n.NotifyAppDeployStarted("privatebin", "PrivateBin") }},
{"app_deploy_failed", func(n *Notifier) { n.NotifyAppDeployFailed("privatebin", "PrivateBin", "") }},
{"app_removed", func(n *Notifier) { n.NotifyAppRemoved("privatebin", "PrivateBin") }},
{"crossdrive_completed", func(n *Notifier) { n.NotifyCrossDriveCompleted(CrossDriveDetails{StackName: "privatebin"}) }},
{"crossdrive_failed", func(n *Notifier) { n.NotifyCrossDriveFailed(CrossDriveDetails{StackName: "privatebin"}) }},
{"disaster_recovery_started", func(n *Notifier) { n.NotifyDRStarted(3) }},
{"disaster_recovery_completed", func(n *Notifier) { n.NotifyDRCompleted(7, 2) }},
}
}
// An ENGLISH household gets an English second sentence, and the Hungarian one is untouched.
func TestNotifierSendsMessageCustomerForAnEnglishHousehold(t *testing.T) {
for _, tc := range convertedProducers() {
t.Run(tc.name, func(t *testing.T) {
n := notifierInLang(t, "en")
var hu, en string
var seen int
n.pushFn = func(_, _, message, messageCustomer string, _ interface{}) {
hu, en, seen = message, messageCustomer, seen+1
}
tc.call(n)
if seen == 0 {
t.Fatal("the producer pushed nothing — nothing below was compared")
}
if en == "" {
t.Fatalf("no household sentence was sent; the English household's mail would carry "+
"the Hungarian %q", hu)
}
if en == hu {
t.Errorf("the household sentence is identical to the Hungarian one: %q", en)
}
// A rendering fault is silent in a mail body — %!s(MISSING) is a real thing customers
// have been sent by projects that did not check this.
for _, bad := range []string{"%!", "MISSING", "EXTRA", "%s", "%d"} {
if strings.Contains(en, bad) || strings.Contains(hu, bad) {
t.Errorf("a sentence carries a formatting fault (%s):\n hu %q\n en %q", bad, hu, en)
}
}
})
}
}
// A HUNGARIAN household sends NO second sentence at all.
//
// That is not an optimisation: it keeps the payload of every box in the fleet byte-for-byte what it
// is today, so the hub's fallback path — the one every existing box uses — stays the path that is
// actually exercised in production rather than becoming a branch nobody takes.
func TestHungarianHouseholdSendsNoSecondSentence(t *testing.T) {
for _, tc := range convertedProducers() {
t.Run(tc.name, func(t *testing.T) {
n := notifierInLang(t, "hu")
var hu, en string
n.pushFn = func(_, _, message, messageCustomer string, _ interface{}) { hu, en = message, messageCustomer }
tc.call(n)
if en != "" {
t.Errorf("a Hungarian household sent a second sentence %q — the payload is no longer "+
"what every box in the fleet sends today", en)
}
if hu == "" {
t.Error("the Hungarian sentence is empty")
}
})
}
}
// A notifier with NO settings at all (setup mode, and several older tests) is Hungarian rather than
// a panic. The nil-prefs panic this project already fixed once is the same shape.
func TestNotifierWithoutSettingsIsHungarian(t *testing.T) {
n := &Notifier{logger: log.New(io.Discard, "", 0)}
var hu, en string
n.pushFn = func(_, _, message, messageCustomer string, _ interface{}) { hu, en = message, messageCustomer }
n.NotifyAppDeployed("privatebin", "PrivateBin")
if en != "" {
t.Errorf("a notifier with no settings sent a household sentence: %q", en)
}
b, err := i18n.Shared()
if err != nil {
t.Fatal(err)
}
if hu != b.Msgf("hu", "event.app_deployed", "PrivateBin") {
t.Errorf("the Hungarian sentence is not the bundle's: %q", hu)
}
}
// The untranslatable tail is appended to BOTH renderings — an English household gets an English
// sentence with a foreign tail, never no sentence at all.
func TestUntranslatableTailIsKeptInBothLanguages(t *testing.T) {
n := notifierInLang(t, "en")
var hu, en string
n.pushFn = func(_, _, message, messageCustomer string, _ interface{}) { hu, en = message, messageCustomer }
n.NotifyAppDeployFailed("privatebin", "PrivateBin", "exit status 1")
for _, s := range []string{hu, en} {
if !strings.HasSuffix(s, " — exit status 1") {
t.Errorf("the untranslatable tail is missing from %q", s)
}
}
if !strings.Contains(en, "PrivateBin") || en == hu {
t.Errorf("the English sentence is wrong: %q (hu %q)", en, hu)
}
}