package vocab import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "testing" "github.com/go-chi/chi/v5" "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()) return 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 } // 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) } } // 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) } }