bef39598d0
gates / gates (push) Successful in 24s
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
170 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|