Reported as "the Portuguese option isn't translating the advice in English — it's just reprinting Portuguese." Nothing was wrong with targetFor. It was reading a direction the account could not leave. learnerPairs held only zh, so SetPair refused learning_pair for pt-PT and every Portuguese account was learning_en by force. targetFor then did exactly what it says: explanations follow the half of the pair she is not learning, which for a forced learning_en account is Portuguese. A Portuguese document, corrected in Portuguese, explained in Portuguese, with no way to ask for English — correct behaviour derived from a fact about the roster that was no longer true. The note in learnerPairs was written one phase too early to see it. It said turning a pair around needs a word list and a dictionary reading into English, and that fr, es and pt-PT had neither. Portuguese has both. Word boundaries are spaces — the megabyte jieba needs is a property of a writing system that doesn't use them, not a debt every pair owes. And the dictionary arrived with dict.db, which reads pt→en as readily as en→pt; dreamProvider.reverse has been answering that question since the pair shipped. What was actually blocking the pair a native English speaker learning Portuguese needs was this list. So pt-PT joins it, and the pt-PT pack gets the learner copy the control renders from — each label in the language of whoever would pick it, since someone on the wrong side of that switch cannot read the side they are reaching for. fr and es clear the same two bars through the same dict.db and stay out: their packs carry no learner block yet, which is a translation question rather than a data one, and the server should keep saying no until one is written. Two things that assumed learning_pair meant Chinese, now that it doesn't. The segmenter gate reads the pair as well as the direction, or a Portuguese learner would load a megabyte of Chinese word list and hover Portuguese words at /api/hanzi. And that endpoint's own comment justified skipping providerFor with a guarantee it no longer has; the real guarantee was always the caller's — it is only ever asked about tokens the Chinese segmenter found — and a stray lookup was already safe, answering a miss with an empty 200. Tests in both packages. The auth test that pinned pt-PT's refusal now pins its acceptance, with fr and es still refused; the suggestions test pins the consequence where it actually lands, which is the language she reads her advice in. Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
276 lines
10 KiB
Go
276 lines
10 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.SetPair("bob", "pt-PT", DirectionLearningEn); 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)
|
|
}
|
|
|
|
// Every pair with a langpack, not just the first one: this list and the
|
|
// frontend's PACKS are two copies of the same fact, and the day they
|
|
// disagree is the day she can pick a pair the app cannot render.
|
|
if err := users.SetPair("bob", "fr", DirectionLearningEn); err != nil {
|
|
t.Fatalf("set fr: %v", err)
|
|
}
|
|
if u, _ := users.Get("bob"); u.PairLang != "fr" {
|
|
t.Fatalf("pair_lang = %q, want fr", u.PairLang)
|
|
}
|
|
|
|
if err := users.SetPair("bob", "es", DirectionLearningEn); err != nil {
|
|
t.Fatalf("set es: %v", err)
|
|
}
|
|
if u, _ := users.Get("bob"); u.PairLang != "es" {
|
|
t.Fatalf("pair_lang = %q, want es", 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.SetPair("bob", "zh", DirectionLearningEn); 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)
|
|
|
|
// The near-misses are the ones that matter, and there are two of them now.
|
|
// "pt-BR" must not be quietly served European copy and a European voice;
|
|
// "es-ES" is the same mistake pointing the other way, because the es pack is
|
|
// deliberately Latin American and reads itself aloud in a Mexican voice. A
|
|
// regional code Petal has not decided about is refused rather than rounded
|
|
// to the nearest pack it happens to have.
|
|
for _, lang := range []string{"es-ES", "pt-BR", "fr-CA", "de", "klingon", "", " "} {
|
|
if err := users.SetPair("bob", lang, DirectionLearningEn); 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.SetPair("nobody", "pt-PT", DirectionLearningEn); 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":"es-ES"}`,
|
|
"unknown direction": `{"direction":"learning_klingon"}`,
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// An empty body used to be a 400, back when pair_lang was the only field and a
|
|
// request that named none of it could only be a client bug. With two optional
|
|
// fields it is an ordinary PATCH that changes nothing, and it has to be: the
|
|
// picker sends one field without knowing the other, and "omitted" has to mean
|
|
// "leave it alone" for that to be safe.
|
|
func TestUpdateMeHandlerEmptyBodyChangesNothing(t *testing.T) {
|
|
_, users, _ := newStores(t)
|
|
|
|
if err := users.SetPair("bob", "zh", DirectionLearningPair); err != nil {
|
|
t.Fatalf("set up: %v", err)
|
|
}
|
|
w := patchMe(t, users, "bob", `{}`)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
u, _ := users.Get("bob")
|
|
if u.PairLang != "zh" || u.Direction != DirectionLearningPair {
|
|
t.Fatalf("empty PATCH moved the account to %q/%q", u.PairLang, u.Direction)
|
|
}
|
|
}
|
|
|
|
// The direction axis: an account can be turned around and turned back, and the
|
|
// default every existing row already carries is the one it had before the column
|
|
// existed.
|
|
func TestDirectionRoundTrip(t *testing.T) {
|
|
_, users, _ := newStores(t)
|
|
|
|
if u, _ := users.Get("bob"); u.Direction != DirectionLearningEn {
|
|
t.Fatalf("a fresh account starts at %q, want %q", u.Direction, DirectionLearningEn)
|
|
}
|
|
|
|
w := patchMe(t, users, "bob", `{"direction":"learning_pair"}`)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("turn around: status = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
var got db.User
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
// The response carries the direction, not just the pair — the client reads
|
|
// its whole state back from here rather than assuming the write took.
|
|
if got.Direction != DirectionLearningPair || got.PairLang != "zh" {
|
|
t.Fatalf("response = %+v, want bob learning zh", got)
|
|
}
|
|
|
|
if w := patchMe(t, users, "bob", `{"direction":"learning_en"}`); w.Code != http.StatusOK {
|
|
t.Fatalf("turn back: status = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if u, _ := users.Get("bob"); u.Direction != DirectionLearningEn {
|
|
t.Fatalf("direction = %q after turning back", u.Direction)
|
|
}
|
|
}
|
|
|
|
// The refusal this axis exists to make: a pair with no learner-side data cannot
|
|
// be learned toward, however good its langpack is. fr and es have copy, voices
|
|
// and spelling dictionaries, and no `learner` block in their packs to offer the
|
|
// choice with — so the server keeps saying no until one is written.
|
|
//
|
|
// pt-PT is deliberately no longer in this list; see TestLearnerDirectionForPtPT
|
|
// below and the argument in `learnerPairs`.
|
|
func TestLearnerDirectionRefusedForPairsWithoutData(t *testing.T) {
|
|
_, users, _ := newStores(t)
|
|
|
|
for _, lang := range []string{"fr", "es"} {
|
|
if err := users.SetPair("bob", lang, DirectionLearningEn); err != nil {
|
|
t.Fatalf("set %s: %v", lang, err)
|
|
}
|
|
w := patchMe(t, users, "bob", `{"direction":"learning_pair"}`)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("%s: status = %d, want 400", lang, w.Code)
|
|
}
|
|
if u, _ := users.Get("bob"); u.Direction != DirectionLearningEn {
|
|
t.Fatalf("%s: a refused write still moved direction to %q", lang, u.Direction)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The other direction of that same rule, and the one a native English speaker
|
|
// writing Portuguese depends on.
|
|
//
|
|
// This is not only a settings toggle: `direction` is what decides which language
|
|
// Petal *explains* in (see suggestions.targetFor), so an account that cannot
|
|
// reach learning_pair gets its Portuguese annotated in Portuguese with no way to
|
|
// ask for English. Pinned in both directions — the move must take, and it must
|
|
// still be there when the account is read back.
|
|
func TestLearnerDirectionForPtPT(t *testing.T) {
|
|
_, users, _ := newStores(t)
|
|
|
|
if err := users.SetPair("bob", "pt-PT", DirectionLearningEn); err != nil {
|
|
t.Fatalf("set pt-PT: %v", err)
|
|
}
|
|
if w := patchMe(t, users, "bob", `{"direction":"learning_pair"}`); w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d (%s), want 200", w.Code, w.Body.String())
|
|
}
|
|
u, _ := users.Get("bob")
|
|
if u.Direction != DirectionLearningPair || u.PairLang != "pt-PT" {
|
|
t.Fatalf("account = %+v, want pt-PT learning_pair", u)
|
|
}
|
|
|
|
// And it can be turned back, the same as zh.
|
|
if w := patchMe(t, users, "bob", `{"direction":"learning_en"}`); w.Code != http.StatusOK {
|
|
t.Fatalf("turn back: status = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if u, _ := users.Get("bob"); u.Direction != DirectionLearningEn {
|
|
t.Fatalf("direction = %q after turning back", u.Direction)
|
|
}
|
|
}
|
|
|
|
// The two-field combination the handler validates as one decision. An account
|
|
// already learning Chinese that asks only to change pair is asking for a state
|
|
// neither field names on its own — French with segmentation — and it must not
|
|
// arrive by leaving one field out.
|
|
func TestPairChangeCannotStrandTheLearnerDirection(t *testing.T) {
|
|
_, users, _ := newStores(t)
|
|
|
|
if err := users.SetPair("bob", "zh", DirectionLearningPair); err != nil {
|
|
t.Fatalf("set up: %v", err)
|
|
}
|
|
if w := patchMe(t, users, "bob", `{"pair_lang":"fr"}`); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", w.Code)
|
|
}
|
|
u, _ := users.Get("bob")
|
|
if u.PairLang != "zh" || u.Direction != DirectionLearningPair {
|
|
t.Fatalf("refused write left the account at %q/%q", u.PairLang, u.Direction)
|
|
}
|
|
|
|
// Naming both at once is how that move is actually made, and it works.
|
|
if w := patchMe(t, users, "bob", `{"pair_lang":"fr","direction":"learning_en"}`); w.Code != http.StatusOK {
|
|
t.Fatalf("both fields: status = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if u, _ := users.Get("bob"); u.PairLang != "fr" || u.Direction != DirectionLearningEn {
|
|
t.Fatalf("account = %q/%q, want fr/learning_en", u.PairLang, u.Direction)
|
|
}
|
|
}
|
|
|
|
// The CHECK constraint is the last line, below the handler and below SetPair:
|
|
// a direction that reaches the column by any other route is still refused.
|
|
func TestDirectionCheckConstraint(t *testing.T) {
|
|
_, users, database := newStores(t)
|
|
if _, err := database.Exec(`UPDATE users SET direction = 'sideways' WHERE id = 'bob'`); err == nil {
|
|
t.Fatal("the users.direction CHECK accepted 'sideways'")
|
|
}
|
|
if u, _ := users.Get("bob"); u.Direction != DirectionLearningEn {
|
|
t.Fatalf("direction = %q after a refused UPDATE", u.Direction)
|
|
}
|
|
}
|