spark-server/internal/module/music/logic/service_test.go
DESKTOP-L8OUL9F\gfl0034 53426af122 feat: 多广场 channel、音乐公开 API 与订阅 package_name
支持 square/squarenovel/squaresteps/squarehealth 实例与 category 筛选;音乐增加免登录 grouped/track;订阅套餐按 package_name 区分;补 migration 0030/0031 与 OpenAPI。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 09:02:28 +08:00

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