All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m19s
278 lines
11 KiB
Go
278 lines
11 KiB
Go
package integrationlog_test
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
|
||
"gorm.io/gorm"
|
||
|
||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
)
|
||
|
||
func TestOutboundAttemptPersistsBeforeConditionalCompletion(t *testing.T) {
|
||
db := testutil.NewPostgresTransaction(t)
|
||
createTemporaryIntegrationLogTable(t, db)
|
||
repository := integrationlog.NewRepository(db)
|
||
|
||
attempt, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||
IntegrationID: "integration-outbound-1",
|
||
Provider: "gateway",
|
||
Direction: constants.IntegrationDirectionOutbound,
|
||
Operation: "query_card_status",
|
||
ResourceType: "iot_card",
|
||
ResourceID: testutil.StringPointer("1001"),
|
||
RequestSummary: map[string]any{
|
||
"iccid": "8986001234567890123",
|
||
"access_token": "must-not-persist",
|
||
"credential": "must-not-persist",
|
||
"private_url": "https://must-not-persist.example",
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("持久化外部尝试失败:%v", err)
|
||
}
|
||
if attempt.Result != constants.IntegrationResultPending {
|
||
t.Fatalf("调用前必须是待处理状态,得到 %q", attempt.Result)
|
||
}
|
||
if strings.Contains(string(attempt.RequestSummary), "must-not-persist") {
|
||
t.Fatalf("请求摘要泄露禁止字段:%s", attempt.RequestSummary)
|
||
}
|
||
|
||
auditEventID := uint(77)
|
||
completed, err := repository.Complete(context.Background(), attempt.IntegrationID, integrationlog.Completion{
|
||
Result: constants.IntegrationResultSuccess,
|
||
HTTPStatus: 200,
|
||
ProviderCode: "0",
|
||
ProviderMessage: "查询成功",
|
||
StateChanged: true,
|
||
AuditEventID: &auditEventID,
|
||
ResponseSummary: map[string]any{"status": "active", "secret": "must-not-persist"},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("终结外部尝试失败:%v", err)
|
||
}
|
||
if completed.Result != constants.IntegrationResultSuccess || !completed.StateChanged ||
|
||
completed.AuditEventID == nil || *completed.AuditEventID != auditEventID {
|
||
t.Fatalf("外部尝试终态错误:%+v", completed)
|
||
}
|
||
if completed.ProviderMessage == nil || strings.Contains(*completed.ProviderMessage, "查询成功") {
|
||
t.Fatalf("不可信渠道消息必须转换为不可逆摘要:%+v", completed.ProviderMessage)
|
||
}
|
||
|
||
if _, err := repository.Complete(context.Background(), attempt.IntegrationID, integrationlog.Completion{
|
||
Result: constants.IntegrationResultFailed,
|
||
}); err == nil {
|
||
t.Fatal("既有终态不得被重复完成改写")
|
||
}
|
||
}
|
||
|
||
func TestIntegrationResultNamesCoverEveryPublicTerminalState(t *testing.T) {
|
||
for _, result := range []string{
|
||
constants.IntegrationResultSuccess, constants.IntegrationResultFailed, constants.IntegrationResultUnknown,
|
||
constants.IntegrationResultNotFound, constants.IntegrationResultInvalidPayload, constants.IntegrationResultIgnored,
|
||
constants.IntegrationResultMerged, constants.IntegrationResultRateLimited, constants.IntegrationResultCompleted,
|
||
constants.IntegrationResultCancelled,
|
||
} {
|
||
if constants.IntegrationResultName(result) == "" {
|
||
t.Fatalf("公开终态 %q 缺少中文名称", result)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestInboundAttemptIsIdempotentAndNeverPersistsRawPayload(t *testing.T) {
|
||
db := testutil.NewPostgresTransaction(t)
|
||
createTemporaryIntegrationLogTable(t, db)
|
||
repository := integrationlog.NewRepository(db)
|
||
|
||
input := integrationlog.InboundAttempt{
|
||
IntegrationID: "integration-inbound-1",
|
||
IdempotencyKey: "wechat:callback:transaction-1",
|
||
Provider: "wechat",
|
||
Operation: "payment_callback",
|
||
ExternalID: "transaction-1",
|
||
RawPayload: []byte(`{"sign":"raw-signature","ciphertext":"raw-ciphertext"}`),
|
||
ContentType: "application/json",
|
||
}
|
||
first, created, err := repository.RecordInbound(context.Background(), input)
|
||
if err != nil || !created {
|
||
t.Fatalf("首次保存入站尝试失败:created=%v err=%v", created, err)
|
||
}
|
||
second, created, err := repository.RecordInbound(context.Background(), input)
|
||
if err != nil || created {
|
||
t.Fatalf("重复入站尝试应返回既有事实:created=%v err=%v", created, err)
|
||
}
|
||
if first.ID != second.ID || first.ContentHash == "" {
|
||
t.Fatalf("重复回调未复用稳定事实或缺少内容哈希:first=%+v second=%+v", first, second)
|
||
}
|
||
serialized := string(first.RequestSummary)
|
||
if strings.Contains(serialized, "raw-signature") || strings.Contains(serialized, "raw-ciphertext") {
|
||
t.Fatalf("入站摘要泄露原始载荷:%s", serialized)
|
||
}
|
||
conflict := input
|
||
conflict.IntegrationID = "integration-inbound-conflict"
|
||
conflict.RawPayload = []byte(`{"different":true}`)
|
||
if _, _, err := repository.RecordInbound(context.Background(), conflict); err == nil {
|
||
t.Fatal("相同入站幂等标识对应不同载荷时必须拒绝")
|
||
}
|
||
}
|
||
|
||
func TestUnknownResultRequiresExplicitRecoveryAndUnsentAttemptIsTerminal(t *testing.T) {
|
||
db := testutil.NewPostgresTransaction(t)
|
||
createTemporaryIntegrationLogTable(t, db)
|
||
repository := integrationlog.NewRepository(db)
|
||
|
||
pending, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||
IntegrationID: "integration-unknown-1", Provider: "wecom",
|
||
Direction: constants.IntegrationDirectionOutbound, Operation: "submit_approval",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("准备结果未知尝试失败:%v", err)
|
||
}
|
||
if _, err := repository.Complete(context.Background(), pending.IntegrationID, integrationlog.Completion{
|
||
Result: constants.IntegrationResultUnknown,
|
||
}); err == nil {
|
||
t.Fatal("结果未知必须记录明确恢复策略")
|
||
}
|
||
unknown, err := repository.Complete(context.Background(), pending.IntegrationID, integrationlog.Completion{
|
||
Result: constants.IntegrationResultUnknown, RecoveryStrategy: "按原外部单号查询结果,禁止盲目重发",
|
||
})
|
||
if err != nil || unknown.RecoveryStrategy == nil {
|
||
t.Fatalf("保存结果未知及恢复策略失败:log=%+v err=%v", unknown, err)
|
||
}
|
||
|
||
for index, terminal := range []string{
|
||
constants.IntegrationResultIgnored, constants.IntegrationResultMerged, constants.IntegrationResultRateLimited,
|
||
constants.IntegrationResultCompleted, constants.IntegrationResultCancelled,
|
||
} {
|
||
unsent, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||
IntegrationID: fmt.Sprintf("integration-unsent-%d", index), Provider: "gateway",
|
||
Direction: constants.IntegrationDirectionOutbound, Operation: "query_card_status", InitialResult: terminal,
|
||
})
|
||
if err != nil || unsent.Result != terminal || unsent.HTTPStatus != nil {
|
||
t.Fatalf("未发送终态不得伪造 HTTP 结果:log=%+v err=%v", unsent, err)
|
||
}
|
||
if _, err := repository.Complete(context.Background(), unsent.IntegrationID, integrationlog.Completion{
|
||
Result: constants.IntegrationResultSuccess,
|
||
}); err == nil {
|
||
t.Fatal("未发送终态不得被后续完成改写")
|
||
}
|
||
}
|
||
if _, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||
Provider: "gateway", Direction: constants.IntegrationDirectionOutbound,
|
||
Operation: "query_card_status", InitialResult: constants.IntegrationResultSuccess,
|
||
}); err == nil {
|
||
t.Fatal("实际调用结果不得绕过 pending 直接写终态")
|
||
}
|
||
}
|
||
|
||
func TestExplicitFailureAndConcurrentCompletionKeepFirstTerminalFact(t *testing.T) {
|
||
db := testutil.NewPostgresDatabase(t)
|
||
integrationIDs := []string{"integration-failed-1", "integration-concurrent-1"}
|
||
t.Cleanup(func() {
|
||
if err := db.Where("integration_id IN ?", integrationIDs).Delete(&model.IntegrationLog{}).Error; err != nil {
|
||
t.Errorf("清理 Integration Log 并发测试数据失败:%v", err)
|
||
}
|
||
})
|
||
if err := db.Where("integration_id IN ?", integrationIDs).Delete(&model.IntegrationLog{}).Error; err != nil {
|
||
t.Fatalf("准备 Integration Log 并发测试隔离数据失败:%v", err)
|
||
}
|
||
repository := integrationlog.NewRepository(db)
|
||
failedAttempt, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||
IntegrationID: "integration-failed-1", Provider: "gateway",
|
||
Direction: constants.IntegrationDirectionOutbound, Operation: "query_card_status",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("准备明确失败尝试失败:%v", err)
|
||
}
|
||
failed, err := repository.Complete(context.Background(), failedAttempt.IntegrationID, integrationlog.Completion{
|
||
Result: constants.IntegrationResultFailed, ProviderCode: "UPSTREAM_REJECTED", ProviderMessage: "上游明确拒绝",
|
||
})
|
||
if err != nil || failed.Result != constants.IntegrationResultFailed {
|
||
t.Fatalf("明确失败未保存为失败终态:log=%+v err=%v", failed, err)
|
||
}
|
||
|
||
attempt, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||
IntegrationID: "integration-concurrent-1", Provider: "gateway",
|
||
Direction: constants.IntegrationDirectionOutbound, Operation: "query_card_status",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("准备并发终结尝试失败:%v", err)
|
||
}
|
||
|
||
results := make(chan error, 2)
|
||
var group sync.WaitGroup
|
||
for _, terminal := range []string{constants.IntegrationResultSuccess, constants.IntegrationResultFailed} {
|
||
group.Add(1)
|
||
go func(result string) {
|
||
defer group.Done()
|
||
_, completeErr := repository.Complete(context.Background(), attempt.IntegrationID, integrationlog.Completion{Result: result})
|
||
results <- completeErr
|
||
}(terminal)
|
||
}
|
||
group.Wait()
|
||
close(results)
|
||
successes := 0
|
||
for completeErr := range results {
|
||
if completeErr == nil {
|
||
successes++
|
||
}
|
||
}
|
||
if successes != 1 {
|
||
t.Fatalf("并发终结必须且只能一个成功,实际成功 %d 次", successes)
|
||
}
|
||
var saved model.IntegrationLog
|
||
if err := db.Where("integration_id = ?", attempt.IntegrationID).First(&saved).Error; err != nil {
|
||
t.Fatalf("读取并发终态失败:%v", err)
|
||
}
|
||
if saved.Result != constants.IntegrationResultSuccess && saved.Result != constants.IntegrationResultFailed {
|
||
t.Fatalf("并发终结未保存明确成功或失败:%+v", saved)
|
||
}
|
||
}
|
||
|
||
func createTemporaryIntegrationLogTable(t *testing.T, db *gorm.DB) {
|
||
t.Helper()
|
||
if err := db.Exec(`CREATE TEMP TABLE tb_integration_log (
|
||
id bigserial PRIMARY KEY,
|
||
integration_id varchar(64) NOT NULL UNIQUE,
|
||
idempotency_key varchar(160),
|
||
provider varchar(32) NOT NULL,
|
||
direction varchar(16) NOT NULL,
|
||
operation varchar(64) NOT NULL,
|
||
external_id varchar(128),
|
||
resource_type varchar(64),
|
||
resource_id varchar(128),
|
||
resource_key varchar(128),
|
||
trigger_source varchar(32),
|
||
trigger_scene varchar(128),
|
||
trigger_series varchar(64),
|
||
scheduled_at timestamptz,
|
||
started_at timestamptz,
|
||
attempt integer NOT NULL DEFAULT 1,
|
||
result varchar(20) NOT NULL,
|
||
http_status integer,
|
||
provider_code varchar(64),
|
||
provider_message varchar(500),
|
||
request_summary jsonb,
|
||
response_summary jsonb,
|
||
content_hash varchar(64),
|
||
duration_ms bigint NOT NULL DEFAULT 0,
|
||
state_changed boolean NOT NULL DEFAULT false,
|
||
metadata jsonb,
|
||
recovery_strategy varchar(255),
|
||
request_id varchar(64),
|
||
correlation_id varchar(64),
|
||
audit_event_id bigint,
|
||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||
CONSTRAINT uq_test_integration_idempotency UNIQUE (provider, operation, idempotency_key)
|
||
) ON COMMIT DROP`).Error; err != nil {
|
||
t.Fatalf("创建 Integration Log 测试表失败:%v", err)
|
||
}
|
||
}
|