homelabby/internal/api/handlers/search_test.go
Mikkel Georgsen 9db7707a64 feat(07-02): SearchHandler — NL query to NetBox filter with Tier 1 LLM
- internal/api/handlers/search.go: SearchHandler, NewSearchHandler, SearchDevices
- Sanitizes query (non-printable stripped, 200 char max) per T-07-05
- LLM extracts catalog_status/name_contains/tag; falls back to substring on parse failure
- internal/api/handlers/search_test.go: 4 tests covering 400, fallback, status filter, combined
- internal/api/router.go: wires GET /api/search with nil guard (503)
- cmd/hwlab/main.go: constructs searchHandler and passes to NewRouter
2026-04-10 07:55:07 +00:00

138 lines
4.5 KiB
Go

package handlers_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.georgsen.dk/hwlab/internal/api/handlers"
"git.georgsen.dk/hwlab/internal/netbox"
)
// mockNetBoxClient satisfies the SearchNetBoxClient interface.
type mockNetBoxClient struct {
devices []netbox.Device
err error
}
func (m *mockNetBoxClient) ListDevices(_ context.Context, _ int) ([]netbox.Device, error) {
return m.devices, m.err
}
// mockAIClient returns canned JSON for TextComplete.
type mockAIClient struct {
response string
err error
}
func (m *mockAIClient) TextComplete(_ context.Context, _ string) (string, error) {
return m.response, m.err
}
func testDevices() []netbox.Device {
return []netbox.Device{
{ID: 1, Name: "10GbE NIC Intel X550", CustomFields: netbox.CustomFields{CatalogStatus: "available", HWID: "HW-0001"}},
{ID: 2, Name: "Raspberry Pi 4B", CustomFields: netbox.CustomFields{CatalogStatus: "draft", HWID: "HW-0002"}},
{ID: 3, Name: "10GbE NIC Mellanox", CustomFields: netbox.CustomFields{CatalogStatus: "complete", HWID: "HW-0003"}},
{ID: 4, Name: "USB-C Hub", CustomFields: netbox.CustomFields{CatalogStatus: "available", HWID: "HW-0004"}},
}
}
// TestSearch_EmptyQ verifies that missing q parameter returns 400.
func TestSearch_EmptyQ(t *testing.T) {
h := handlers.NewSearchHandler(&mockNetBoxClient{}, &mockAIClient{})
req := httptest.NewRequest(http.MethodGet, "/api/search", nil)
w := httptest.NewRecorder()
h.SearchDevices(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
var body map[string]string
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if !strings.Contains(body["error"], "q parameter") {
t.Errorf("expected error about q parameter, got %q", body["error"])
}
}
// TestSearch_LLMParseFallback verifies that when LLM returns unparseable JSON,
// the handler falls back to name substring match against the raw query.
func TestSearch_LLMParseFallback(t *testing.T) {
nb := &mockNetBoxClient{devices: testDevices()}
ai := &mockAIClient{response: "not valid json {{ broken"}
h := handlers.NewSearchHandler(nb, ai)
req := httptest.NewRequest(http.MethodGet, "/api/search?q=NIC", nil)
w := httptest.NewRecorder()
h.SearchDevices(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
var items []map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&items); err != nil {
t.Fatalf("decode: %v", err)
}
// "NIC" substring matches devices 1 and 3 (both contain "NIC")
if len(items) != 2 {
t.Errorf("expected 2 NIC results, got %d: %v", len(items), items)
}
}
// TestSearch_CatalogStatusFilter verifies that LLM-extracted catalog_status filters results.
func TestSearch_CatalogStatusFilter(t *testing.T) {
nb := &mockNetBoxClient{devices: testDevices()}
aiResp := `{"catalog_status": "available", "name_contains": "", "tag": ""}`
ai := &mockAIClient{response: aiResp}
h := handlers.NewSearchHandler(nb, ai)
req := httptest.NewRequest(http.MethodGet, "/api/search?q=show+me+available+items", nil)
w := httptest.NewRecorder()
h.SearchDevices(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
var items []map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&items); err != nil {
t.Fatalf("decode: %v", err)
}
// Devices 1 and 4 have catalog_status "available"
if len(items) != 2 {
t.Errorf("expected 2 available items, got %d", len(items))
}
for _, item := range items {
if item["catalog_status"] != "available" {
t.Errorf("unexpected catalog_status: %v", item["catalog_status"])
}
}
}
// TestSearch_NameContainsAndStatus verifies combined filtering works.
func TestSearch_NameContainsAndStatus(t *testing.T) {
nb := &mockNetBoxClient{devices: testDevices()}
aiResp := `{"catalog_status": "available", "name_contains": "NIC", "tag": ""}`
ai := &mockAIClient{response: aiResp}
h := handlers.NewSearchHandler(nb, ai)
req := httptest.NewRequest(http.MethodGet, "/api/search?q=available+NICs", nil)
w := httptest.NewRecorder()
h.SearchDevices(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var items []map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&items); err != nil {
t.Fatalf("decode: %v", err)
}
// Only device 1: NIC + available (device 3 is "complete")
if len(items) != 1 {
t.Errorf("expected 1 result, got %d", len(items))
}
}