fix: harden API, DB schema, and CLI safety

- Add 'reminder' to glyph CHECK constraint (was accepted by parser but
  rejected by DB)
- Default serve bind to 127.0.0.1, add --host flag for LAN access
- Validate card_data as JSON in Store.Create/Update/Promote
- Return pagination envelope {data,total,limit,offset} from list endpoint
- Append absorb breadcrumb to source entity before soft-delete
- Add Levenshtein fuzzy match to catch command typos before routing to add
- Replace DDL string-matching migrations with versioned schema_version table
- Update web UI and API tests for envelope response format
This commit is contained in:
2026-05-19 18:30:17 -04:00
parent babf1d6620
commit e09919b679
9 changed files with 243 additions and 89 deletions
+21 -14
View File
@@ -25,6 +25,20 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) {
return srv, store
}
type listEnvelope struct {
Data []EntityResponse `json:"data"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func decodeList(t *testing.T, resp *http.Response) []EntityResponse {
t.Helper()
var env listEnvelope
json.NewDecoder(resp.Body).Decode(&env)
return env.Data
}
func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
t.Helper()
b, err := json.Marshal(body)
@@ -157,8 +171,7 @@ func TestListEntities_Default(t *testing.T) {
}
defer resp.Body.Close()
var entities []EntityResponse
json.NewDecoder(resp.Body).Decode(&entities)
entities := decodeList(t, resp)
if len(entities) != 2 {
t.Fatalf("expected 2, got %d", len(entities))
}
@@ -175,8 +188,7 @@ func TestListEntities_FilterTag(t *testing.T) {
}
defer resp.Body.Close()
var entities []EntityResponse
json.NewDecoder(resp.Body).Decode(&entities)
entities := decodeList(t, resp)
if len(entities) != 1 {
t.Fatalf("expected 1, got %d", len(entities))
}
@@ -198,8 +210,7 @@ func TestListEntities_CardsOnly(t *testing.T) {
}
defer resp.Body.Close()
var entities []EntityResponse
json.NewDecoder(resp.Body).Decode(&entities)
entities := decodeList(t, resp)
if len(entities) != 1 {
t.Fatalf("expected 1 card, got %d", len(entities))
}
@@ -215,16 +226,14 @@ func TestListEntities_Pagination(t *testing.T) {
if err != nil {
t.Fatal(err)
}
var page1 []EntityResponse
json.NewDecoder(resp.Body).Decode(&page1)
page1 := decodeList(t, resp)
resp.Body.Close()
resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
if err != nil {
t.Fatal(err)
}
var page2 []EntityResponse
json.NewDecoder(resp.Body).Decode(&page2)
page2 := decodeList(t, resp)
resp.Body.Close()
if len(page1) != 2 || len(page2) != 2 {
@@ -517,8 +526,7 @@ func TestAbsorbEntity_Success(t *testing.T) {
if err != nil {
t.Fatal(err)
}
var entities []EntityResponse
json.NewDecoder(listResp.Body).Decode(&entities)
entities := decodeList(t, listResp)
listResp.Body.Close()
for _, ent := range entities {
if ent.ID == source.ID {
@@ -686,8 +694,7 @@ func TestListEntities_TitleInResponse(t *testing.T) {
}
defer resp.Body.Close()
var entities []EntityResponse
json.NewDecoder(resp.Body).Decode(&entities)
entities := decodeList(t, resp)
if len(entities) != 1 {
t.Fatalf("expected 1, got %d", len(entities))
}
+29 -3
View File
@@ -102,6 +102,15 @@ func listEntities(store *db.Store) http.HandlerFunc {
}
p.Offset = offset
}
if p.Limit <= 0 {
p.Limit = 50
}
total, err := store.Count(p)
if err != nil {
writeInternalError(w, err)
return
}
entities, err := store.List(p)
if err != nil {
@@ -109,11 +118,16 @@ func listEntities(store *db.Store) http.HandlerFunc {
return
}
resp := make([]EntityResponse, len(entities))
items := make([]EntityResponse, len(entities))
for i, e := range entities {
resp[i] = entityToResponse(e)
items[i] = entityToResponse(e)
}
writeJSON(w, http.StatusOK, resp)
writeJSON(w, http.StatusOK, map[string]any{
"data": items,
"total": total,
"limit": p.Limit,
"offset": p.Offset,
})
}
}
@@ -161,6 +175,10 @@ func createEntity(store *db.Store) http.HandlerFunc {
}
if err := store.Create(e); err != nil {
if err == db.ErrInvalidCardData {
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
return
}
writeInternalError(w, err)
return
}
@@ -227,6 +245,10 @@ func updateEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
if err == db.ErrInvalidCardData {
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
return
}
writeInternalError(w, err)
return
}
@@ -291,6 +313,10 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
return
}
if err == db.ErrInvalidCardData {
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
return
}
writeInternalError(w, err)
return
}