Files
MusicServer/internal/backend/uploads_test.go
SansanandClaude Opus 5.5 a7e2280969
CI / integration (pull_request) Successful in 21s
CI / test (pull_request) Successful in 26s
CI / publish (pull_request) Skipped
CI / docker (pull_request) Successful in 43s
Upload new soundtracks through the API
A client starts an upload with the soundtrack's name, sends one MP3 per
request, and finishes it. The server then moves the songs into a new folder
in the music folder and syncs only that folder, so the soundtrack is in the
library, lists and search right away.

- POST /api/v1/soundtracks/uploads, GET/DELETE .../uploads/{id},
  PUT .../uploads/{id}/files/{file} and POST .../uploads/{id}/finish.
- Names must work as file names on every system and be free, ignoring case.
  File names are plain: clients flatten disc folders.
- Only MP3 files. Size limits from UPLOAD_MAX_FILE_MB (50) and
  UPLOAD_MAX_TOTAL_MB (2048).
- Refused while a sync runs, and a sync cannot start while an upload is
  finished. Songs equal to an existing soundtrack's are refused, as the sync
  would take them for that soundtrack renamed.
- Unfinished uploads live in MUSIC_PATH/.uploads and are removed after a day.
  The sync now skips every hidden folder.

Closes #33

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:38:26 +02:00

271 lines
9.0 KiB
Go

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)
}