Files
petal/internal/auth/pairlang_test.go
T
prosolis 1f4ca4775a Let her choose her own pair
Raised by the user, not by the plan: there was no way to change language
in the mobile UI. There was no way anywhere. `users.pair_lang` has been
readable since Phase 19 and writable by nobody — /api/me was GET-only and
Upsert deliberately skips the column — which is also why "no pt-PT account
exists yet" has stood through two phases. Nothing could create one.

PATCH /api/me answers with the whole user rather than 204, so the client
re-reads the pair from the server instead of trusting its own request. One
write reaches everything: langpack, Hunspell dictionary, Piper voice,
lexicon provider and prompt language all read the column at use time.

The server refuses a pair it has no copy for, and auth.shippedPairs is
deliberately not internal/llm's list. That one names pairs the prompts can
talk about (fr and es, since Phase 19); this one names pairs Petal can
render itself in, which needs a langpack. Storing fr today would strand
her on Chinese with no way back except a lucky guess at a button she
cannot read.

The picker sits in the sidebar footer because the sidebar is the mobile
drawer — always one tap away. The status bar exists only while a document
is open, which is the wrong moment to find the app speaking a language you
can't read. Each language names itself, 中文 and Português: the one place
bilingual copy would get in the way.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 15:06:33 -07:00

106 lines
3.3 KiB
Go

package auth
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// patchMe drives UpdateMeHandler as the given user would reach it: behind the
// middleware, which is the only thing that puts an id in the context.
func patchMe(t *testing.T, users *UserStore, id, body string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(http.MethodPatch, "/me", strings.NewReader(body))
r = r.WithContext(WithUser(r.Context(), id))
w := httptest.NewRecorder()
users.UpdateMeHandler()(w, r)
return w
}
func TestSetPairLang(t *testing.T) {
_, users, _ := newStores(t)
if err := users.SetPairLang("bob", "pt-PT"); err != nil {
t.Fatalf("set pt-PT: %v", err)
}
if u, _ := users.Get("bob"); u.PairLang != "pt-PT" {
t.Fatalf("pair_lang = %q, want pt-PT", u.PairLang)
}
// And back — a writer who tries a pair and doesn't like it must be able to
// return, which is the whole reason the picker exists.
if err := users.SetPairLang("bob", "zh"); err != nil {
t.Fatalf("set zh: %v", err)
}
if u, _ := users.Get("bob"); u.PairLang != "zh" {
t.Fatalf("pair_lang = %q, want zh", u.PairLang)
}
}
// A pair the frontend has no langpack for must not be storable. Accepting it
// would leave her looking at Chinese copy with no way back except a lucky guess.
func TestSetPairLangRejectsUnshippedPairs(t *testing.T) {
_, users, _ := newStores(t)
for _, lang := range []string{"fr", "es", "pt-BR", "klingon", "", " "} {
if err := users.SetPairLang("bob", lang); err == nil {
t.Fatalf("stored unshipped pair %q", lang)
}
}
if u, _ := users.Get("bob"); u.PairLang != "zh" {
t.Fatalf("a refused write still moved pair_lang to %q", u.PairLang)
}
}
func TestSetPairLangUnknownUser(t *testing.T) {
_, users, _ := newStores(t)
if err := users.SetPairLang("nobody", "pt-PT"); err == nil {
t.Fatal("set a pair language on an account that does not exist")
}
}
func TestUpdateMeHandler(t *testing.T) {
_, users, _ := newStores(t)
w := patchMe(t, users, "bob", `{"pair_lang":"pt-PT"}`)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (%s)", w.Code, w.Body.String())
}
// The whole user comes back, so the client can re-read the pair from the
// server instead of assuming its request took.
var got db.User
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.ID != "bob" || got.PairLang != "pt-PT" {
t.Fatalf("response = %+v, want bob on pt-PT", got)
}
}
func TestUpdateMeHandlerRejects(t *testing.T) {
_, users, _ := newStores(t)
for name, body := range map[string]string{
"unshipped pair": `{"pair_lang":"fr"}`,
"missing field": `{}`,
"not json": `pt-PT`,
} {
if w := patchMe(t, users, "bob", body); w.Code != http.StatusBadRequest {
t.Fatalf("%s: status = %d, want 400", name, w.Code)
}
}
if u, _ := users.Get("bob"); u.PairLang != "zh" {
t.Fatalf("a rejected request still moved pair_lang to %q", u.PairLang)
}
// A caller the middleware never resolved (or whose row is gone) is a lapsed
// session, not a bad request — the client turns 401 into the sign-in overlay.
if w := patchMe(t, users, "nobody", `{"pair_lang":"pt-PT"}`); w.Code != http.StatusUnauthorized {
t.Fatalf("unknown user: status = %d, want 401", w.Code)
}
}