package main import ( "net/url" "strings" "testing" ) // decodeURIComponent is what static/app.js calls on X-Reply-Text. PathUnescape // is its Go equivalent for this purpose: both turn %XX into bytes and both // leave a literal "+" alone. That last part is the whole defect — QueryEscape // wrote spaces as "+" and the client had no way to tell those from a plus the // speaker actually said. func decodeURIComponent(t *testing.T, s string) string { t.Helper() out, err := url.PathUnescape(s) if err != nil { t.Fatalf("decodeURIComponent(%q): %v", s, err) } return out } // The reply the QA session actually saw was "на+04.08.2026+ничего+нет." // (Vikunja #533). Round-tripping through the client's decoder is the assertion // that matters — checking the encoder in isolation would have passed with // QueryEscape too. func TestReplyTextSurvivesTheClientDecoder(t *testing.T) { cases := []string{ "на 04.08.2026 ничего нет.", "Я поставила тебе напоминание позвонить маме через час.", // A literal plus must stay a plus, which is the case that makes // "just replace + with space on the JS side" the wrong fix. "два плюс два = 2+2", // Headers cannot carry a raw newline. PathEscape writes %0A. "первая строка\nвторая строка", "", // no reply text at all } for _, want := range cases { encoded := url.PathEscape(want) if strings.ContainsAny(encoded, "\r\n") { t.Errorf("encoded %q contains a raw newline, which is not a legal header value", want) } if got := decodeURIComponent(t, encoded); got != want { t.Errorf("round trip: got %q, want %q", got, want) } } } // The specific regression, named. QueryEscape is form encoding and this header // is not a form. func TestReplyTextDoesNotUseFormEncoding(t *testing.T) { const spoken = "на 04.08.2026 ничего нет." if got := decodeURIComponent(t, url.QueryEscape(spoken)); got == spoken { t.Skip("QueryEscape round-trips here, so this test proves nothing — check the decoder stand-in") } if strings.Contains(url.PathEscape(spoken), "+") { t.Errorf("PathEscape(%q) still writes a plus", spoken) } }