package artwork import ( "context" "errors" "os" "path/filepath" "sync" "testing" "time" "streamrip-go/internal/config" ) // fakeDownloader models the real downloader's behaviour for this race: the // first attempt at a given output path succeeds, and any later attempt at that // same path fails and removes its partial output (downloader.go does // os.Remove(outputPath) on failure). That is exactly how a losing worker used // to delete the cover a winning worker had just written. type fakeDownloader struct { payload []byte mu sync.Mutex seen map[string]bool delay time.Duration } func newFakeDownloader(payload []byte, delay time.Duration) *fakeDownloader { return &fakeDownloader{payload: payload, seen: map[string]bool{}, delay: delay} } func (f *fakeDownloader) File(ctx context.Context, sourceURL, outputPath string) error { return f.FileNoProgress(ctx, sourceURL, outputPath) } func (f *fakeDownloader) FileNoProgress(_ context.Context, _, outputPath string) error { f.mu.Lock() first := !f.seen[outputPath] f.seen[outputPath] = true f.mu.Unlock() if !first { _ = os.Remove(outputPath) return errors.New("download failed") } if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { return err } // Widen the window between another worker's existence check and this write. time.Sleep(f.delay) return os.WriteFile(outputPath, f.payload, 0o600) } func artworkCfg() config.ArtworkConfig { return config.ArtworkConfig{Embed: true, EmbedSize: "large", SaveArtwork: true} } func albumMeta() map[string]any { return map[string]any{"image": map[string]any{ "original": "https://example.invalid/large.jpg", "large": "https://example.invalid/large.jpg", }} } // TestPrepareConcurrentSameFolder is the regression test for the cover race: // many workers calling Prepare for one album folder must leave exactly one // intact cover.jpg. Run with -race. func TestPrepareConcurrentSameFolder(t *testing.T) { folder := t.TempDir() payload := []byte("jpeg-bytes") dl := newFakeDownloader(payload, 5*time.Millisecond) const workers = 16 var wg sync.WaitGroup results := make([]Result, workers) for i := 0; i < workers; i++ { wg.Add(1) go func(idx int) { defer wg.Done() res, err := Prepare(context.Background(), dl, folder, albumMeta(), artworkCfg(), false) if err != nil { t.Errorf("Prepare: %v", err) } results[idx] = res }(i) } wg.Wait() saved := filepath.Join(folder, "cover.jpg") got, err := os.ReadFile(saved) if err != nil { t.Fatalf("cover.jpg missing after %d concurrent Prepare calls: %v", workers, err) } if string(got) != string(payload) { t.Fatalf("cover.jpg corrupted: got %q want %q", got, payload) } // No .part- temp files may survive. entries, err := os.ReadDir(folder) if err != nil { t.Fatal(err) } for _, e := range entries { if filepath.Ext(e.Name()) != ".jpg" { t.Errorf("leftover temp artifact in album folder: %s", e.Name()) } } for i, res := range results { if res.SavedPath != saved { t.Errorf("worker %d: SavedPath = %q want %q", i, res.SavedPath, saved) } if res.EmbedPath == "" { t.Errorf("worker %d: EmbedPath empty", i) } } } // TestPrepareFailedDownloadKeepsExistingCover covers the specific corruption // mode: a failing download must not remove a cover that is already on disk. func TestPrepareFailedDownloadKeepsExistingCover(t *testing.T) { folder := t.TempDir() saved := filepath.Join(folder, "cover.jpg") if err := os.WriteFile(saved, []byte("good-cover"), 0o600); err != nil { t.Fatal(err) } // Mark the saved path as already attempted so any download of it fails. dl := newFakeDownloader([]byte("new"), 0) dl.seen[saved] = true if _, err := Prepare(context.Background(), dl, folder, albumMeta(), artworkCfg(), false); err != nil { t.Fatalf("Prepare: %v", err) } got, err := os.ReadFile(saved) if err != nil { t.Fatalf("cover.jpg removed: %v", err) } if string(got) != "good-cover" { t.Fatalf("existing cover overwritten: got %q", got) } } // TestFetchAtomicFailureLeavesNoPartial asserts a failed fetch neither creates // the destination nor leaves a temp file behind. func TestFetchAtomicFailureLeavesNoPartial(t *testing.T) { folder := t.TempDir() dest := filepath.Join(folder, "cover.jpg") dl := newFakeDownloader([]byte("x"), 0) dl.seen[dest+".forced"] = true // Force failure by pre-marking every temp path is impractical, so use a // downloader that always fails instead. always := &failingDownloader{} if ok := fetchAtomic(context.Background(), always, "https://example.invalid/a.jpg", dest, 0); ok { t.Fatal("fetchAtomic reported success on a failing download") } if _, err := os.Stat(dest); !os.IsNotExist(err) { t.Fatalf("destination should not exist, stat err = %v", err) } entries, err := os.ReadDir(folder) if err != nil { t.Fatal(err) } if len(entries) != 0 { t.Fatalf("temp files left behind: %v", entries) } } type failingDownloader struct{} func (failingDownloader) File(context.Context, string, string) error { return errors.New("nope") } func (failingDownloader) FileNoProgress(_ context.Context, _, outputPath string) error { _ = os.WriteFile(outputPath, []byte("partial"), 0o600) return errors.New("nope") }