Files

298 lines
8.7 KiB
Go
Raw Permalink Normal View History

package ebookconvert
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
func newTestCache(t *testing.T, opts CacheOptions) *Cache {
t.Helper()
conv := newTestConverter(t)
if opts.Dir == "" {
opts.Dir = t.TempDir()
}
c, err := NewCache(conv, opts)
if err != nil {
t.Fatalf("NewCache: %v", err)
}
return c
}
func keyFor(t *testing.T, fileID int, path string) SourceKey {
t.Helper()
k, err := SourceKeyFromStat(fileID, path)
if err != nil {
t.Fatal(err)
}
return k
}
func TestCache_MissThenHit(t *testing.T) {
c := newTestCache(t, CacheOptions{})
src := filepath.Join("testdata", "sample-ncx.mobi")
key := keyFor(t, 1, src)
p1, err := c.GetOrConvert(context.Background(), src, key)
if err != nil {
t.Fatalf("first GetOrConvert: %v", err)
}
assertValidEpub(t, p1)
fi1, _ := os.Stat(p1)
p2, err := c.GetOrConvert(context.Background(), src, key)
if err != nil {
t.Fatalf("second GetOrConvert: %v", err)
}
if p1 != p2 {
t.Fatalf("hit returned different path: %s vs %s", p1, p2)
}
fi2, _ := os.Stat(p2)
// A hit refreshes mtime for LRU but must NOT reconvert: the cached entry is
// the same underlying file, not a freshly converted+renamed replacement.
if !os.SameFile(fi1, fi2) {
t.Fatal("cache hit reconverted the file (replaced the cached entry)")
}
}
func TestCache_KeyChangesOnModTime(t *testing.T) {
c := newTestCache(t, CacheOptions{})
src := filepath.Join("testdata", "sample-ncx.mobi")
k1 := keyFor(t, 1, src)
k2 := k1
k2.ModTimeNano = k1.ModTimeNano + 1 // simulate a re-scanned/replaced file
if k1.hash() == k2.hash() {
t.Fatal("expected different cache keys for different mtime")
}
p1, err := c.GetOrConvert(context.Background(), src, k1)
if err != nil {
t.Fatalf("convert k1: %v", err)
}
p2, err := c.GetOrConvert(context.Background(), src, k2)
if err != nil {
t.Fatalf("convert k2: %v", err)
}
if p1 == p2 {
t.Fatal("different keys must map to different cache files")
}
}
// A transient timeout is NOT negatively cached: it would otherwise wedge a
// slow-but-convertible book onto the raw-fallback path for the whole TTL.
func TestCache_TimeoutNotNegativelyCached(t *testing.T) {
conv, err := NewConverter(context.Background(), Options{Timeout: time.Nanosecond})
if err != nil {
t.Fatalf("NewConverter: %v", err)
}
t.Cleanup(func() { _ = conv.Close(context.Background()) })
c, err := NewCache(conv, CacheOptions{Dir: t.TempDir()})
if err != nil {
t.Fatalf("NewCache: %v", err)
}
src := filepath.Join("testdata", "sample-ncx.mobi")
key := keyFor(t, 1, src)
_, err = c.GetOrConvert(context.Background(), src, key)
if !errors.Is(err, ErrConversionTimedOut) {
t.Fatalf("got %v, want ErrConversionTimedOut", err)
}
c.mu.Lock()
_, cached := c.neg[key.hash()]
c.mu.Unlock()
if cached {
t.Fatal("a transient timeout must not be negatively cached")
}
}
// Eviction must never delete the EPUB it is about to hand back, even when the
// configured budget is smaller than a single converted file.
func TestCache_EvictionKeepsJustConverted(t *testing.T) {
c := newTestCache(t, CacheOptions{Dir: t.TempDir(), MaxBytes: 1})
src := filepath.Join("testdata", "sample-ncx.mobi")
p, err := c.GetOrConvert(context.Background(), src, keyFor(t, 1, src))
if err != nil {
t.Fatalf("convert: %v", err)
}
if fi, statErr := os.Stat(p); statErr != nil || fi.Size() == 0 {
t.Fatalf("returned path must exist and be non-empty (eviction deleted it): stat=%v", statErr)
}
}
// Eviction must ignore other conversions' in-flight temp files; deleting one
// would corrupt a concurrent conversion mid-flight.
func TestCache_EvictionSkipsInFlightTemps(t *testing.T) {
dir := t.TempDir()
c := newTestCache(t, CacheOptions{Dir: dir, MaxBytes: 1})
tmp := filepath.Join(dir, "converting-inflight.epub")
if err := os.WriteFile(tmp, make([]byte, 4096), 0o644); err != nil {
t.Fatal(err)
}
old := time.Now().Add(-time.Hour) // make the temp the "oldest" candidate
_ = os.Chtimes(tmp, old, old)
src := filepath.Join("testdata", "sample-ncx.mobi")
if _, err := c.GetOrConvert(context.Background(), src, keyFor(t, 1, src)); err != nil {
t.Fatalf("convert: %v", err)
}
if _, err := os.Stat(tmp); err != nil {
t.Fatalf("eviction deleted an in-flight temp file: %v", err)
}
}
// A cache hit refreshes mtime so the mtime-ordered budget eviction behaves as a
// real LRU (most-recently-read entries survive).
func TestCache_HitTouchesModTimeForLRU(t *testing.T) {
c := newTestCache(t, CacheOptions{})
src := filepath.Join("testdata", "sample-ncx.mobi")
key := keyFor(t, 1, src)
p, err := c.GetOrConvert(context.Background(), src, key)
if err != nil {
t.Fatalf("convert: %v", err)
}
old := time.Now().Add(-time.Hour)
_ = os.Chtimes(p, old, old)
if _, err := c.GetOrConvert(context.Background(), src, key); err != nil {
t.Fatalf("hit: %v", err)
}
fi, _ := os.Stat(p)
if time.Since(fi.ModTime()) > time.Minute {
t.Fatalf("cache hit did not refresh mtime for LRU; mtime is %v old", time.Since(fi.ModTime()))
}
}
// A single caller abandoning its request (cancellation) must not abort the
// shared conversion for everyone else: the detached work still completes and
// populates the cache.
func TestCache_CallerCancelDoesNotAbortSharedWork(t *testing.T) {
c := newTestCache(t, CacheOptions{})
src := filepath.Join("testdata", "sample-ncx.mobi")
key := keyFor(t, 1, src)
ctx, cancel := context.WithCancel(context.Background())
cancel() // caller bails before the conversion can finish
_, _ = c.GetOrConvert(ctx, src, key)
dst := filepath.Join(c.opts.Dir, key.hash()+".epub")
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if fi, err := os.Stat(dst); err == nil && fi.Size() > 0 {
return // shared work completed despite the caller canceling — good
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("detached conversion did not populate the cache after the caller canceled")
}
func TestCache_NegativeCachesDRM(t *testing.T) {
c := newTestCache(t, CacheOptions{})
src := filepath.Join("testdata", "sample-drm-v1.mobi")
key := keyFor(t, 7, src)
err1 := mustConvertErr(t, c, src, key)
if !errors.Is(err1, ErrDRMProtected) {
t.Fatalf("got %v, want ErrDRMProtected", err1)
}
// Second call should be served from the negative cache (still DRM error).
err2 := mustConvertErr(t, c, src, key)
if !errors.Is(err2, ErrDRMProtected) {
t.Fatalf("negative-cached call: got %v, want ErrDRMProtected", err2)
}
c.mu.Lock()
_, cached := c.neg[key.hash()]
c.mu.Unlock()
if !cached {
t.Fatal("DRM result was not negatively cached")
}
}
func TestCache_Singleflight(t *testing.T) {
c := newTestCache(t, CacheOptions{})
src := filepath.Join("testdata", "sample-ncx.mobi")
key := keyFor(t, 3, src)
const n = 8
var wg sync.WaitGroup
var firstPath atomic.Value
errs := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
p, err := c.GetOrConvert(context.Background(), src, key)
if err != nil {
errs <- err
return
}
firstPath.Store(p)
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("concurrent GetOrConvert: %v", err)
}
if p, _ := firstPath.Load().(string); p == "" {
t.Fatal("no path produced")
}
}
func TestCache_BudgetEviction(t *testing.T) {
dir := t.TempDir()
// Budget fits one converted EPUB (~2.4 KiB) but not two, so converting a
// second entry evicts the older one — while never evicting the entry just
// handed back.
const budget = 3000
c := newTestCache(t, CacheOptions{Dir: dir, MaxBytes: budget})
src := filepath.Join("testdata", "sample-ncx.mobi")
p1, err := c.GetOrConvert(context.Background(), src, keyFor(t, 1, src))
if err != nil {
t.Fatalf("convert 1: %v", err)
}
// Force the first entry to be the oldest (the eviction victim).
old := time.Now().Add(-time.Hour)
_ = os.Chtimes(p1, old, old)
k2 := keyFor(t, 2, src)
k2.FileID = 2
p2, err := c.GetOrConvert(context.Background(), src, k2)
if err != nil {
t.Fatalf("convert 2: %v", err)
}
if _, err := os.Stat(p2); err != nil {
t.Fatalf("just-converted entry must survive eviction: %v", err)
}
if _, err := os.Stat(p1); !os.IsNotExist(err) {
t.Fatalf("older entry should have been evicted, stat err = %v", err)
}
epubs, _ := filepath.Glob(filepath.Join(dir, "*.epub"))
var total int64
for _, e := range epubs {
fi, _ := os.Stat(e)
total += fi.Size()
}
if total > budget {
t.Fatalf("budget not enforced: total=%d > %d (%d files)", total, budget, len(epubs))
}
}
func TestModuleVersion_Stable(t *testing.T) {
if len(moduleVersion) != 16 {
t.Fatalf("moduleVersion len = %d, want 16", len(moduleVersion))
}
}
func mustConvertErr(t *testing.T, c *Cache, src string, key SourceKey) error {
t.Helper()
_, err := c.GetOrConvert(context.Background(), src, key)
return err
}