package cache import ( "encoding/json" "os" "path/filepath" "testing" "time" ) func TestNew(t *testing.T) { c := New("/tmp/test.json", 60) if c == nil { t.Fatal("New returned nil") } if c.ttl != 60*time.Second { t.Errorf("ttl: got %v, want %v", c.ttl, 60*time.Second) } } func TestLoad_MissingFile(t *testing.T) { c := New("/nonexistent/path/cache.json", 60) if err := c.Load(); err != nil { t.Errorf("Load on missing file should not error, got: %v", err) } } func TestLoad_InvalidJSON(t *testing.T) { f := tempFile(t, []byte("not json")) c := New(f, 60) if err := c.Load(); err == nil { t.Error("Load on invalid JSON should return an error") } } func TestSaveAndLoad_Roundtrip(t *testing.T) { path := filepath.Join(t.TempDir(), "cache.json") c := New(path, 3600) c.Upsert(Entry{Name: "host-a", IP: "10.0.0.1", Kind: "device"}) c.Upsert(Entry{Name: "host-b", IP: "10.0.0.2", Kind: "vm", Tags: []string{"prod"}}) if err := c.Save(); err != nil { t.Fatalf("Save: %v", err) } c2 := New(path, 3600) if err := c2.Load(); err != nil { t.Fatalf("Load: %v", err) } e, _ := c2.Get("host-a") if e.IP != "10.0.0.1" { t.Errorf("host-a IP: got %q, want %q", e.IP, "10.0.0.1") } e2, _ := c2.Get("host-b") if len(e2.Tags) != 1 || e2.Tags[0] != "prod" { t.Errorf("host-b tags: got %v, want [prod]", e2.Tags) } } func TestSave_CreatesDirectory(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "sub", "dir", "cache.json") c := New(path, 60) c.Upsert(Entry{Name: "x", IP: "1.2.3.4", Kind: "device"}) if err := c.Save(); err != nil { t.Fatalf("Save: %v", err) } if _, err := os.Stat(path); err != nil { t.Errorf("cache file not created: %v", err) } } func TestUpsert_SetsTimestamp(t *testing.T) { c := New("", 60) before := time.Now() c.Upsert(Entry{Name: "h", IP: "1.1.1.1", Kind: "device"}) e, _ := c.Get("h") if e.CachedAt.Before(before) { t.Error("CachedAt should be set to current time on Upsert") } } func TestUpsert_Overwrites(t *testing.T) { c := New("", 60) c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"}) c.Upsert(Entry{Name: "host", IP: "10.0.0.2", Kind: "device"}) e, _ := c.Get("host") if e.IP != "10.0.0.2" { t.Errorf("Upsert should overwrite: got %q, want %q", e.IP, "10.0.0.2") } } func TestSearch_PrefixMatch(t *testing.T) { c := New("", 60) c.Upsert(Entry{Name: "app-server-01", IP: "10.0.0.1", Kind: "device"}) c.Upsert(Entry{Name: "app-server-02", IP: "10.0.0.2", Kind: "vm"}) c.Upsert(Entry{Name: "db-server-01", IP: "10.0.0.3", Kind: "device"}) results := c.Search("app") if len(results) != 2 { t.Errorf("Search(app): got %d results, want 2", len(results)) } } func TestSearch_CaseInsensitive(t *testing.T) { c := New("", 60) c.Upsert(Entry{Name: "App-Server", IP: "10.0.0.1", Kind: "device"}) if len(c.Search("app")) != 1 { t.Error("Search should be case-insensitive") } if len(c.Search("APP")) != 1 { t.Error("Search should be case-insensitive for uppercase") } } func TestSearch_EmptyPrefix(t *testing.T) { c := New("", 60) c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"}) c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "vm"}) if len(c.Search("")) != 2 { t.Error("Search('') should return all entries") } } func TestSearch_NoMatch(t *testing.T) { c := New("", 60) c.Upsert(Entry{Name: "host", IP: "1.1.1.1", Kind: "device"}) if len(c.Search("xyz")) != 0 { t.Error("Search should return empty slice when no match") } } func TestGet_Fresh(t *testing.T) { c := New("", 3600) c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"}) e, fresh := c.Get("host") if !fresh { t.Error("entry just inserted should be fresh") } if e.IP != "10.0.0.1" { t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.1") } } func TestGet_Expired(t *testing.T) { c := New("", 1) // 1 second TTL e := Entry{Name: "host", IP: "10.0.0.1", Kind: "device", CachedAt: time.Now().Add(-2 * time.Second)} c.mu.Lock() c.entries["host"] = e c.mu.Unlock() _, fresh := c.Get("host") if fresh { t.Error("entry older than TTL should not be fresh") } } func TestGet_ZeroTTL_AlwaysStale(t *testing.T) { c := New("", 0) // TTL=0 means never fresh for connect mode c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"}) _, fresh := c.Get("host") if fresh { t.Error("TTL=0 should always return fresh=false") } } func TestGet_Missing(t *testing.T) { c := New("", 60) _, fresh := c.Get("nonexistent") if fresh { t.Error("missing entry should not be fresh") } } func TestClear(t *testing.T) { c := New("", 60) c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"}) c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "vm"}) c.Clear() if len(c.All()) != 0 { t.Error("Clear should remove all entries") } } func TestAll(t *testing.T) { c := New("", 60) c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"}) c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "vm"}) all := c.All() if len(all) != 2 { t.Errorf("All: got %d entries, want 2", len(all)) } } func TestSave_ProducesValidJSON(t *testing.T) { path := filepath.Join(t.TempDir(), "cache.json") c := New(path, 60) c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device", Tags: []string{"mgmt"}}) if err := c.Save(); err != nil { t.Fatalf("Save: %v", err) } data, _ := os.ReadFile(path) var df diskFormat if err := json.Unmarshal(data, &df); err != nil { t.Fatalf("saved file is not valid JSON: %v", err) } if len(df.Entries) != 1 { t.Errorf("expected 1 entry in JSON, got %d", len(df.Entries)) } } func TestMarkUsed_SetsLastUsed(t *testing.T) { c := New("", 60) c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"}) before := time.Now() c.MarkUsed("host") e, _ := c.Get("host") if e.LastUsed.Before(before) { t.Error("LastUsed should be set to current time by MarkUsed") } } func TestMarkUsed_NoopForMissingEntry(t *testing.T) { c := New("", 60) c.MarkUsed("nonexistent") // should not panic } func TestRecentlyUsed_ReturnsTopN(t *testing.T) { c := New("", 60) c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"}) c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "device"}) c.Upsert(Entry{Name: "c", IP: "3.3.3.3", Kind: "device"}) c.MarkUsed("c") time.Sleep(time.Millisecond) c.MarkUsed("a") results := c.RecentlyUsed(2) if len(results) != 2 { t.Fatalf("RecentlyUsed(2): got %d results, want 2", len(results)) } if results[0].Name != "a" { t.Errorf("first result: got %q, want %q", results[0].Name, "a") } if results[1].Name != "c" { t.Errorf("second result: got %q, want %q", results[1].Name, "c") } } func TestRecentlyUsed_ExcludesNeverUsed(t *testing.T) { c := New("", 60) c.Upsert(Entry{Name: "used", IP: "1.1.1.1", Kind: "device"}) c.Upsert(Entry{Name: "unused", IP: "2.2.2.2", Kind: "device"}) c.MarkUsed("used") results := c.RecentlyUsed(10) if len(results) != 1 || results[0].Name != "used" { t.Errorf("RecentlyUsed should exclude entries with zero LastUsed, got %v", results) } } func TestRecentlyUsed_EmptyCache(t *testing.T) { c := New("", 60) if results := c.RecentlyUsed(10); len(results) != 0 { t.Errorf("RecentlyUsed on empty cache: got %d, want 0", len(results)) } } func TestMarkUsed_RoundtripViaSave(t *testing.T) { path := filepath.Join(t.TempDir(), "cache.json") c := New(path, 3600) c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"}) c.MarkUsed("host") if err := c.Save(); err != nil { t.Fatalf("Save: %v", err) } c2 := New(path, 3600) if err := c2.Load(); err != nil { t.Fatalf("Load: %v", err) } results := c2.RecentlyUsed(1) if len(results) != 1 || results[0].Name != "host" { t.Errorf("LastUsed not persisted: %v", results) } } // tempFile writes content to a temp file and returns its path. func tempFile(t *testing.T, content []byte) string { t.Helper() f, err := os.CreateTemp(t.TempDir(), "cache-*.json") if err != nil { t.Fatal(err) } if _, err := f.Write(content); err != nil { t.Fatal(err) } f.Close() return f.Name() }