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