ncro/internal/cache/db_test.go
NotAShelf b0ea022dc2
cache: add negative cache; router: skip race for cached 404s
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ibeb44b313850395898bb20f2d947b0b76a6a6964
2026-04-05 22:48:01 +03:00

257 lines
5.7 KiB
Go

package cache_test
import (
"os"
"testing"
"time"
"notashelf.dev/ncro/internal/cache"
)
func newTestDB(t *testing.T) *cache.DB {
t.Helper()
f, err := os.CreateTemp("", "ncro-test-*.db")
if err != nil {
t.Fatal(err)
}
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
db, err := cache.Open(f.Name(), 1000)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestGetSetRoute(t *testing.T) {
db := newTestDB(t)
entry := &cache.RouteEntry{
StorePath: "abc123xyz-hello-2.12",
UpstreamURL: "https://cache.nixos.org",
LatencyMs: 12.5,
LatencyEMA: 12.5,
LastVerified: time.Now().UTC().Truncate(time.Second),
QueryCount: 1,
TTL: time.Now().Add(time.Hour).UTC().Truncate(time.Second),
}
if err := db.SetRoute(entry); err != nil {
t.Fatalf("SetRoute: %v", err)
}
got, err := db.GetRoute("abc123xyz-hello-2.12")
if err != nil {
t.Fatalf("GetRoute: %v", err)
}
if got == nil {
t.Fatal("GetRoute returned nil")
}
if got.UpstreamURL != entry.UpstreamURL {
t.Errorf("upstream = %q, want %q", got.UpstreamURL, entry.UpstreamURL)
}
if got.QueryCount != 1 {
t.Errorf("query_count = %d, want 1", got.QueryCount)
}
}
func TestGetRouteNotFound(t *testing.T) {
db := newTestDB(t)
got, err := db.GetRoute("nonexistent")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
}
func TestSetRouteUpsert(t *testing.T) {
db := newTestDB(t)
entry := &cache.RouteEntry{
StorePath: "abc123-pkg",
UpstreamURL: "https://cache.nixos.org",
LatencyMs: 20.0,
LatencyEMA: 20.0,
QueryCount: 1,
TTL: time.Now().Add(time.Hour),
}
db.SetRoute(entry)
entry.LatencyEMA = 18.0
entry.QueryCount = 2
if err := db.SetRoute(entry); err != nil {
t.Fatalf("upsert: %v", err)
}
got, _ := db.GetRoute("abc123-pkg")
if got.LatencyEMA != 18.0 {
t.Errorf("ema = %f, want 18.0", got.LatencyEMA)
}
if got.QueryCount != 2 {
t.Errorf("query_count = %d, want 2", got.QueryCount)
}
}
func TestExpireOldRoutes(t *testing.T) {
db := newTestDB(t)
// Insert expired route
expired := &cache.RouteEntry{
StorePath: "expired-pkg",
UpstreamURL: "https://cache.nixos.org",
TTL: time.Now().Add(-time.Minute), // already expired
}
db.SetRoute(expired)
// Insert valid route
valid := &cache.RouteEntry{
StorePath: "valid-pkg",
UpstreamURL: "https://cache.nixos.org",
TTL: time.Now().Add(time.Hour),
}
db.SetRoute(valid)
if err := db.ExpireOldRoutes(); err != nil {
t.Fatalf("ExpireOldRoutes: %v", err)
}
got, _ := db.GetRoute("expired-pkg")
if got != nil {
t.Error("expired route should have been deleted")
}
got2, _ := db.GetRoute("valid-pkg")
if got2 == nil {
t.Error("valid route should still exist")
}
}
func TestRouteEntryIsValidExpired(t *testing.T) {
expired := &cache.RouteEntry{TTL: time.Now().Add(-time.Minute)}
if expired.IsValid() {
t.Error("expired entry should not be valid")
}
}
func TestRouteEntryIsValidFuture(t *testing.T) {
valid := &cache.RouteEntry{TTL: time.Now().Add(time.Hour)}
if !valid.IsValid() {
t.Error("future-TTL entry should be valid")
}
}
func TestDBOpenCreatesSchema(t *testing.T) {
db := newTestDB(t)
// RouteCount works only if schema was created.
count, err := db.RouteCount()
if err != nil {
t.Fatalf("RouteCount after fresh open: %v", err)
}
if count != 0 {
t.Errorf("expected 0 routes in fresh DB, got %d", count)
}
}
func TestRouteCountAfterExpiry(t *testing.T) {
db := newTestDB(t)
for i := range 3 {
ttl := time.Now().Add(-time.Minute) // all expired
db.SetRoute(&cache.RouteEntry{
StorePath: "pkg-" + string(rune('a'+i)),
UpstreamURL: "https://cache.nixos.org",
TTL: ttl,
})
}
before, _ := db.RouteCount()
if err := db.ExpireOldRoutes(); err != nil {
t.Fatal(err)
}
after, _ := db.RouteCount()
if after >= before {
t.Errorf("count did not decrease after expiry: before=%d after=%d", before, after)
}
if after != 0 {
t.Errorf("expected 0 routes after expiring all, got %d", after)
}
}
func TestNegativeCacheSetAndCheck(t *testing.T) {
db, err := cache.Open(":memory:", 100)
if err != nil {
t.Fatal(err)
}
defer db.Close()
neg, err := db.IsNegative("missing-path")
if err != nil {
t.Fatalf("IsNegative: %v", err)
}
if neg {
t.Error("expected false for unknown path")
}
if err := db.SetNegative("missing-path", 10*time.Minute); err != nil {
t.Fatalf("SetNegative: %v", err)
}
neg, err = db.IsNegative("missing-path")
if err != nil {
t.Fatalf("IsNegative after set: %v", err)
}
if !neg {
t.Error("expected true after SetNegative")
}
}
func TestNegativeCacheExpiry(t *testing.T) {
db, err := cache.Open(":memory:", 100)
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Set with negative duration so it's already expired.
if err := db.SetNegative("expires-now", -time.Second); err != nil {
t.Fatalf("SetNegative: %v", err)
}
if err := db.ExpireNegatives(); err != nil {
t.Fatalf("ExpireNegatives: %v", err)
}
neg, _ := db.IsNegative("expires-now")
if neg {
t.Error("expired negative should not be returned")
}
}
func TestLRUEviction(t *testing.T) {
// Use maxEntries=3 to trigger eviction easily
f, _ := os.CreateTemp("", "ncro-lru-*.db")
f.Close()
defer os.Remove(f.Name())
db, _ := cache.Open(f.Name(), 3)
defer db.Close()
for i := range 4 {
db.SetRoute(&cache.RouteEntry{
StorePath: "pkg-" + string(rune('a'+i)),
UpstreamURL: "https://cache.nixos.org",
LastVerified: time.Now().Add(time.Duration(i) * time.Second),
TTL: time.Now().Add(time.Hour),
})
}
count, err := db.RouteCount()
if err != nil {
t.Fatal(err)
}
if count > 3 {
t.Errorf("expected count <= 3 after LRU eviction, got %d", count)
}
}