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:
+21
-14
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user