fix: code principles audit — correctness, security, testability
- Add rows.Err() checks after all scan loops (entities, tags, resolve) - Surface time.Parse errors instead of silently discarding - Extract entityRow scan helper to eliminate Get/List duplication - Cap request body at 1MB via MaxBytesReader - Stop leaking internal errors to API clients (log server-side only) - Block javascript: URIs in link card open button (XSS) - Fix all go vet failures in api_test.go (unchecked http errors) - Add tests for display package, generateCardData, absorb-source-card - Run go mod tidy to fix direct/indirect dep markers
This commit is contained in:
+112
-41
@@ -25,15 +25,22 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) {
|
||||
return srv, store
|
||||
}
|
||||
|
||||
func postJSON(srv *httptest.Server, path string, body any) *http.Response {
|
||||
b, _ := json.Marshal(body)
|
||||
resp, _ := http.Post(srv.URL+path, "application/json", bytes.NewReader(b))
|
||||
func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := http.Post(srv.URL+path, "application/json", bytes.NewReader(b))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []string) EntityResponse {
|
||||
t.Helper()
|
||||
resp := postJSON(srv, "/api/entities", map[string]any{
|
||||
resp := postJSON(t, srv, "/api/entities", map[string]any{
|
||||
"body": body,
|
||||
"tags": tags,
|
||||
})
|
||||
@@ -49,7 +56,7 @@ func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []st
|
||||
func TestCreateEntity_Note(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
|
||||
resp := postJSON(srv, "/api/entities", map[string]any{
|
||||
resp := postJSON(t, srv, "/api/entities", map[string]any{
|
||||
"body": "test note",
|
||||
"tags": []string{"demo"},
|
||||
})
|
||||
@@ -76,7 +83,7 @@ func TestCreateEntity_Note(t *testing.T) {
|
||||
func TestCreateEntity_MissingBody(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
|
||||
resp := postJSON(srv, "/api/entities", map[string]any{})
|
||||
resp := postJSON(t, srv, "/api/entities", map[string]any{})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
@@ -93,7 +100,7 @@ func TestCreateEntity_MissingBody(t *testing.T) {
|
||||
func TestCreateEntity_InvalidGlyph(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
|
||||
resp := postJSON(srv, "/api/entities", map[string]any{
|
||||
resp := postJSON(t, srv, "/api/entities", map[string]any{
|
||||
"body": "test",
|
||||
"glyph": "invalid",
|
||||
})
|
||||
@@ -108,7 +115,10 @@ func TestGetEntity_Success(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
created := createTestEntity(t, srv, "test", nil)
|
||||
|
||||
resp, _ := http.Get(srv.URL + "/api/entities/" + created.ID)
|
||||
resp, err := http.Get(srv.URL + "/api/entities/" + created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -125,7 +135,10 @@ func TestGetEntity_Success(t *testing.T) {
|
||||
func TestGetEntity_NotFound(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
|
||||
resp, _ := http.Get(srv.URL + "/api/entities/NONEXISTENT")
|
||||
resp, err := http.Get(srv.URL + "/api/entities/NONEXISTENT")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
@@ -138,7 +151,10 @@ func TestListEntities_Default(t *testing.T) {
|
||||
createTestEntity(t, srv, "one", nil)
|
||||
createTestEntity(t, srv, "two", nil)
|
||||
|
||||
resp, _ := http.Get(srv.URL + "/api/entities")
|
||||
resp, err := http.Get(srv.URL + "/api/entities")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var entities []EntityResponse
|
||||
@@ -153,7 +169,10 @@ func TestListEntities_FilterTag(t *testing.T) {
|
||||
createTestEntity(t, srv, "a", []string{"ops"})
|
||||
createTestEntity(t, srv, "b", []string{"home"})
|
||||
|
||||
resp, _ := http.Get(srv.URL + "/api/entities?tag=ops")
|
||||
resp, err := http.Get(srv.URL + "/api/entities?tag=ops")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var entities []EntityResponse
|
||||
@@ -167,13 +186,16 @@ func TestListEntities_CardsOnly(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
createTestEntity(t, srv, "fluid", nil)
|
||||
|
||||
resp := postJSON(srv, "/api/entities", map[string]any{
|
||||
resp := postJSON(t, srv, "/api/entities", map[string]any{
|
||||
"body": "card",
|
||||
"card_type": "snippet",
|
||||
})
|
||||
resp.Body.Close()
|
||||
|
||||
resp, _ = http.Get(srv.URL + "/api/entities?cards_only=true")
|
||||
resp, err := http.Get(srv.URL + "/api/entities?cards_only=true")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var entities []EntityResponse
|
||||
@@ -189,12 +211,18 @@ func TestListEntities_Pagination(t *testing.T) {
|
||||
createTestEntity(t, srv, "note", nil)
|
||||
}
|
||||
|
||||
resp, _ := http.Get(srv.URL + "/api/entities?limit=2&offset=0")
|
||||
resp, err := http.Get(srv.URL + "/api/entities?limit=2&offset=0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var page1 []EntityResponse
|
||||
json.NewDecoder(resp.Body).Decode(&page1)
|
||||
resp.Body.Close()
|
||||
|
||||
resp, _ = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
|
||||
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)
|
||||
resp.Body.Close()
|
||||
@@ -211,10 +239,16 @@ func TestUpdateEntity_Body(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
created := createTestEntity(t, srv, "old", nil)
|
||||
|
||||
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
|
||||
req, err := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
|
||||
mustJSON(map[string]any{"body": "new"})))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -233,8 +267,14 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
|
||||
created := createTestEntity(t, srv, "doomed", nil)
|
||||
|
||||
// Soft delete
|
||||
req, _ := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
req, err := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var delResp DeleteResponse
|
||||
json.NewDecoder(resp.Body).Decode(&delResp)
|
||||
resp.Body.Close()
|
||||
@@ -246,7 +286,14 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
|
||||
}
|
||||
|
||||
// Hard delete
|
||||
resp, _ = http.DefaultClient.Do(req)
|
||||
req, err = http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&delResp)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -257,7 +304,10 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
|
||||
}
|
||||
|
||||
// Gone
|
||||
resp, _ = http.Get(srv.URL + "/api/entities/" + created.ID)
|
||||
resp, err = http.Get(srv.URL + "/api/entities/" + created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 after hard delete, got %d", resp.StatusCode)
|
||||
@@ -268,7 +318,7 @@ func TestPromoteEntity_Success(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
created := createTestEntity(t, srv, "trick", nil)
|
||||
|
||||
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||
"card_type": "snippet",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
@@ -288,11 +338,11 @@ func TestPromoteEntity_AlreadyPromoted(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
created := createTestEntity(t, srv, "trick", nil)
|
||||
|
||||
postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||
postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||
"card_type": "snippet",
|
||||
}).Body.Close()
|
||||
|
||||
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||
"card_type": "template",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
@@ -312,7 +362,7 @@ func TestPromoteEntity_InvalidType(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
created := createTestEntity(t, srv, "trick", nil)
|
||||
|
||||
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||
"card_type": "bogus",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
@@ -326,11 +376,11 @@ func TestDemoteEntity_Success(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
created := createTestEntity(t, srv, "trick", nil)
|
||||
|
||||
postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||
postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
||||
"card_type": "snippet",
|
||||
}).Body.Close()
|
||||
|
||||
resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil)
|
||||
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/demote", nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -348,7 +398,7 @@ func TestDemoteEntity_AlreadyFluid(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
created := createTestEntity(t, srv, "trick", nil)
|
||||
|
||||
resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil)
|
||||
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/demote", nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
@@ -360,7 +410,7 @@ func TestUseEntity_Success(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
created := createTestEntity(t, srv, "trick", nil)
|
||||
|
||||
resp := postJSON(srv, "/api/entities/"+created.ID+"/use", nil)
|
||||
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/use", nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -379,7 +429,10 @@ func TestListTags_WithCounts(t *testing.T) {
|
||||
createTestEntity(t, srv, "a", []string{"ops"})
|
||||
createTestEntity(t, srv, "b", []string{"ops", "nginx"})
|
||||
|
||||
resp, _ := http.Get(srv.URL + "/api/tags")
|
||||
resp, err := http.Get(srv.URL + "/api/tags")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var tags []TagResponse
|
||||
@@ -391,14 +444,23 @@ func TestListTags_WithCounts(t *testing.T) {
|
||||
|
||||
func TestCORS_DevMode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
store, _ := db.Open(path)
|
||||
store, err := db.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
router := NewRouter(store, true)
|
||||
srv := httptest.NewServer(router)
|
||||
defer srv.Close()
|
||||
|
||||
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
req, err := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
|
||||
@@ -412,8 +474,14 @@ func TestCORS_DevMode(t *testing.T) {
|
||||
func TestCORS_ProdMode(t *testing.T) {
|
||||
srv, _ := testServer(t) // devMode=false
|
||||
|
||||
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
req, err := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.Header.Get("Access-Control-Allow-Origin") != "" {
|
||||
@@ -426,7 +494,7 @@ func TestAbsorbEntity_Success(t *testing.T) {
|
||||
target := createTestEntity(t, srv, "target body", []string{"ops"})
|
||||
source := createTestEntity(t, srv, "source body", []string{"ops", "infra"})
|
||||
|
||||
resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
|
||||
resp := postJSON(t, srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
|
||||
"source_id": source.ID,
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
@@ -445,7 +513,10 @@ func TestAbsorbEntity_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
// Source should be soft-deleted (not in default list)
|
||||
listResp, _ := http.Get(srv.URL + "/api/entities")
|
||||
listResp, err := http.Get(srv.URL + "/api/entities")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var entities []EntityResponse
|
||||
json.NewDecoder(listResp.Body).Decode(&entities)
|
||||
listResp.Body.Close()
|
||||
@@ -461,11 +532,11 @@ func TestAbsorbEntity_TargetCrystallized(t *testing.T) {
|
||||
target := createTestEntity(t, srv, "target", nil)
|
||||
source := createTestEntity(t, srv, "source", nil)
|
||||
|
||||
postJSON(srv, "/api/entities/"+target.ID+"/promote", map[string]any{
|
||||
postJSON(t, srv, "/api/entities/"+target.ID+"/promote", map[string]any{
|
||||
"card_type": "snippet",
|
||||
}).Body.Close()
|
||||
|
||||
resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
|
||||
resp := postJSON(t, srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
|
||||
"source_id": source.ID,
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
@@ -485,7 +556,7 @@ func TestAbsorbEntity_SameEntity(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
e := createTestEntity(t, srv, "self", nil)
|
||||
|
||||
resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{
|
||||
resp := postJSON(t, srv, "/api/entities/"+e.ID+"/absorb", map[string]any{
|
||||
"source_id": e.ID,
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
@@ -499,7 +570,7 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) {
|
||||
srv, _ := testServer(t)
|
||||
e := createTestEntity(t, srv, "target", nil)
|
||||
|
||||
resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{})
|
||||
resp := postJSON(t, srv, "/api/entities/"+e.ID+"/absorb", map[string]any{})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
|
||||
+14
-14
@@ -100,7 +100,7 @@ func listEntities(store *db.Store) http.HandlerFunc {
|
||||
|
||||
entities, err := store.List(p)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ func createEntity(store *db.Store) http.HandlerFunc {
|
||||
}
|
||||
|
||||
if err := store.Create(e); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ func getEntity(store *db.Store) http.HandlerFunc {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entityToResponse(e))
|
||||
@@ -215,13 +215,13 @@ func updateEntity(store *db.Store) http.HandlerFunc {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
e, err := store.Get(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entityToResponse(e))
|
||||
@@ -241,7 +241,7 @@ func deleteEntity(store *db.Store) http.HandlerFunc {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
label := "soft"
|
||||
@@ -279,13 +279,13 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
|
||||
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
e, err := store.Get(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entityToResponse(e))
|
||||
@@ -305,13 +305,13 @@ func demoteEntity(store *db.Store) http.HandlerFunc {
|
||||
writeError(w, http.StatusBadRequest, "invalid_demote", "entity is already fluid")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
e, err := store.Get(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entityToResponse(e))
|
||||
@@ -349,13 +349,13 @@ func absorbEntity(store *db.Store) http.HandlerFunc {
|
||||
writeError(w, http.StatusBadRequest, "invalid_absorb", "target is crystallized — demote first")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
e, err := store.Get(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entityToResponse(e))
|
||||
@@ -371,13 +371,13 @@ func useEntity(store *db.Store) http.HandlerFunc {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
e, err := store.Get(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entityToResponse(e))
|
||||
|
||||
@@ -2,12 +2,15 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
)
|
||||
|
||||
const maxBodySize = 1 << 20 // 1 MB
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
@@ -41,6 +44,7 @@ func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", "malformed JSON: "+err.Error())
|
||||
return false
|
||||
@@ -48,6 +52,11 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func writeInternalError(w http.ResponseWriter, err error) {
|
||||
log.Printf("internal error: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
|
||||
}
|
||||
|
||||
func entityToResponse(e *db.Entity) EntityResponse {
|
||||
resp := EntityResponse{
|
||||
ID: e.ID,
|
||||
|
||||
@@ -15,7 +15,7 @@ func listTags(store *db.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tags, err := store.ListTags()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+62
-42
@@ -141,18 +141,12 @@ func (s *Store) Create(e *Entity) error {
|
||||
|
||||
func (s *Store) Get(id string) (*Entity, error) {
|
||||
e := &Entity{}
|
||||
var createdAt, modifiedAt string
|
||||
var completedAt, deletedAt, lastUsedAt sql.NullString
|
||||
var timeAnchor, cardType, cardData sql.NullString
|
||||
var pinned int
|
||||
row := newEntityRow()
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, created_at, modified_at, body, glyph, time_anchor,
|
||||
completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at
|
||||
FROM entities WHERE id = ?`, id).Scan(
|
||||
&e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor,
|
||||
&completedAt, &pinned, &deletedAt, &cardType, &cardData, &e.UseCount, &lastUsedAt,
|
||||
)
|
||||
FROM entities WHERE id = ?`, id).Scan(row.ptrs(e)...)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -160,15 +154,9 @@ func (s *Store) Get(id string) (*Entity, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt)
|
||||
e.TimeAnchor = nullToPtr(timeAnchor)
|
||||
e.CompletedAt = parseTimePtr(completedAt)
|
||||
e.Pinned = pinned != 0
|
||||
e.DeletedAt = parseTimePtr(deletedAt)
|
||||
e.CardType = nullToCardType(cardType)
|
||||
e.CardData = nullToPtr(cardData)
|
||||
e.LastUsedAt = parseTimePtr(lastUsedAt)
|
||||
if err := row.apply(e); err != nil {
|
||||
return nil, fmt.Errorf("scan entity %s: %w", id, err)
|
||||
}
|
||||
|
||||
tags, err := s.loadTags(id)
|
||||
if err != nil {
|
||||
@@ -253,31 +241,18 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
|
||||
var entities []*Entity
|
||||
for rows.Next() {
|
||||
e := &Entity{}
|
||||
var createdAt, modifiedAt string
|
||||
var completedAt, deletedAt, lastUsedAt sql.NullString
|
||||
var timeAnchor, cardType, cardData sql.NullString
|
||||
var pinned int
|
||||
|
||||
if err := rows.Scan(
|
||||
&e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor,
|
||||
&completedAt, &pinned, &deletedAt, &cardType, &cardData,
|
||||
&e.UseCount, &lastUsedAt,
|
||||
); err != nil {
|
||||
row := newEntityRow()
|
||||
if err := rows.Scan(row.ptrs(e)...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := row.apply(e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt)
|
||||
e.TimeAnchor = nullToPtr(timeAnchor)
|
||||
e.CompletedAt = parseTimePtr(completedAt)
|
||||
e.Pinned = pinned != 0
|
||||
e.DeletedAt = parseTimePtr(deletedAt)
|
||||
e.CardType = nullToCardType(cardType)
|
||||
e.CardData = nullToPtr(cardData)
|
||||
e.LastUsedAt = parseTimePtr(lastUsedAt)
|
||||
|
||||
entities = append(entities, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.batchLoadTags(entities); err != nil {
|
||||
return nil, err
|
||||
@@ -502,6 +477,9 @@ func (s *Store) Resolve(prefix string) (string, error) {
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch len(ids) {
|
||||
case 0:
|
||||
@@ -513,6 +491,43 @@ func (s *Store) Resolve(prefix string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// entityRow holds intermediate scan values for a single entity row.
|
||||
// Both Get and List use this to avoid duplicating the 14-var scan + mapping logic.
|
||||
type entityRow struct {
|
||||
createdAt, modifiedAt string
|
||||
completedAt, deletedAt, lastUsedAt sql.NullString
|
||||
timeAnchor, cardType, cardData sql.NullString
|
||||
pinned int
|
||||
}
|
||||
|
||||
func newEntityRow() *entityRow { return &entityRow{} }
|
||||
|
||||
func (r *entityRow) ptrs(e *Entity) []any {
|
||||
return []any{
|
||||
&e.ID, &r.createdAt, &r.modifiedAt, &e.Body, &e.Glyph, &r.timeAnchor,
|
||||
&r.completedAt, &r.pinned, &r.deletedAt, &r.cardType, &r.cardData,
|
||||
&e.UseCount, &r.lastUsedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *entityRow) apply(e *Entity) error {
|
||||
var err error
|
||||
if e.CreatedAt, err = time.Parse(time.RFC3339, r.createdAt); err != nil {
|
||||
return fmt.Errorf("created_at: %w", err)
|
||||
}
|
||||
if e.ModifiedAt, err = time.Parse(time.RFC3339, r.modifiedAt); err != nil {
|
||||
return fmt.Errorf("modified_at: %w", err)
|
||||
}
|
||||
e.TimeAnchor = nullToPtr(r.timeAnchor)
|
||||
e.CompletedAt = parseTimePtr(r.completedAt)
|
||||
e.Pinned = r.pinned != 0
|
||||
e.DeletedAt = parseTimePtr(r.deletedAt)
|
||||
e.CardType = nullToCardType(r.cardType)
|
||||
e.CardData = nullToPtr(r.cardData)
|
||||
e.LastUsedAt = parseTimePtr(r.lastUsedAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
func (s *Store) batchLoadTags(entities []*Entity) error {
|
||||
@@ -568,6 +583,9 @@ func (s *Store) loadTags(entityID string) ([]string, error) {
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
@@ -631,11 +649,13 @@ func boolToInt(b bool) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (e *Entity) CardDataJSON() map[string]interface{} {
|
||||
func (e *Entity) CardDataJSON() (map[string]interface{}, error) {
|
||||
if e.CardData == nil {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal([]byte(*e.CardData), &m)
|
||||
return m
|
||||
if err := json.Unmarshal([]byte(*e.CardData), &m); err != nil {
|
||||
return nil, fmt.Errorf("card_data: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -441,3 +441,34 @@ func TestResolve_NotFound(t *testing.T) {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbsorb_SourceIsCard(t *testing.T) {
|
||||
s := testStore(t)
|
||||
target := &Entity{Body: "target", Glyph: GlyphNote, Tags: []string{"a"}}
|
||||
s.Create(target)
|
||||
|
||||
source := &Entity{Body: "source", Glyph: GlyphNote}
|
||||
s.Create(source)
|
||||
s.Promote(source.ID, CardSnippet, nil)
|
||||
s.IncrementUse(source.ID)
|
||||
|
||||
if err := s.Absorb(target.ID, source.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, _ := s.Get(target.ID)
|
||||
if got.Body != "target\nsource" {
|
||||
t.Errorf("merged body: %q", got.Body)
|
||||
}
|
||||
|
||||
src, _ := s.Get(source.ID)
|
||||
if src.CardType != nil {
|
||||
t.Error("source card_type should be cleared after absorb")
|
||||
}
|
||||
if src.UseCount != 0 {
|
||||
t.Errorf("source use_count should be reset, got %d", src.UseCount)
|
||||
}
|
||||
if src.DeletedAt == nil {
|
||||
t.Error("source should be soft-deleted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ func (s *Store) ListTags() ([]TagCount, error) {
|
||||
}
|
||||
tags = append(tags, tc)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []TagCount{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
)
|
||||
|
||||
func TestDisplayGlyph_Fluid(t *testing.T) {
|
||||
tests := []struct {
|
||||
glyph db.Glyph
|
||||
want string
|
||||
}{
|
||||
{db.GlyphNote, "—"},
|
||||
{db.GlyphTodo, "○"},
|
||||
{db.GlyphEvent, "◇"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := DisplayGlyph(tt.glyph, nil)
|
||||
if got != tt.want {
|
||||
t.Errorf("DisplayGlyph(%q, nil) = %q, want %q", tt.glyph, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayGlyph_Card(t *testing.T) {
|
||||
tests := []struct {
|
||||
cardType db.CardType
|
||||
want string
|
||||
}{
|
||||
{db.CardSnippet, "◆"},
|
||||
{db.CardTemplate, "◈"},
|
||||
{db.CardChecklist, "☐"},
|
||||
{db.CardDecision, "⚖"},
|
||||
{db.CardLink, "↗"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
ct := tt.cardType
|
||||
got := DisplayGlyph(db.GlyphNote, &ct)
|
||||
if got != tt.want {
|
||||
t.Errorf("DisplayGlyph(note, %q) = %q, want %q", tt.cardType, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayGlyph_CardOverridesGlyph(t *testing.T) {
|
||||
ct := db.CardSnippet
|
||||
got := DisplayGlyph(db.GlyphTodo, &ct)
|
||||
if got != "◆" {
|
||||
t.Errorf("card_type should override glyph, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayGlyph_UnknownFallback(t *testing.T) {
|
||||
got := DisplayGlyph(db.Glyph("unknown"), nil)
|
||||
if got != "—" {
|
||||
t.Errorf("unknown glyph should fall back to —, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatID_Long(t *testing.T) {
|
||||
got := FormatID("01HXYZ1234567890ABCDEFGH")
|
||||
if got != "01HXYZ123456" {
|
||||
t.Errorf("expected 12-char truncation, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatID_Short(t *testing.T) {
|
||||
got := FormatID("ABC")
|
||||
if got != "ABC" {
|
||||
t.Errorf("short ID should pass through, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatID_Exact12(t *testing.T) {
|
||||
got := FormatID("123456789012")
|
||||
if got != "123456789012" {
|
||||
t.Errorf("exact 12-char should pass through, got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user