03094706c3
- Fix N+1 tag query in List() with batched IN clause
- Add inline body editing in web detail pane (dblclick or e key)
- Delete API returns {result: "soft"|"hard"} with 200 instead of 204
- SPA handler serves index.html for all extensionless paths
- Link glyph changed from emoji 🔗 to unicode ↗ for terminal alignment
- Capture bar contrast and hover glow increased
- Comment on load-bearing "--" in root.go
428 lines
11 KiB
Go
428 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/lerko/nib/internal/db"
|
|
)
|
|
|
|
func testServer(t *testing.T) (*httptest.Server, *db.Store) {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "test.db")
|
|
store, err := db.Open(path)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
router := NewRouter(store, false)
|
|
srv := httptest.NewServer(router)
|
|
t.Cleanup(srv.Close)
|
|
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))
|
|
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{
|
|
"body": body,
|
|
"tags": tags,
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create failed: %d", resp.StatusCode)
|
|
}
|
|
var e EntityResponse
|
|
json.NewDecoder(resp.Body).Decode(&e)
|
|
resp.Body.Close()
|
|
return e
|
|
}
|
|
|
|
func TestCreateEntity_Note(t *testing.T) {
|
|
srv, _ := testServer(t)
|
|
|
|
resp := postJSON(srv, "/api/entities", map[string]any{
|
|
"body": "test note",
|
|
"tags": []string{"demo"},
|
|
})
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var e EntityResponse
|
|
json.NewDecoder(resp.Body).Decode(&e)
|
|
|
|
if e.Body != "test note" {
|
|
t.Errorf("body: %q", e.Body)
|
|
}
|
|
if e.Glyph != "note" {
|
|
t.Errorf("glyph: %q", e.Glyph)
|
|
}
|
|
if len(e.Tags) != 1 || e.Tags[0] != "demo" {
|
|
t.Errorf("tags: %v", e.Tags)
|
|
}
|
|
}
|
|
|
|
func TestCreateEntity_MissingBody(t *testing.T) {
|
|
srv, _ := testServer(t)
|
|
|
|
resp := postJSON(srv, "/api/entities", map[string]any{})
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var errResp ErrorResponse
|
|
json.NewDecoder(resp.Body).Decode(&errResp)
|
|
if errResp.Error != "invalid_input" {
|
|
t.Errorf("error code: %q", errResp.Error)
|
|
}
|
|
}
|
|
|
|
func TestCreateEntity_InvalidGlyph(t *testing.T) {
|
|
srv, _ := testServer(t)
|
|
|
|
resp := postJSON(srv, "/api/entities", map[string]any{
|
|
"body": "test",
|
|
"glyph": "invalid",
|
|
})
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestGetEntity_Success(t *testing.T) {
|
|
srv, _ := testServer(t)
|
|
created := createTestEntity(t, srv, "test", nil)
|
|
|
|
resp, _ := http.Get(srv.URL + "/api/entities/" + created.ID)
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var e EntityResponse
|
|
json.NewDecoder(resp.Body).Decode(&e)
|
|
if e.ID != created.ID {
|
|
t.Errorf("id mismatch: %q != %q", e.ID, created.ID)
|
|
}
|
|
}
|
|
|
|
func TestGetEntity_NotFound(t *testing.T) {
|
|
srv, _ := testServer(t)
|
|
|
|
resp, _ := http.Get(srv.URL + "/api/entities/NONEXISTENT")
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestListEntities_Default(t *testing.T) {
|
|
srv, _ := testServer(t)
|
|
createTestEntity(t, srv, "one", nil)
|
|
createTestEntity(t, srv, "two", nil)
|
|
|
|
resp, _ := http.Get(srv.URL + "/api/entities")
|
|
defer resp.Body.Close()
|
|
|
|
var entities []EntityResponse
|
|
json.NewDecoder(resp.Body).Decode(&entities)
|
|
if len(entities) != 2 {
|
|
t.Fatalf("expected 2, got %d", len(entities))
|
|
}
|
|
}
|
|
|
|
func TestListEntities_FilterTag(t *testing.T) {
|
|
srv, _ := testServer(t)
|
|
createTestEntity(t, srv, "a", []string{"ops"})
|
|
createTestEntity(t, srv, "b", []string{"home"})
|
|
|
|
resp, _ := http.Get(srv.URL + "/api/entities?tag=ops")
|
|
defer resp.Body.Close()
|
|
|
|
var entities []EntityResponse
|
|
json.NewDecoder(resp.Body).Decode(&entities)
|
|
if len(entities) != 1 {
|
|
t.Fatalf("expected 1, got %d", len(entities))
|
|
}
|
|
}
|
|
|
|
func TestListEntities_CardsOnly(t *testing.T) {
|
|
srv, _ := testServer(t)
|
|
createTestEntity(t, srv, "fluid", nil)
|
|
|
|
resp := postJSON(srv, "/api/entities", map[string]any{
|
|
"body": "card",
|
|
"card_type": "snippet",
|
|
})
|
|
resp.Body.Close()
|
|
|
|
resp, _ = http.Get(srv.URL + "/api/entities?cards_only=true")
|
|
defer resp.Body.Close()
|
|
|
|
var entities []EntityResponse
|
|
json.NewDecoder(resp.Body).Decode(&entities)
|
|
if len(entities) != 1 {
|
|
t.Fatalf("expected 1 card, got %d", len(entities))
|
|
}
|
|
}
|
|
|
|
func TestListEntities_Pagination(t *testing.T) {
|
|
srv, _ := testServer(t)
|
|
for i := 0; i < 5; i++ {
|
|
createTestEntity(t, srv, "note", nil)
|
|
}
|
|
|
|
resp, _ := http.Get(srv.URL + "/api/entities?limit=2&offset=0")
|
|
var page1 []EntityResponse
|
|
json.NewDecoder(resp.Body).Decode(&page1)
|
|
resp.Body.Close()
|
|
|
|
resp, _ = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
|
|
var page2 []EntityResponse
|
|
json.NewDecoder(resp.Body).Decode(&page2)
|
|
resp.Body.Close()
|
|
|
|
if len(page1) != 2 || len(page2) != 2 {
|
|
t.Fatalf("pages: %d, %d", len(page1), len(page2))
|
|
}
|
|
if page1[0].ID == page2[0].ID {
|
|
t.Error("pages overlap")
|
|
}
|
|
}
|
|
|
|
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(
|
|
mustJSON(map[string]any{"body": "new"})))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, _ := http.DefaultClient.Do(req)
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var e EntityResponse
|
|
json.NewDecoder(resp.Body).Decode(&e)
|
|
if e.Body != "new" {
|
|
t.Errorf("body: %q", e.Body)
|
|
}
|
|
}
|
|
|
|
func TestDeleteEntity_SoftThenHard(t *testing.T) {
|
|
srv, _ := testServer(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)
|
|
var delResp DeleteResponse
|
|
json.NewDecoder(resp.Body).Decode(&delResp)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("soft delete: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
if delResp.Result != "soft" {
|
|
t.Fatalf("soft delete: expected result 'soft', got %q", delResp.Result)
|
|
}
|
|
|
|
// Hard delete
|
|
resp, _ = http.DefaultClient.Do(req)
|
|
json.NewDecoder(resp.Body).Decode(&delResp)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("hard delete: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
if delResp.Result != "hard" {
|
|
t.Fatalf("hard delete: expected result 'hard', got %q", delResp.Result)
|
|
}
|
|
|
|
// Gone
|
|
resp, _ = http.Get(srv.URL + "/api/entities/" + created.ID)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("expected 404 after hard delete, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
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{
|
|
"card_type": "snippet",
|
|
})
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var e EntityResponse
|
|
json.NewDecoder(resp.Body).Decode(&e)
|
|
if e.CardType == nil || *e.CardType != "snippet" {
|
|
t.Errorf("card_type: %v", e.CardType)
|
|
}
|
|
}
|
|
|
|
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{
|
|
"card_type": "snippet",
|
|
}).Body.Close()
|
|
|
|
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
|
|
"card_type": "template",
|
|
})
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var errResp ErrorResponse
|
|
json.NewDecoder(resp.Body).Decode(&errResp)
|
|
if errResp.Error != "invalid_promote" {
|
|
t.Errorf("error: %q", errResp.Error)
|
|
}
|
|
}
|
|
|
|
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{
|
|
"card_type": "bogus",
|
|
})
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
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{
|
|
"card_type": "snippet",
|
|
}).Body.Close()
|
|
|
|
resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil)
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var e EntityResponse
|
|
json.NewDecoder(resp.Body).Decode(&e)
|
|
if e.CardType != nil {
|
|
t.Errorf("card_type should be nil: %v", e.CardType)
|
|
}
|
|
}
|
|
|
|
func TestDemoteEntity_AlreadyFluid(t *testing.T) {
|
|
srv, _ := testServer(t)
|
|
created := createTestEntity(t, srv, "trick", nil)
|
|
|
|
resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil)
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestUseEntity_Success(t *testing.T) {
|
|
srv, _ := testServer(t)
|
|
created := createTestEntity(t, srv, "trick", nil)
|
|
|
|
resp := postJSON(srv, "/api/entities/"+created.ID+"/use", nil)
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var e EntityResponse
|
|
json.NewDecoder(resp.Body).Decode(&e)
|
|
if e.UseCount != 1 {
|
|
t.Errorf("use_count: %d", e.UseCount)
|
|
}
|
|
}
|
|
|
|
func TestListTags_WithCounts(t *testing.T) {
|
|
srv, _ := testServer(t)
|
|
createTestEntity(t, srv, "a", []string{"ops"})
|
|
createTestEntity(t, srv, "b", []string{"ops", "nginx"})
|
|
|
|
resp, _ := http.Get(srv.URL + "/api/tags")
|
|
defer resp.Body.Close()
|
|
|
|
var tags []TagResponse
|
|
json.NewDecoder(resp.Body).Decode(&tags)
|
|
if len(tags) != 2 {
|
|
t.Fatalf("expected 2 tags, got %d", len(tags))
|
|
}
|
|
}
|
|
|
|
func TestCORS_DevMode(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "test.db")
|
|
store, _ := db.Open(path)
|
|
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)
|
|
defer resp.Body.Close()
|
|
|
|
if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
|
|
t.Error("CORS header missing in dev mode")
|
|
}
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Errorf("OPTIONS: expected 204, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
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)
|
|
defer resp.Body.Close()
|
|
|
|
if resp.Header.Get("Access-Control-Allow-Origin") != "" {
|
|
t.Error("CORS header should not be set in prod mode")
|
|
}
|
|
}
|
|
|
|
func mustJSON(v any) []byte {
|
|
b, _ := json.Marshal(v)
|
|
return b
|
|
}
|