- internal/research/agent.go: Agent with RunOnce+Start, sanitizeQuery, interface adapters - internal/research/agent_test.go: stub-based unit tests (sanitize, enrich, skip, empty) - internal/ai/client.go: TierClient.TextComplete for text-only LLM calls - internal/api/handlers/research.go: POST /api/research/trigger handler (202 Accepted) - internal/api/router.go: researchHandler param + /api/research/trigger route - cmd/hwlab/main.go: research agent goroutine started with 10min interval
180 lines
4.9 KiB
Go
180 lines
4.9 KiB
Go
package research_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"git.georgsen.dk/hwlab/internal/ai"
|
|
"git.georgsen.dk/hwlab/internal/inventory"
|
|
"git.georgsen.dk/hwlab/internal/netbox"
|
|
"git.georgsen.dk/hwlab/internal/research"
|
|
)
|
|
|
|
// --- Stubs ---
|
|
|
|
// stubResearchClient returns canned SearchResults.
|
|
type stubResearchClient struct {
|
|
results []ai.SearchResult
|
|
err error
|
|
calls []string
|
|
}
|
|
|
|
func (s *stubResearchClient) Search(_ context.Context, query string) ([]ai.SearchResult, error) {
|
|
s.calls = append(s.calls, query)
|
|
return s.results, s.err
|
|
}
|
|
|
|
// stubTextCompleter returns a canned LLM response text.
|
|
type stubTextCompleter struct {
|
|
response string
|
|
err error
|
|
calls []string
|
|
}
|
|
|
|
func (s *stubTextCompleter) TextComplete(_ context.Context, prompt string) (string, error) {
|
|
s.calls = append(s.calls, prompt)
|
|
return s.response, s.err
|
|
}
|
|
|
|
// stubNetBoxClient satisfies the research.NetBoxer interface used by Agent.
|
|
type stubNetBoxClient struct {
|
|
devices []netbox.Device
|
|
patches map[int64]map[string]interface{}
|
|
}
|
|
|
|
func (s *stubNetBoxClient) ListDevicesWithStatus(_ context.Context, status string) ([]netbox.Device, error) {
|
|
return s.devices, nil
|
|
}
|
|
|
|
func (s *stubNetBoxClient) PatchCustomFields(_ context.Context, deviceID int64, patch map[string]interface{}) error {
|
|
if s.patches == nil {
|
|
s.patches = make(map[int64]map[string]interface{})
|
|
}
|
|
s.patches[deviceID] = patch
|
|
return nil
|
|
}
|
|
|
|
// stubCatalogUpdater records transitions.
|
|
type stubCatalogUpdater struct {
|
|
transitions []struct {
|
|
id int64
|
|
current inventory.CatalogStatus
|
|
next inventory.CatalogStatus
|
|
}
|
|
}
|
|
|
|
func (s *stubCatalogUpdater) UpdateCatalogStatus(_ context.Context, deviceID int64, current, next inventory.CatalogStatus) (inventory.CatalogStatus, error) {
|
|
s.transitions = append(s.transitions, struct {
|
|
id int64
|
|
current inventory.CatalogStatus
|
|
next inventory.CatalogStatus
|
|
}{deviceID, current, next})
|
|
return next, nil
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
func TestSanitizeQuery(t *testing.T) {
|
|
cases := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"Intel NIC i350", "Intel NIC i350"},
|
|
{"Dell<script>alert(1)</script>", "Dell script alert 1 script"},
|
|
{"HP ProLiant DL380 Gen9", "HP ProLiant DL380 Gen9"},
|
|
{" trim ", "trim"},
|
|
{"special!@#$%chars", "special chars"},
|
|
{"dots.and-dashes_ok", "dots.and-dashes_ok"},
|
|
}
|
|
for _, tc := range cases {
|
|
got := research.SanitizeQuery(tc.input)
|
|
if got != tc.expected {
|
|
t.Errorf("SanitizeQuery(%q) = %q, want %q", tc.input, got, tc.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunOnce_EnrichesDevice(t *testing.T) {
|
|
nb := &stubNetBoxClient{
|
|
devices: []netbox.Device{
|
|
{
|
|
ID: 42,
|
|
Name: "Intel i350 NIC",
|
|
CustomFields: netbox.CustomFields{
|
|
CatalogStatus: "needs_research",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
rc := &stubResearchClient{
|
|
results: []ai.SearchResult{
|
|
{Title: "Intel i350", URL: "https://ark.intel.com", Snippet: "Quad-port GbE"},
|
|
{Title: "Datasheet", URL: "https://intel.com/ds", Snippet: "Technical specs"},
|
|
},
|
|
}
|
|
llm := &stubTextCompleter{
|
|
response: `{"ai_notes": "Intel i350 quad-port GbE adapter", "product_url": "https://ark.intel.com"}`,
|
|
}
|
|
updater := &stubCatalogUpdater{}
|
|
|
|
agent := research.NewAgent(nb, rc, llm, updater)
|
|
enriched, err := agent.RunOnce(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("RunOnce error: %v", err)
|
|
}
|
|
if enriched != 1 {
|
|
t.Errorf("expected enriched=1, got %d", enriched)
|
|
}
|
|
if len(updater.transitions) != 1 {
|
|
t.Fatalf("expected 1 status transition, got %d", len(updater.transitions))
|
|
}
|
|
tr := updater.transitions[0]
|
|
if tr.id != 42 {
|
|
t.Errorf("expected device id=42, got %d", tr.id)
|
|
}
|
|
if tr.current != inventory.StatusNeedsResearch {
|
|
t.Errorf("unexpected current status: %s", tr.current)
|
|
}
|
|
if tr.next != inventory.StatusResearched {
|
|
t.Errorf("unexpected next status: %s", tr.next)
|
|
}
|
|
}
|
|
|
|
func TestRunOnce_SkipsDeviceWithNoResults(t *testing.T) {
|
|
nb := &stubNetBoxClient{
|
|
devices: []netbox.Device{
|
|
{ID: 10, Name: "Mystery Device", CustomFields: netbox.CustomFields{CatalogStatus: "needs_research"}},
|
|
},
|
|
}
|
|
rc := &stubResearchClient{results: []ai.SearchResult{}} // empty
|
|
llm := &stubTextCompleter{}
|
|
updater := &stubCatalogUpdater{}
|
|
|
|
agent := research.NewAgent(nb, rc, llm, updater)
|
|
enriched, err := agent.RunOnce(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("RunOnce error: %v", err)
|
|
}
|
|
if enriched != 0 {
|
|
t.Errorf("expected enriched=0 (skipped), got %d", enriched)
|
|
}
|
|
if len(updater.transitions) != 0 {
|
|
t.Errorf("expected 0 transitions (device skipped), got %d", len(updater.transitions))
|
|
}
|
|
}
|
|
|
|
func TestRunOnce_NoDevices(t *testing.T) {
|
|
nb := &stubNetBoxClient{devices: []netbox.Device{}}
|
|
rc := &stubResearchClient{}
|
|
llm := &stubTextCompleter{}
|
|
updater := &stubCatalogUpdater{}
|
|
|
|
agent := research.NewAgent(nb, rc, llm, updater)
|
|
enriched, err := agent.RunOnce(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if enriched != 0 {
|
|
t.Errorf("expected 0 enriched, got %d", enriched)
|
|
}
|
|
}
|