package backend import ( "context" "os" "path/filepath" "strings" "testing" "time" "music-server/internal/db/repository" "music-server/internal/testutil/fakedb" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // mp3 returns data that starts like an MP3 file, made unique by content. func mp3(content string) []byte { return append([]byte("ID3"), content...) } type uploadFixture struct { t *testing.T musicDir string store *fakedb.Store sync *SyncService uploads *UploadService } func newUploadFixture(t *testing.T) *uploadFixture { musicDir := t.TempDir() store := fakedb.New() syncService := NewSyncService(store, musicDir, nil) return &uploadFixture{ t: t, musicDir: musicDir, store: store, sync: syncService, uploads: NewUploadService(store, musicDir, syncService, NewLibraryService(store), 1<<20, 3<<20), } } func (f *uploadFixture) start(name string) Upload { f.t.Helper() upload, err := f.uploads.Start(context.Background(), name) require.NoError(f.t, err) return upload } func (f *uploadFixture) put(id uuid.UUID, fileName, content string) { f.t.Helper() _, err := f.uploads.PutFile(id, fileName, mp3(content)) require.NoError(f.t, err) } func TestUploadSoundtrack(t *testing.T) { f := newUploadFixture(t) ctx := context.Background() upload := f.start(" Chrono Trigger ") assert.Equal(t, "Chrono Trigger", upload.Name, "the name is trimmed") f.put(upload.ID, "02 Corridors of Time.mp3", "b") f.put(upload.ID, "01 Wind Scene.MP3", "a") f.put(upload.ID, "02 Corridors of Time.mp3", "b2") // sent again got, err := f.uploads.Get(upload.ID) require.NoError(t, err) assert.Equal(t, []UploadFile{{Name: "01 Wind Scene.MP3", Size: 4}, {Name: "02 Corridors of Time.mp3", Size: 5}}, got.Files) assert.Equal(t, int64(9), got.TotalSize) _, err = f.store.GetSoundtrackById(ctx, 1) assert.Error(t, err, "nothing is in the library before finishing") soundtrack, err := f.uploads.Finish(ctx, upload.ID) require.NoError(t, err) assert.Equal(t, "Chrono Trigger", soundtrack.Name) require.Len(t, soundtrack.Songs, 2) assert.Equal(t, "01 Wind Scene", soundtrack.Songs[0].Name) assert.FileExists(t, filepath.Join(f.musicDir, "Chrono Trigger", "01 Wind Scene.MP3")) entries, err := os.ReadDir(filepath.Join(f.musicDir, uploadsFolder)) require.NoError(t, err) assert.Empty(t, entries, "nothing is left of the upload") _, err = f.uploads.Get(upload.ID) assert.ErrorIs(t, err, ErrNotFound) // The next sync sees nothing new. require.True(t, f.sync.Start(false)) f.sync.Wait() result := f.sync.Result() assert.Empty(t, result.SoundtracksAdded) assert.Empty(t, result.CatchedErrors) } func TestUploadNames(t *testing.T) { f := newUploadFixture(t) ctx := context.Background() f.store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Kingdom Hearts"}) writeFile(t, filepath.Join(f.musicDir, "Folder Only", "x.txt"), "x") f.start("Pending") for name, want := range map[string]error{ "": ErrInvalidName, " ": ErrInvalidName, "a/b": ErrInvalidName, `a\b`: ErrInvalidName, "..": ErrInvalidName, ".hidden": ErrInvalidName, "ends with dot.": ErrInvalidName, "what?": ErrInvalidName, "tab\tname": ErrInvalidName, "characters": ErrInvalidName, strings.Repeat("a", 201): ErrInvalidName, "kingdom hearts": ErrConflict, "FOLDER ONLY": ErrConflict, "pending": ErrConflict, } { _, err := f.uploads.Start(ctx, name) assert.ErrorIs(t, err, want, "%q", name) } _, err := f.uploads.Start(ctx, "Ys: Origin") assert.ErrorIs(t, err, ErrInvalidName, "':' is not allowed on Windows") _, err = f.uploads.Start(ctx, "Pokémon HeartGold & SoulSilver (DS)") assert.NoError(t, err) } func TestUploadFileChecks(t *testing.T) { f := newUploadFixture(t) upload := f.start("Game") for fileName, want := range map[string]error{ "song.wav": ErrInvalidName, "song": ErrInvalidName, "Disc 1/01.mp3": ErrInvalidName, "../escape.mp3": ErrInvalidName, ".hidden.mp3": ErrInvalidName, } { _, err := f.uploads.PutFile(upload.ID, fileName, mp3("x")) assert.ErrorIs(t, err, want, fileName) } _, err := f.uploads.PutFile(upload.ID, "text.mp3", []byte("just text")) assert.ErrorIs(t, err, ErrInvalidName, "not an MP3 file") _, err = f.uploads.PutFile(upload.ID, "frame.mp3", []byte{0xFF, 0xFB, 0x90, 0x00}) assert.NoError(t, err, "an MP3 without an ID3 tag starts with a frame") _, err = f.uploads.PutFile(uuid.New(), "song.mp3", mp3("x")) assert.ErrorIs(t, err, ErrNotFound) _, err = f.uploads.PutFile(upload.ID, "big.mp3", mp3(strings.Repeat("x", 1<<20))) assert.ErrorIs(t, err, ErrTooLarge, "over the size of one song") for i := range 3 { f.put(upload.ID, string(rune('a'+i))+".mp3", strings.Repeat("x", 1<<20-10)) } _, err = f.uploads.PutFile(upload.ID, "d.mp3", mp3(strings.Repeat("x", 1000))) assert.ErrorIs(t, err, ErrTooLarge, "over the size of the whole upload") _, err = f.uploads.PutFile(upload.ID, "a.mp3", mp3("small")) assert.NoError(t, err, "replacing a song counts its new size only") } func TestUploadFinishChecks(t *testing.T) { f := newUploadFixture(t) ctx := context.Background() empty := f.start("Empty") _, err := f.uploads.Finish(ctx, empty.ID) assert.ErrorIs(t, err, ErrInvalidName, "an upload without songs") _, err = f.uploads.Finish(ctx, uuid.New()) assert.ErrorIs(t, err, ErrNotFound) // A folder of the same name appeared meanwhile, e.g. from ResilioSync. clash := f.start("Clash") f.put(clash.ID, "01.mp3", "c") writeFile(t, filepath.Join(f.musicDir, "Clash", "01.mp3"), "other") _, err = f.uploads.Finish(ctx, clash.ID) assert.ErrorIs(t, err, ErrConflict) assert.FileExists(t, filepath.Join(f.musicDir, uploadsFolder, clash.ID.String(), "01.mp3"), "the upload is kept") // The same songs as a soundtrack that is already in the library. original := f.start("Original") f.put(original.ID, "01.mp3", "same") _, err = f.uploads.Finish(ctx, original.ID) require.NoError(t, err) duplicate := f.start("Copy") f.put(duplicate.ID, "01.mp3", "same") _, err = f.uploads.Finish(ctx, duplicate.ID) assert.ErrorIs(t, err, ErrConflict) assert.Contains(t, err.Error(), "Original") assert.NoDirExists(t, filepath.Join(f.musicDir, "Copy")) } func TestUploadWhileSyncing(t *testing.T) { f := newUploadFixture(t) release := make(chan struct{}) f.sync = NewSyncService(f.store, f.musicDir, func(context.Context) { <-release }) f.uploads.sync = f.sync upload := f.start("Game") f.put(upload.ID, "01.mp3", "a") require.True(t, f.sync.Start(false)) _, err := f.uploads.Start(context.Background(), "Other") assert.ErrorIs(t, err, ErrSyncing) _, err = f.uploads.Finish(context.Background(), upload.ID) assert.ErrorIs(t, err, ErrSyncing) assert.NoDirExists(t, filepath.Join(f.musicDir, "Game"), "nothing was moved") close(release) f.sync.Wait() _, err = f.uploads.Finish(context.Background(), upload.ID) assert.NoError(t, err) } func TestUploadFinishBlocksASync(t *testing.T) { f := newUploadFixture(t) upload := f.start("Game") f.put(upload.ID, "01.mp3", "a") started := make(chan bool, 1) _, err := f.sync.SyncFolder(context.Background(), "Game", func() error { started <- f.sync.Start(false) return os.Rename(f.uploads.dir(upload.ID), filepath.Join(f.musicDir, "Game")) }) require.NoError(t, err) assert.False(t, <-started, "a sync cannot start while a folder is being added") } func TestUploadCancelAndCleanup(t *testing.T) { f := newUploadFixture(t) cancelled := f.start("Cancelled") f.put(cancelled.ID, "01.mp3", "a") require.NoError(t, f.uploads.Cancel(cancelled.ID)) assert.NoDirExists(t, f.uploads.dir(cancelled.ID)) assert.ErrorIs(t, f.uploads.Cancel(cancelled.ID), ErrNotFound) old := f.start("Old") f.put(old.ID, "01.mp3", "a") recent := f.start("Recent") f.uploads.now = func() time.Time { return time.Now().Add(uploadMaxAge - time.Minute) } f.uploads.RemoveOld() _, err := f.uploads.Get(old.ID) assert.NoError(t, err, "not a day old yet") f.uploads.now = func() time.Time { return time.Now().Add(uploadMaxAge + time.Minute) } f.uploads.RemoveOld() _, err = f.uploads.Get(old.ID) assert.ErrorIs(t, err, ErrNotFound) _, err = f.uploads.Get(recent.ID) assert.ErrorIs(t, err, ErrNotFound) // A new upload of the same name works after the old one is gone. f.uploads.now = time.Now f.start("Old") } func TestSyncSkipsUploadsAndHiddenFolders(t *testing.T) { f := newSyncFixture(t) f.write("Game A/01.mp3", "a") f.write(".uploads/123/01.mp3", "b") f.write(".sync/x/01.mp3", "c") result := f.run(false) assert.Equal(t, []string{"Game A"}, result.SoundtracksAdded) } func TestUploadsUnavailableWhenMusicFolderIsReadOnly(t *testing.T) { if os.Getuid() == 0 { t.Skip("root can write to read-only folders") } f := newUploadFixture(t) require.NoError(t, os.Chmod(f.musicDir, 0o555)) t.Cleanup(func() { os.Chmod(f.musicDir, 0o755) }) _, err := f.uploads.Start(context.Background(), "Game") assert.ErrorIs(t, err, ErrUploadsUnavailable) }