Files
petal/internal/vocab/handlers_test.go
T
prosolis 6901cdbbe4 Multi-user groundwork: request-scoped user identity
Petal ran as a single hardcoded user, with db.LocalUserID named directly
at ~35 query sites. That made the caller's identity a compile-time
constant scattered across every package — nothing a real login could
replace without touching all of them.

New internal/auth moves it into the request context:

  - Middleware(Resolver) resolves the caller once per API request
  - handlers read auth.UserID(r.Context()) instead of naming a user
  - Resolver is the seam an Authentik session check drops into
  - StaticResolver(db.LocalUserID) keeps Petal single-user today

Behavior is unchanged. UserID returns "" rather than panicking when the
middleware is absent, so a mis-wired route fails closed: every query is
WHERE user_id = ?, which then matches nothing.

main.go splits /api into a public group (/health, /version) and an
authenticated group for everything else — a monitoring probe must not
need a session.

Two pre-existing access-control gaps fixed while threading, both
harmless with one user and not with two:

  - setStatus (accept/dismiss) updated a suggestion by bare id with no
    ownership check at all
  - listForDoc/fetchPending read a document's suggestions by doc_id
    alone; a suggestion quotes the sentence it corrects, so that leaked
    the source prose

Both now scope through documents.user_id.

Tests: internal/auth covers the context round-trip, the absent-context
case, and both 401 paths. Two-user isolation suites in docs and
suggestions mount the same routers twice behind two resolvers over one
database and assert a stranger gets 404 on every id-taking path, sees
nothing in list/search, and leaves the owner's data untouched.

Those suites earned their keep immediately: docs.fetch gained a userID
parameter but kept binding db.LocalUserID in the query. Unused
parameters are legal Go, so it compiled clean, vet was silent, and every
existing test passed while the lookup stayed unscoped.

Still global, out of scope and flagged in BUILD_PLAN.md: the image store
has no per-user association, and frontend localStorage keys are
per-browser rather than per-account.
2026-07-26 21:42:37 -07:00

256 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package vocab
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
)
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("/vocab", New(database).Routes())
// Behind the same auth middleware main.go installs: handlers resolve the
// caller from the request context, so a bare router would see no user and
// every user-scoped query would match nothing.
authed := auth.Middleware(auth.StaticResolver(db.LocalUserID))(r)
return authed, 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
}
// TestCaptureUpsertAndReview walks the garden lifecycle: capture a word (lands
// in the future, not yet due), re-capture refreshes context without resetting
// schedule, a forced-due word shows up in /due, a review reschedules it, and
// delete removes it.
func TestCaptureUpsertAndReview(t *testing.T) {
srv, database := newTestServer(t)
// Capture a new word → 201, due tomorrow (interval 1), not in /due yet.
rec := do(t, srv, http.MethodPost, "/vocab",
`{"word":"serendipity","gloss":"机缘巧合","phonetic":"/ˌserənˈdɪpəti/","example":"What a serendipity."}`)
if rec.Code != http.StatusCreated {
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
}
var w Word
_ = json.Unmarshal(rec.Body.Bytes(), &w)
if w.ID == "" || w.Word != "serendipity" || w.Gloss != "机缘巧合" {
t.Fatalf("captured word wrong: %+v", w)
}
if w.IntervalDays != 1 || w.Reps != 0 {
t.Fatalf("new word should start at interval 1, reps 0: %+v", w)
}
// Not due yet (due tomorrow).
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
var due []Word
_ = json.Unmarshal(rec.Body.Bytes(), &due)
if len(due) != 0 {
t.Fatalf("freshly-captured word should not be due yet, got %d", len(due))
}
// Re-capture with a new gloss → still one row (idempotent upsert), refreshed.
rec = do(t, srv, http.MethodPost, "/vocab",
`{"word":"serendipity","gloss":"意外的好运","phonetic":"/x/","example":""}`)
if rec.Code != http.StatusCreated {
t.Fatalf("re-capture: code=%d body=%s", rec.Code, rec.Body)
}
rec = do(t, srv, http.MethodGet, "/vocab", "")
var all []Word
_ = json.Unmarshal(rec.Body.Bytes(), &all)
if len(all) != 1 {
t.Fatalf("re-capture should not add a row, got %d", len(all))
}
if all[0].Gloss != "意外的好运" {
t.Fatalf("gloss should refresh on re-capture, got %q", all[0].Gloss)
}
if all[0].Example != "What a serendipity." {
t.Fatalf("empty example should not clobber the prior one, got %q", all[0].Example)
}
// Force it due now, then it appears in /due.
if _, err := database.Exec(`UPDATE vocab_words SET due_at = datetime('now','-1 hour')`); err != nil {
t.Fatalf("force due: %v", err)
}
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
_ = json.Unmarshal(rec.Body.Bytes(), &due)
if len(due) != 1 {
t.Fatalf("forced-due word should be in /due, got %d", len(due))
}
// Review "good" → reschedules forward (interval 3 = second ladder rung, since
// reps was 0 and a capture set interval 1 but reps 0; first good → rung 1 = 1).
rec = do(t, srv, http.MethodPost, "/vocab/"+w.ID+"/review", `{"grade":"good"}`)
if rec.Code != http.StatusOK {
t.Fatalf("review: code=%d body=%s", rec.Code, rec.Body)
}
var reviewed Word
_ = json.Unmarshal(rec.Body.Bytes(), &reviewed)
if reviewed.Reps != 1 || reviewed.IntervalDays != 1 {
t.Fatalf("first good review should be reps 1, interval 1: %+v", reviewed)
}
if reviewed.LastReviewed == nil {
t.Fatalf("review should stamp last_reviewed")
}
// After a forward review it's no longer due.
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
_ = json.Unmarshal(rec.Body.Bytes(), &due)
if len(due) != 0 {
t.Fatalf("reviewed word should leave /due, got %d", len(due))
}
// Bad grade → 400.
if rec = do(t, srv, http.MethodPost, "/vocab/"+w.ID+"/review", `{"grade":"nope"}`); rec.Code != http.StatusBadRequest {
t.Fatalf("bad grade: want 400, got %d", rec.Code)
}
// Delete → 204, garden empty.
if rec = do(t, srv, http.MethodDelete, "/vocab/"+w.ID, ""); rec.Code != http.StatusNoContent {
t.Fatalf("delete: code=%d", rec.Code)
}
if rec = do(t, srv, http.MethodDelete, "/vocab/"+w.ID, ""); rec.Code != http.StatusNotFound {
t.Fatalf("re-delete: want 404, got %d", rec.Code)
}
}
// TestCaptureValidation rejects an empty word.
func TestCaptureValidation(t *testing.T) {
srv, _ := newTestServer(t)
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":" "}`); rec.Code != http.StatusBadRequest {
t.Fatalf("empty word: want 400, got %d", rec.Code)
}
}
// TestCaptureStoresDefinitionFallback proves a word with an English definition
// but no Chinese gloss still round-trips its definition, so review has a meaning
// to reveal instead of a blank card.
func TestCaptureStoresDefinitionFallback(t *testing.T) {
srv, _ := newTestServer(t)
rec := do(t, srv, http.MethodPost, "/vocab",
`{"word":"ineffable","definition":"too great to be expressed in words"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
}
var w Word
_ = json.Unmarshal(rec.Body.Bytes(), &w)
if w.Gloss != "" {
t.Fatalf("expected no gloss, got %q", w.Gloss)
}
if w.Definition != "too great to be expressed in words" {
t.Fatalf("definition not stored, got %q", w.Definition)
}
}
// TestCaptureCaseInsensitive proves "Apple" (sentence start) and "apple"
// (mid-line) land in the SAME garden card — capture normalizes to lower+trim
// like the lexicon, so the idempotent upsert isn't defeated by casing.
func TestCaptureCaseInsensitive(t *testing.T) {
srv, _ := newTestServer(t)
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"Apple","gloss":"苹果"}`); rec.Code != http.StatusCreated {
t.Fatalf("capture Apple: code=%d body=%s", rec.Code, rec.Body)
}
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":" apple ","gloss":"苹果"}`); rec.Code != http.StatusCreated {
t.Fatalf("capture apple: code=%d body=%s", rec.Code, rec.Body)
}
rec := do(t, srv, http.MethodGet, "/vocab", "")
var all []Word
_ = json.Unmarshal(rec.Body.Bytes(), &all)
if len(all) != 1 {
t.Fatalf("Apple/apple should be one card, got %d", len(all))
}
if all[0].Word != "apple" {
t.Fatalf("word should be normalized to lowercase, got %q", all[0].Word)
}
}
// TestCaptureUnknownDocID rejects an unowned/stale doc_id with a clean 400
// rather than letting it hit the foreign key and leak a raw 500.
func TestCaptureUnknownDocID(t *testing.T) {
srv, _ := newTestServer(t)
rec := do(t, srv, http.MethodPost, "/vocab",
`{"word":"quixotic","gloss":"不切实际的","doc_id":"does-not-exist"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("unknown doc_id: want 400, got %d body=%s", rec.Code, rec.Body)
}
// A blank doc_id is treated as none, not an error.
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"limpid","gloss":"清澈的","doc_id":""}`); rec.Code != http.StatusCreated {
t.Fatalf("blank doc_id should be accepted as none: code=%d body=%s", rec.Code, rec.Body)
}
}
// TestCaptureClampsLongFields proves over-long fields are clamped (not rejected)
// so a lookup never fails on length, and the word key stays bounded.
func TestCaptureClampsLongFields(t *testing.T) {
srv, _ := newTestServer(t)
longWord := strings.Repeat("a", maxWordLen+50)
longDef := strings.Repeat("x", maxDefinitionLen+50)
rec := do(t, srv, http.MethodPost, "/vocab",
`{"word":"`+longWord+`","definition":"`+longDef+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
}
var w Word
_ = json.Unmarshal(rec.Body.Bytes(), &w)
if len([]rune(w.Word)) != maxWordLen {
t.Fatalf("word should clamp to %d runes, got %d", maxWordLen, len([]rune(w.Word)))
}
if len([]rune(w.Definition)) != maxDefinitionLen {
t.Fatalf("definition should clamp to %d runes, got %d", maxDefinitionLen, len([]rune(w.Definition)))
}
}
// TestDocLinkSurvivesDocDelete proves the ON DELETE SET NULL keeps a word in the
// garden when its source document is removed.
func TestDocLinkSurvivesDocDelete(t *testing.T) {
srv, database := newTestServer(t)
var docID string
if err := database.QueryRow(
`INSERT INTO documents (user_id, content_text) VALUES (?, 'hi') RETURNING id`, db.LocalUserID,
).Scan(&docID); err != nil {
t.Fatalf("seed doc: %v", err)
}
rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"ephemeral","gloss":"短暂的","doc_id":"`+docID+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
}
if _, err := database.Exec(`DELETE FROM documents WHERE id = ?`, docID); err != nil {
t.Fatalf("delete doc: %v", err)
}
rec = do(t, srv, http.MethodGet, "/vocab", "")
var all []Word
_ = json.Unmarshal(rec.Body.Bytes(), &all)
if len(all) != 1 {
t.Fatalf("word should survive doc deletion, got %d", len(all))
}
if all[0].DocID != nil {
t.Fatalf("doc_id should be nulled after doc delete, got %v", *all[0].DocID)
}
}