Tickets 已有聚合与双 ORM 基础证据,但当前“Ready”主要证明统一持久化与 owner-local contracts 收口,不等于工单业务、跨模块事件和生产运维已经 GA。本页把结构就绪与商业交付就绪分开验收。
1. 现有测试资产
| 测试集 | 已证明 |
|---|---|
TicketTransitionTests | 7 个允许迁移、4 个非法迁移 |
TicketPlatformTests | 六事件、字段限制、详情更新、评论 JSON、分派授权、附件拒绝 |
TicketReadQueryHandlerTests | Query store 调用、详情 requester、非参与人 Forbidden |
TicketsCommandHandlerTemplateBoundaryTests | 状态/分派 Handler 使用共享流程模板 |
TicketsInfrastructureArchitectureTests | ORM 中立、统一聚合元数据、旧 Entity/Mapper 删除、fail-closed 默认 |
PortRepositoryParityTests | 双 ORM 保存、完整 JSON 还原、分派更新、缺失语义 |
ReadModelStoreParityTests | 双 ORM 工单、项目、看板、迭代 Query Shape parity |
PersistenceRegistrationManifestArchitectureTests | generated topic 与公开业务字段映射 |
TicketReportingEventConsumerTests | 六事件顺序、重复事件和陈旧事件处理 |
ProductionAdapterReadinessEvidenceTests | 生产审计适配器替换与 Null deny-list |
这些测试仍没有证明 36 个声明对应的真实 HTTP、实际 CAP 字节到 typed consumer 的绑定、Webhook 桥接、受限深分页或并发冲突。
2. 分层测试策略
单元测试固定纯规则;双 ORM 固定存储和查询;HTTP 固定认证/授权/绑定/Problem Details;consumer contract 固定可独立交付的消息;E2E 只验证少量关键路径,不替代前面确定性证据。
3. 状态机完整测试
当前只抽样迁移。完整矩阵应枚举 6×6,并单独验证重复目标的幂等。每个成功行为还要断言 Status、ModifyTime、ResolvedAt/ClosedAt、领域事件数与字段;失败不得修改任何状态。
public static IEnumerable<object[]> EveryTransition(){ // 所有状态两两组合,期望值来自唯一的业务规格,而不是再调用生产规则计算。 foreach (var from in AllStatuses) foreach (var to in AllStatuses) yield return new object[] { from, to, Expected[from, to] };}
[Theory][MemberData(nameof(EveryTransition))]public void Transition_Should_Match_Spec( TicketStatus from, TicketStatus to, bool shouldSucceed){ var ticket = RestoredAt(from); var before = Snapshot(ticket);
// Act 通过公开聚合行为执行,不直接调用私有 TransitionTo。 var result = ApplyPublicBehavior(ticket, to, Now);
result.IsSuccess.ShouldBe(shouldSucceed); if (!shouldSucceed) Snapshot(ticket).ShouldBe(before);}4. 并发红测试
必须在真实 SqlSugar 与 EF Core 上同时运行,而不是 FakeTicketRepository。两个独立 scope 读取相同 Version,各自追加不同 Comment 或执行不同状态,然后提交。目标是一个成功、一个 Ticket.VersionConflict;当前实现可能两个都成功且丢失一个修改。
// 两个独立 UnitOfWork 从相同版本读取同一工单。var left = await LoadInNewScopeAsync(ticketId);var right = await LoadInNewScopeAsync(ticketId);left.Version.ShouldBe(right.Version);
left.AddComment("comment-a", "user-a", "A", Now);right.AddComment("comment-b", "user-b", "B", Now);
// 第一次提交成功;第二次必须得到类型化并发冲突。(await SaveAsync(left, expectedVersion: left.Version)).IsSuccess.ShouldBeTrue();var stale = await SaveAsync(right, expectedVersion: right.Version);stale.Error.Code.ShouldBe("Ticket.VersionConflict");
// 重新加载后至少保留已提交事实,绝不能返回成功却无声覆盖。var final = await ReloadAsync(ticketId);final.Comments.Select(x => x.CommentId).ShouldContain("comment-a");5. 事件契约红测试
独立 consumer contract 应只引用发布包/序列化 schema,不引用 Tickets Application/Infrastructure。用 producer 实际生成 envelope,再交给 Reporting consumer 的反序列化入口。
// 执行真实 Open Handler + UoW + outbox,捕获 broker 中最终消息。var opened = await api.OpenTicketAsync( new("Cannot login", "MFA fails", "High", DueAt: Now.AddHours(4)));var message = await broker.TakeAsync("ticket.opened", cancellationToken);
// Wire payload 必须能由实际 Reporting typed contract 绑定。var contract = serializer.Deserialize<TicketOpenedIntegrationEvent>(message.Body);contract.EventId.ShouldNotBeNullOrWhiteSpace();contract.TicketId.ShouldBe(opened.TicketId);contract.TicketId.ShouldNotBe("0");
// 租户负责投影隔离;当前契约尚缺 Version/sequence。contract.TenantId.ShouldBe("tenant-a");当前生成式注册表已经把 TicketId、TenantId、Subject 等公开业务字段加入参数字典,且异常会触发事务回滚。上面的端到端测试仍应为红色,原因是 Open domain event 可能保留 TicketId 0,而且仓库尚无“实际 CAP serializer 字节 → Reporting DTO”证据。
6. HTTP 契约矩阵
36 个 GenerateEndpoint 声明按能力组逐条验证;QUERY 还要覆盖同路径 POST _query fallback:
- 匿名 401;错误动作权限 403;正确权限到 Handler;
- route
ticketId与 body 绑定,未知/重复字段策略; - SmartEnum 的字符串/数字/非法值;
- null、空白、255/256 主题,超长 Body/Id;
- 成功 JSON 与公开 DTO,不意外暴露聚合内部字段;
- NotFound/Forbidden/Conflict/Validation 的 Problem Details;
- StandardCommand timeout 是否只应用于源码明确声明的命令;
- Idempotency-Key/ExpectedVersion/If-Match 的目标契约;
- rate limit、请求大小和 trace correlation。
当前 Hosts 下的 OpenTicketRequest 等 request records 只在 JSON source context 中出现,生成端点以 Command 为真实契约。不要针对未接线路的 request type 写假契约测试。
7. 评论与附件测试
评论要覆盖同 CommentId 同载荷、不同载荷、跨 Ticket、并发 CommentId、超长 Body、迁移中写入、迁移后新写、损坏 JSON 与报告对账。
附件要覆盖 FileAsset 不存在、跨租户、PendingUpload、Deleted/Quarantined、public、owner、elevated permission、非 owner、重复 AttachmentId 不同 FileId、重复 FileId、绑定后 requester/assignee 下载和文件后续删除。
Comments 单一写模型完成后增加架构门禁:Tickets Application 不得调用 Ticket.AddComment,CommentsJson 不得出现在普通保存集合中。
8. 通知与审计测试
当前通知创建 Failure 会让 Ticket Command 返回 Failure,并由 Transaction pipeline 回滚。测试需要固定该语义,以及 actor=requester、actor=assignee、requester=assignee、用户组展开和重新分派的去重。渠道送达仍要通过 Notifications outbox/attempt 证明,不能用 mock NotificationService 代替。
生产组合根已经用 PersistentTicketAuditSink 替换 Null,readiness guard 也禁止 Null。还要验证每个写用例是否确实有一条记录、幂等重放不重复、批量命令是否遵循同一策略、审计异常回滚,以及日志不泄露评论/Description。
9. 数据库与性能基线
至少建立这些分位数与规模:
| 场景 | 数据形状 | 观察项 |
|---|---|---|
| requester 首页 | 每租户 100K Ticket | P95、扫描行、索引、稳定分页 |
| support 状态+assignee | 热状态 20K | 组合索引命中 |
| Subject/Description Contains | 大文本 1M | 全表扫描、超时、限流 |
| 追加评论 | 10/100/1K/10K 条 JSON | 序列化、行大小、锁、GC |
| 详情 | 大 Description+评论+附件 | payload、内存、压缩 |
| 并发更新 | 10/100 writer | 冲突率、丢失更新、重试 |
Query 排序需增加 TicketId 稳定尾键;深分页应限制或改为 cursor。大 JSON 达到阈值后必须迁出,不能只调大 timeout。
10. 指标与日志
建议指标:
ticket_command_total{command,result,error_code};ticket_state_transition_total{from,to,result};ticket_version_conflict_total{command};ticket_query_duration_seconds{scope,filter};ticket_json_bytes{field}分布;ticket_event_outbox_age_seconds{topic};ticket_projection_lag_seconds{consumer};ticket_audit_append_total{result};ticket_notification_intent_total{code,result}。
日志最小字段:TicketId、tenant_hash、actor_hash、command、from/to、version、error_code、traceId。禁止记录 Description、Comment Body、文件 StorageKey、通知 Body、原始 TenantId/UserId。
11. SLO 示例
目标需由产品和部署规模确认:创建/状态更新 P95 小于 300ms(不等待外部渠道);列表 P95 小于 500ms;outbox 最旧消息小于 60s;审计持久化成功 99.99%;事件投影 P95 延迟小于 30s;丢失更新为 0;跨租户错误读取为 0。
“通知已发送”应使用 provider accepted/delivered SLI,不能用 Ticket Handler 成功率替代。
12. 事故手册
事件投影停滞:暂停有害 consumer,检查 contract deserialization 和 topic,保留 outbox/dead messages,修复后按 EventId+Version 重放。禁止直接把 Reporting 行改成期望值而不保留重放证据。
评论丢失:冻结该 Ticket 写入,对比审计、数据库备份、CommentsJson 与 SysComment;识别并发覆盖或迁移双写;恢复后补 consumer/并发回归测试。
附件泄露:撤销 FileAsset 访问/签名 URL,定位 Ticket/FileId/actor 审计,确认是否把绑定误当共享授权;轮换受影响凭证并补资源关系策略。
NullAuditSink:视为合规事件,禁止继续高价值写入或切到明确降级 Profile;不能只打 Warning 后保持健康绿色。
13. GA 阻断清单
- 36 个端点声明及全部 QUERY fallback 的 HTTP 契约独立通过;
- typed event 与 Reporting/Webhook wire contract 通过;
- opened event 使用最终 TicketId;
- Start/Reopen 已有可重建状态的事件;
- Ticket update 比较并递增 Version;
- Comments 只有一个写事实源;
- Ticket 参与人能按明确策略下载附件;
- assignee 同租户/存在/启用/删除状态校验;
- assignee 技能、容量、排班与失效后转派策略;
- 生产审计适配器与 Null readiness guard;
- 批量命令的事件、通知与审计语义和单项命令一致或有明确差异;
- SLA 宣称与实际策略/任务/指标一致;
- 搜索、深分页、大 JSON 容量基线;
- 备份恢复与事件重放演练。
14. 全局审查命令
# 事件/消费者契约必须看到同一 topic、同一版本化 payload。rg -n "ticket\.(opened|assigned|started|resolved|closed|reopened)|tickets\.opened|Ticket.*IntegrationEvent|CapSubscribe" src tests -g '*.cs'
# 审计生产替换、通知失败与并发版本都要有直接证据。rg -n "NullTicketAuditSink|NotificationService|UpdateWhereAsync|VersionConflict|BuildParameters" src/Platform/Tickets src/Framework -g '*.cs'
# 评论外迁后旧写入口应清零;附件必须保留 FileAsset 资源授权。rg -n "AddComment|CommentsJson|MigrateTicketComments|EnsureCanAttach|FileAssetOwnerPolicy" src/Platform/Tickets src/Platform/Comments src/Platform/Files -g '*.cs'
# 路由契约测试应逐条命中;声明数量可直接对照源代码。rg -n "\[GenerateEndpoint|/api/tickets|/api/query-options/ticket" src/Platform/Tickets tests -g '*.cs'