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