Files
junhong_cmp_fiber/internal/application/outbox/recovery_integration_test.go
break 17782d5f8e
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
实现七月迭代公共技术基础
2026-07-23 17:52:48 +09:00

130 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package outbox_test
import (
"context"
stderrors "errors"
"testing"
"time"
"gorm.io/gorm"
outboxapp "github.com/break/junhong_cmp_fiber/internal/application/outbox"
infraoutbox "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/testutil"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
type databaseAuditWriter struct {
fail bool
}
func (w *databaseAuditWriter) WriteRecovery(_ context.Context, tx *gorm.DB, audit outboxapp.RecoveryAudit) error {
if w.fail {
return stderrors.New("测试审计不可用")
}
return tx.Exec(`INSERT INTO test_outbox_recovery_audit (batch_id, operation_type, event_count)
VALUES (?, ?, ?)`, audit.BatchID, audit.OperationType, len(audit.EventIDs)).Error
}
func TestSelectiveReplayPreservesIdentityAndWritesAudit(t *testing.T) {
db := testutil.NewPostgresTransaction(t)
testutil.CreateTemporaryOutboxTable(t, db)
createTemporaryRecoveryAuditTable(t, db)
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
repository := infraoutbox.NewRepository()
event, err := repository.Append(context.Background(), db, infraoutbox.Envelope{
EventID: "recovery-stable", EventType: "example.recovery", AggregateType: "example", AggregateID: "1",
ResourceType: "example", ResourceID: "1", CorrelationID: "correlation-stable",
Payload: struct {
Secret string `json:"secret"`
}{Secret: "preserved-in-database-only"},
})
if err != nil {
t.Fatalf("准备恢复事件失败:%v", err)
}
if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Updates(map[string]any{
"status": constants.OutboxStatusFailed, "retry_count": 10, "last_error_code": "FINAL",
}).Error; err != nil {
t.Fatalf("设置最终失败状态失败:%v", err)
}
var storedBefore model.OutboxEvent
if err := db.First(&storedBefore, event.ID).Error; err != nil {
t.Fatalf("读取重放前事件失败:%v", err)
}
service, err := outboxapp.NewRecoveryService(db, &databaseAuditWriter{}, func() time.Time { return now })
if err != nil {
t.Fatalf("创建恢复服务失败:%v", err)
}
batchID, err := service.Replay(context.Background(), outboxapp.Operator{
ID: 7, SuperAdmin: true, RequestID: "request-recovery", CorrelationID: "correlation-recovery",
}, []uint{event.ID}, "确认消费者已具备幂等保护")
if err != nil || batchID == "" {
t.Fatalf("选择性重放失败:%v", err)
}
var recovered model.OutboxEvent
if err := db.First(&recovered, event.ID).Error; err != nil {
t.Fatalf("读取重放事件失败:%v", err)
}
if recovered.Status != constants.OutboxStatusPending || recovered.EventID != event.EventID ||
recovered.CorrelationID != event.CorrelationID || string(recovered.Payload) != string(storedBefore.Payload) {
t.Fatalf("重放改变了事件身份或内容:%+v", recovered)
}
var auditCount int64
if err := db.Table("test_outbox_recovery_audit").Where("batch_id = ?", batchID).Count(&auditCount).Error; err != nil || auditCount != 1 {
t.Fatalf("恢复审计事实缺失:%v数量%d", err, auditCount)
}
}
func TestRecoveryRejectsActiveLeaseUnauthorizedOperatorAndAuditFailure(t *testing.T) {
db := testutil.NewPostgresTransaction(t)
testutil.CreateTemporaryOutboxTable(t, db)
createTemporaryRecoveryAuditTable(t, db)
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
repository := infraoutbox.NewRepository()
event, err := repository.Append(context.Background(), db, infraoutbox.Envelope{
EventID: "active-lease", EventType: "example.recovery", AggregateType: "example", AggregateID: "2",
ResourceType: "example", ResourceID: "2", Payload: struct {
Visible bool `json:"visible"`
}{Visible: true},
})
if err != nil {
t.Fatalf("准备有效租约事件失败:%v", err)
}
if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Updates(map[string]any{
"status": constants.OutboxStatusDelivering, "lease_owner": "active-worker", "lease_expires_at": now.Add(time.Minute),
}).Error; err != nil {
t.Fatalf("设置有效租约失败:%v", err)
}
service, _ := outboxapp.NewRecoveryService(db, &databaseAuditWriter{}, func() time.Time { return now })
if _, err := service.ReleaseExpiredLeases(context.Background(), outboxapp.Operator{ID: 7, SuperAdmin: true}, []uint{event.ID}, "测试释放"); err == nil {
t.Fatal("不得释放仍然有效的租约")
}
if _, err := service.Replay(context.Background(), outboxapp.Operator{ID: 8, SuperAdmin: false}, []uint{event.ID}, "越权测试"); err == nil {
t.Fatal("非超级管理员不得执行人工恢复")
}
if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Update("status", constants.OutboxStatusFailed).Error; err != nil {
t.Fatalf("设置失败事件失败:%v", err)
}
failClosed, _ := outboxapp.NewRecoveryService(db, &databaseAuditWriter{fail: true}, func() time.Time { return now })
if _, err := failClosed.Replay(context.Background(), outboxapp.Operator{ID: 7, SuperAdmin: true}, []uint{event.ID}, "审计失败测试"); err == nil {
t.Fatal("统一审计不可用时恢复事务必须失败")
}
var unchanged model.OutboxEvent
if err := db.First(&unchanged, event.ID).Error; err != nil || unchanged.Status != constants.OutboxStatusFailed {
t.Fatalf("审计失败后事件状态未回滚:%v状态%d", err, unchanged.Status)
}
}
func createTemporaryRecoveryAuditTable(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.Exec(`CREATE TEMP TABLE test_outbox_recovery_audit (
id bigserial PRIMARY KEY, batch_id varchar(64) NOT NULL,
operation_type varchar(100) NOT NULL, event_count integer NOT NULL
) ON COMMIT DROP`).Error; err != nil {
t.Fatalf("创建恢复审计测试表失败:%v", err)
}
}