feat: enhance host resolution, filtering, and cache management
Release / release (push) Successful in 49s

- **Strategies**: Add resolver strategy input validation and parsing in setup wizard. Support comma-separated input with known strategy mapping.
- **Client**: Extend Search and SearchAll to include kind and tag filters. Add pagination for full cache refresh handling large datasets.
- **Cache**: Introduce `RecentlyUsed` and `MarkUsed`. Persist `LastUsed` timestamps for entries.
- **TUI**: Add recent hosts view, tag/kind filters, and inline editor for user/port override.
- **Tests**: Comprehensive unit tests for new features, including strategy validation, cache behavior, and client filtering.
- **Docs**: Update README with new TUI features and cache subcommands.
This commit is contained in:
Sebastian Unterschütz
2026-05-23 17:06:24 +02:00
parent cdf750081e
commit d127a3b957
10 changed files with 795 additions and 97 deletions
+91 -9
View File
@@ -60,7 +60,7 @@ func TestSearch_ReturnsBothDevicesAndVMs(t *testing.T) {
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "")
results, err := c.Search(context.Background(), "", SearchOptions{})
if err != nil {
t.Fatalf("Search: %v", err)
}
@@ -89,7 +89,7 @@ func TestSearch_MapsKindCorrectly(t *testing.T) {
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "")
results, _ := c.Search(context.Background(), "", SearchOptions{})
for _, r := range results {
switch r.Name {
@@ -115,7 +115,7 @@ func TestSearch_StripsPrefixFromPrimaryIP(t *testing.T) {
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "host")
results, _ := c.Search(context.Background(), "host", SearchOptions{})
if len(results) == 0 {
t.Fatal("expected at least one result")
}
@@ -140,7 +140,7 @@ func TestSearch_TagsAreMapped(t *testing.T) {
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "")
results, _ := c.Search(context.Background(), "", SearchOptions{})
if len(results[0].Tags) != 2 {
t.Errorf("tags: got %v, want [prod mgmt]", results[0].Tags)
}
@@ -161,7 +161,7 @@ func TestSearch_PartialFailure_ReturnsAvailableResults(t *testing.T) {
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "")
results, err := c.Search(context.Background(), "", SearchOptions{})
if err != nil {
t.Fatalf("partial failure should not return error, got: %v", err)
}
@@ -179,7 +179,7 @@ func TestSearch_BothFail_ReturnsError(t *testing.T) {
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
_, err := c.Search(context.Background(), "")
_, err := c.Search(context.Background(), "", SearchOptions{})
if err == nil {
t.Error("both endpoints failing should return an error")
}
@@ -289,7 +289,7 @@ func Test403_V1Token_HintsUpgrade(t *testing.T) {
defer srv.Close()
c := NewClient(srv.URL, "legacytoken", 1)
_, err := c.Search(context.Background(), "host")
_, err := c.Search(context.Background(), "host", SearchOptions{})
if err == nil {
t.Fatal("expected error on 403")
}
@@ -305,7 +305,7 @@ func Test403_V2Token_NoV1Hint(t *testing.T) {
defer srv.Close()
c := NewClient(srv.URL, "nbt_secret", 2)
_, err := c.Search(context.Background(), "host")
_, err := c.Search(context.Background(), "host", SearchOptions{})
if err == nil {
t.Fatal("expected error on 403")
}
@@ -328,7 +328,7 @@ func TestGet_SendsAuthorizationHeader(t *testing.T) {
defer srv.Close()
c := NewClient(srv.URL, "nbt_mytoken", 2)
c.Search(context.Background(), "") //nolint:errcheck
c.Search(context.Background(), "", SearchOptions{}) //nolint:errcheck
want := "Token nbt_mytoken"
if gotAuth != want {
@@ -336,6 +336,88 @@ func TestGet_SendsAuthorizationHeader(t *testing.T) {
}
}
func TestSearchAll_PaginatesResults(t *testing.T) {
// Simulate a NetBox endpoint with 3 total devices split across 2 pages.
// count=3 throughout; first page has 2 results, second has 1.
callCount := 0
mux := http.NewServeMux()
mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) {
callCount++
offset := r.URL.Query().Get("offset")
w.Header().Set("Content-Type", "application/json")
var resp netboxListResponse[netboxDevice]
switch offset {
case "", "0":
resp = netboxListResponse[netboxDevice]{
Count: 3,
Results: []netboxDevice{{ID: 1, Name: "d-01"}, {ID: 2, Name: "d-02"}},
}
default:
resp = netboxListResponse[netboxDevice]{
Count: 3,
Results: []netboxDevice{{ID: 3, Name: "d-03"}},
}
}
b, _ := json.Marshal(resp)
w.Write(b)
})
mux.HandleFunc("/api/virtualization/virtual-machines/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
b, _ := json.Marshal(vmListResponse())
w.Write(b)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
results, err := c.SearchAll(context.Background(), "", SearchOptions{})
if err != nil {
t.Fatalf("SearchAll: %v", err)
}
if len(results) != 3 {
t.Errorf("SearchAll: got %d results, want 3", len(results))
}
if callCount < 2 {
t.Errorf("expected at least 2 device API calls for pagination, got %d", callCount)
}
}
func TestSearch_KindFilterDeviceOnly(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/dcim/devices/": deviceListResponse(
netboxDevice{ID: 1, Name: "sw-01"},
),
})
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "", SearchOptions{Kind: "device"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(results) != 1 || results[0].Name != "sw-01" {
t.Errorf("expected 1 device result, got %v", results)
}
}
func TestSearch_KindFilterVMOnly(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/virtualization/virtual-machines/": vmListResponse(
netboxVM{ID: 1, Name: "vm-01"},
),
})
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "", SearchOptions{Kind: "vm"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(results) != 1 || results[0].Name != "vm-01" {
t.Errorf("expected 1 vm result, got %v", results)
}
}
func TestStripPrefix(t *testing.T) {
tests := []struct {
in string