ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Failing after 4m4s
ci / api / api (push) Successful in 55s
ci / db / db (push) Failing after 1m14s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
229 lines
8.0 KiB
Go
229 lines
8.0 KiB
Go
package uploads
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"pipu/api/internal/auth"
|
|
)
|
|
|
|
type failingReader struct{ err error }
|
|
|
|
func (r failingReader) Read([]byte) (int, error) { return 0, r.err }
|
|
|
|
func TestTusInterruptedWriteCanResume(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
err error
|
|
status int
|
|
code string
|
|
}{
|
|
{"disk full", &os.PathError{Op: "write", Path: "/uploads/file", Err: syscall.ENOSPC}, http.StatusInsufficientStorage, "uploads.storage_full"},
|
|
{"disk quota", syscall.EDQUOT, http.StatusInsufficientStorage, "uploads.storage_full"},
|
|
{"connection interrupted", io.ErrUnexpectedEOF, http.StatusInternalServerError, "common.internal"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
e := newTusEnv(t, 10)
|
|
req := httptest.NewRequest(http.MethodPatch, TusURL(e.files.f.ID), io.MultiReader(bytes.NewBufferString("hello"), failingReader{tc.err}))
|
|
req.Header.Set("Tus-Resumable", tusVersion)
|
|
req.Header.Set("Content-Type", "application/offset+octet-stream")
|
|
req.Header.Set("Upload-Offset", "0")
|
|
req = req.WithContext(auth.WithPrincipal(req.Context(), auth.Principal{UserID: e.author, Role: auth.RoleModerator}))
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, req)
|
|
require.Equal(t, tc.status, rec.Code, rec.Body.String())
|
|
assert.Contains(t, rec.Body.String(), tc.code)
|
|
assert.NotContains(t, rec.Body.String(), "/uploads/file")
|
|
assert.EqualValues(t, 5, e.files.f.Received)
|
|
assert.Equal(t, 0, e.complete)
|
|
assert.Equal(t, "5", e.do(e.author, http.MethodHead, 0, "").Header().Get("Upload-Offset"))
|
|
require.Equal(t, http.StatusNoContent, e.do(e.author, http.MethodPatch, 5, "world").Code)
|
|
assert.Equal(t, 1, e.complete)
|
|
got, err := os.ReadFile(filepath.Join(e.dir, e.files.f.StagingPath))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "helloworld", string(got))
|
|
})
|
|
}
|
|
}
|
|
|
|
type fakeFiles struct{ f File }
|
|
|
|
func (m *fakeFiles) File(_ context.Context, id uuid.UUID) (File, error) {
|
|
if id != m.f.ID {
|
|
return File{}, ErrNotFound
|
|
}
|
|
return m.f, nil
|
|
}
|
|
|
|
func (m *fakeFiles) SetReceived(_ context.Context, _ uuid.UUID, offset int64) error {
|
|
m.f.Received = offset
|
|
return nil
|
|
}
|
|
|
|
type tusEnv struct {
|
|
h http.Handler
|
|
files *fakeFiles
|
|
dir string
|
|
author uuid.UUID
|
|
complete int
|
|
}
|
|
|
|
func newTusEnv(t *testing.T, size int64) *tusEnv {
|
|
e := &tusEnv{dir: t.TempDir(), author: uuid.New()}
|
|
id := uuid.New()
|
|
e.files = &fakeFiles{f: File{ID: id, AuthorID: e.author, Status: StatusUploading, Size: size, StagingPath: "batch/" + id.String()}}
|
|
tus := NewTUS(e.files, e.dir, func(_ context.Context, f File) error {
|
|
e.complete++
|
|
e.files.f.Status = StatusProcessing
|
|
return nil
|
|
}, slog.New(slog.DiscardHandler))
|
|
r := chi.NewRouter()
|
|
tus.Mount(r)
|
|
e.h = r
|
|
return e
|
|
}
|
|
|
|
func (e *tusEnv) do(user uuid.UUID, method string, offset int64, body string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, TusURL(e.files.f.ID), bytes.NewBufferString(body))
|
|
req.Header.Set("Tus-Resumable", tusVersion)
|
|
if method == http.MethodPatch {
|
|
req.Header.Set("Content-Type", "application/offset+octet-stream")
|
|
req.Header.Set("Upload-Offset", strconv.FormatInt(offset, 10))
|
|
}
|
|
req = req.WithContext(auth.WithPrincipal(req.Context(), auth.Principal{UserID: user, Role: auth.RoleModerator}))
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestTusResumableUpload(t *testing.T) {
|
|
e := newTusEnv(t, 10)
|
|
|
|
rec := e.do(e.author, http.MethodHead, 0, "")
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
assert.Equal(t, "0", rec.Header().Get("Upload-Offset"))
|
|
assert.Equal(t, "10", rec.Header().Get("Upload-Length"))
|
|
|
|
rec = e.do(e.author, http.MethodPatch, 0, "hello")
|
|
require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
|
|
assert.Equal(t, "5", rec.Header().Get("Upload-Offset"))
|
|
|
|
// Повтор с устаревшим смещением (клиент не получил ответ) — 409 и текущее смещение.
|
|
rec = e.do(e.author, http.MethodPatch, 0, "hello")
|
|
assert.Equal(t, http.StatusConflict, rec.Code)
|
|
assert.Equal(t, "5", rec.Header().Get("Upload-Offset"))
|
|
|
|
// Больше заявленного размера — отказ, на диск ничего лишнего не попадает.
|
|
rec = e.do(e.author, http.MethodPatch, 5, "world!!")
|
|
assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
|
|
assert.Equal(t, 0, e.complete)
|
|
|
|
rec = e.do(e.author, http.MethodPatch, 5, "world")
|
|
require.Equal(t, http.StatusNoContent, rec.Code)
|
|
assert.Equal(t, 1, e.complete, "full file goes to processing")
|
|
got, err := os.ReadFile(filepath.Join(e.dir, "batch", e.files.f.ID.String()))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "helloworld", string(got))
|
|
|
|
// Принятый файл: HEAD сообщает, что всё на месте; PATCH больше не принимается.
|
|
rec = e.do(e.author, http.MethodHead, 0, "")
|
|
assert.Equal(t, "10", rec.Header().Get("Upload-Offset"))
|
|
assert.Equal(t, http.StatusConflict, e.do(e.author, http.MethodPatch, 10, "x").Code)
|
|
}
|
|
|
|
func TestTusOnlyAuthor(t *testing.T) {
|
|
e := newTusEnv(t, 10)
|
|
assert.Equal(t, http.StatusNotFound, e.do(uuid.New(), http.MethodHead, 0, "").Code)
|
|
assert.Equal(t, http.StatusNotFound, e.do(uuid.New(), http.MethodPatch, 0, "hello").Code)
|
|
|
|
e.files.f.Status = StatusRejected
|
|
assert.Equal(t, http.StatusGone, e.do(e.author, http.MethodHead, 0, "").Code)
|
|
}
|
|
|
|
func TestQuotaStatuses(t *testing.T) {
|
|
for _, s := range []Status{StatusUploading, StatusProcessing, StatusPendingReview, StatusApproved, StatusImporting, StatusImportFailed} {
|
|
assert.True(t, s.Occupies(), s)
|
|
}
|
|
for _, s := range []Status{StatusImported, StatusDuplicate, StatusInvalid, StatusRejected, StatusWithdrawn, StatusExpired} {
|
|
assert.False(t, s.Occupies(), s)
|
|
}
|
|
}
|
|
|
|
func TestExtFor(t *testing.T) {
|
|
codec := func(c string) *string { return &c }
|
|
cases := []struct {
|
|
name string
|
|
codec *string
|
|
want string
|
|
}{
|
|
{"track.FLAC", codec("flac"), "flac"},
|
|
{"track.mp4", codec("aac"), "m4a"}, // по содержимому, а не по имени
|
|
{"track.ogg", codec("opus"), "opus"},
|
|
{"track.aiff", codec("pcm_s16be"), "aiff"},
|
|
{"track.raw", codec("pcm_s16le"), "wav"},
|
|
{"track.Mp3", nil, "mp3"},
|
|
}
|
|
for _, c := range cases {
|
|
assert.Equal(t, c.want, extFor(File{OriginalName: c.name, Codec: c.codec}), c.name)
|
|
}
|
|
}
|
|
|
|
func TestInitialAlbumCover(t *testing.T) {
|
|
s := &Service{cfg: Config{StagingDir: t.TempDir()}}
|
|
if cover, err := s.createCover(nil); err != nil || cover != nil {
|
|
t.Fatalf("missing cover = %v, %v", cover, err)
|
|
}
|
|
if _, err := s.createCover([]byte("not an image")); err == nil {
|
|
t.Fatal("invalid cover must be rejected")
|
|
}
|
|
data := []byte{0xff, 0xd8, 0xff, 1}
|
|
rel, err := s.createCover(data)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, rel)
|
|
got, err := os.ReadFile(s.stagingAbs(*rel))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, data, got)
|
|
second, err := s.createCover(data)
|
|
require.NoError(t, err)
|
|
assert.NotEqual(t, *rel, *second, "album batches must not overwrite each other's cover")
|
|
}
|
|
|
|
func TestReuseExistingFolders(t *testing.T) {
|
|
root := t.TempDir()
|
|
require.NoError(t, os.MkdirAll(filepath.Join(root, "Кино", "1988 - Группа крови"), 0o755))
|
|
assert.Equal(t, "Кино/1988 - Группа крови/01 - x.flac", reuseExisting(root, "КИНО/1988 - группа крови/01 - x.flac"))
|
|
assert.Equal(t, "Кино/1990 - Чёрный/01 - x.flac", reuseExisting(root, "кино/1990 - Чёрный/01 - x.flac"))
|
|
assert.Equal(t, "Ария/x.flac", reuseExisting(root, "Ария/x.flac"))
|
|
}
|
|
|
|
func TestBestMatch(t *testing.T) {
|
|
a := make([]uint32, 200)
|
|
for i := range a {
|
|
a[i] = uint32(i) * 2654435761
|
|
}
|
|
other := make([]uint32, 200)
|
|
for i := range other {
|
|
other[i] = ^a[i]
|
|
}
|
|
track := uuid.New()
|
|
c, ok := bestMatch(a, []PrintCandidate{{Points: other}, {TrackID: &track, Points: a}})
|
|
require.True(t, ok)
|
|
assert.Equal(t, &track, c.TrackID)
|
|
_, ok = bestMatch(a, []PrintCandidate{{Points: other}})
|
|
assert.False(t, ok)
|
|
}
|