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) } }