Skip to content

Commit ac755e2

Browse files
authored
test(mail): 补齐 Campaign 侧验收自检覆盖 (GFX-135) (#91)
1 parent deb0c44 commit ac755e2

2 files changed

Lines changed: 274 additions & 0 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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&gt;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&lt;=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+
}

GameFrameX.Hotfix/GameFrameX.Hotfix.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
<PackageReference Include="GameFrameX.SuperSocket.ClientEngine" Version="1.2.0" />
3333
</ItemGroup>
3434

35+
<!-- 暴露 internal 给测试工程:供 MailCampaignRegistryTests 调用 ResetForTest 隔离进程内 registry 状态(SelfCheck 工程移除后改由 xunit 承担自检)。 -->
36+
<ItemGroup>
37+
<InternalsVisibleTo Include="GameFrameX.Hotfix.Tests" />
38+
</ItemGroup>
39+
3540
<ItemGroup>
3641
<Folder Include="Logic\" />
3742
</ItemGroup>

0 commit comments

Comments
 (0)