package services import ( "database/sql" "errors" "testing" "time" "quyun/v2/app/commands/testx" "quyun/v2/app/errorx" "quyun/v2/app/http/tenant/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" "go.ipao.vip/atom/contracts" "go.ipao.vip/gen/types" "go.uber.org/dig" "gorm.io/gorm" ) type ContentTestSuiteInjectParams struct { dig.In DB *sql.DB Initials []contracts.Initial `group:"initials"` // nolint:structcheck } 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_Create() { Convey("Content.Create", s.T(), func() { ctx := s.T().Context() tenantID := int64(1) userID := int64(2) database.Truncate(ctx, s.DB, models.TableNameContent) Convey("成功创建草稿内容并应用默认策略", func() { form := &dto.ContentCreateForm{ Title: "标题", Description: "描述", } m, err := Content.Create(ctx, tenantID, userID, form) So(err, ShouldBeNil) So(m, ShouldNotBeNil) So(m.TenantID, ShouldEqual, tenantID) So(m.UserID, ShouldEqual, userID) So(m.Status, ShouldEqual, consts.ContentStatusDraft) So(m.Visibility, ShouldEqual, consts.ContentVisibilityTenantOnly) So(m.PreviewSeconds, ShouldEqual, consts.DefaultContentPreviewSeconds) So(m.PreviewDownloadable, ShouldBeFalse) }) }) } func (s *ContentTestSuite) Test_Update() { Convey("Content.Update", s.T(), func() { ctx := s.T().Context() tenantID := int64(1) userID := int64(2) database.Truncate(ctx, s.DB, models.TableNameContent) m := &models.Content{ TenantID: tenantID, UserID: userID, Title: "标题", Description: "描述", Status: consts.ContentStatusDraft, Visibility: consts.ContentVisibilityTenantOnly, PreviewSeconds: consts.DefaultContentPreviewSeconds, PreviewDownloadable: false, } So(m.Create(ctx), ShouldBeNil) Convey("发布内容应写入 published_at", func() { status := consts.ContentStatusPublished form := &dto.ContentUpdateForm{Status: &status} updated, err := Content.Update(ctx, tenantID, userID, m.ID, form) So(err, ShouldBeNil) So(updated, ShouldNotBeNil) So(updated.Status, ShouldEqual, consts.ContentStatusPublished) So(updated.PublishedAt.IsZero(), ShouldBeFalse) }) }) } func (s *ContentTestSuite) Test_UpsertPrice() { Convey("Content.UpsertPrice", s.T(), func() { ctx := s.T().Context() tenantID := int64(1) userID := int64(2) database.Truncate(ctx, s.DB, models.TableNameContentPrice, models.TableNameContent) content := &models.Content{ TenantID: tenantID, UserID: userID, Title: "标题", Description: "描述", Status: consts.ContentStatusDraft, Visibility: consts.ContentVisibilityTenantOnly, PreviewSeconds: consts.DefaultContentPreviewSeconds, PreviewDownloadable: false, } So(content.Create(ctx), ShouldBeNil) Convey("首次 upsert 应创建价格记录", func() { form := &dto.ContentPriceUpsertForm{ PriceAmount: 100, } price, err := Content.UpsertPrice(ctx, tenantID, userID, content.ID, form) So(err, ShouldBeNil) So(price, ShouldNotBeNil) So(price.PriceAmount, ShouldEqual, 100) So(price.Currency, ShouldEqual, consts.CurrencyCNY) So(price.DiscountType, ShouldEqual, consts.DiscountTypeNone) }) Convey("再次 upsert 应更新价格记录", func() { form1 := &dto.ContentPriceUpsertForm{PriceAmount: 100} _, err := Content.UpsertPrice(ctx, tenantID, userID, content.ID, form1) So(err, ShouldBeNil) form2 := &dto.ContentPriceUpsertForm{ PriceAmount: 200, DiscountType: consts.DiscountTypePercent, DiscountValue: 10, } price2, err := Content.UpsertPrice(ctx, tenantID, userID, content.ID, form2) So(err, ShouldBeNil) So(price2.PriceAmount, ShouldEqual, 200) So(price2.DiscountType, ShouldEqual, consts.DiscountTypePercent) So(price2.DiscountValue, ShouldEqual, 10) }) }) } func (s *ContentTestSuite) Test_AttachAsset() { Convey("Content.AttachAsset", s.T(), func() { ctx := s.T().Context() now := time.Now().UTC() tenantID := int64(1) userID := int64(2) database.Truncate(ctx, s.DB, models.TableNameContentAsset, models.TableNameMediaAsset, models.TableNameContent) content := &models.Content{ TenantID: tenantID, UserID: userID, Title: "标题", Description: "描述", Status: consts.ContentStatusDraft, Visibility: consts.ContentVisibilityTenantOnly, PreviewSeconds: consts.DefaultContentPreviewSeconds, PreviewDownloadable: false, } So(content.Create(ctx), ShouldBeNil) asset := &models.MediaAsset{ TenantID: tenantID, UserID: userID, Type: consts.MediaAssetTypeVideo, Status: consts.MediaAssetStatusReady, Provider: "test", Bucket: "bucket", ObjectKey: "obj", Meta: types.JSON([]byte("{}")), } So(asset.Create(ctx), ShouldBeNil) Convey("成功绑定资源到内容", func() { m, err := Content.AttachAsset(ctx, tenantID, userID, content.ID, asset.ID, consts.ContentAssetRoleMain, 1, now) So(err, ShouldBeNil) So(m, ShouldNotBeNil) So(m.ContentID, ShouldEqual, content.ID) So(m.AssetID, ShouldEqual, asset.ID) So(m.Role, ShouldEqual, consts.ContentAssetRoleMain) }) Convey("preview role 只能绑定 preview variant,且必须有 source_asset_id", func() { previewAsset := &models.MediaAsset{ TenantID: tenantID, UserID: userID, Type: consts.MediaAssetTypeVideo, Status: consts.MediaAssetStatusReady, Provider: "test", Bucket: "bucket", ObjectKey: "obj-preview", Meta: types.JSON([]byte("{}")), } So(previewAsset.Create(ctx), ShouldBeNil) // 标记为 preview 产物,但不设置 source_asset_id,应被拒绝。 _, err := s.DB.ExecContext(ctx, "UPDATE media_assets SET variant = 'preview', source_asset_id = NULL WHERE tenant_id = $1 AND id = $2", tenantID, previewAsset.ID) So(err, ShouldBeNil) _, err = Content.AttachAsset(ctx, tenantID, userID, content.ID, previewAsset.ID, consts.ContentAssetRolePreview, 1, now) So(err, ShouldNotBeNil) var appErr *errorx.AppError So(errors.As(err, &appErr), ShouldBeTrue) So(appErr.Code, ShouldEqual, errorx.ErrPreconditionFailed.Code) }) Convey("preview role 绑定 main variant 应被拒绝", func() { _, err := Content.AttachAsset(ctx, tenantID, userID, content.ID, asset.ID, consts.ContentAssetRolePreview, 1, now) So(err, ShouldNotBeNil) var appErr *errorx.AppError So(errors.As(err, &appErr), ShouldBeTrue) So(appErr.Code, ShouldEqual, errorx.ErrPreconditionFailed.Code) }) Convey("main role 绑定 preview variant 应被拒绝", func() { previewAsset := &models.MediaAsset{ TenantID: tenantID, UserID: userID, Type: consts.MediaAssetTypeVideo, Status: consts.MediaAssetStatusReady, Provider: "test", Bucket: "bucket", ObjectKey: "obj-preview2", Meta: types.JSON([]byte("{}")), } So(previewAsset.Create(ctx), ShouldBeNil) // 将该资源标记为 preview 产物,并设置一个合法来源(指向已有 main 资源 asset)。 _, err := s.DB.ExecContext(ctx, "UPDATE media_assets SET variant = 'preview', source_asset_id = $1 WHERE tenant_id = $2 AND id = $3", asset.ID, tenantID, previewAsset.ID) So(err, ShouldBeNil) _, err = Content.AttachAsset(ctx, tenantID, userID, content.ID, previewAsset.ID, consts.ContentAssetRoleMain, 1, now) So(err, ShouldNotBeNil) var appErr *errorx.AppError So(errors.As(err, &appErr), ShouldBeTrue) So(appErr.Code, ShouldEqual, errorx.ErrPreconditionFailed.Code) }) }) } func (s *ContentTestSuite) Test_HasAccess() { Convey("Content.HasAccess", s.T(), func() { ctx := s.T().Context() now := time.Now().UTC() tenantID := int64(1) userID := int64(2) database.Truncate(ctx, s.DB, models.TableNameContentAccess, models.TableNameContent) content := &models.Content{ TenantID: tenantID, UserID: userID, Title: "标题", Description: "描述", Status: consts.ContentStatusPublished, Visibility: consts.ContentVisibilityTenantOnly, PreviewSeconds: consts.DefaultContentPreviewSeconds, PreviewDownloadable: false, PublishedAt: now, } So(content.Create(ctx), ShouldBeNil) Convey("未授予权益应返回 false", func() { ok, err := Content.HasAccess(ctx, tenantID, userID, content.ID) So(err, ShouldBeNil) So(ok, ShouldBeFalse) }) Convey("权益 active 应返回 true", func() { access := &models.ContentAccess{ TenantID: tenantID, UserID: userID, ContentID: content.ID, OrderID: 0, Status: consts.ContentAccessStatusActive, RevokedAt: time.Time{}, CreatedAt: now, UpdatedAt: now, } So(access.Create(ctx), ShouldBeNil) ok, err := Content.HasAccess(ctx, tenantID, userID, content.ID) So(err, ShouldBeNil) So(ok, ShouldBeTrue) }) }) } func (s *ContentTestSuite) Test_ListPublicPublished() { Convey("Content.ListPublicPublished", s.T(), func() { ctx := s.T().Context() now := time.Now().UTC() tenantID := int64(1) ownerID := int64(2) database.Truncate(ctx, s.DB, models.TableNameContentAccess, models.TableNameContentPrice, models.TableNameContent, ) publicPaid := &models.Content{ TenantID: tenantID, UserID: ownerID, Title: "public_paid", Description: "d", Status: consts.ContentStatusPublished, Visibility: consts.ContentVisibilityPublic, PreviewSeconds: consts.DefaultContentPreviewSeconds, PreviewDownloadable: false, PublishedAt: now, CreatedAt: now, UpdatedAt: now, } So(publicPaid.Create(ctx), ShouldBeNil) So((&models.ContentPrice{ TenantID: tenantID, UserID: ownerID, ContentID: publicPaid.ID, Currency: consts.CurrencyCNY, PriceAmount: 100, CreatedAt: now, UpdatedAt: now, }).Create(ctx), ShouldBeNil) publicFree := &models.Content{ TenantID: tenantID, UserID: ownerID, Title: "public_free", Description: "d", Status: consts.ContentStatusPublished, Visibility: consts.ContentVisibilityPublic, PreviewSeconds: consts.DefaultContentPreviewSeconds, PreviewDownloadable: false, PublishedAt: now, CreatedAt: now, UpdatedAt: now, } So(publicFree.Create(ctx), ShouldBeNil) tenantOnly := &models.Content{ TenantID: tenantID, UserID: ownerID, Title: "tenant_only", Description: "d", Status: consts.ContentStatusPublished, Visibility: consts.ContentVisibilityTenantOnly, PreviewSeconds: consts.DefaultContentPreviewSeconds, PreviewDownloadable: false, PublishedAt: now, CreatedAt: now, UpdatedAt: now, } So(tenantOnly.Create(ctx), ShouldBeNil) privateContent := &models.Content{ TenantID: tenantID, UserID: ownerID, Title: "private", Description: "d", Status: consts.ContentStatusPublished, Visibility: consts.ContentVisibilityPrivate, PreviewSeconds: consts.DefaultContentPreviewSeconds, PreviewDownloadable: false, PublishedAt: now, CreatedAt: now, UpdatedAt: now, } So(privateContent.Create(ctx), ShouldBeNil) draftPublic := &models.Content{ TenantID: tenantID, UserID: ownerID, Title: "draft_public", Description: "d", Status: consts.ContentStatusDraft, Visibility: consts.ContentVisibilityPublic, PreviewSeconds: consts.DefaultContentPreviewSeconds, PreviewDownloadable: false, CreatedAt: now, UpdatedAt: now, } So(draftPublic.Create(ctx), ShouldBeNil) deletedPublic := &models.Content{ TenantID: tenantID, UserID: ownerID, Title: "deleted_public", Description: "d", Status: consts.ContentStatusPublished, Visibility: consts.ContentVisibilityPublic, PreviewSeconds: consts.DefaultContentPreviewSeconds, PreviewDownloadable: false, PublishedAt: now, DeletedAt: gorm.DeletedAt{Time: now, Valid: true}, CreatedAt: now, UpdatedAt: now, } So(deletedPublic.Create(ctx), ShouldBeNil) Convey("游客仅能看到 public+published,且免费内容 has_access=true", func() { pager, err := Content.ListPublicPublished(ctx, tenantID, 0, &dto.ContentListFilter{Pagination: requests.Pagination{Page: 1, Limit: 20}}) So(err, ShouldBeNil) So(pager.Total, ShouldEqual, 2) items := pager.Items.([]*dto.ContentItem) So(len(items), ShouldEqual, 2) got := map[int64]*dto.ContentItem{} for _, it := range items { got[it.Content.ID] = it } So(got[publicPaid.ID], ShouldNotBeNil) So(got[publicFree.ID], ShouldNotBeNil) So(got[publicPaid.ID].HasAccess, ShouldBeFalse) So(got[publicFree.ID].HasAccess, ShouldBeTrue) }) Convey("已登录用户若有权益则 has_access=true", func() { viewerID := int64(99) access := &models.ContentAccess{ TenantID: tenantID, UserID: viewerID, ContentID: publicPaid.ID, OrderID: 123, Status: consts.ContentAccessStatusActive, CreatedAt: now, UpdatedAt: now, } So(access.Create(ctx), ShouldBeNil) pager, err := Content.ListPublicPublished(ctx, tenantID, viewerID, &dto.ContentListFilter{Pagination: requests.Pagination{Page: 1, Limit: 20}}) So(err, ShouldBeNil) So(pager.Total, ShouldEqual, 2) items := pager.Items.([]*dto.ContentItem) got := map[int64]*dto.ContentItem{} for _, it := range items { got[it.Content.ID] = it } So(got[publicPaid.ID].HasAccess, ShouldBeTrue) }) }) } func (s *ContentTestSuite) Test_PublicDetail() { Convey("Content.PublicDetail", s.T(), func() { ctx := s.T().Context() now := time.Now().UTC() tenantID := int64(1) ownerID := int64(2) database.Truncate(ctx, s.DB, models.TableNameContentAccess, models.TableNameContentPrice, models.TableNameContent, ) publicPaid := &models.Content{ TenantID: tenantID, UserID: ownerID, Title: "public_paid", Description: "d", Status: consts.ContentStatusPublished, Visibility: consts.ContentVisibilityPublic, PreviewSeconds: consts.DefaultContentPreviewSeconds, PreviewDownloadable: false, PublishedAt: now, CreatedAt: now, UpdatedAt: now, } So(publicPaid.Create(ctx), ShouldBeNil) So((&models.ContentPrice{ TenantID: tenantID, UserID: ownerID, ContentID: publicPaid.ID, Currency: consts.CurrencyCNY, PriceAmount: 100, CreatedAt: now, UpdatedAt: now, }).Create(ctx), ShouldBeNil) tenantOnly := &models.Content{ TenantID: tenantID, UserID: ownerID, Title: "tenant_only", Description: "d", Status: consts.ContentStatusPublished, Visibility: consts.ContentVisibilityTenantOnly, PreviewSeconds: consts.DefaultContentPreviewSeconds, PreviewDownloadable: false, PublishedAt: now, CreatedAt: now, UpdatedAt: now, } So(tenantOnly.Create(ctx), ShouldBeNil) Convey("游客访问 public+paid:可见但无正片权限", func() { out, err := Content.PublicDetail(ctx, tenantID, 0, publicPaid.ID) So(err, ShouldBeNil) So(out, ShouldNotBeNil) So(out.HasAccess, ShouldBeFalse) }) Convey("tenant_only 在 public detail 下应表现为 not found", func() { _, err := Content.PublicDetail(ctx, tenantID, 0, tenantOnly.ID) So(err, ShouldNotBeNil) var appErr *errorx.AppError So(errors.As(err, &appErr), ShouldBeTrue) So(appErr.Code, ShouldEqual, errorx.ErrRecordNotFound.Code) }) Convey("有权益的已登录用户访问 public+paid:has_access=true", func() { viewerID := int64(99) access := &models.ContentAccess{ TenantID: tenantID, UserID: viewerID, ContentID: publicPaid.ID, OrderID: 123, Status: consts.ContentAccessStatusActive, CreatedAt: now, UpdatedAt: now, } So(access.Create(ctx), ShouldBeNil) out, err := Content.PublicDetail(ctx, tenantID, viewerID, publicPaid.ID) So(err, ShouldBeNil) So(out.HasAccess, ShouldBeTrue) }) }) }