|
| 1 | +// ========================================================================================== |
| 2 | +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 |
| 3 | +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights |
| 4 | +// 均受中华人民共和国及相关国际法律法规保护。 |
| 5 | +// are protected by the laws of the People's Republic of China and relevant international regulations. |
| 6 | +// |
| 7 | +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 |
| 8 | +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. |
| 9 | +// |
| 10 | +// 本项目采用 MIT 许可证与 Apache License 2.0 双许可证分发, |
| 11 | +// This project is dual-licensed under the MIT License and Apache License 2.0, |
| 12 | +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 |
| 13 | +// please refer to the LICENSE file in the root directory of the source code for the full license text. |
| 14 | +// |
| 15 | +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 |
| 16 | +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, |
| 17 | +// 侵犯他人合法权益等法律法规所禁止的行为! |
| 18 | +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! |
| 19 | +// 因基于本项目二次开发所产生的一切法律纠纷与责任, |
| 20 | +// Any legal disputes and liabilities arising from secondary development based on this project |
| 21 | +// 本项目组织与贡献者概不承担。 |
| 22 | +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. |
| 23 | +// |
| 24 | +// GitHub 仓库:https://github.com/GameFrameX |
| 25 | +// GitHub Repository: https://github.com/GameFrameX |
| 26 | +// Gitee 仓库:https://gitee.com/GameFrameX |
| 27 | +// Gitee Repository: https://gitee.com/GameFrameX |
| 28 | +// 官方文档:https://gameframex.doc.alianblank.com/ |
| 29 | +// Official Documentation: https://gameframex.doc.alianblank.com/ |
| 30 | +// ========================================================================================== |
| 31 | + |
| 32 | +using System; |
| 33 | +using System.Collections.Generic; |
| 34 | +using GameFrameX.Apps.Player.Mail; |
| 35 | +using GameFrameX.Apps.Player.Mail.Entity; |
| 36 | +using GameFrameX.Hotfix.Logic.Player.Mail; |
| 37 | +using GameFrameX.Proto.Proto; |
| 38 | +using Xunit; |
| 39 | + |
| 40 | +namespace GameFrameX.Hotfix.Tests; |
| 41 | + |
| 42 | +/// <summary> |
| 43 | +/// 运营邮件 Campaign 注册表(<see cref="MailCampaignRegistry"/>)总体验收单测(GFX-135 / F2)。 |
| 44 | +/// 覆盖校验 / 发布(B1 发布后不可修改)/ 撤回(B3 状态字段写入 + 重复撤回 CampaignAlreadyRevoked)/ |
| 45 | +/// B5(撤回后同 CampaignId 拒绝再发布)/ 端到端 smoke(发布 → 查询 → 撤回 → 查询)/ 查询过滤。 |
| 46 | +/// 本组用例把 e921348a 移除 SelfCheck 工程时一并删除、且未迁移到 xunit 的 Campaign 侧 self-check 场景移植回归, |
| 47 | +/// 与 <see cref="MailAttachmentClaimTests"/>(领取侧 B2/B3/B4/B6)合并满足 GFX-135 验收硬性覆盖。 |
| 48 | +/// 进程内 registry 为静态字典,每个用例开头 <see cref="MailCampaignRegistry.ResetForTest"/> 隔离全局状态。 |
| 49 | +/// </summary> |
| 50 | +public class MailCampaignRegistryTests |
| 51 | +{ |
| 52 | + /// <summary>校验:null / 无 title / blank title / 重复 SlotId / MinLevel>MaxLevel 均非法,合法 Campaign 通过。</summary> |
| 53 | + [Fact] |
| 54 | + public void Validate_Returns_InvalidCampaignParameter_For_Null_And_Bad_State() |
| 55 | + { |
| 56 | + MailCampaignRegistry.ResetForTest(); |
| 57 | + |
| 58 | + Assert.Equal(OperationStatusCode.InvalidCampaignParameter, MailCampaignRegistry.Validate(null)); |
| 59 | + |
| 60 | + var noTitle = new MailCampaignState { MailType = MailType.Operation }; |
| 61 | + Assert.Equal(OperationStatusCode.InvalidCampaignParameter, MailCampaignRegistry.Validate(noTitle)); |
| 62 | + |
| 63 | + var blankTitle = new MailCampaignState |
| 64 | + { |
| 65 | + MailType = MailType.Operation, |
| 66 | + Titles = new List<MailLocalizedContent> { new MailLocalizedContent { Language = "", Text = "" } }, |
| 67 | + }; |
| 68 | + Assert.Equal(OperationStatusCode.InvalidCampaignParameter, MailCampaignRegistry.Validate(blankTitle)); |
| 69 | + |
| 70 | + var dupSlot = new MailCampaignState |
| 71 | + { |
| 72 | + MailType = MailType.Operation, |
| 73 | + Titles = new List<MailLocalizedContent> { new MailLocalizedContent { Language = "zh-cn", Text = "title" } }, |
| 74 | + Attachments = new List<MailAttachmentState> |
| 75 | + { |
| 76 | + new MailAttachmentState { SlotId = 1, Amount = 10 }, |
| 77 | + new MailAttachmentState { SlotId = 1, Amount = 20 }, |
| 78 | + }, |
| 79 | + }; |
| 80 | + Assert.Equal(OperationStatusCode.InvalidCampaignParameter, MailCampaignRegistry.Validate(dupSlot)); |
| 81 | + |
| 82 | + var levelRangeBad = MakeValidCampaign(); |
| 83 | + levelRangeBad.MinLevel = 50; |
| 84 | + levelRangeBad.MaxLevel = 10; |
| 85 | + Assert.Equal(OperationStatusCode.InvalidCampaignParameter, MailCampaignRegistry.Validate(levelRangeBad)); |
| 86 | + |
| 87 | + Assert.Equal(OperationStatusCode.Ok, MailCampaignRegistry.Validate(MakeValidCampaign())); |
| 88 | + } |
| 89 | + |
| 90 | + /// <summary>校验:附件 amount<=0 返回 InvalidReward。</summary> |
| 91 | + [Fact] |
| 92 | + public void Validate_Returns_InvalidReward_For_NonPositive_Amount() |
| 93 | + { |
| 94 | + MailCampaignRegistry.ResetForTest(); |
| 95 | + |
| 96 | + var badAttachment = new MailCampaignState |
| 97 | + { |
| 98 | + MailType = MailType.Operation, |
| 99 | + Titles = new List<MailLocalizedContent> { new MailLocalizedContent { Language = "zh-cn", Text = "title" } }, |
| 100 | + Attachments = new List<MailAttachmentState> { new MailAttachmentState { SlotId = 1, Amount = 0 } }, |
| 101 | + }; |
| 102 | + |
| 103 | + Assert.Equal(OperationStatusCode.InvalidReward, MailCampaignRegistry.Validate(badAttachment)); |
| 104 | + } |
| 105 | + |
| 106 | + /// <summary>B1:PublishOrUpdate 分配 CampaignId / PublishVersion=1 / Status=Published / PublishedAt / PublishOperator; |
| 107 | + /// 已发布 Campaign 再次以同 CampaignId 提交应抛 InvalidOperationException(主体字段不可修改)。</summary> |
| 108 | + [Fact] |
| 109 | + public void PublishOrUpdate_Assigns_Fields_And_B1_Blocks_Republish_Of_Published() |
| 110 | + { |
| 111 | + MailCampaignRegistry.ResetForTest(); |
| 112 | + |
| 113 | + var campaign = MakeValidCampaign(); |
| 114 | + var published = MailCampaignRegistry.PublishOrUpdate(campaign, "admin-test", 1_700_000_000L); |
| 115 | + |
| 116 | + Assert.True(published.CampaignId > 0); |
| 117 | + Assert.Equal(1, published.PublishVersion); |
| 118 | + Assert.Equal(MailCampaignStatus.Published, published.Status); |
| 119 | + Assert.Equal(1_700_000_000L, published.PublishedAt); |
| 120 | + Assert.Equal("admin-test", published.PublishOperator); |
| 121 | + |
| 122 | + // B1:重复发布已发布 Campaign 应抛异常。 |
| 123 | + var republish = MakeValidCampaign(); |
| 124 | + republish.CampaignId = published.CampaignId; |
| 125 | + Assert.Throws<InvalidOperationException>(() => |
| 126 | + MailCampaignRegistry.PublishOrUpdate(republish, "admin-test", 1_700_000_100L)); |
| 127 | + } |
| 128 | + |
| 129 | + /// <summary>B3:Revoke 对未知 CampaignId 返回 CampaignNotFound;成功撤回写入 Status=Revoked / RevokedAt / RevokeOperator; |
| 130 | + /// 重复撤回返回 CampaignAlreadyRevoked。</summary> |
| 131 | + [Fact] |
| 132 | + public void Revoke_Returns_NotFound_For_Unknown_Then_Sets_Status_B3() |
| 133 | + { |
| 134 | + MailCampaignRegistry.ResetForTest(); |
| 135 | + |
| 136 | + var published = MailCampaignRegistry.PublishOrUpdate(MakeValidCampaign(), "admin-test", 1_700_000_200L); |
| 137 | + |
| 138 | + Assert.Equal(OperationStatusCode.CampaignNotFound, MailCampaignRegistry.Revoke(999_999L, "admin-test", 1_700_000_300L)); |
| 139 | + |
| 140 | + Assert.Equal(OperationStatusCode.Ok, MailCampaignRegistry.Revoke(published.CampaignId, "admin-test", 1_700_000_300L)); |
| 141 | + |
| 142 | + Assert.True(MailCampaignRegistry.TryQuery(published.CampaignId, out var revoked)); |
| 143 | + Assert.Equal(MailCampaignStatus.Revoked, revoked.Status); |
| 144 | + Assert.Equal(1_700_000_300L, revoked.RevokedAt); |
| 145 | + Assert.Equal("admin-test", revoked.RevokeOperator); |
| 146 | + |
| 147 | + // B3:重复撤回返回 CampaignAlreadyRevoked(资产回滚由统一发放接口幂等账本保证,不在此校验)。 |
| 148 | + Assert.Equal(OperationStatusCode.CampaignAlreadyRevoked, |
| 149 | + MailCampaignRegistry.Revoke(published.CampaignId, "admin-test", 1_700_000_400L)); |
| 150 | + } |
| 151 | + |
| 152 | + /// <summary>B5(Campaign 侧兜底):撤回后的 Campaign,再次以同 CampaignId 调 PublishOrUpdate 应抛 InvalidOperationException, |
| 153 | + /// 且撤回状态保持不变(不被再发布覆盖),呼应 U1 §4.5 / §4.6「撤回 Campaign 防重实例化」。</summary> |
| 154 | + [Fact] |
| 155 | + public void B5_Revoke_Blocks_Republish_Of_Same_CampaignId() |
| 156 | + { |
| 157 | + MailCampaignRegistry.ResetForTest(); |
| 158 | + |
| 159 | + var published = MailCampaignRegistry.PublishOrUpdate(MakeValidCampaign(), "admin-test", 1_700_010_000L); |
| 160 | + Assert.Equal(OperationStatusCode.Ok, MailCampaignRegistry.Revoke(published.CampaignId, "admin-test", 1_700_010_100L)); |
| 161 | + |
| 162 | + var republish = MakeValidCampaign(); |
| 163 | + republish.CampaignId = published.CampaignId; |
| 164 | + Assert.Throws<InvalidOperationException>(() => |
| 165 | + MailCampaignRegistry.PublishOrUpdate(republish, "admin-test", 1_700_010_200L)); |
| 166 | + |
| 167 | + // 撤回状态应保持不变(不被再发布覆盖)。 |
| 168 | + Assert.True(MailCampaignRegistry.TryQuery(published.CampaignId, out var afterAttempt)); |
| 169 | + Assert.Equal(MailCampaignStatus.Revoked, afterAttempt.Status); |
| 170 | + Assert.Equal(1_700_010_100L, afterAttempt.RevokedAt); |
| 171 | + } |
| 172 | + |
| 173 | + /// <summary>端到端 smoke(Campaign 侧):发布 → TryQuery 命中 Published → 撤回 → |
| 174 | + /// TryQuery 命中 Revoked(RevokedAt / RevokeOperator)→ QueryAll(status=Revoked) 过滤命中 → 重复撤回 CampaignAlreadyRevoked。</summary> |
| 175 | + [Fact] |
| 176 | + public void EndToEnd_Publish_Query_Revoke_Query() |
| 177 | + { |
| 178 | + MailCampaignRegistry.ResetForTest(); |
| 179 | + |
| 180 | + var campaign = MakeValidCampaign(); |
| 181 | + campaign.MailType = MailType.Operation; |
| 182 | + campaign.ServerIds = new List<int> { 200 }; |
| 183 | + campaign.MinLevel = 20; |
| 184 | + campaign.MaxLevel = 80; |
| 185 | + |
| 186 | + // 1. 发布 → Server 保存不可变快照(B1)。 |
| 187 | + var published = MailCampaignRegistry.PublishOrUpdate(campaign, "admin-flow", 1_700_020_000L); |
| 188 | + Assert.True(published.CampaignId > 0); |
| 189 | + Assert.Equal(MailCampaignStatus.Published, published.Status); |
| 190 | + Assert.Equal("admin-flow", published.PublishOperator); |
| 191 | + |
| 192 | + // 2. TryQuery 命中 Published。 |
| 193 | + Assert.True(MailCampaignRegistry.TryQuery(published.CampaignId, out var q1)); |
| 194 | + Assert.Equal(MailCampaignStatus.Published, q1.Status); |
| 195 | + Assert.Equal(0L, q1.RevokedAt); |
| 196 | + |
| 197 | + // 3. 撤回(B3:仅置状态字段)。 |
| 198 | + Assert.Equal(OperationStatusCode.Ok, MailCampaignRegistry.Revoke(published.CampaignId, "admin-revoke", 1_700_020_500L)); |
| 199 | + |
| 200 | + // 4. TryQuery 命中 Revoked + RevokedAt + RevokeOperator。 |
| 201 | + Assert.True(MailCampaignRegistry.TryQuery(published.CampaignId, out var q2)); |
| 202 | + Assert.Equal(MailCampaignStatus.Revoked, q2.Status); |
| 203 | + Assert.Equal(1_700_020_500L, q2.RevokedAt); |
| 204 | + Assert.Equal("admin-revoke", q2.RevokeOperator); |
| 205 | + |
| 206 | + // 5. QueryAll(status=Revoked) 过滤命中;重复撤回返回 CampaignAlreadyRevoked。 |
| 207 | + var revokedList = MailCampaignRegistry.QueryAll(status: MailCampaignStatus.Revoked); |
| 208 | + Assert.Contains(revokedList, c => c.CampaignId == published.CampaignId); |
| 209 | + Assert.Equal(OperationStatusCode.CampaignAlreadyRevoked, |
| 210 | + MailCampaignRegistry.Revoke(published.CampaignId, "admin-revoke", 1_700_020_600L)); |
| 211 | + } |
| 212 | + |
| 213 | + /// <summary>查询过滤:QueryAll() 返回全部;QueryAll(mailType) / QueryAll(serverId) 命中正确子集。</summary> |
| 214 | + [Fact] |
| 215 | + public void QueryAll_Filters_By_MailType_And_ServerId() |
| 216 | + { |
| 217 | + MailCampaignRegistry.ResetForTest(); |
| 218 | + |
| 219 | + var c1 = MakeValidCampaign(); |
| 220 | + c1.MailType = MailType.Operation; |
| 221 | + c1.MinLevel = 10; |
| 222 | + c1.MaxLevel = 50; |
| 223 | + c1.ServerIds = new List<int> { 100 }; |
| 224 | + var p1 = MailCampaignRegistry.PublishOrUpdate(c1, "admin-test", 1_700_000_500L); |
| 225 | + |
| 226 | + var c2 = MakeValidCampaign(); |
| 227 | + c2.MailType = MailType.Compensation; |
| 228 | + var p2 = MailCampaignRegistry.PublishOrUpdate(c2, "admin-test", 1_700_000_600L); |
| 229 | + |
| 230 | + Assert.True(MailCampaignRegistry.QueryAll().Count >= 2); |
| 231 | + |
| 232 | + var filtered = MailCampaignRegistry.QueryAll(mailType: MailType.Operation); |
| 233 | + Assert.NotEmpty(filtered); |
| 234 | + Assert.All(filtered, c => Assert.Equal(MailType.Operation, c.MailType)); |
| 235 | + |
| 236 | + var byServer = MailCampaignRegistry.QueryAll(serverId: 100); |
| 237 | + Assert.NotEmpty(byServer); |
| 238 | + Assert.All(byServer, c => Assert.Contains(100, c.ServerIds)); |
| 239 | + |
| 240 | + // TryQuery 命中已发布 / 未命中不存在。 |
| 241 | + Assert.True(MailCampaignRegistry.TryQuery(p1.CampaignId, out _)); |
| 242 | + Assert.False(MailCampaignRegistry.TryQuery(p2.CampaignId + 10_000L, out _)); |
| 243 | + } |
| 244 | + |
| 245 | + // ---------- fixtures ---------- |
| 246 | + |
| 247 | + private static MailCampaignState MakeValidCampaign() |
| 248 | + { |
| 249 | + return new MailCampaignState |
| 250 | + { |
| 251 | + MailType = MailType.Operation, |
| 252 | + Titles = new List<MailLocalizedContent> |
| 253 | + { |
| 254 | + new MailLocalizedContent { Language = "zh-cn", Text = "测试标题" }, |
| 255 | + new MailLocalizedContent { Language = "en-us", Text = "Test Title" }, |
| 256 | + }, |
| 257 | + Contents = new List<MailLocalizedContent> |
| 258 | + { |
| 259 | + new MailLocalizedContent { Language = "zh-cn", Text = "测试正文" }, |
| 260 | + }, |
| 261 | + Attachments = new List<MailAttachmentState> |
| 262 | + { |
| 263 | + new MailAttachmentState { SlotId = 1, RewardType = 0, ItemId = 1001, Amount = 5 }, |
| 264 | + }, |
| 265 | + ExpireAttachmentPolicy = ExpireAttachmentPolicy.DiscardUnclaimed, |
| 266 | + ExpireAt = 1_700_999_999L, |
| 267 | + }; |
| 268 | + } |
| 269 | +} |
0 commit comments