test: strengthen admin state contract
This commit is contained in:
parent
c5311aa9c0
commit
2341296271
@ -127,6 +127,9 @@ MemoryStore 与 PostgreSQL Adapter 运行相同契约,至少覆盖:
|
|||||||
- Routing CAS、目标校验和 100 个并发请求最多一个成功。
|
- Routing CAS、目标校验和 100 个并发请求最多一个成功。
|
||||||
- 任一审计/Outbox 写故障导致状态完全回滚。
|
- 任一审计/Outbox 写故障导致状态完全回滚。
|
||||||
- Outbox 有界 claim、租约到期重试、错误 consumer ACK 拒绝和顺序稳定。
|
- Outbox 有界 claim、租约到期重试、错误 consumer ACK 拒绝和顺序稳定。
|
||||||
|
- 审计按 ID 稳定分页并保留 Actor、资源、动作、修订和 UTC 时间;Routing no-op
|
||||||
|
写审计但不写 Outbox。
|
||||||
|
- 批量 ACK 先完整校验所有事件再提交,任一未知或冲突 ID 不得部分发布。
|
||||||
- 上下文取消、错误脱敏和 Snapshot 防止调用方修改内部状态。
|
- 上下文取消、错误脱敏和 Snapshot 防止调用方修改内部状态。
|
||||||
- 真实 PostgreSQL 重复迁移、事务回滚和禁止数据表边界。
|
- 真实 PostgreSQL 重复迁移、事务回滚和禁止数据表边界。
|
||||||
|
|
||||||
|
|||||||
@ -26,9 +26,18 @@ func Run(t *testing.T, factory Factory) {
|
|||||||
t.Run("routing compare and swap", func(t *testing.T) {
|
t.Run("routing compare and swap", func(t *testing.T) {
|
||||||
runRoutingContract(t, factory(t))
|
runRoutingContract(t, factory(t))
|
||||||
})
|
})
|
||||||
|
t.Run("routing no-op audit", func(t *testing.T) {
|
||||||
|
runRoutingNoOpContract(t, factory(t))
|
||||||
|
})
|
||||||
|
t.Run("audit pagination and fields", func(t *testing.T) {
|
||||||
|
runAuditContract(t, factory(t))
|
||||||
|
})
|
||||||
t.Run("outbox lease and acknowledgement", func(t *testing.T) {
|
t.Run("outbox lease and acknowledgement", func(t *testing.T) {
|
||||||
runOutboxContract(t, factory(t))
|
runOutboxContract(t, factory(t))
|
||||||
})
|
})
|
||||||
|
t.Run("outbox acknowledgement is atomic", func(t *testing.T) {
|
||||||
|
runAtomicAcknowledgeContract(t, factory(t))
|
||||||
|
})
|
||||||
t.Run("context cancellation", func(t *testing.T) {
|
t.Run("context cancellation", func(t *testing.T) {
|
||||||
runContextContract(t, factory(t))
|
runContextContract(t, factory(t))
|
||||||
})
|
})
|
||||||
@ -185,6 +194,67 @@ func runRoutingContract(t *testing.T, store adminstate.Store) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runRoutingNoOpContract(t *testing.T, store adminstate.Store) {
|
||||||
|
t.Helper()
|
||||||
|
now := contractNow()
|
||||||
|
commit(t, store, configCommand("req-config", "cfg-1", strings.Repeat("a", adminstate.SHA256HexBytes)))
|
||||||
|
|
||||||
|
result, err := store.SwitchRouting(context.Background(), adminstate.SwitchRoutingCommand{
|
||||||
|
RequestID: "req-switch-noop", Actor: contractActor(), OccurredAt: now.Add(time.Second),
|
||||||
|
Name: "checkout", ExpectedCurrent: "provider-a", Target: "provider-a", Reason: "already selected",
|
||||||
|
})
|
||||||
|
if err != nil || result.Changed || result.Revision != 1 {
|
||||||
|
t.Fatalf("SwitchRouting(no-op) = %+v, %v", result, err)
|
||||||
|
}
|
||||||
|
audits, err := store.ReadAudit(context.Background(), adminstate.AuditQuery{Limit: 10})
|
||||||
|
if err != nil || len(audits) != 2 || audits[1].Action != adminstate.ActionSwitchRoute ||
|
||||||
|
audits[1].Changed || audits[1].Revision != 1 || audits[1].Reason != "already selected" {
|
||||||
|
t.Fatalf("ReadAudit(no-op) = %+v, %v", audits, err)
|
||||||
|
}
|
||||||
|
events, err := store.Claim(context.Background(), adminstate.ClaimCommand{
|
||||||
|
ConsumerID: "publisher-a", Now: now.Add(2 * time.Second), Limit: 10, Lease: time.Minute,
|
||||||
|
})
|
||||||
|
if err != nil || len(events) != 1 || events[0].Type != "config.committed" {
|
||||||
|
t.Fatalf("Claim(after no-op) = %+v, %v", events, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAuditContract(t *testing.T, store adminstate.Store) {
|
||||||
|
t.Helper()
|
||||||
|
now := contractNow()
|
||||||
|
commit(t, store, configCommand("req-config", "cfg-1", strings.Repeat("a", adminstate.SHA256HexBytes)))
|
||||||
|
_, err := store.SetUpstreamEnabled(context.Background(), adminstate.SetUpstreamCommand{
|
||||||
|
RequestID: "req-disable", Actor: adminstate.Actor{ID: "admin-b", SourceIP: "::ffff:192.0.2.11"},
|
||||||
|
OccurredAt: now.Add(time.Second), Name: "provider-a", Enabled: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetUpstreamEnabled(): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := store.ReadAudit(context.Background(), adminstate.AuditQuery{Limit: 1})
|
||||||
|
if err != nil || len(first) != 1 {
|
||||||
|
t.Fatalf("ReadAudit(first page) = %+v, %v", first, err)
|
||||||
|
}
|
||||||
|
if first[0].ID == 0 || first[0].RequestID != "req-config" || first[0].Actor != contractActor() ||
|
||||||
|
first[0].Action != adminstate.ActionCommitConfig || first[0].ResourceType != "config" ||
|
||||||
|
first[0].ResourceName != "cfg-1" || !first[0].Changed || first[0].Revision != 1 ||
|
||||||
|
!first[0].OccurredAt.Equal(now) {
|
||||||
|
t.Fatalf("first audit record = %+v", first[0])
|
||||||
|
}
|
||||||
|
second, err := store.ReadAudit(context.Background(), adminstate.AuditQuery{AfterID: first[0].ID, Limit: 1})
|
||||||
|
if err != nil || len(second) != 1 || second[0].ID <= first[0].ID || second[0].RequestID != "req-disable" ||
|
||||||
|
second[0].Actor.ID != "admin-b" || second[0].Actor.SourceIP != "192.0.2.11" ||
|
||||||
|
second[0].Action != adminstate.ActionSetUpstream || second[0].ResourceType != "upstream" ||
|
||||||
|
second[0].ResourceName != "provider-a" || !second[0].Changed || second[0].Revision != 2 ||
|
||||||
|
!second[0].OccurredAt.Equal(now.Add(time.Second)) {
|
||||||
|
t.Fatalf("ReadAudit(second page) = %+v, %v", second, err)
|
||||||
|
}
|
||||||
|
empty, err := store.ReadAudit(context.Background(), adminstate.AuditQuery{AfterID: second[0].ID, Limit: 1})
|
||||||
|
if err != nil || len(empty) != 0 {
|
||||||
|
t.Fatalf("ReadAudit(after end) = %+v, %v", empty, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func runOutboxContract(t *testing.T, store adminstate.Store) {
|
func runOutboxContract(t *testing.T, store adminstate.Store) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
now := contractNow()
|
now := contractNow()
|
||||||
@ -235,6 +305,36 @@ func runOutboxContract(t *testing.T, store adminstate.Store) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runAtomicAcknowledgeContract(t *testing.T, store adminstate.Store) {
|
||||||
|
t.Helper()
|
||||||
|
now := contractNow()
|
||||||
|
commit(t, store, configCommand("req-config", "cfg-1", strings.Repeat("a", adminstate.SHA256HexBytes)))
|
||||||
|
if _, err := store.SetUpstreamEnabled(context.Background(), adminstate.SetUpstreamCommand{
|
||||||
|
RequestID: "req-disable", Actor: contractActor(), OccurredAt: now.Add(time.Second),
|
||||||
|
Name: "provider-a", Enabled: false,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetUpstreamEnabled(): %v", err)
|
||||||
|
}
|
||||||
|
events, err := store.Claim(context.Background(), adminstate.ClaimCommand{
|
||||||
|
ConsumerID: "publisher-a", Now: now.Add(2 * time.Second), Limit: 10, Lease: time.Minute,
|
||||||
|
})
|
||||||
|
if err != nil || len(events) != 2 {
|
||||||
|
t.Fatalf("Claim() = %+v, %v", events, err)
|
||||||
|
}
|
||||||
|
if err := store.Acknowledge(context.Background(), adminstate.AcknowledgeCommand{
|
||||||
|
ConsumerID: "publisher-a", Now: now.Add(3 * time.Second), EventIDs: []uint64{events[0].ID, events[1].ID + 1000},
|
||||||
|
}); !errors.Is(err, adminstate.ErrNotFound) {
|
||||||
|
t.Fatalf("Acknowledge(partially invalid) error = %v", err)
|
||||||
|
}
|
||||||
|
for _, event := range events {
|
||||||
|
if err := store.Acknowledge(context.Background(), adminstate.AcknowledgeCommand{
|
||||||
|
ConsumerID: "publisher-a", Now: now.Add(4 * time.Second), EventIDs: []uint64{event.ID},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Acknowledge(%d) after failed batch: %v", event.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func runContextContract(t *testing.T, store adminstate.Store) {
|
func runContextContract(t *testing.T, store adminstate.Store) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@ -243,6 +343,18 @@ func runContextContract(t *testing.T, store adminstate.Store) {
|
|||||||
if _, err := store.CommitConfig(ctx, command); !errors.Is(err, context.Canceled) {
|
if _, err := store.CommitConfig(ctx, command); !errors.Is(err, context.Canceled) {
|
||||||
t.Fatalf("CommitConfig(canceled) error = %v", err)
|
t.Fatalf("CommitConfig(canceled) error = %v", err)
|
||||||
}
|
}
|
||||||
|
if _, err := store.SetUpstreamEnabled(ctx, adminstate.SetUpstreamCommand{
|
||||||
|
RequestID: "req-upstream", Actor: contractActor(), OccurredAt: contractNow(),
|
||||||
|
Name: "provider-a", Enabled: false,
|
||||||
|
}); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("SetUpstreamEnabled(canceled) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.SwitchRouting(ctx, adminstate.SwitchRoutingCommand{
|
||||||
|
RequestID: "req-switch", Actor: contractActor(), OccurredAt: contractNow(),
|
||||||
|
Name: "checkout", ExpectedCurrent: "provider-a", Target: "provider-b",
|
||||||
|
}); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("SwitchRouting(canceled) error = %v", err)
|
||||||
|
}
|
||||||
if _, err := store.Snapshot(ctx); !errors.Is(err, context.Canceled) {
|
if _, err := store.Snapshot(ctx); !errors.Is(err, context.Canceled) {
|
||||||
t.Fatalf("Snapshot(canceled) error = %v", err)
|
t.Fatalf("Snapshot(canceled) error = %v", err)
|
||||||
}
|
}
|
||||||
@ -254,6 +366,11 @@ func runContextContract(t *testing.T, store adminstate.Store) {
|
|||||||
}); !errors.Is(err, context.Canceled) {
|
}); !errors.Is(err, context.Canceled) {
|
||||||
t.Fatalf("Claim(canceled) error = %v", err)
|
t.Fatalf("Claim(canceled) error = %v", err)
|
||||||
}
|
}
|
||||||
|
if err := store.Acknowledge(ctx, adminstate.AcknowledgeCommand{
|
||||||
|
ConsumerID: "publisher-a", Now: contractNow(), EventIDs: []uint64{1},
|
||||||
|
}); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("Acknowledge(canceled) error = %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func configCommand(requestID, version, checksum string) adminstate.CommitConfigCommand {
|
func configCommand(requestID, version, checksum string) adminstate.CommitConfigCommand {
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
## 2026-07-29
|
## 2026-07-29
|
||||||
|
|
||||||
|
- 扩展 `adminstate/contracttest` 公用契约:审计完整字段和 AfterID 分页、Routing
|
||||||
|
no-op 审计且不写 Outbox、批量 ACK 零部分提交,以及六个 Store 方法的 Context
|
||||||
|
取消;MemoryStore 全部通过,后续 PostgreSQL Adapter 必须运行同一套契约。
|
||||||
- `adminstate` 六类命令/查询已封装公用 `Validate()`,MemoryStore 改为统一复用;
|
- `adminstate` 六类命令/查询已封装公用 `Validate()`,MemoryStore 改为统一复用;
|
||||||
后续 pgx Adapter 不再重复实现名称、引用、分页和 Outbox 租约输入校验。
|
后续 pgx Adapter 不再重复实现名称、引用、分页和 Outbox 租约输入校验。
|
||||||
- 一次命令装配审计误用了不存在的 `controller/distribution/service.go`;实际领域
|
- 一次命令装配审计误用了不存在的 `controller/distribution/service.go`;实际领域
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user