package services import ( "context" "database/sql" "errors" "testing" "quyun/v2/app/commands/testx" "quyun/v2/app/errorx" content_dto "quyun/v2/app/http/v1/dto" "quyun/v2/app/requests" "quyun/v2/database" "quyun/v2/database/models" "quyun/v2/pkg/consts" . "github.com/smartystreets/goconvey/convey" "github.com/stretchr/testify/suite" "go.ipao.vip/atom/contracts" "go.ipao.vip/gen/types" "go.uber.org/dig" ) type ContentTestSuiteInjectParams struct { dig.In DB *sql.DB Initials []contracts.Initial `group:"initials"` } type ContentTestSuite struct { suite.Suite ContentTestSuiteInjectParams } func Test_Content(t *testing.T) { providers := testx.Default().With(Provide) testx.Serve(providers, t, func(p ContentTestSuiteInjectParams) { suite.Run(t, &ContentTestSuite{ContentTestSuiteInjectParams: p}) }) } func (s *ContentTestSuite) Test_List() { Convey("List", s.T(), func() { ctx := s.T().Context() tenantID := int64(1) database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameUser) // Create Author author := &models.User{Nickname: "Author1", Username: "author1", Phone: "13800000001"} models.UserQuery.WithContext(ctx).Create(author) // Create Contents c1 := &models.Content{ TenantID: 1, UserID: author.ID, Title: "Content A", Status: consts.ContentStatusPublished, Genre: "video", } c2 := &models.Content{ TenantID: 1, UserID: author.ID, Title: "Content B", Status: consts.ContentStatusDraft, // Draft Genre: "video", } models.ContentQuery.WithContext(ctx).Create(c1, c2) Convey("should list only published contents", func() { tid := int64(1) filter := &content_dto.ContentListFilter{ TenantID: &tid, Pagination: requests.Pagination{ Page: 1, Limit: 10, }, } res, err := Content.List(ctx, tenantID, filter) So(err, ShouldBeNil) So(res.Total, ShouldEqual, 1) items := res.Items.([]content_dto.ContentItem) So(items[0].Title, ShouldEqual, "Content A") So(items[0].AuthorName, ShouldEqual, "Author1") }) }) } func (s *ContentTestSuite) Test_Get() { Convey("Get", s.T(), func() { ctx := s.T().Context() tenantID := int64(1) database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameMediaAsset, models.TableNameContentAsset, models.TableNameTenantUser, models.TableNameUser) // Author author := &models.User{Nickname: "Author1", Username: "author1", Phone: "13800000002"} models.UserQuery.WithContext(ctx).Create(author) // Asset asset := &models.MediaAsset{ TenantID: 1, UserID: author.ID, ObjectKey: "test.mp4", Type: consts.MediaAssetTypeVideo, } models.MediaAssetQuery.WithContext(ctx).Create(asset) // Content content := &models.Content{ TenantID: 1, UserID: author.ID, Title: "Detail Content", Status: consts.ContentStatusPublished, } models.ContentQuery.WithContext(ctx).Create(content) member := &models.User{Nickname: "Member", Username: "member1", Phone: "13800000003"} admin := &models.User{Nickname: "Admin", Username: "admin1", Phone: "13800000005"} guest := &models.User{Nickname: "Guest", Username: "guest1", Phone: "13800000004"} models.UserQuery.WithContext(ctx).Create(member, admin, guest) models.TenantUserQuery.WithContext(ctx).Create(&models.TenantUser{ TenantID: 1, UserID: member.ID, Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember}, Status: consts.UserStatusVerified, }) models.TenantUserQuery.WithContext(ctx).Create(&models.TenantUser{ TenantID: 1, UserID: admin.ID, Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleTenantAdmin}, Status: consts.UserStatusVerified, }) tenantOnly := &models.Content{ TenantID: 1, UserID: author.ID, Title: "Member Only", Status: consts.ContentStatusPublished, Visibility: consts.ContentVisibilityTenantOnly, } privateContent := &models.Content{ TenantID: 1, UserID: author.ID, Title: "Private Content", Status: consts.ContentStatusPublished, Visibility: consts.ContentVisibilityPrivate, } models.ContentQuery.WithContext(ctx).Create(tenantOnly, privateContent) // Link Asset ca := &models.ContentAsset{ TenantID: 1, UserID: author.ID, ContentID: content.ID, AssetID: asset.ID, Sort: 1, Role: consts.ContentAssetRoleMain, // Explicitly set role } models.ContentAssetQuery.WithContext(ctx).Create(ca) tenantMain := &models.MediaAsset{ TenantID: 1, UserID: author.ID, ObjectKey: "tenant_main.mp4", Type: consts.MediaAssetTypeVideo, } tenantPrev := &models.MediaAsset{ TenantID: 1, UserID: author.ID, ObjectKey: "tenant_preview.mp4", Type: consts.MediaAssetTypeVideo, } models.MediaAssetQuery.WithContext(ctx).Create(tenantMain, tenantPrev) models.ContentAssetQuery.WithContext(ctx).Create( &models.ContentAsset{ TenantID: 1, UserID: author.ID, ContentID: tenantOnly.ID, AssetID: tenantMain.ID, Sort: 1, Role: consts.ContentAssetRoleMain, }, &models.ContentAsset{ TenantID: 1, UserID: author.ID, ContentID: tenantOnly.ID, AssetID: tenantPrev.ID, Sort: 2, Role: consts.ContentAssetRolePreview, }, ) // Set context to author ctx = context.WithValue(ctx, consts.CtxKeyUser, author.ID) Convey("should get detail with assets", func() { detail, err := Content.Get(ctx, tenantID, author.ID, content.ID) So(err, ShouldBeNil) So(detail.Title, ShouldEqual, "Detail Content") So(detail.AuthorName, ShouldEqual, "Author1") So(len(detail.MediaUrls), ShouldEqual, 1) So(detail.MediaUrls[0].URL, ShouldContainSubstring, "test.mp4") }) Convey("should allow tenant_only content for member", func() { detail, err := Content.Get(ctx, tenantID, member.ID, tenantOnly.ID) So(err, ShouldBeNil) So(detail.Title, ShouldEqual, "Member Only") So(len(detail.MediaUrls), ShouldEqual, 2) }) Convey("should allow tenant_only content for admin", func() { detail, err := Content.Get(ctx, tenantID, admin.ID, tenantOnly.ID) So(err, ShouldBeNil) So(detail.Title, ShouldEqual, "Member Only") So(len(detail.MediaUrls), ShouldEqual, 2) }) Convey("should reject tenant_only content for non-member", func() { _, err := Content.Get(ctx, tenantID, guest.ID, tenantOnly.ID) So(err, ShouldNotBeNil) var appErr *errorx.AppError So(errors.As(err, &appErr), ShouldBeTrue) So(appErr.Code, ShouldEqual, errorx.ErrForbidden.Code) }) Convey("should reject private content for non-owner", func() { _, err := Content.Get(ctx, tenantID, member.ID, privateContent.ID) So(err, ShouldNotBeNil) var appErr *errorx.AppError So(errors.As(err, &appErr), ShouldBeTrue) So(appErr.Code, ShouldEqual, errorx.ErrForbidden.Code) }) Convey("should allow private content for author", func() { detail, err := Content.Get(ctx, tenantID, author.ID, privateContent.ID) So(err, ShouldBeNil) So(detail.Title, ShouldEqual, "Private Content") }) }) } func (s *ContentTestSuite) Test_CreateComment() { Convey("CreateComment", s.T(), func() { ctx := s.T().Context() tenantID := int64(1) database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameComment, models.TableNameUser) // User & Content u := &models.User{Username: "user1", Phone: "13900000001"} models.UserQuery.WithContext(ctx).Create(u) c := &models.Content{TenantID: 1, UserID: u.ID, Title: "C"} models.ContentQuery.WithContext(ctx).Create(c) // Auth context ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID) Convey("should create comment", func() { form := &content_dto.CommentCreateForm{ Content: "Nice!", } err := Content.CreateComment(ctx, tenantID, u.ID, c.ID, form) So(err, ShouldBeNil) count, _ := models.CommentQuery.WithContext(ctx).Where(models.CommentQuery.ContentID.Eq(c.ID)).Count() So(count, ShouldEqual, 1) }) }) } func (s *ContentTestSuite) Test_Library() { Convey("Library", s.T(), func() { ctx := s.T().Context() tenantID := int64(1) database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameContentAccess, models.TableNameUser, models.TableNameContentAsset, models.TableNameMediaAsset) // User u := &models.User{Username: "user_lib", Phone: "13900000002"} models.UserQuery.WithContext(ctx).Create(u) ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID) // Content c := &models.Content{TenantID: 1, UserID: u.ID, Title: "Paid Content", Genre: "video"} models.ContentQuery.WithContext(ctx).Create(c) // Asset (Video & Cover) assetVid := &models.MediaAsset{TenantID: 1, UserID: u.ID, Type: consts.MediaAssetTypeVideo, ObjectKey: "video.mp4"} assetImg := &models.MediaAsset{TenantID: 1, UserID: u.ID, Type: consts.MediaAssetTypeImage, ObjectKey: "cover.jpg"} models.MediaAssetQuery.WithContext(ctx).Create(assetVid, assetImg) models.ContentAssetQuery.WithContext(ctx).Create( &models.ContentAsset{ContentID: c.ID, AssetID: assetVid.ID, Role: consts.ContentAssetRoleMain}, &models.ContentAsset{ContentID: c.ID, AssetID: assetImg.ID, Role: consts.ContentAssetRoleCover}, ) // Access models.ContentAccessQuery.WithContext(ctx).Create(&models.ContentAccess{ TenantID: 1, UserID: u.ID, ContentID: c.ID, Status: "active", }) Convey("should get library content with details", func() { list, err := Content.GetLibrary(ctx, tenantID, u.ID) So(err, ShouldBeNil) So(len(list), ShouldEqual, 1) So(list[0].Title, ShouldEqual, "Paid Content") So(list[0].Type, ShouldEqual, "video") So(list[0].Cover, ShouldContainSubstring, "cover.jpg") So(list[0].IsPurchased, ShouldBeTrue) }) }) } func (s *ContentTestSuite) Test_Interact() { Convey("Interact", s.T(), func() { ctx := s.T().Context() tenantID := int64(1) database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameUserContentAction, models.TableNameUser) // User & Content u := &models.User{Username: "user_act", Phone: "13900000003"} models.UserQuery.WithContext(ctx).Create(u) c := &models.Content{TenantID: 1, UserID: u.ID, Title: "Liked Content", Likes: 0} models.ContentQuery.WithContext(ctx).Create(c) ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID) Convey("Like flow", func() { // Add Like err := Content.AddLike(ctx, tenantID, u.ID, c.ID) So(err, ShouldBeNil) // Verify count cReload, _ := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(c.ID)).First() So(cReload.Likes, ShouldEqual, 1) // Get Likes likes, err := Content.GetLikes(ctx, tenantID, u.ID) So(err, ShouldBeNil) So(len(likes), ShouldEqual, 1) So(likes[0].ID, ShouldEqual, c.ID) // Remove Like err = Content.RemoveLike(ctx, tenantID, u.ID, c.ID) So(err, ShouldBeNil) // Verify count cReload, _ = models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(c.ID)).First() So(cReload.Likes, ShouldEqual, 0) }) Convey("Favorite flow", func() { // Add Favorite err := Content.AddFavorite(ctx, tenantID, u.ID, c.ID) So(err, ShouldBeNil) // Get Favorites favs, err := Content.GetFavorites(ctx, tenantID, u.ID) So(err, ShouldBeNil) So(len(favs), ShouldEqual, 1) So(favs[0].ID, ShouldEqual, c.ID) // Remove Favorite err = Content.RemoveFavorite(ctx, tenantID, u.ID, c.ID) So(err, ShouldBeNil) // Get Favorites favs, err = Content.GetFavorites(ctx, tenantID, u.ID) So(err, ShouldBeNil) So(len(favs), ShouldEqual, 0) }) Convey("should reject interactions for unpublished content", func() { other := &models.User{Username: "user_act2", Phone: "13900000099"} models.UserQuery.WithContext(ctx).Create(other) draft := &models.Content{ TenantID: 1, UserID: u.ID, Title: "Draft Content", Status: consts.ContentStatusDraft, } models.ContentQuery.WithContext(ctx).Create(draft) err := Content.AddLike(ctx, tenantID, other.ID, draft.ID) So(err, ShouldNotBeNil) err = Content.AddFavorite(ctx, tenantID, other.ID, draft.ID) So(err, ShouldNotBeNil) }) }) } func (s *ContentTestSuite) Test_ListTopics() { Convey("ListTopics", s.T(), func() { ctx := s.T().Context() tenantID := int64(1) database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameUser) u := &models.User{Username: "user_t", Phone: "13900000005"} models.UserQuery.WithContext(ctx).Create(u) // Create Contents: 2 video, 1 audio models.ContentQuery.WithContext(ctx).Create( &models.Content{TenantID: 1, UserID: u.ID, Title: "V1", Genre: "video", Status: consts.ContentStatusPublished}, &models.Content{TenantID: 1, UserID: u.ID, Title: "V2", Genre: "video", Status: consts.ContentStatusPublished}, &models.Content{TenantID: 1, UserID: u.ID, Title: "A1", Genre: "audio", Status: consts.ContentStatusPublished}, &models.Content{TenantID: 1, UserID: u.ID, Title: "D1", Genre: "draft", Status: consts.ContentStatusDraft}, // Should ignore ) Convey("should aggregate topics", func() { topics, err := Content.ListTopics(ctx, tenantID) So(err, ShouldBeNil) So(len(topics), ShouldBeGreaterThanOrEqualTo, 2) var videoCount, audioCount int for _, t := range topics { if t.Tag == "video" { videoCount = t.Count } if t.Tag == "audio" { audioCount = t.Count } } So(videoCount, ShouldEqual, 2) So(audioCount, ShouldEqual, 1) }) }) } func (s *ContentTestSuite) Test_PreviewLogic() { Convey("Preview Logic", s.T(), func() { ctx := s.T().Context() tenantID := int64(1) database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameContentAsset, models.TableNameContentAccess, models.TableNameUser, models.TableNameMediaAsset) author := &models.User{Username: "author_p", Phone: "13900000006"} models.UserQuery.WithContext(ctx).Create(author) c := &models.Content{ TenantID: 1, UserID: author.ID, Title: "Premium", Status: consts.ContentStatusPublished, Visibility: consts.ContentVisibilityPublic, } models.ContentQuery.WithContext(ctx).Create(c) assetMain := &models.MediaAsset{ObjectKey: "main.mp4", Type: consts.MediaAssetTypeVideo} assetPrev := &models.MediaAsset{ObjectKey: "preview.mp4", Type: consts.MediaAssetTypeVideo} models.MediaAssetQuery.WithContext(ctx).Create(assetMain, assetPrev) models.ContentAssetQuery.WithContext(ctx).Create( &models.ContentAsset{ContentID: c.ID, AssetID: assetMain.ID, Role: consts.ContentAssetRoleMain}, &models.ContentAsset{ContentID: c.ID, AssetID: assetPrev.ID, Role: consts.ContentAssetRolePreview}, ) Convey("guest should see preview only", func() { guest := &models.User{Username: "guest", Phone: "13900000007"} models.UserQuery.WithContext(ctx).Create(guest) guestCtx := context.WithValue(ctx, consts.CtxKeyUser, guest.ID) detail, err := Content.Get(guestCtx, tenantID, 0, c.ID) So(err, ShouldBeNil) So(len(detail.MediaUrls), ShouldEqual, 1) So(detail.MediaUrls[0].URL, ShouldContainSubstring, "preview.mp4") So(detail.IsPurchased, ShouldBeFalse) }) Convey("owner should see all", func() { ownerCtx := context.WithValue(ctx, consts.CtxKeyUser, author.ID) detail, err := Content.Get(ownerCtx, tenantID, author.ID, c.ID) So(err, ShouldBeNil) So(len(detail.MediaUrls), ShouldEqual, 2) So(detail.IsPurchased, ShouldBeTrue) }) Convey("buyer should see all", func() { buyer := &models.User{Username: "buyer_p", Phone: "13900000008"} models.UserQuery.WithContext(ctx).Create(buyer) buyerCtx := context.WithValue(ctx, consts.CtxKeyUser, buyer.ID) models.ContentAccessQuery.WithContext(ctx).Create(&models.ContentAccess{ UserID: buyer.ID, ContentID: c.ID, Status: consts.ContentAccessStatusActive, }) detail, err := Content.Get(buyerCtx, tenantID, buyer.ID, c.ID) So(err, ShouldBeNil) So(len(detail.MediaUrls), ShouldEqual, 2) So(detail.IsPurchased, ShouldBeTrue) }) }) } func (s *ContentTestSuite) Test_ViewCounting() { Convey("ViewCounting", s.T(), func() { ctx := s.T().Context() tenantID := int64(1) database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameUser) author := &models.User{Username: "author_v", Phone: "13900000009"} models.UserQuery.WithContext(ctx).Create(author) c := &models.Content{ TenantID: 1, UserID: author.ID, Title: "View Me", Views: 0, Status: consts.ContentStatusPublished, Visibility: consts.ContentVisibilityPublic, } models.ContentQuery.WithContext(ctx).Create(c) Convey("should increment views", func() { _, err := Content.Get(ctx, tenantID, 0, c.ID) So(err, ShouldBeNil) cReload, _ := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(c.ID)).First() So(cReload.Views, ShouldEqual, 1) }) }) }