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: "", }, { 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ézze meg a rendszermonitort" { t.Errorf("the Hungarian sentence moved: %q", hu4) } if hu7 != "7 telepített alkalmazás nem fut — nézze meg a rendszermonitort" { 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ézze meg a rendszermonitort" { t.Errorf("Hungarian must have one form at every count: %q", got) } }