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) } } }