- 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
138 lines
4.5 KiB
Go
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))
|
|
}
|
|
}
|