diff --git a/cmd/promote_test.go b/cmd/promote_test.go new file mode 100644 index 0000000..b200278 --- /dev/null +++ b/cmd/promote_test.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "encoding/json" + "testing" + + "github.com/lerko/nib/internal/db" +) + +func TestGenerateCardData_Snippet(t *testing.T) { + data := generateCardData(db.CardSnippet, "some snippet") + if data == nil || *data != "{}" { + t.Errorf("snippet should produce {}, got %v", data) + } +} + +func TestGenerateCardData_Template(t *testing.T) { + data := generateCardData(db.CardTemplate, "deploy ${host} to ${env}") + if data == nil { + t.Fatal("expected non-nil data") + } + + var parsed struct { + Slots []struct { + Name string `json:"name"` + Default string `json:"default"` + } `json:"slots"` + } + if err := json.Unmarshal([]byte(*data), &parsed); err != nil { + t.Fatal(err) + } + if len(parsed.Slots) != 2 { + t.Fatalf("expected 2 slots, got %d", len(parsed.Slots)) + } + if parsed.Slots[0].Name != "host" { + t.Errorf("first slot: %q", parsed.Slots[0].Name) + } + if parsed.Slots[1].Name != "env" { + t.Errorf("second slot: %q", parsed.Slots[1].Name) + } +} + +func TestGenerateCardData_TemplateDedupe(t *testing.T) { + data := generateCardData(db.CardTemplate, "${x} and ${x}") + var parsed struct { + Slots []struct { + Name string `json:"name"` + } `json:"slots"` + } + json.Unmarshal([]byte(*data), &parsed) + if len(parsed.Slots) != 1 { + t.Errorf("duplicate slots should be deduped, got %d", len(parsed.Slots)) + } +} + +func TestGenerateCardData_TemplateNoSlots(t *testing.T) { + data := generateCardData(db.CardTemplate, "no placeholders here") + var parsed struct { + Slots []struct { + Name string `json:"name"` + } `json:"slots"` + } + json.Unmarshal([]byte(*data), &parsed) + if len(parsed.Slots) != 0 { + t.Errorf("expected empty slots, got %d", len(parsed.Slots)) + } +} + +func TestGenerateCardData_Checklist(t *testing.T) { + body := "[ ] step one\n[x] step two\n[ ] step three" + data := generateCardData(db.CardChecklist, body) + if data == nil { + t.Fatal("expected non-nil data") + } + + var parsed struct { + Steps []struct { + Text string `json:"text"` + Done bool `json:"done"` + } `json:"steps"` + } + if err := json.Unmarshal([]byte(*data), &parsed); err != nil { + t.Fatal(err) + } + if len(parsed.Steps) != 3 { + t.Fatalf("expected 3 steps, got %d", len(parsed.Steps)) + } + if parsed.Steps[0].Text != "step one" || parsed.Steps[0].Done { + t.Errorf("step 0: %+v", parsed.Steps[0]) + } + if parsed.Steps[1].Text != "step two" || !parsed.Steps[1].Done { + t.Errorf("step 1: %+v", parsed.Steps[1]) + } +} + +func TestGenerateCardData_ChecklistFallback(t *testing.T) { + data := generateCardData(db.CardChecklist, "no checkbox syntax") + var parsed struct { + Steps []struct { + Text string `json:"text"` + Done bool `json:"done"` + } `json:"steps"` + } + json.Unmarshal([]byte(*data), &parsed) + if len(parsed.Steps) != 1 { + t.Fatalf("fallback should produce 1 step, got %d", len(parsed.Steps)) + } + if parsed.Steps[0].Text != "no checkbox syntax" { + t.Errorf("fallback step text: %q", parsed.Steps[0].Text) + } +} + +func TestGenerateCardData_Decision(t *testing.T) { + data := generateCardData(db.CardDecision, "which db?") + var parsed struct { + Chose string `json:"chose"` + Why string `json:"why"` + Rejected []string `json:"rejected"` + } + if err := json.Unmarshal([]byte(*data), &parsed); err != nil { + t.Fatal(err) + } + if parsed.Chose != "" || parsed.Why != "" { + t.Error("decision fields should start empty") + } + if len(parsed.Rejected) != 0 { + t.Error("rejected should start empty") + } +} + +func TestGenerateCardData_Link(t *testing.T) { + data := generateCardData(db.CardLink, "check https://example.com/path for details") + var parsed struct { + URL string `json:"url"` + } + json.Unmarshal([]byte(*data), &parsed) + if parsed.URL != "https://example.com/path" { + t.Errorf("url: %q", parsed.URL) + } +} + +func TestGenerateCardData_LinkNoURL(t *testing.T) { + data := generateCardData(db.CardLink, "no url here") + var parsed struct { + URL string `json:"url"` + } + json.Unmarshal([]byte(*data), &parsed) + if parsed.URL != "" { + t.Errorf("expected empty url, got %q", parsed.URL) + } +} diff --git a/go.mod b/go.mod index 8ee951d..9972a46 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,24 @@ module github.com/lerko/nib go 1.24.4 require ( - github.com/atotto/clipboard v0.1.4 // indirect + github.com/atotto/clipboard v0.1.4 + github.com/go-chi/chi/v5 v5.2.5 + github.com/oklog/ulid/v2 v2.1.1 + github.com/spf13/cobra v1.10.2 + modernc.org/sqlite v1.37.1 +) + +require ( github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/sys v0.33.0 // indirect modernc.org/libc v1.65.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.37.1 // indirect ) diff --git a/go.sum b/go.sum index 7fe58f6..d394f26 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -26,15 +28,37 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= +modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= +modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 303e508..2ff96a3 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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 { diff --git a/internal/api/entities.go b/internal/api/entities.go index 592bb2e..1d40595 100644 --- a/internal/api/entities.go +++ b/internal/api/entities.go @@ -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)) diff --git a/internal/api/helpers.go b/internal/api/helpers.go index d9723ad..5a3a56f 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -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, diff --git a/internal/api/tags.go b/internal/api/tags.go index 9d26ca1..79607ed 100644 --- a/internal/api/tags.go +++ b/internal/api/tags.go @@ -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 } diff --git a/internal/db/entities.go b/internal/db/entities.go index fbc180f..ff5f6c5 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -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 } diff --git a/internal/db/entities_test.go b/internal/db/entities_test.go index 19ef9e2..1592d95 100644 --- a/internal/db/entities_test.go +++ b/internal/db/entities_test.go @@ -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") + } +} diff --git a/internal/db/tags.go b/internal/db/tags.go index 29b2787..52b8f56 100644 --- a/internal/db/tags.go +++ b/internal/db/tags.go @@ -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{} } diff --git a/internal/display/glyph_test.go b/internal/display/glyph_test.go new file mode 100644 index 0000000..5ad53a9 --- /dev/null +++ b/internal/display/glyph_test.go @@ -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) + } +} diff --git a/web/app.js b/web/app.js index 1db5e94..14572ad 100644 --- a/web/app.js +++ b/web/app.js @@ -349,7 +349,7 @@ `; case 'link': - if (data.url) { + if (data.url && isSafeUrl(data.url)) { return `
`; @@ -754,6 +754,10 @@ return escHtml(s).replace(/'/g, '''); } + function isSafeUrl(url) { + return /^https?:\/\//i.test(url); + } + // ========== Theme ========== const themeToggle = $('#theme-toggle');