package audio import ( "context" "os" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" ) func TestMaterializeS3AudioCacheMissDownloadsAndPopulatesCache(t *testing.T) { root := t.TempDir() fake := &storage.FakeBackend{} key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("audio")}) req := testMaterializeRequest(t, root, fake, key, int64(len("audio"))) result, err := MaterializeS3Audio(context.Background(), req) if err != nil { t.Fatalf("MaterializeS3Audio() error = %v", err) } if !result.Downloaded || result.CacheHit { t.Fatalf("result = %#v, want downloaded miss", result) } if len(fake.Downloads) != 1 { t.Fatalf("downloads = %d, want 1", len(fake.Downloads)) } assertFileEquals(t, req.DestPath, "audio") assertFileEquals(t, req.SpoolPath, "audio") assertFileEquals(t, result.CachePath, "audio") if result.Checksum == "" { t.Fatalf("checksum is empty") } } func TestMaterializeS3AudioCacheHitSkipsDownload(t *testing.T) { root := t.TempDir() fake := &storage.FakeBackend{} key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("audio")}) req := testMaterializeRequest(t, root, fake, key, int64(len("audio"))) if _, err := MaterializeS3Audio(context.Background(), req); err != nil { t.Fatalf("populate cache: %v", err) } result, err := MaterializeS3Audio(context.Background(), req) if err != nil { t.Fatalf("MaterializeS3Audio() error = %v", err) } if !result.CacheHit || result.Downloaded { t.Fatalf("result = %#v, want cache hit", result) } if len(fake.Downloads) != 1 { t.Fatalf("downloads = %d, want only the initial population download", len(fake.Downloads)) } assertFileEquals(t, req.DestPath, "audio") if result.SpoolPath != "" { t.Fatalf("spool path = %q, want empty on cache hit", result.SpoolPath) } } func TestMaterializeS3AudioRefreshesSameSizeChangedGeneration(t *testing.T) { root := t.TempDir() fake := &storage.FakeBackend{} key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("audio-one"), ETag: "generation-one"}) req := testMaterializeRequest(t, root, fake, key, int64(len("audio-one"))) req.Object.ETag = "generation-one" if _, err := MaterializeS3Audio(context.Background(), req); err != nil { t.Fatalf("initial MaterializeS3Audio() error = %v", err) } fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("audio-two"), ETag: "generation-two"}) req.Object.ETag = "generation-two" result, err := MaterializeS3Audio(context.Background(), req) if err != nil { t.Fatalf("refreshed MaterializeS3Audio() error = %v", err) } if !result.Downloaded || result.CacheHit || len(fake.Downloads) != 2 { t.Fatalf("result=%#v downloads=%d, want refreshed download", result, len(fake.Downloads)) } assertFileEquals(t, req.DestPath, "audio-two") } func TestMaterializeS3AudioRefreshesCorruptedSameSizeCache(t *testing.T) { root := t.TempDir() fake := &storage.FakeBackend{} key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("fresh-audio"), ETag: "generation"}) req := testMaterializeRequest(t, root, fake, key, int64(len("fresh-audio"))) req.Object.ETag = "generation" if _, err := MaterializeS3Audio(context.Background(), req); err != nil { t.Fatalf("initial MaterializeS3Audio() error = %v", err) } writeAudioTestFile(t, cachePathForTest(t, req), "stale-audio") result, err := MaterializeS3Audio(context.Background(), req) if err != nil { t.Fatalf("refreshed MaterializeS3Audio() error = %v", err) } if !result.Downloaded || result.CacheHit || len(fake.Downloads) != 2 { t.Fatalf("result=%#v downloads=%d, want refreshed download", result, len(fake.Downloads)) } assertFileEquals(t, req.DestPath, "fresh-audio") } func TestMaterializeS3AudioMissingIdentityDoesNotReuseCache(t *testing.T) { root := t.TempDir() fake := &storage.FakeBackend{} key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("fresh-audio"), ETag: "generation"}) req := testMaterializeRequest(t, root, fake, key, int64(len("fresh-audio"))) req.Object.ETag = "generation" writeAudioTestFile(t, cachePathForTest(t, req), "stale-audio") result, err := MaterializeS3Audio(context.Background(), req) if err != nil { t.Fatalf("MaterializeS3Audio() error = %v", err) } if !result.Downloaded || result.CacheHit || len(fake.Downloads) != 1 { t.Fatalf("result=%#v downloads=%d, want cache refresh", result, len(fake.Downloads)) } } func TestMaterializeS3AudioTreatsMultipartETagAsGeneration(t *testing.T) { root := t.TempDir() fake := &storage.FakeBackend{} key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("fresh-audio"), ETag: "not-a-checksum-2"}) req := testMaterializeRequest(t, root, fake, key, int64(len("fresh-audio"))) req.Object.ETag = "not-a-checksum-2" if _, err := MaterializeS3Audio(context.Background(), req); err != nil { t.Fatalf("populate cache: %v", err) } result, err := MaterializeS3Audio(context.Background(), req) if err != nil { t.Fatalf("reuse cache: %v", err) } if !result.CacheHit || result.Downloaded || len(fake.Downloads) != 1 { t.Fatalf("result=%#v downloads=%d, want verified cache hit", result, len(fake.Downloads)) } } func TestMaterializeS3AudioUnknownRemoteSizeDoesNotReuseCache(t *testing.T) { root := t.TempDir() fake := &storage.FakeBackend{} key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("fresh-audio"), ETag: "generation"}) req := testMaterializeRequest(t, root, fake, key, 0) req.Object.ETag = "generation" if _, err := MaterializeS3Audio(context.Background(), req); err != nil { t.Fatalf("first MaterializeS3Audio() error = %v", err) } result, err := MaterializeS3Audio(context.Background(), req) if err != nil { t.Fatalf("second MaterializeS3Audio() error = %v", err) } if !result.Downloaded || result.CacheHit || len(fake.Downloads) != 2 { t.Fatalf("result=%#v downloads=%d, want refresh without identity evidence", result, len(fake.Downloads)) } } func TestMaterializeS3AudioSymlinkedCacheRefreshesWithoutFollowingLink(t *testing.T) { root := t.TempDir() fake := &storage.FakeBackend{} key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("fresh-audio"), ETag: "generation"}) req := testMaterializeRequest(t, root, fake, key, int64(len("fresh-audio"))) req.Object.ETag = "generation" if _, err := MaterializeS3Audio(context.Background(), req); err != nil { t.Fatalf("populate cache: %v", err) } cachePath := cachePathForTest(t, req) outsidePath := filepath.Join(root, "outside-audio") writeAudioTestFile(t, outsidePath, "untrusted-audio") if err := os.Remove(cachePath); err != nil { t.Fatalf("remove cache file: %v", err) } if err := os.Symlink(outsidePath, cachePath); err != nil { t.Fatalf("create cache symlink: %v", err) } result, err := MaterializeS3Audio(context.Background(), req) if err != nil { t.Fatalf("refresh symlinked cache: %v", err) } if !result.Downloaded || result.CacheHit || len(fake.Downloads) != 2 { t.Fatalf("result=%#v downloads=%d, want safe refresh", result, len(fake.Downloads)) } assertFileEquals(t, outsidePath, "untrusted-audio") assertFileEquals(t, req.DestPath, "fresh-audio") } func TestMaterializeS3AudioNonRegularCacheFailsClearly(t *testing.T) { root := t.TempDir() fake := &storage.FakeBackend{} key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("fresh-audio"), ETag: "generation"}) req := testMaterializeRequest(t, root, fake, key, int64(len("fresh-audio"))) req.Object.ETag = "generation" if _, err := MaterializeS3Audio(context.Background(), req); err != nil { t.Fatalf("populate cache: %v", err) } cachePath := cachePathForTest(t, req) if err := os.Remove(cachePath); err != nil { t.Fatalf("remove cache file: %v", err) } if err := os.Mkdir(cachePath, 0o755); err != nil { t.Fatalf("create cache directory: %v", err) } _, err := MaterializeS3Audio(context.Background(), req) if err == nil || !strings.Contains(err.Error(), "populate audio cache") { t.Fatalf("error = %v, want clear cache refresh error", err) } } func TestMaterializeS3AudioInterruptedRefreshCanRetry(t *testing.T) { root := t.TempDir() fake := &storage.FakeBackend{} key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("audio-one"), ETag: "generation-one"}) req := testMaterializeRequest(t, root, fake, key, int64(len("audio-one"))) req.Object.ETag = "generation-one" if _, err := MaterializeS3Audio(context.Background(), req); err != nil { t.Fatalf("populate cache: %v", err) } fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("audio-two"), ETag: "generation-two"}) req.Object.ETag = "generation-two" fake.DownloadErr = os.ErrPermission if _, err := MaterializeS3Audio(context.Background(), req); err == nil { t.Fatal("MaterializeS3Audio() error = nil, want interrupted refresh failure") } fake.DownloadErr = nil result, err := MaterializeS3Audio(context.Background(), req) if err != nil { t.Fatalf("retry refresh: %v", err) } if !result.Downloaded || result.CacheHit { t.Fatalf("result = %#v, want refreshed download after retry", result) } assertFileEquals(t, req.DestPath, "audio-two") } func TestMaterializeS3AudioInvalidCacheRefreshesFromS3(t *testing.T) { root := t.TempDir() fake := &storage.FakeBackend{} key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("fresh-audio")}) req := testMaterializeRequest(t, root, fake, key, int64(len("fresh-audio"))) cachePath, err := artifacts.S3AudioCachePath(req.CacheRoot, req.Bucket, key) if err != nil { t.Fatalf("cache path: %v", err) } writeAudioTestFile(t, cachePath, "stale") result, err := MaterializeS3Audio(context.Background(), req) if err != nil { t.Fatalf("MaterializeS3Audio() error = %v", err) } if !result.Downloaded || result.CacheHit { t.Fatalf("result = %#v, want refreshed miss", result) } if len(fake.Downloads) != 1 { t.Fatalf("downloads = %d, want 1", len(fake.Downloads)) } assertFileEquals(t, req.DestPath, "fresh-audio") assertFileEquals(t, cachePath, "fresh-audio") } func TestMaterializeS3AudioDownloadFailureLeavesDestinationMissing(t *testing.T) { root := t.TempDir() fake := &storage.FakeBackend{DownloadErr: os.ErrPermission} key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("audio")}) req := testMaterializeRequest(t, root, fake, key, int64(len("audio"))) _, err := MaterializeS3Audio(context.Background(), req) if err == nil || !strings.Contains(err.Error(), "download s3 audio object") { t.Fatalf("error = %v, want download error", err) } assertMissing(t, req.DestPath) } func testMaterializeRequest(t *testing.T, root string, fake *storage.FakeBackend, key string, size int64) S3MaterializeRequest { t.Helper() return S3MaterializeRequest{ Store: fake, Object: storage.ObjectInfo{Key: key, Size: size, ETag: "etag"}, Bucket: "my-dnd-archive", CacheRoot: filepath.Join(root, "cache"), CacheEnabled: true, SpoolPath: filepath.Join(root, "spool", "alice.flac"), DestPath: filepath.Join(root, "work", "audio", "alice.flac"), } } func cachePathForTest(t *testing.T, req S3MaterializeRequest) string { t.Helper() cachePath, err := artifacts.S3AudioCachePath(req.CacheRoot, req.Bucket, req.Object.Key) if err != nil { t.Fatalf("cache path: %v", err) } return cachePath } func writeAudioTestFile(t *testing.T, path, contents string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir %q: %v", path, err) } if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { t.Fatalf("write %q: %v", path, err) } } func assertFileEquals(t *testing.T, path, want string) { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("read %q: %v", path, err) } if string(data) != want { t.Fatalf("%q = %q, want %q", path, string(data), want) } } func assertMissing(t *testing.T, path string) { t.Helper() if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("stat %q = %v, want not exists", path, err) } }