支持 square/squarenovel/squaresteps/squarehealth 实例与 category 筛选;音乐增加免登录 grouped/track;订阅套餐按 package_name 区分;补 migration 0030/0031 与 OpenAPI。 Co-authored-by: Cursor <cursoragent@cursor.com>
458 lines
13 KiB
Go
458 lines
13 KiB
Go
package logic
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"spark-server/internal/module/music/entity"
|
|
"spark-server/internal/pkg/errs"
|
|
)
|
|
|
|
type stubMusicRepo struct {
|
|
tracks map[int64]*entity.Track
|
|
categories map[int64]*entity.Category
|
|
trackCategories map[int64][]int64
|
|
nextID int64
|
|
nextCatID int64
|
|
preview int
|
|
}
|
|
|
|
func (s *stubMusicRepo) ensureCats() {
|
|
if s.categories == nil {
|
|
s.categories = map[int64]*entity.Category{}
|
|
}
|
|
}
|
|
|
|
func (s *stubMusicRepo) ListAll(_ context.Context) ([]entity.Track, error) {
|
|
out := make([]entity.Track, 0, len(s.tracks))
|
|
for _, t := range s.tracks {
|
|
out = append(out, *t)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *stubMusicRepo) ListEnabled(_ context.Context) ([]entity.Track, error) {
|
|
out := make([]entity.Track, 0)
|
|
for _, t := range s.tracks {
|
|
if t.Enabled {
|
|
out = append(out, *t)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *stubMusicRepo) ListEnabledByCategory(_ context.Context, categoryID int64) ([]entity.Track, error) {
|
|
out := make([]entity.Track, 0)
|
|
for _, t := range s.tracks {
|
|
if !t.Enabled {
|
|
continue
|
|
}
|
|
ids := s.trackCategories[t.ID]
|
|
if ids == nil {
|
|
ids = t.CategoryIDs
|
|
}
|
|
for _, id := range ids {
|
|
if id == categoryID {
|
|
out = append(out, *t)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *stubMusicRepo) Get(_ context.Context, id int64) (*entity.Track, error) {
|
|
t, ok := s.tracks[id]
|
|
if !ok {
|
|
return nil, errs.ErrNotFound
|
|
}
|
|
cp := *t
|
|
if s.trackCategories != nil {
|
|
cp.CategoryIDs = append([]int64(nil), s.trackCategories[id]...)
|
|
}
|
|
return &cp, nil
|
|
}
|
|
|
|
func (s *stubMusicRepo) Insert(_ context.Context, t *entity.Track) error {
|
|
if s.tracks == nil {
|
|
s.tracks = map[int64]*entity.Track{}
|
|
}
|
|
s.nextID++
|
|
t.ID = s.nextID
|
|
cp := *t
|
|
s.tracks[t.ID] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (s *stubMusicRepo) Update(_ context.Context, t *entity.Track) error {
|
|
if s.tracks[t.ID] == nil {
|
|
return errs.ErrNotFound
|
|
}
|
|
cp := *t
|
|
s.tracks[t.ID] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (s *stubMusicRepo) Delete(_ context.Context, id int64) error {
|
|
if s.tracks[id] == nil {
|
|
return errs.ErrNotFound
|
|
}
|
|
delete(s.tracks, id)
|
|
return nil
|
|
}
|
|
|
|
func (s *stubMusicRepo) MemberPreviewSeconds(context.Context) (int, error) {
|
|
if s.preview == 0 {
|
|
return entity.DefaultPreviewSeconds, nil
|
|
}
|
|
return s.preview, nil
|
|
}
|
|
|
|
func (s *stubMusicRepo) SetSetting(context.Context, string, string) error { return nil }
|
|
|
|
func (s *stubMusicRepo) ListCategories(_ context.Context) ([]entity.Category, error) {
|
|
s.ensureCats()
|
|
out := make([]entity.Category, 0, len(s.categories))
|
|
for _, c := range s.categories {
|
|
out = append(out, *c)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *stubMusicRepo) ListEnabledCategories(ctx context.Context) ([]entity.Category, error) {
|
|
all, err := s.ListCategories(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]entity.Category, 0)
|
|
for i := range all {
|
|
if all[i].Enabled {
|
|
out = append(out, all[i])
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *stubMusicRepo) GetCategory(_ context.Context, id int64) (*entity.Category, error) {
|
|
s.ensureCats()
|
|
c, ok := s.categories[id]
|
|
if !ok {
|
|
return nil, errs.ErrNotFound
|
|
}
|
|
cp := *c
|
|
return &cp, nil
|
|
}
|
|
|
|
func (s *stubMusicRepo) InsertCategory(_ context.Context, c *entity.Category) error {
|
|
s.ensureCats()
|
|
s.nextCatID++
|
|
c.ID = s.nextCatID
|
|
cp := *c
|
|
s.categories[c.ID] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (s *stubMusicRepo) UpdateCategory(_ context.Context, c *entity.Category) error {
|
|
s.ensureCats()
|
|
if s.categories[c.ID] == nil {
|
|
return errs.ErrNotFound
|
|
}
|
|
cp := *c
|
|
s.categories[c.ID] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (s *stubMusicRepo) DeleteCategory(_ context.Context, id int64) error {
|
|
s.ensureCats()
|
|
if s.categories[id] == nil {
|
|
return errs.ErrNotFound
|
|
}
|
|
delete(s.categories, id)
|
|
return nil
|
|
}
|
|
|
|
func (s *stubMusicRepo) SetTrackCategories(_ context.Context, trackID int64, categoryIDs []int64) error {
|
|
if s.trackCategories == nil {
|
|
s.trackCategories = map[int64][]int64{}
|
|
}
|
|
s.trackCategories[trackID] = append([]int64(nil), categoryIDs...)
|
|
return nil
|
|
}
|
|
|
|
type stubObjects struct {
|
|
ready bool
|
|
}
|
|
|
|
func (o stubObjects) Ready() bool { return o.ready }
|
|
|
|
func (o stubObjects) PutLarge(context.Context, string, []byte, string, int) error { return nil }
|
|
|
|
func (o stubObjects) Delete(context.Context, string) error { return nil }
|
|
|
|
func newTestService() (*Service, *stubMusicRepo) {
|
|
repo := &stubMusicRepo{preview: 30, tracks: map[int64]*entity.Track{}}
|
|
return NewService(repo, stubObjects{ready: true}, "https://cdn.example", "spark"), repo
|
|
}
|
|
|
|
func TestServicePlaylistAndSettings(t *testing.T) {
|
|
svc, repo := newTestService()
|
|
_ = repo.Insert(context.Background(), &entity.Track{Title: "a", Enabled: true})
|
|
pl, err := svc.Playlist(context.Background(), PlaylistQuery{})
|
|
if err != nil || len(pl.Items) != 1 || pl.MemberPreviewSeconds != 30 {
|
|
t.Fatalf("playlist %+v %v", pl, err)
|
|
}
|
|
st, err := svc.Settings(context.Background())
|
|
if err != nil || st.MemberPreviewSeconds != 30 {
|
|
t.Fatalf("settings %+v %v", st, err)
|
|
}
|
|
all, err := svc.ListAdmin(context.Background())
|
|
if err != nil || len(all) != 1 {
|
|
t.Fatalf("admin list %+v %v", all, err)
|
|
}
|
|
}
|
|
|
|
func TestServiceGetPublicAndRequireMember(t *testing.T) {
|
|
svc, repo := newTestService()
|
|
ctx := context.Background()
|
|
_ = repo.Insert(ctx, &entity.Track{Title: "free", Enabled: true, RequireMember: false})
|
|
_ = repo.Insert(ctx, &entity.Track{Title: "vip", Enabled: true, RequireMember: true})
|
|
_ = repo.Insert(ctx, &entity.Track{Title: "off", Enabled: false})
|
|
|
|
got, err := svc.GetPublic(ctx, 1)
|
|
if err != nil || got.Title != "free" {
|
|
t.Fatalf("get %+v %v", got, err)
|
|
}
|
|
if _, err := svc.GetPublic(ctx, 3); err == nil {
|
|
t.Fatal("want disabled not found")
|
|
}
|
|
if _, err := svc.GetPublic(ctx, 0); err == nil {
|
|
t.Fatal("want invalid id")
|
|
}
|
|
|
|
wantVIP := true
|
|
vipOnly, err := svc.Playlist(ctx, PlaylistQuery{RequireMember: &wantVIP})
|
|
if err != nil || len(vipOnly.Items) != 1 || !vipOnly.Items[0].RequireMember {
|
|
t.Fatalf("vip filter %+v %v", vipOnly, err)
|
|
}
|
|
wantFree := false
|
|
freeOnly, err := svc.Playlist(ctx, PlaylistQuery{RequireMember: &wantFree})
|
|
if err != nil || len(freeOnly.Items) != 1 || freeOnly.Items[0].RequireMember {
|
|
t.Fatalf("free filter %+v %v", freeOnly, err)
|
|
}
|
|
}
|
|
|
|
func TestServicePlaylistGrouped(t *testing.T) {
|
|
svc, _ := newTestService()
|
|
ctx := context.Background()
|
|
root, err := svc.CreateCategory(ctx, CategoryInput{Name: "Home"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pid := root.ID
|
|
rain, err := svc.CreateCategory(ctx, CategoryInput{Name: "Rain", ParentID: &pid})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wind, err := svc.CreateCategory(ctx, CategoryInput{Name: "Wind", ParentID: &pid})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := svc.Create(ctx, UpsertInput{Title: "soft rain", CategoryIDs: []int64{rain.ID}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := svc.Create(ctx, UpsertInput{Title: "gentle wind", CategoryIDs: []int64{wind.ID}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
all, err := svc.PlaylistGrouped(ctx, 0)
|
|
if err != nil || len(all.Groups) != 2 {
|
|
t.Fatalf("grouped all %+v %v", all, err)
|
|
}
|
|
underHome, err := svc.PlaylistGrouped(ctx, root.ID)
|
|
if err != nil || len(underHome.Groups) != 2 {
|
|
t.Fatalf("under home %+v %v", underHome, err)
|
|
}
|
|
one, err := svc.PlaylistGrouped(ctx, rain.ID)
|
|
if err != nil || len(one.Groups) != 1 || one.Groups[0].CategoryID != rain.ID || len(one.Groups[0].Items) != 1 {
|
|
t.Fatalf("leaf group %+v %v", one, err)
|
|
}
|
|
}
|
|
|
|
func TestServiceCRUD(t *testing.T) {
|
|
svc, _ := newTestService()
|
|
ctx := context.Background()
|
|
created, err := svc.Create(ctx, UpsertInput{Title: " hi "})
|
|
if err != nil || created.ID != 1 || created.Title != "hi" {
|
|
t.Fatalf("create %+v %v", created, err)
|
|
}
|
|
req := true
|
|
updated, err := svc.Update(ctx, 1, UpsertInput{Title: "new", RequireMember: &req})
|
|
if err != nil || !updated.RequireMember || updated.Title != "new" {
|
|
t.Fatalf("update %+v %v", updated, err)
|
|
}
|
|
if err := svc.Delete(ctx, 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := svc.Delete(ctx, 1); err == nil {
|
|
t.Fatal("want not found")
|
|
}
|
|
}
|
|
|
|
func TestServiceUpdateSettingsValidation(t *testing.T) {
|
|
svc, _ := newTestService()
|
|
_, err := svc.UpdateSettings(context.Background(), -1)
|
|
var ae *errs.AppError
|
|
if !errors.As(err, &ae) || ae.Code != errs.ErrInvalidArgument.Code {
|
|
t.Fatalf("want invalid %v", err)
|
|
}
|
|
ok, err := svc.UpdateSettings(context.Background(), 45)
|
|
if err != nil || ok.MemberPreviewSeconds != 45 {
|
|
t.Fatalf("settings %+v %v", ok, err)
|
|
}
|
|
}
|
|
|
|
func TestServiceUploadValidation(t *testing.T) {
|
|
svc, _ := newTestService()
|
|
ctx := context.Background()
|
|
if _, err := svc.Create(ctx, UpsertInput{Title: ""}); err == nil {
|
|
t.Fatal("empty title")
|
|
}
|
|
if _, err := svc.UploadCover(ctx, 1, "x.jpg", nil, ""); err == nil {
|
|
t.Fatal("missing track")
|
|
}
|
|
track, _ := svc.Create(ctx, UpsertInput{Title: "t"})
|
|
if _, err := svc.UploadCover(ctx, track.ID, "x.jpg", nil, ""); err == nil {
|
|
t.Fatal("empty body")
|
|
}
|
|
if _, err := svc.UploadCover(ctx, track.ID, "x.bin", []byte("x"), "application/octet-stream"); err == nil {
|
|
t.Fatal("bad mime")
|
|
}
|
|
if _, err := svc.UploadAudio(ctx, track.ID, "a.mp3", []byte{1, 2}, mimeMPEG); err != nil {
|
|
t.Fatalf("audio %v", err)
|
|
}
|
|
got, err := svc.repo.Get(ctx, track.ID)
|
|
if err != nil || got.AudioKey == "" || got.AudioURL == "" {
|
|
t.Fatalf("track %+v %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeMIMEAndHelpers(t *testing.T) {
|
|
if got := normalizeMIME("", "photo.JPG"); got != mimeJPEG {
|
|
t.Fatalf("jpg %q", got)
|
|
}
|
|
if got := normalizeMIME("image/png", ""); got != mimePNG {
|
|
t.Fatalf("png %q", got)
|
|
}
|
|
if !isImageMIME(mimeGIF) || isImageMIME("text/plain") {
|
|
t.Fatal("image mime")
|
|
}
|
|
if !isAudioMIME(mimeMPEG, "a.mp3") || !isAudioMIME("", "song.mp3") {
|
|
t.Fatal("audio mime")
|
|
}
|
|
if extFor(mimePNG, "", "jpg") != "png" || extFor("", "file.weB", "bin") != "web" {
|
|
t.Fatal("extFor")
|
|
}
|
|
svc := NewService(&stubMusicRepo{}, nil, "https://cdn.example", "spark")
|
|
if u := svc.publicURL("perm/a.jpg"); u != "https://cdn.example/spark/perm/a.jpg" {
|
|
t.Fatalf("url %q", u)
|
|
}
|
|
}
|
|
|
|
func TestServiceUploadBackground(t *testing.T) {
|
|
svc, _ := newTestService()
|
|
ctx := context.Background()
|
|
track, err := svc.Create(ctx, UpsertInput{Title: "bg"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := []byte{0xff, 0xd8, 0xff}
|
|
updated, err := svc.UploadBackground(ctx, track.ID, "bg.jpg", body, mimeJPEG)
|
|
if err != nil || updated.BackgroundKey == "" || updated.BackgroundURL == "" {
|
|
t.Fatalf("background %+v %v", updated, err)
|
|
}
|
|
}
|
|
|
|
func TestServicePutWithoutStorage(t *testing.T) {
|
|
repo := &stubMusicRepo{tracks: map[int64]*entity.Track{1: {ID: 1, Title: "x"}}}
|
|
svc := NewService(repo, stubObjects{ready: false}, "", "spark")
|
|
_, err := svc.UploadAudio(context.Background(), 1, "a.mp3", []byte{1}, mimeMPEG)
|
|
if err == nil {
|
|
t.Fatal("want storage error")
|
|
}
|
|
}
|
|
|
|
func TestNewServicePublicURLDefaults(t *testing.T) {
|
|
svc := NewService(&stubMusicRepo{}, nil, "https://x", "")
|
|
if svc.publicURL("k") != "https://x/spark/k" {
|
|
t.Fatalf("%q", svc.publicURL("k"))
|
|
}
|
|
}
|
|
|
|
func TestServiceNotReady(t *testing.T) {
|
|
var svc *Service
|
|
if _, err := svc.Playlist(context.Background(), PlaylistQuery{}); !errors.Is(err, ErrNotReady) {
|
|
t.Fatalf("%v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceCategories(t *testing.T) {
|
|
svc, _ := newTestService()
|
|
ctx := context.Background()
|
|
|
|
root, err := svc.CreateCategory(ctx, CategoryInput{Name: "Root"})
|
|
if err != nil || root.ID != 1 {
|
|
t.Fatalf("root %+v %v", root, err)
|
|
}
|
|
pid := root.ID
|
|
child, err := svc.CreateCategory(ctx, CategoryInput{Name: "Child", ParentID: &pid})
|
|
if err != nil || child.ParentID == nil || *child.ParentID != root.ID {
|
|
t.Fatalf("child %+v %v", child, err)
|
|
}
|
|
if _, err := svc.CreateCategory(ctx, CategoryInput{Name: "bad", ParentID: &child.ID}); err == nil {
|
|
t.Fatal("want depth limit")
|
|
}
|
|
tree, err := svc.CategoryTree(ctx, true)
|
|
if err != nil || len(tree) != 1 || len(tree[0].Children) != 1 {
|
|
t.Fatalf("tree %+v %v", tree, err)
|
|
}
|
|
if _, err := svc.ListCategoriesAdmin(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dis := false
|
|
_, err = svc.UpdateCategory(ctx, root.ID, CategoryInput{ParentID: int64Ptr(0), Enabled: &dis})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
track, err := svc.Create(ctx, UpsertInput{Title: "t", CategoryIDs: []int64{root.ID, root.ID, 0}})
|
|
if err != nil || len(track.CategoryIDs) != 1 {
|
|
t.Fatalf("track cats %+v %v", track, err)
|
|
}
|
|
updated, err := svc.SetTrackCategories(ctx, track.ID, []int64{child.ID})
|
|
if err != nil || len(updated.CategoryIDs) != 1 || updated.CategoryIDs[0] != child.ID {
|
|
t.Fatalf("set cats %+v %v", updated, err)
|
|
}
|
|
if err := svc.DeleteCategory(ctx, root.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func int64Ptr(v int64) *int64 { return &v }
|
|
|
|
func TestDedupeIDs(t *testing.T) {
|
|
got := dedupeIDs([]int64{1, 1, 0, -1, 2, 2})
|
|
if len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
|
t.Fatalf("%v", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildCategoryTreeOrphan(t *testing.T) {
|
|
tree := buildCategoryTree([]entity.Category{
|
|
{ID: 2, Name: "orphan", ParentID: int64Ptr(99)},
|
|
{ID: 1, Name: "root"},
|
|
})
|
|
if len(tree) != 2 {
|
|
t.Fatalf("%+v", tree)
|
|
}
|
|
}
|