Files
felhom-controller/controller/internal/notify/message_customer_test.go
T
admin e81ad613ae
gates / gates (push) Successful in 25s
v0.256.1: the push log says whether a household sentence was attached (R-558)
Found while trying to PROVE v0.256.0 on a live box: there was nothing to look
at. The box logs the Hungarian message only, the hub logs the Hungarian message
only, and the payload is inside TLS - so "did the box attach a second
sentence?" was unanswerable from either end. That is the fork in the diagnosis
when an English household reports a Hungarian mail.

  [INFO] Event pushed: app_deployed (info) [+household(en)] — Alkalmazás ...
  [INFO] Event pushed: app_deployed (info) [hu-only] — Alkalmazás ...

The sentence itself is not logged: it is the same sentence twice and one copy
is already on the line. Pinned in both languages.

A feature whose only failure mode is "the wrong language arrived" needs an
observable that says which branch was taken. Without one, every diagnosis is a
guess.

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

214 lines
8.4 KiB
Go

package notify
import (
"io"
"log"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"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)
}
}
// The push log says WHETHER a household sentence was attached.
//
// It is the only observable an operator has. The hub logs the Hungarian message only, and the
// payload itself is inside TLS — so without this line, "did the box send a second sentence?" is
// unanswerable from either end, which is exactly the question a mis-languaged mail raises.
func TestPushLogNamesWhetherAHouseholdSentenceWasAttached(t *testing.T) {
for _, tc := range []struct {
lang, want, notWant string
}{
{"en", "+household(en)", "hu-only"},
{"hu", "hu-only", "+household"},
} {
var buf strings.Builder
sett, err := settings.Load(filepath.Join(t.TempDir(), "s.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := sett.SetLanguage(tc.lang); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
n := New(srv.URL, "k", "c1", sett, log.New(&buf, "", 0), false)
n.NotifyAppDeployed("privatebin", "PrivateBin")
// PushEvent's send is a goroutine; wait for the line rather than sleeping a fixed time.
for i := 0; i < 200 && !strings.Contains(buf.String(), "Event pushed"); i++ {
time.Sleep(10 * time.Millisecond)
}
got := buf.String()
if !strings.Contains(got, tc.want) {
t.Errorf("%s: the push log does not say %q:\n%s", tc.lang, tc.want, got)
}
if strings.Contains(got, tc.notWant) {
t.Errorf("%s: the push log says %q:\n%s", tc.lang, tc.notWant, got)
}
}
}