Backend internal/docs: chi sub-router (list/create/get/update/delete) mounted at /api/docs, scoped to the local user. Create uses RETURNING; update is a COALESCE partial-update so rename and full editor save share one PUT. JSON 404/400 errors; handlers_test.go walks the lifecycle. Frontend: api/client.ts, useAutoSave (1.5s debounce + saveNow flush before doc switch), EditorCore (Tiptap StarterKit/Underline/TextAlign/ Placeholder/CharacterCount) + Toolbar, DocList/DocListItem, StatusBar, and an App.tsx that orchestrates load/select/create/delete with optimistic sidebar patching. content + content_text + word_count are emitted together on every edit. .petal-prose styling (Lora body, Nunito headings). Verified: tsc clean, vite build, go build, full CRUD smoke test incl. CJK title round-trip and SPA serve. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
104 lines
3.2 KiB
Go
104 lines
3.2 KiB
Go
package docs
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
)
|
|
|
|
// newTestServer spins up an isolated on-disk database and the docs router.
|
|
func newTestServer(t *testing.T) http.Handler {
|
|
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() })
|
|
return New(database).Routes()
|
|
}
|
|
|
|
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 TestDocumentCRUD(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
|
|
// Empty list serializes as [].
|
|
rec := do(t, srv, http.MethodGet, "/", "")
|
|
if rec.Code != http.StatusOK || bytes.TrimSpace(rec.Body.Bytes())[0] != '[' {
|
|
t.Fatalf("list empty: code=%d body=%s", rec.Code, rec.Body)
|
|
}
|
|
|
|
// Create.
|
|
rec = do(t, srv, http.MethodPost, "/", "")
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("create: code=%d body=%s", rec.Code, rec.Body)
|
|
}
|
|
var created db.Document
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
|
|
t.Fatalf("decode create: %v", err)
|
|
}
|
|
if created.ID == "" || created.Title != "Untitled" {
|
|
t.Fatalf("unexpected created doc: %+v", created)
|
|
}
|
|
|
|
// Update (auto-save shape) keeps content + content_text + word count together.
|
|
body := `{"title":"My Essay","content":"{\"x\":1}","content_text":"hello world","word_count":2}`
|
|
rec = do(t, srv, http.MethodPut, "/"+created.ID, body)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("update: code=%d body=%s", rec.Code, rec.Body)
|
|
}
|
|
var updated db.Document
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &updated)
|
|
if updated.Title != "My Essay" || updated.ContentText != "hello world" || updated.WordCount != 2 {
|
|
t.Fatalf("update not applied: %+v", updated)
|
|
}
|
|
|
|
// Partial update (rename only) leaves other fields intact.
|
|
rec = do(t, srv, http.MethodPut, "/"+created.ID, `{"title":"Renamed"}`)
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &updated)
|
|
if updated.Title != "Renamed" || updated.WordCount != 2 {
|
|
t.Fatalf("partial update clobbered fields: %+v", updated)
|
|
}
|
|
|
|
// Get.
|
|
rec = do(t, srv, http.MethodGet, "/"+created.ID, "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("get: code=%d", rec.Code)
|
|
}
|
|
|
|
// List now has one entry.
|
|
rec = do(t, srv, http.MethodGet, "/", "")
|
|
var summaries []docSummary
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &summaries)
|
|
if len(summaries) != 1 || summaries[0].Title != "Renamed" {
|
|
t.Fatalf("list after create: %+v", summaries)
|
|
}
|
|
|
|
// Delete, then 404 on subsequent get/delete.
|
|
if rec = do(t, srv, http.MethodDelete, "/"+created.ID, ""); rec.Code != http.StatusNoContent {
|
|
t.Fatalf("delete: code=%d", rec.Code)
|
|
}
|
|
if rec = do(t, srv, http.MethodGet, "/"+created.ID, ""); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("get after delete: code=%d", rec.Code)
|
|
}
|
|
if rec = do(t, srv, http.MethodPut, "/missing", `{"title":"x"}`); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("update missing: code=%d", rec.Code)
|
|
}
|
|
}
|