package outbox_test import ( "context" stderrors "errors" "sync" "testing" "time" "github.com/bytedance/sonic" "github.com/hibiken/asynq" "go.uber.org/zap" "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" "github.com/break/junhong_cmp_fiber/pkg/queue" ) type recordingPublisher struct { mu sync.Mutex envelopes []outbox.DeliveryEnvelope err error } func (p *recordingPublisher) Publish(_ context.Context, envelope outbox.DeliveryEnvelope) error { p.mu.Lock() defer p.mu.Unlock() p.envelopes = append(p.envelopes, envelope) return p.err } func TestExpiredLeaseRedeliversOriginalEnvelope(t *testing.T) { db := testutil.NewPostgresTransaction(t) createTemporaryOutboxTables(t, db) repository := outbox.NewRepository() event, err := repository.Append(context.Background(), db, newEnvelope("event-stable", "business-stable")) if err != nil { t.Fatalf("准备 Outbox 事件失败:%v", err) } now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC) firstPublisher := &recordingPublisher{} first, err := outbox.NewRelay(db, firstPublisher, zap.NewNop(), outbox.RelayOptions{ Owner: "relay-a", BatchSize: 1, LeaseDuration: time.Second, Now: func() time.Time { return now }, }) if err != nil { t.Fatalf("创建首个 Relay 失败:%v", err) } claimed, err := first.ClaimBatch(context.Background()) if err != nil || len(claimed) != 1 { t.Fatalf("首个 Relay 领取失败:%v,数量:%d", err, len(claimed)) } // 模拟入队成功但数据库标记前崩溃:公开队列信封已经可观察,但租约没有完成。 if err := firstPublisher.Publish(context.Background(), deliveryFromModel(claimed[0])); err != nil { t.Fatalf("模拟首次入队失败:%v", err) } now = now.Add(2 * time.Second) secondPublisher := &recordingPublisher{} second, err := outbox.NewRelay(db, secondPublisher, zap.NewNop(), outbox.RelayOptions{ Owner: "relay-b", BatchSize: 1, LeaseDuration: time.Minute, Now: func() time.Time { return now }, }) if err != nil { t.Fatalf("创建恢复 Relay 失败:%v", err) } processed, err := second.ProcessBatch(context.Background()) if err != nil || processed != 1 { t.Fatalf("恢复投递失败:%v,数量:%d", err, processed) } if len(firstPublisher.envelopes) != 1 || len(secondPublisher.envelopes) != 1 { t.Fatalf("至少一次投递次数不正确:%d/%d", len(firstPublisher.envelopes), len(secondPublisher.envelopes)) } firstEnvelope := firstPublisher.envelopes[0] secondEnvelope := secondPublisher.envelopes[0] if firstEnvelope.EventID != event.EventID || secondEnvelope.EventID != event.EventID || firstEnvelope.CorrelationID != secondEnvelope.CorrelationID || string(firstEnvelope.Payload) != string(secondEnvelope.Payload) { t.Fatalf("恢复投递改变了事件身份或载荷:%+v / %+v", firstEnvelope, secondEnvelope) } } func TestRelayFailureBackoffAndFinalFailure(t *testing.T) { db := testutil.NewPostgresTransaction(t) createTemporaryOutboxTables(t, db) repository := outbox.NewRepository() event, err := repository.Append(context.Background(), db, newEnvelope("event-retry", "business-retry")) if err != nil { t.Fatalf("准备 Outbox 事件失败:%v", err) } if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Update("max_retries", 2).Error; err != nil { t.Fatalf("设置最大重试次数失败:%v", err) } now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC) publisher := &recordingPublisher{err: stderrors.New("测试队列失败")} relay, err := outbox.NewRelay(db, publisher, zap.NewNop(), outbox.RelayOptions{ Owner: "relay-retry", BatchSize: 1, LeaseDuration: time.Minute, Now: func() time.Time { return now }, }) if err != nil { t.Fatalf("创建 Relay 失败:%v", err) } if _, err := relay.ProcessBatch(context.Background()); err != nil { t.Fatalf("记录首次失败失败:%v", err) } var afterFirst model.OutboxEvent if err := db.First(&afterFirst, event.ID).Error; err != nil { t.Fatalf("读取首次失败事实失败:%v", err) } if afterFirst.Status != constants.OutboxStatusPending || afterFirst.RetryCount != 1 || !afterFirst.NextAttemptAt.After(now) { t.Fatalf("首次失败未按退避重试:%+v", afterFirst) } now = afterFirst.NextAttemptAt if _, err := relay.ProcessBatch(context.Background()); err != nil { t.Fatalf("记录最终失败失败:%v", err) } var final model.OutboxEvent if err := db.First(&final, event.ID).Error; err != nil { t.Fatalf("读取最终失败事实失败:%v", err) } if final.Status != constants.OutboxStatusFailed || final.RetryCount != 2 || final.LastErrorSummary != "队列暂时不可用" { t.Fatalf("最终失败事实不正确:%+v", final) } } func TestRelayPermanentFailureStopsRetryImmediately(t *testing.T) { db := testutil.NewPostgresTransaction(t) createTemporaryOutboxTables(t, db) repository := outbox.NewRepository() event, err := repository.Append(context.Background(), db, newEnvelope("event-permanent", "business-permanent")) if err != nil { t.Fatalf("准备永久失败事件失败:%v", err) } now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC) publisher := &recordingPublisher{err: outbox.Permanent(stderrors.New("载荷版本不受支持"))} relay, err := outbox.NewRelay(db, publisher, zap.NewNop(), outbox.RelayOptions{ Owner: "relay-permanent", BatchSize: 1, LeaseDuration: time.Minute, Now: func() time.Time { return now }, }) if err != nil { t.Fatalf("创建 Relay 失败:%v", err) } if _, err := relay.ProcessBatch(context.Background()); err != nil { t.Fatalf("记录永久失败事实失败:%v", err) } var failed model.OutboxEvent if err := db.First(&failed, event.ID).Error; err != nil { t.Fatalf("读取永久失败事实失败:%v", err) } if failed.Status != constants.OutboxStatusFailed || failed.RetryCount != 1 || failed.LastErrorCode != "OUTBOX_PERMANENT_FAILURE" { t.Fatalf("永久失败未立即停止重试:%+v", failed) } } type recordingConsumer struct { envelope outbox.DeliveryEnvelope } func (c *recordingConsumer) Consume(_ context.Context, envelope outbox.DeliveryEnvelope) error { c.envelope = envelope return nil } func TestPublicAsynqHandlerObservesStructuredEnvelope(t *testing.T) { t.Parallel() envelope := outbox.DeliveryEnvelope{ EventID: "event-handler", EventType: "foundation.example.created", PayloadVersion: 1, CorrelationID: "correlation-handler", Payload: sonic.NoCopyRawMessage(`{"visible":true}`), } payload, err := sonic.Marshal(envelope) if err != nil { t.Fatalf("序列化公开信封失败:%v", err) } consumer := &recordingConsumer{} handler := outbox.NewHandler(consumer) if err := handler.Handle(context.Background(), asynq.NewTask(constants.TaskTypeOutboxDeliver, payload)); err != nil { t.Fatalf("公开 Handler 处理失败:%v", err) } if consumer.envelope.EventID != envelope.EventID || consumer.envelope.CorrelationID != envelope.CorrelationID { t.Fatalf("公开 Handler 未原样传播身份:%+v", consumer.envelope) } } func TestPostgresRelayRedisAsynqAndPublicHandlerChain(t *testing.T) { db := testutil.NewPostgresTransaction(t) redisClient := testutil.NewRedisClient(t) createTemporaryOutboxTables(t, db) repository := outbox.NewRepository() event, err := repository.Append(context.Background(), db, newEnvelope("event-real-chain", "business-real-chain")) if err != nil { t.Fatalf("准备真实链路事件失败:%v", err) } queueClient := queue.NewClient(redisClient, zap.NewNop()) t.Cleanup(func() { _ = queueClient.Close() }) publisher := outbox.NewQueuePublisher(queueClient) relay, err := outbox.NewRelay(db, publisher, zap.NewNop(), outbox.RelayOptions{Owner: "relay-real-chain", BatchSize: 1}) if err != nil { t.Fatalf("创建真实链路 Relay 失败:%v", err) } processed, err := relay.ProcessBatch(context.Background()) if err != nil || processed != 1 { t.Fatalf("真实链路投递失败:%v,数量:%d", err, processed) } options := redisClient.Options() inspector := asynq.NewInspector(asynq.RedisClientOpt{Addr: options.Addr, Password: options.Password, DB: options.DB}) t.Cleanup(func() { _ = inspector.Close() }) tasks, err := inspector.ListPendingTasks(constants.QueueOutboxDeliver, asynq.PageSize(1000)) if err != nil { t.Fatalf("检查 Asynq 待处理任务失败:%v", err) } var matched *asynq.TaskInfo for _, info := range tasks { var queued outbox.DeliveryEnvelope if sonic.Unmarshal(info.Payload, &queued) == nil && queued.EventID == event.EventID { matched = info break } } if matched == nil { t.Fatal("真实 Asynq 队列中未找到本次公共事件") } t.Cleanup(func() { _ = inspector.DeleteTask(matched.Queue, matched.ID) }) consumer := &recordingConsumer{} handler := outbox.NewHandler(consumer) if err := handler.Handle(context.Background(), asynq.NewTask(matched.Type, matched.Payload)); err != nil { t.Fatalf("公开 Handler 处理真实队列载荷失败:%v", err) } if consumer.envelope.EventID != event.EventID || consumer.envelope.CorrelationID != event.CorrelationID { t.Fatalf("真实链路未原样传播事件身份:%+v", consumer.envelope) } var delivered model.OutboxEvent if err := db.First(&delivered, event.ID).Error; err != nil { t.Fatalf("读取已投递事件失败:%v", err) } if delivered.Status != constants.OutboxStatusDelivered || delivered.DeliveredAt == nil { t.Fatalf("真实链路未完成 Outbox 状态:%+v", delivered) } } func deliveryFromModel(event model.OutboxEvent) outbox.DeliveryEnvelope { return outbox.DeliveryEnvelope{ EventID: event.EventID, EventType: event.EventType, PayloadVersion: event.PayloadVersion, AggregateType: event.AggregateType, AggregateID: event.AggregateID, ResourceType: event.ResourceType, ResourceID: event.ResourceID, BusinessKey: event.BusinessKey, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: sonic.NoCopyRawMessage(event.Payload), } }