homelabby/internal/research/agent_test.go
Mikkel Georgsen 0072aa41bd feat(07-01): ResearchAgent worker, trigger endpoint, main.go wiring
- 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
2026-04-10 07:51:13 +00:00

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)
}
}