Files
petal/internal/spell/handlers_test.go
prosolis 30d5e691c9 Phase 18: settings that belong to the writer, not the browser
The mute toggle, the falling-petals toggle and the chosen companion lived in
localStorage, which is a property of the machine. Now that two people can sign
in to one Petal, sharing a laptop would have meant sharing a mascot and one
person's silence muting the other. Each key is namespaced by user id.

The awkward part is timing: sounds.ts and petals.ts read their value the moment
they are imported, long before /api/me can have answered. Rather than block
startup on the network for a mute flag, a read before the answer arrives sees
the old un-namespaced key -- on a single-writer browser, exactly the right
value -- and setPrefsScope then adopts it into that account's namespace and
tells every reader to look again. Adoption moves rather than copies, so the
first account inherits what was set before accounts existed and the second
starts from Petal's defaults.

The personal spelling dictionary moves further than that: onto the server. It
is built from her own writing, so it should not be readable by whoever sits
down at the same browser next -- but merely namespacing it would have split the
list she already has between her laptop and her tablet, which is worse than
where we started. A table keyed (user_id, lang, word) follows her instead. The
lang is the dictionary's, not hers: an English exception must not silence a
pt-PT flag once the second pair ships.

Adding a word takes effect in the editor immediately and persists in the
background, so the underline goes away the instant she asks. A browser still
holding the old list hands it over on first load, and only lets go once the
server has taken it.

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

214 lines
7.5 KiB
Go

package spell
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// newTestServer mounts the routes behind the same auth middleware main.go
// installs — a bare router resolves no caller, so every scoped query would
// silently match nothing.
func newTestServer(t *testing.T) (http.Handler, *db.DB) {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
r := chi.NewRouter()
r.Mount("/spell", New(database).Routes())
return auth.Middleware(auth.StaticResolver(db.LocalUserID))(r), database
}
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
var r *http.Request
if body != "" {
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
} else {
r = httptest.NewRequest(method, path, nil)
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, r)
return rec
}
func decodeWords(t *testing.T, rec *httptest.ResponseRecorder) wordsResponse {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body)
}
var got wordsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v (body %s)", err, rec.Body)
}
return got
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestLifecycle walks add → list → re-add → delete, and asserts the two
// properties the client relies on: adds are idempotent, and every response
// carries the full resulting list so the browser never has to merge.
func TestLifecycle(t *testing.T) {
srv, _ := newTestServer(t)
// Empty to start, and a list, never null.
got := decodeWords(t, do(t, srv, http.MethodGet, "/spell/words", ""))
if got.Lang != "en" || len(got.Words) != 0 {
t.Fatalf("fresh list = %+v, want empty en", got)
}
if !bytes.Contains(
do(t, srv, http.MethodGet, "/spell/words", "").Body.Bytes(), []byte(`"words":[]`),
) {
t.Fatal("empty list encoded as null, not []")
}
// One word, then a bulk add (the shape the browser's one-time adoption uses).
got = decodeWords(t, do(t, srv, http.MethodPost, "/spell/words", `{"word":"Petal"}`))
if !equal(got.Words, []string{"Petal"}) {
t.Fatalf("after add = %v", got.Words)
}
got = decodeWords(t, do(t, srv, http.MethodPost, "/spell/words",
`{"words":["hanfu","qipao","Petal"]}`))
if !equal(got.Words, []string{"Petal", "hanfu", "qipao"}) {
t.Fatalf("after bulk add = %v, want sorted and de-duplicated", got.Words)
}
// Re-adding an existing word must not error or duplicate it.
got = decodeWords(t, do(t, srv, http.MethodPost, "/spell/words", `{"word":"hanfu"}`))
if !equal(got.Words, []string{"Petal", "hanfu", "qipao"}) {
t.Fatalf("re-add changed the list: %v", got.Words)
}
// Delete, then delete again — forgetting a word Petal never knew is a
// success, since the caller's intent already holds.
got = decodeWords(t, do(t, srv, http.MethodDelete, "/spell/words?word=qipao", ""))
if !equal(got.Words, []string{"Petal", "hanfu"}) {
t.Fatalf("after delete = %v", got.Words)
}
got = decodeWords(t, do(t, srv, http.MethodDelete, "/spell/words?word=qipao", ""))
if !equal(got.Words, []string{"Petal", "hanfu"}) {
t.Fatalf("repeat delete = %v", got.Words)
}
}
// TestLanguagesDoNotMerge is the reason `lang` is in the primary key: a word the
// writer excused in English must not silence the pt-PT dictionary too.
func TestLanguagesDoNotMerge(t *testing.T) {
srv, _ := newTestServer(t)
do(t, srv, http.MethodPost, "/spell/words", `{"word":"tarde"}`)
got := decodeWords(t, do(t, srv, http.MethodPost, "/spell/words",
`{"lang":"pt-PT","word":"tarde"}`))
if !equal(got.Words, []string{"tarde"}) || got.Lang != "pt-pt" {
t.Fatalf("pt list = %+v", got)
}
// Removing it from one dictionary leaves the other alone.
do(t, srv, http.MethodDelete, "/spell/words?lang=pt-PT&word=tarde", "")
if got = decodeWords(t, do(t, srv, http.MethodGet, "/spell/words?lang=pt-PT", "")); len(got.Words) != 0 {
t.Fatalf("pt list after delete = %v", got.Words)
}
if got = decodeWords(t, do(t, srv, http.MethodGet, "/spell/words", "")); !equal(got.Words, []string{"tarde"}) {
t.Fatalf("en list collaterally damaged: %v", got.Words)
}
// Case and whitespace in the tag must not split one list into three.
got = decodeWords(t, do(t, srv, http.MethodGet, "/spell/words?lang=EN", ""))
if !equal(got.Words, []string{"tarde"}) {
t.Fatalf("uppercase lang tag saw a different list: %v", got.Words)
}
}
func TestRejectsJunk(t *testing.T) {
srv, _ := newTestServer(t)
cases := []struct{ name, method, path, body string }{
{"empty word", http.MethodPost, "/spell/words", `{"word":" "}`},
{"no word at all", http.MethodPost, "/spell/words", `{"lang":"en"}`},
{"not json", http.MethodPost, "/spell/words", `nonsense`},
{"delete without a word", http.MethodDelete, "/spell/words", ""},
}
for _, c := range cases {
if rec := do(t, srv, c.method, c.path, c.body); rec.Code != http.StatusBadRequest {
t.Errorf("%s: code=%d, want 400", c.name, rec.Code)
}
}
// An over-long entry is dropped rather than stored — a pasted paragraph is
// not a word. Dropping the only entry leaves nothing to add, hence 400.
long := `{"word":"` + string(bytes.Repeat([]byte("a"), MaxWordLen+1)) + `"}`
if rec := do(t, srv, http.MethodPost, "/spell/words", long); rec.Code != http.StatusBadRequest {
t.Errorf("over-long word: code=%d, want 400", rec.Code)
}
}
// TestTwoUsersDoNotShare is the standing rule for every user-scoped endpoint:
// mount the same routes twice behind two resolvers over one database.
func TestTwoUsersDoNotShare(t *testing.T) {
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
if _, err := database.Exec(
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`,
"bob", "bob@petal.local", "Bob",
); err != nil {
t.Fatalf("seed second user: %v", err)
}
mount := func(userID string) http.Handler {
r := chi.NewRouter()
r.Mount("/spell", New(database).Routes())
return auth.Middleware(auth.StaticResolver(userID))(r)
}
alice, bob := mount(db.LocalUserID), mount("bob")
do(t, alice, http.MethodPost, "/spell/words", `{"words":["Xiaolan","hanfu"]}`)
// Bob sees none of it — a personal dictionary is built from private writing.
if got := decodeWords(t, do(t, bob, http.MethodGet, "/spell/words", "")); len(got.Words) != 0 {
t.Fatalf("bob sees alice's words: %v", got.Words)
}
// Bob's own identical word is his own row, and deleting it leaves hers.
do(t, bob, http.MethodPost, "/spell/words", `{"word":"hanfu"}`)
do(t, bob, http.MethodDelete, "/spell/words?word=hanfu", "")
if got := decodeWords(t, do(t, alice, http.MethodGet, "/spell/words", "")); !equal(got.Words, []string{"Xiaolan", "hanfu"}) {
t.Fatalf("bob's delete reached alice's list: %v", got.Words)
}
// Deleting the account takes the dictionary with it.
if _, err := database.Exec(`DELETE FROM users WHERE id = 'bob'`); err != nil {
t.Fatalf("delete user: %v", err)
}
var n int
if err := database.QueryRow(
`SELECT COUNT(*) FROM personal_words WHERE user_id = 'bob'`).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 0 {
t.Fatalf("%d orphaned rows after the user was deleted", n)
}
}