package jikan import ( "context" "database/sql" "encoding/json" "io" "mal/internal/config" "mal/internal/db" "mal/internal/observability" "net/http" "strings" "testing" "time" _ "github.com/mattn/go-sqlite3" ) type roundTripFunc func(*http.Request) (*http.Response, error) func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) { sqlDB := newTestCacheDB(t) defer func() { if err := sqlDB.Close(); err != nil { t.Errorf("close sqlite: %v", err) } }() queries := db.New(sqlDB) client := NewClient(config.Config{}, queries, observability.NewMetrics()) stale := TopAnimeResponse{Data: []Anime{{MalID: 1, Title: "stale"}}} insertCachedResponse(t, sqlDB, "top:1", stale, time.Now().Add(-time.Hour)) client.fetcher.HTTPClient = &http.Client{ Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { body := `{"data":[{"mal_id":2,"title":"fresh"}]}` return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), }, nil }), } var got TopAnimeResponse if err := client.getWithCache(context.Background(), "top:1", time.Hour, "https://example.test/top", &got); err != nil { t.Fatalf("getWithCache: %v", err) } if len(got.Data) != 1 || got.Data[0].Title != "stale" { t.Fatalf("got %+v, want stale cache response", got.Data) } waitForFreshCache(t, sqlDB, client, "top:1") } func TestGetWithCacheAllowsEmptySearchResults(t *testing.T) { sqlDB := newTestCacheDB(t) defer func() { if err := sqlDB.Close(); err != nil { t.Errorf("close sqlite: %v", err) } }() queries := db.New(sqlDB) client := NewClient(config.Config{}, queries, observability.NewMetrics()) client.fetcher.HTTPClient = &http.Client{ Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { body := `{"pagination":{"has_next_page":false},"data":[]}` return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), }, nil }), } var got SearchResponse if err := client.getWithCache(context.Background(), "search::::::12:0:true:1:24", time.Hour, "https://example.test/anime?genres=12", &got); err != nil { t.Fatalf("getWithCache() returned error for empty search response: %v", err) } if len(got.Data) != 0 { t.Fatalf("getWithCache() data length = %d, want 0", len(got.Data)) } } func newTestCacheDB(t *testing.T) *sql.DB { t.Helper() ctx := context.Background() sqlDB, err := sql.Open("sqlite3", ":memory:") if err != nil { t.Fatalf("open sqlite: %v", err) } sqlDB.SetMaxOpenConns(1) _, err = sqlDB.ExecContext(ctx, ` CREATE TABLE jikan_cache ( key TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); `) if err != nil { if closeErr := sqlDB.Close(); closeErr != nil { t.Fatalf("create cache table: %v; close sqlite: %v", err, closeErr) } t.Fatalf("create cache table: %v", err) } return sqlDB } func insertCachedResponse(t *testing.T, sqlDB *sql.DB, key string, value TopAnimeResponse, expiresAt time.Time) { t.Helper() ctx := context.Background() encoded, err := json.Marshal(value) if err != nil { t.Fatalf("marshal cached response: %v", err) } _, err = sqlDB.ExecContext( ctx, `INSERT INTO jikan_cache (key, data, expires_at) VALUES (?, ?, ?)`, key, string(encoded), expiresAt, ) if err != nil { t.Fatalf("insert cached response: %v", err) } } func waitForFreshCache(t *testing.T, sqlDB *sql.DB, client *Client, key string) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { var refreshed TopAnimeResponse if client.getCache(context.Background(), key, &refreshed) && len(refreshed.Data) == 1 && refreshed.Data[0].Title == "fresh" { return } time.Sleep(10 * time.Millisecond) } var rawData string var rawExpires string if err := sqlDB.QueryRowContext(context.Background(), `SELECT data, expires_at FROM jikan_cache WHERE key = ?`, key).Scan(&rawData, &rawExpires); err != nil { t.Fatalf("query cached refresh result: %v", err) } t.Fatalf("cache was not refreshed asynchronously; data=%s expires_at=%s", rawData, rawExpires) }