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