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 TestAbsorbEntity_Success(t *testing.T) { srv, _ := testServer(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{ "source_id": source.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.Body != "target body\nsource body" { t.Errorf("merged body: %q", e.Body) } if len(e.Tags) != 2 { t.Errorf("expected 2 tags, got %v", e.Tags) } // Source should be soft-deleted (not in default list) listResp, _ := http.Get(srv.URL + "/api/entities") var entities []EntityResponse json.NewDecoder(listResp.Body).Decode(&entities) listResp.Body.Close() for _, ent := range entities { if ent.ID == source.ID { t.Error("source should be soft-deleted and hidden from default list") } } } func TestAbsorbEntity_TargetCrystallized(t *testing.T) { srv, _ := testServer(t) target := createTestEntity(t, srv, "target", nil) source := createTestEntity(t, srv, "source", nil) postJSON(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{ "source_id": source.ID, }) 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_absorb" { t.Errorf("error: %q", errResp.Error) } } 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{ "source_id": e.ID, }) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } 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{}) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestCreateEntity_WithTitle(t *testing.T) { srv, _ := testServer(t) resp := postJSON(srv, "/api/entities", map[string]any{ "body": "body text", "title": "nginx trick", "description": "always forget this", "tags": []string{"ops"}, }) 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.Title == nil || *e.Title != "nginx trick" { t.Errorf("title: %v", e.Title) } if e.Description == nil || *e.Description != "always forget this" { t.Errorf("description: %v", e.Description) } } func TestCreateEntity_TitleOnly(t *testing.T) { srv, _ := testServer(t) title := "title only" resp := postJSON(srv, "/api/entities", map[string]any{ "title": title, }) 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.Title == nil || *e.Title != "title only" { t.Errorf("title: %v", e.Title) } } func TestUpdateEntity_Title(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "body", nil) req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader( mustJSON(map[string]any{"title": "new title"}))) 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.Title == nil || *e.Title != "new title" { t.Errorf("title: %v", e.Title) } } func TestUpdateEntity_Description(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "body", nil) req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader( mustJSON(map[string]any{"description": "new desc"}))) 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.Description == nil || *e.Description != "new desc" { t.Errorf("description: %v", e.Description) } } func TestListEntities_TitleInResponse(t *testing.T) { srv, _ := testServer(t) title := "list title" postJSON(srv, "/api/entities", map[string]any{ "body": "body", "title": title, }).Body.Close() resp, _ := http.Get(srv.URL + "/api/entities") 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)) } if entities[0].Title == nil || *entities[0].Title != "list title" { t.Errorf("title: %v", entities[0].Title) } } func mustJSON(v any) []byte { b, _ := json.Marshal(v) return b }