feat: implement rate limiting and caching layer for MusicBrainz provider
- Add golang.org/x/time/rate dependency for token-bucket rate limiting - Replace custom channel-based rate limiter with rate.NewLimiter(1, 1) - Add context.Context support to doGet for cancellation - Add cached_at column to external_releases via migration 005 - Implement cache hit/miss queries with TTL-based filtering - Add CacheStats type for tracking cached RGIDs - Update ExternalRelease struct with CachedAt field - Add rate limiting tests (1 req/sec enforcement, burst behavior) - Add cache tests (hit, miss, expired, mixed, empty artist) - Update migration count test for new migration
This commit is contained in:
255
internal/musicbrainz/cache_test.go
Normal file
255
internal/musicbrainz/cache_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package musicbrainz
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"naviwatcher/internal/database"
|
||||
)
|
||||
|
||||
func insertTestArtistForCache(db *database.DB, id string) error {
|
||||
_, err := db.Conn().Exec(
|
||||
"INSERT OR IGNORE INTO artist_settings (id, name) VALUES (?, ?)",
|
||||
id, "Test Artist "+id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func TestGetCachedReleases_CacheHit(t *testing.T) {
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
artistID := "artist-cache-hit"
|
||||
if err := insertTestArtistForCache(db, artistID); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
|
||||
// Insert releases with recent cached_at timestamps
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
_, err = db.Conn().Exec(
|
||||
"INSERT INTO external_releases (rgid, artist_id, title, type, cached_at) VALUES (?, ?, ?, ?, ?)",
|
||||
"rg-hit-1", artistID, "Cached Album 1", "album", now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert rg-hit-1: %v", err)
|
||||
}
|
||||
_, err = db.Conn().Exec(
|
||||
"INSERT INTO external_releases (rgid, artist_id, title, type, cached_at) VALUES (?, ?, ?, ?, ?)",
|
||||
"rg-hit-2", artistID, "Cached Album 2", "single", now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert rg-hit-2: %v", err)
|
||||
}
|
||||
|
||||
ttl := 24 * time.Hour
|
||||
stats, err := GetCachedReleases(db, artistID, ttl)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||
}
|
||||
|
||||
if stats.CacheHitCount != 2 {
|
||||
t.Errorf("CacheHitCount = %d, want 2", stats.CacheHitCount)
|
||||
}
|
||||
|
||||
if !stats.IsCached("rg-hit-1") {
|
||||
t.Error("expected rg-hit-1 to be cached")
|
||||
}
|
||||
if !stats.IsCached("rg-hit-2") {
|
||||
t.Error("expected rg-hit-2 to be cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCachedReleases_CacheMiss_Expired(t *testing.T) {
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
artistID := "artist-cache-miss"
|
||||
if err := insertTestArtistForCache(db, artistID); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
|
||||
// Insert a release with an expired cached_at (48 hours ago)
|
||||
expired := time.Now().Add(-48 * time.Hour).Format("2006-01-02 15:04:05")
|
||||
_, err = db.Conn().Exec(
|
||||
"INSERT INTO external_releases (rgid, artist_id, title, type, cached_at) VALUES (?, ?, ?, ?, ?)",
|
||||
"rg-expired", artistID, "Expired Album", "album", expired,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert expired release: %v", err)
|
||||
}
|
||||
|
||||
ttl := 24 * time.Hour
|
||||
stats, err := GetCachedReleases(db, artistID, ttl)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||
}
|
||||
|
||||
if stats.CacheHitCount != 0 {
|
||||
t.Errorf("CacheHitCount = %d, want 0 (expired entry should not be cached)", stats.CacheHitCount)
|
||||
}
|
||||
|
||||
if stats.IsCached("rg-expired") {
|
||||
t.Error("expected rg-expired to NOT be cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCachedReleases_CacheMiss_NoCachedAt(t *testing.T) {
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
artistID := "artist-no-cached"
|
||||
if err := insertTestArtistForCache(db, artistID); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
|
||||
// Insert a release WITHOUT cached_at (NULL)
|
||||
_, err = db.Conn().Exec(
|
||||
"INSERT INTO external_releases (rgid, artist_id, title, type) VALUES (?, ?, ?, ?)",
|
||||
"rg-nocached", artistID, "Uncached Album", "album",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert uncached release: %v", err)
|
||||
}
|
||||
|
||||
ttl := 24 * time.Hour
|
||||
stats, err := GetCachedReleases(db, artistID, ttl)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||
}
|
||||
|
||||
if stats.CacheHitCount != 0 {
|
||||
t.Errorf("CacheHitCount = %d, want 0 (NULL cached_at should not be cached)", stats.CacheHitCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCachedReleases_EmptyArtist(t *testing.T) {
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ttl := 24 * time.Hour
|
||||
stats, err := GetCachedReleases(db, "nonexistent-artist", ttl)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||
}
|
||||
|
||||
if stats.CacheHitCount != 0 {
|
||||
t.Errorf("CacheHitCount = %d, want 0 for nonexistent artist", stats.CacheHitCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsArtistCacheValid_Valid(t *testing.T) {
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
artistID := "artist-valid-cache"
|
||||
if err := insertTestArtistForCache(db, artistID); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
_, err = db.Conn().Exec(
|
||||
"INSERT INTO external_releases (rgid, artist_id, title, cached_at) VALUES (?, ?, ?, ?)",
|
||||
"rg-valid", artistID, "Valid Album", now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
ttl := 24 * time.Hour
|
||||
valid, err := IsArtistCacheValid(db, artistID, ttl)
|
||||
if err != nil {
|
||||
t.Fatalf("IsArtistCacheValid() error: %v", err)
|
||||
}
|
||||
if !valid {
|
||||
t.Error("expected cache to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsArtistCacheValid_Invalid(t *testing.T) {
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ttl := 24 * time.Hour
|
||||
|
||||
// No releases at all
|
||||
valid, err := IsArtistCacheValid(db, "no-releases", ttl)
|
||||
if err != nil {
|
||||
t.Fatalf("IsArtistCacheValid() error: %v", err)
|
||||
}
|
||||
if valid {
|
||||
t.Error("expected cache to be invalid for artist with no releases")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStats_IsCached_Empty(t *testing.T) {
|
||||
stats := &CacheStats{}
|
||||
if stats.IsCached("anything") {
|
||||
t.Error("expected IsCached to return false for empty stats")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCachedReleases_MixedExpiry(t *testing.T) {
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
artistID := "artist-mixed"
|
||||
if err := insertTestArtistForCache(db, artistID); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
expired := time.Now().Add(-48 * time.Hour).Format("2006-01-02 15:04:05")
|
||||
|
||||
// Mix of fresh and expired
|
||||
_, err = db.Conn().Exec(
|
||||
"INSERT INTO external_releases (rgid, artist_id, title, cached_at) VALUES (?, ?, ?, ?)",
|
||||
"rg-fresh", artistID, "Fresh Album", now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert fresh: %v", err)
|
||||
}
|
||||
_, err = db.Conn().Exec(
|
||||
"INSERT INTO external_releases (rgid, artist_id, title, cached_at) VALUES (?, ?, ?, ?)",
|
||||
"rg-old", artistID, "Old Album", expired,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert old: %v", err)
|
||||
}
|
||||
|
||||
ttl := 24 * time.Hour
|
||||
stats, err := GetCachedReleases(db, artistID, ttl)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||
}
|
||||
|
||||
if stats.CacheHitCount != 1 {
|
||||
t.Errorf("CacheHitCount = %d, want 1 (only fresh entry)", stats.CacheHitCount)
|
||||
}
|
||||
if !stats.IsCached("rg-fresh") {
|
||||
t.Error("expected rg-fresh to be cached")
|
||||
}
|
||||
if stats.IsCached("rg-old") {
|
||||
t.Error("expected rg-old to NOT be cached (expired)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user