package chain import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tcs-iptv/tcs/internal/model" ) // RunClientConformance 是 chain.Client 的契约测试套件,校验任意实现都满足同一组 // 不可变业务规则(权限/1:1强绑定/防换壳/状态机/集级粒度)。 // // 各实现(MemoryChain / PersistentChain / ChainMakerClient)复用本套件, // 保证「换实现不换行为」,这是平滑替换真实链的安全保障。 // // newClient 必须返回一个干净(空状态)的 Client 实例。 func RunClientConformance(t *testing.T, newClient func(t *testing.T) Client) { // 构造一条标准发码请求(集级 3 集)。 issueReq := func(ma, ctid, fh string) IssueRequest { return IssueRequest{ CCCode: ma, ContentTwinID: ctid, MerkleRoot: "mr-" + fh, FileHash: fh, PerceptualHash: "ph-" + fh, Episodes: []model.EpisodeHash{ {Episode: 1, FileSHA256: fh + "-E1"}, {Episode: 2, FileSHA256: fh + "-E2"}, {Episode: 3, FileSHA256: fh + "-E3"}, }, Content: model.Content{Title: "契约测试剧", EpisodeCount: 3, MAType: "WD", Issuer: "测试局"}, } } const ma = "MA.156.8531.6101/WD/20260000001" const ctid = "ctid-conf-001" const fh = "fh-conf-001" t.Run("IssueMA_仅监管可发码", func(t *testing.T) { c := newClient(t) _, err := c.IssueMA(RoleCP, issueReq(ma, ctid, fh)) assert.ErrorIs(t, err, ErrPermissionDenied) }) t.Run("IssueMA_成功并可查询", func(t *testing.T) { c := newClient(t) tx, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh)) require.NoError(t, err) assert.NotEmpty(t, tx) got, err := c.QueryContent(ma) require.NoError(t, err) assert.Equal(t, "契约测试剧", got.Title) assert.Equal(t, model.StatusApproved, got.Status) }) t.Run("IssueMA_不可重复签发", func(t *testing.T) { c := newClient(t) _, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh)) require.NoError(t, err) _, err = c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh)) assert.ErrorIs(t, err, ErrMAAlreadyIssued) }) t.Run("防换壳_同哈希不可绑不同MA", func(t *testing.T) { c := newClient(t) _, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh)) require.NoError(t, err) _, exists := c.HashExists(fh) assert.True(t, exists) _, err = c.IssueMA(RoleRegulator, issueReq("MA.156.8531.6101/WD/20260000002", "ctid-x", fh)) assert.ErrorIs(t, err, ErrHashExists) }) t.Run("VerifyHash_匹配与不匹配", func(t *testing.T) { c := newClient(t) _, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh)) require.NoError(t, err) r, err := c.VerifyHash(ma, fh) require.NoError(t, err) assert.True(t, r.Match) r2, _ := c.VerifyHash(ma, "tampered") assert.False(t, r2.Match) }) t.Run("集级验真与列出", func(t *testing.T) { c := newClient(t) _, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh)) require.NoError(t, err) r, err := c.VerifyEpisodeHash(ma, 2, fh+"-E2") require.NoError(t, err) assert.True(t, r.Match) eps, err := c.ListEpisodes(ma) require.NoError(t, err) assert.Len(t, eps, 3) }) t.Run("映射注册需先发码且可查", func(t *testing.T) { c := newClient(t) // 未发码不可注册映射 _, err := c.RegisterMapping(RoleCP, model.Mapping{ContentTwinID: "ctid-none", Party: model.PartyCP, PartyID: "X"}) assert.ErrorIs(t, err, ErrMANotIssued) // 发码后可注册并查询 _, err = c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh)) require.NoError(t, err) _, err = c.RegisterMapping(RoleOperator, model.Mapping{ ContentTwinID: ctid, Party: model.PartyOperator, PartyID: "OP-1", CDNEndpoint: "cdn://x", }) require.NoError(t, err) mr, err := c.QueryMappings(ma) require.NoError(t, err) assert.GreaterOrEqual(t, len(mr.Mappings), 1) assert.Contains(t, mr.CDNEndpoints, "cdn://x") }) t.Run("下架_仅监管且状态变更", func(t *testing.T) { c := newClient(t) _, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh)) require.NoError(t, err) _, err = c.Revoke(RoleCP, ma, "x") assert.ErrorIs(t, err, ErrPermissionDenied) _, err = c.Revoke(RoleRegulator, ma, "违规") require.NoError(t, err) got, _ := c.QueryContent(ma) assert.Equal(t, model.StatusRevoked, got.Status) }) t.Run("集级下架与恢复", func(t *testing.T) { c := newClient(t) _, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh)) require.NoError(t, err) require.NoError(t, c.RevokeEpisode(RoleRegulator, ma, 2, "违规集")) eps, _ := c.ListEpisodes(ma) for _, e := range eps { if e.Episode == 2 { assert.True(t, e.Revoked) } } require.NoError(t, c.RestoreEpisode(RoleRegulator, ma, 2)) eps, _ = c.ListEpisodes(ma) for _, e := range eps { if e.Episode == 2 { assert.False(t, e.Revoked) } } }) t.Run("状态流转与按状态列举", func(t *testing.T) { c := newClient(t) _, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh)) require.NoError(t, err) require.NoError(t, c.SetContentStatus(ma, model.StatusPublished)) pub, err := c.ListContents(model.StatusPublished) require.NoError(t, err) assert.Len(t, pub, 1) }) t.Run("多维度查询_按Hash反查", func(t *testing.T) { c := newClient(t) _, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh)) require.NoError(t, err) // 注册 CP 映射(省级内容编码) _, err = c.RegisterMapping(RoleCP, model.Mapping{ ContentTwinID: ctid, Party: model.PartyCP, PartyID: "PROV-001", PartyName: "陕西CP", }) require.NoError(t, err) // 注册媒资库映射(片库文件 ID) _, err = c.RegisterMapping(RoleReviewer, model.Mapping{ ContentTwinID: ctid, Party: model.PartyReviewer, PartyID: "LIB-001", PartyName: "陕西媒资库", }) require.NoError(t, err) // 按 Hash 查询 res, err := c.QueryByHash(fh) require.NoError(t, err) assert.True(t, res.Found) assert.Equal(t, ma, res.Content.CCCode) assert.Equal(t, "契约测试剧", res.Content.Title) // 按省级内容编码查询 res2, err := c.QueryByProvincialCode("PROV-001") require.NoError(t, err) assert.True(t, res2.Found) assert.Equal(t, ma, res2.Content.CCCode) // 按片库文件 ID 查询 res3, err := c.QueryByLibraryFileID("LIB-001") require.NoError(t, err) assert.True(t, res3.Found) assert.Equal(t, ma, res3.Content.CCCode) // 不存在的 Hash _, err = c.QueryByHash("nonexistent") assert.ErrorIs(t, err, ErrNotFound) }) t.Run("MA合并_仅监管且迁移绑定", func(t *testing.T) { c := newClient(t) // 发码两个 MA _, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh)) require.NoError(t, err) ma2 := "MA.156.8531.6101/WD/20260000002" ctid2 := "ctid-conf-002" fh2 := "fh-conf-002" _, err = c.IssueMA(RoleRegulator, issueReq(ma2, ctid2, fh2)) require.NoError(t, err) // 非监管不可合并 _, err = c.MergeMA(RoleCP, model.MergeRequest{ PrimaryCCCode: ma, SecondaryCCCodes: []string{ma2}, Reason: "重复发码", }) assert.ErrorIs(t, err, ErrPermissionDenied) // 监管合并 res, err := c.MergeMA(RoleRegulator, model.MergeRequest{ PrimaryCCCode: ma, SecondaryCCCodes: []string{ma2}, Reason: "重复发码", Operator: "监管局", }) require.NoError(t, err) assert.Equal(t, ma, res.PrimaryCCCode) assert.Contains(t, res.MergedCCCodes, ma2) assert.Greater(t, res.MigratedBindings, 0) // 被合并的 MA 状态为 merged got, _ := c.QueryContent(ma2) assert.Equal(t, model.StatusMerged, got.Status) // 按 fh2 查询应指向主 MA 码 qr, err := c.QueryByHash(fh2) require.NoError(t, err) assert.Equal(t, ma, qr.Content.CCCode) }) t.Run("MA拆分_仅监管且按集迁移", func(t *testing.T) { c := newClient(t) _, err := c.IssueMA(RoleRegulator, issueReq(ma, ctid, fh)) require.NoError(t, err) // 非监管不可拆分 _, err = c.SplitMA(RoleCP, model.SplitRequest{ SourceCCCode: ma, Splits: []model.SplitTarget{{NewCCCode: "MA.156.8531.6101/WD/20260000010", Title: "拆分剧A", Episodes: []int{1, 2}}}, }) assert.ErrorIs(t, err, ErrPermissionDenied) // 监管拆分 newMA := "MA.156.8531.6101/WD/20260000011" res, err := c.SplitMA(RoleRegulator, model.SplitRequest{ SourceCCCode: ma, Splits: []model.SplitTarget{{NewCCCode: newMA, Title: "拆分剧A", Episodes: []int{1, 2}}}, Reason: "合集拆分", Operator: "监管局", }) require.NoError(t, err) assert.Contains(t, res.NewCCCodes, newMA) assert.Greater(t, res.MigratedBindings, 0) // 源 MA 状态为 split got, _ := c.QueryContent(ma) assert.Equal(t, model.StatusSplit, got.Status) // 新 MA 可查询 newContent, err := c.QueryContent(newMA) require.NoError(t, err) assert.Equal(t, "拆分剧A", newContent.Title) }) } // TestMemoryChain_Conformance 让内存实现跑契约套件(始终运行)。 func TestMemoryChain_Conformance(t *testing.T) { RunClientConformance(t, func(t *testing.T) Client { return NewMemoryChain() }) }