Files
felhom-controller/controller/internal/web/i18n_flash_test.go
T
admin e4774e6a06 R-516 items 7-10: one banner per drive, local times, te-form, health banners in the household's language
- item 7: the storage page shows the disconnect time in local time (fmtTimeStr), not the raw RFC3339 UTC.
- item 8: a disconnected drive no longer has two banners - the health check's warning for it is dropped
  when the dedicated alert.storage.disconnected banner was built (it stays on the wire).
- item 9: alert.deadapp.group says "nezd meg" (te-form); formal ceiling 14 -> 13.
- item 10: the disk/memory/CPU/temperature health banners show the dashboard's own sentence
  (health.* keys, hu + en) via HealthReport.WarningMsgs/IssueMsgs; the wire text is unchanged.
  checkResources split out of RunHealthCheck as the test seam.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:01:12 +02:00

220 lines
9.0 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// Localisation slice 2 (R-557), scenario S1 — a flash follows the language of the request that READS
// it, and a link minted by an older controller still reads correctly.
//
// Why this is not obvious: a flash does not travel in the response that produced it. The handler
// redirects, the browser asks for the destination, and a SECOND request renders the line. Before
// v0.252.0 the sentence itself rode in the query string, so it was written in the language of the
// handler that redirected — which is the household's language only by luck. The value is now a bundle
// key; the reader renders it.
//
// The compatibility half is the load-bearing one. A customer's open tab, a bookmark, the browser's
// back-forward cache and a mail client can all replay a URL minted by 0.251.0, whose `flash` is
// Hungarian prose. Such a value is shown verbatim, never as a raw key and never dropped.
func flashServer(t *testing.T) *Server {
t.Helper()
b, err := i18n.Load()
if err != nil {
t.Fatalf("bundle: %v", err)
}
return &Server{i18n: b}
}
func flashRequest(t *testing.T, rawQuery string) *http.Request {
t.Helper()
u, err := url.Parse("/launcher?" + rawQuery)
if err != nil {
t.Fatalf("query %q: %v", rawQuery, err)
}
return httptest.NewRequest(http.MethodGet, u.String(), nil)
}
func TestFlashKeyRoundTrip(t *testing.T) {
s := flashServer(t)
// The writer's side: launcherShareRedirect builds this.
q := flashQuery("flash", "flash.share.enabled")
if got := flashRequest(t, q).URL.Query().Get("flash"); got != "flash.share.enabled" {
t.Fatalf("the key did not survive the URL: %q", got)
}
cases := []struct {
name, query, lang, want string
}{
{
name: "hungarian reads the Hungarian sentence, byte for byte",
query: q, lang: "hu",
want: "A megosztás bekapcsolva.",
},
{
name: "english reads English",
query: q, lang: "en",
want: "Sharing is on.",
},
{
// A URL minted by v0.251.0 or earlier. `+` is a space in a query string.
name: "a legacy link carries prose and is shown verbatim",
query: "flash=Sikeres+ment%C3%A9s", lang: "en",
want: "Sikeres mentés",
},
{
name: "an unknown key-shaped value is not invented into a sentence",
query: "flash=flash.share.no_such_key", lang: "en",
want: "flash.share.no_such_key",
},
{
// The escaping case. flashText returns the value; html/template escapes it at render, as
// it always did. What must NOT happen is the value being treated as a key or dropped.
name: "a hand-typed script tag survives as text, to be escaped at render",
query: "flash=%3Cscript%3Ealert(1)%3C%2Fscript%3E", lang: "en",
want: "<script>alert(1)</script>",
},
{
name: "no flash at all is empty, not a key",
query: "", lang: "en",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := flashRequest(t, tc.query+"&lang="+tc.lang)
if got := s.flashFrom(r, "flash"); got != tc.want {
t.Errorf("flash\n got %q\n want %q", got, tc.want)
}
})
}
}
// TestFlashKeyCarriesParameters — a flash whose sentence names something (an app, a drive) carries
// that name as a separate `fa` parameter, so English may put it somewhere else in the sentence.
func TestFlashKeyCarriesParameters(t *testing.T) {
s := flashServer(t)
// The parameter-carrying flash in the bundle today. Asserted through the real bundle rather than a
// fixture, and self-arming: if no flash message takes a parameter any more the test says so out
// loud instead of passing vacuously.
const key = "flash.offbox.config_invalid"
if !s.i18n.Has(i18n.Default, key) {
t.Fatalf("%s left the bundle — this test no longer covers a parameter-carrying flash", key)
}
if !strings.Contains(s.i18n.Msg(i18n.Default, key), "%s") {
t.Fatalf("%s no longer takes a parameter — point this test at one that does", key)
}
r := flashRequest(t, flashQuery("flash_error", key, "a port nem szam")+"&lang=hu")
got := s.flashFrom(r, "flash_error")
if want := "Érvénytelen beállítás: a port nem szam"; got != want {
t.Errorf("parameter did not reach the message\n got %q\n want %q", got, want)
}
// And the same key, same parameter, in English: the parameter survives a different word order.
r = flashRequest(t, flashQuery("flash_error", key, "a port nem szam")+"&lang=en")
if got := s.flashFrom(r, "flash_error"); !strings.Contains(got, "a port nem szam") {
t.Errorf("the parameter was lost in English: %q", got)
}
}
// TestFlashOnAServerWithNoBundleField — a Server built without going through loadTemplates still
// renders SENTENCES, never raw keys. s.bundle() falls back to the embedded i18n.Shared() for exactly
// this: a page that shows „flash.share.enabled" to a household is the failure mode §4 of the
// localisation design forbids, and it was reachable from any construction path but the real one.
//
// RED-PROOF (REPORT): make s.bundle() return s.i18n unchanged → this test reads back the key.
func TestFlashOnAServerWithNoBundleField(t *testing.T) {
s := &Server{} // i18n field never set
if got := s.flashFrom(flashRequest(t, "flash=flash.share.enabled"), "flash"); got != "A megosztás bekapcsolva." {
t.Errorf("a Server with no bundle field showed %q instead of the sentence", got)
}
// And a legacy sentence still passes through untouched.
if got := s.flashFrom(flashRequest(t, "flash=Sikeres+ment%C3%A9s"), "flash"); got != "Sikeres mentés" {
t.Errorf("got %q, want the legacy text verbatim", got)
}
}
// flashSentence resolves the flash a redirect Location carries, in Hungarian.
//
// It exists because v0.252.0 moved the SENTENCE out of the URL and put a bundle KEY there instead.
// A test that searched the Location header for „elindult" or „letét" was never asserting the URL —
// it was asserting what the customer is told — and that is still exactly checkable, one lookup
// later. Tests assert through this rather than against the key, so a key renamed with its message
// intact stays green and a message REWORDED still fails, which is the right way round.
func flashSentence(t *testing.T, location string) string {
t.Helper()
u, err := url.Parse(location)
if err != nil {
t.Fatalf("Location %q: %v", location, err)
}
s := flashServer(t)
r := httptest.NewRequest(http.MethodGet, location, nil)
for _, p := range []string{"flash", "flash_error"} {
if v := u.Query().Get(p); v != "" {
return s.flashText(r, v)
}
}
return ""
}
// TestPluralEnglish — localisation slice 2 release B (R-557), scenario S4.
//
// English needs two forms where the noun or the verb changes with the count; Hungarian does not
// inflect after a numeral and therefore has ONE form, which is the form it already had. So this test
// asserts two different things about the same message: that English changes with the count, and that
// Hungarian does NOT — the second is the parity half, and it is the one that would fail silently.
//
// It goes through the real producer (buildDeadAppAlerts → GetAlerts), not through the bundle, so it
// also pins that the alert path carries the count as its first parameter, which is what the plural
// rule in i18n.Bundle.form depends on.
func TestPluralEnglish(t *testing.T) {
dead := func(n int) []DeadApp {
out := make([]DeadApp, 0, n)
for i := 0; i < n; i++ {
out = append(out, DeadApp{Name: "app" + strconv.Itoa(i), DisplayName: "App " + strconv.Itoa(i)})
}
return out
}
// Above deadAppGroupThreshold the banner collapses to ONE counted sentence — that is the message
// with a count in it. 4 and 7 are both "other"; there is no "one" case for the grouped banner by
// construction, so the singular is checked on the bundle directly below.
am := NewAlertManager(nil)
am.SetDeadAppAlerts(dead(4))
hu4 := am.GetAlerts("hu")[0].Message
en4 := am.GetAlerts("en")[0].Message
am.SetDeadAppAlerts(dead(7))
hu7 := am.GetAlerts("hu")[0].Message
en7 := am.GetAlerts("en")[0].Message
if hu4 != "4 telepített alkalmazás nem fut — nézd meg a rendszermonitort" { // R-516 item 9: te-form
t.Errorf("the Hungarian sentence moved: %q", hu4)
}
if hu7 != "7 telepített alkalmazás nem fut — nézd meg a rendszermonitort" { // R-516 item 9: te-form
t.Errorf("the Hungarian sentence moved: %q", hu7)
}
if !strings.Contains(en4, "4 installed apps are not running") {
t.Errorf("English plural: %q", en4)
}
if !strings.Contains(en7, "7 installed apps are not running") {
t.Errorf("English plural: %q", en7)
}
// The singular, straight off the bundle: same key, count 1.
b := flashServer(t).i18n
if got := b.Msgf("en", "alert.deadapp.group", 1); !strings.Contains(got, "1 installed app is not running") {
t.Errorf("English singular: %q", got)
}
// And Hungarian at 1 is the SAME single form — no .one, no change.
if got := b.Msgf("hu", "alert.deadapp.group", 1); got != "1 telepített alkalmazás nem fut — nézd meg a rendszermonitort" {
t.Errorf("Hungarian must have one form at every count: %q", got)
}
}