重置项目上下文与规范文档
This commit is contained in:
@@ -1,130 +0,0 @@
|
||||
package asynctask_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "github.com/break/junhong_cmp_fiber/internal/infrastructure/asynctask"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
contract "github.com/break/junhong_cmp_fiber/pkg/asynctask"
|
||||
)
|
||||
|
||||
func TestPostgresTaskTransitionsAreConditionalAndRecoverExpiredLease(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTaskContractTable(t, db)
|
||||
store, err := storepkg.NewStore(db, taskDefinition())
|
||||
if err != nil {
|
||||
t.Fatalf("创建任务契约 Store 失败:%v", err)
|
||||
}
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
if err := db.Exec("INSERT INTO test_async_contract_task (id, status, updated_at) VALUES (1, 1, ?)", now).Error; err != nil {
|
||||
t.Fatalf("准备待处理任务失败:%v", err)
|
||||
}
|
||||
claimed, err := store.Claim(context.Background(), 1, "worker-a", now, time.Minute)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("领取待处理任务失败:%v,领取:%v", err, claimed)
|
||||
}
|
||||
claimed, err = store.Claim(context.Background(), 1, "worker-b", now.Add(30*time.Second), time.Minute)
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("不得抢占有效租约:%v,领取:%v", err, claimed)
|
||||
}
|
||||
claimed, err = store.Claim(context.Background(), 1, "worker-b", now.Add(2*time.Minute), time.Minute)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("过期任务应由新 Worker 恢复:%v,领取:%v", err, claimed)
|
||||
}
|
||||
renewed, err := store.Renew(context.Background(), 1, "worker-a", now.Add(150*time.Second), 2*time.Minute)
|
||||
if err != nil || renewed {
|
||||
t.Fatalf("旧租约所有者不得续租:%v,续租:%v", err, renewed)
|
||||
}
|
||||
renewed, err = store.Renew(context.Background(), 1, "worker-b", now.Add(150*time.Second), 2*time.Minute)
|
||||
if err != nil || !renewed {
|
||||
t.Fatalf("有效租约所有者续租失败:%v,续租:%v", err, renewed)
|
||||
}
|
||||
claimed, err = store.Claim(context.Background(), 1, "worker-c", now.Add(3*time.Minute), time.Minute)
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("续租后不得被其他 Worker 领取:%v,领取:%v", err, claimed)
|
||||
}
|
||||
finished, err := store.Finish(context.Background(), 1, "worker-a", contract.TerminalResult{
|
||||
TaskID: "1", Status: contract.StatusCompleted, TotalCount: 10, SuccessCount: 7, FailedCount: 3, UpdatedAt: now,
|
||||
}, now.Add(3*time.Minute))
|
||||
if err != nil || finished {
|
||||
t.Fatalf("旧租约所有者不得完成任务:%v,完成:%v", err, finished)
|
||||
}
|
||||
finished, err = store.Finish(context.Background(), 1, "worker-b", contract.TerminalResult{
|
||||
TaskID: "1", Status: contract.StatusCompleted, TotalCount: 10, SuccessCount: 7, FailedCount: 3, UpdatedAt: now,
|
||||
}, now.Add(3*time.Minute))
|
||||
if err != nil || !finished {
|
||||
t.Fatalf("当前租约所有者完成任务失败:%v,完成:%v", err, finished)
|
||||
}
|
||||
claimed, err = store.Claim(context.Background(), 1, "worker-c", now.Add(4*time.Minute), time.Minute)
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("终态重复消费必须无副作用:%v,领取:%v", err, claimed)
|
||||
}
|
||||
cancelled, err := store.Cancel(context.Background(), 1, now.Add(4*time.Minute))
|
||||
if err != nil || cancelled {
|
||||
t.Fatalf("终态任务不得再次取消:%v,取消:%v", err, cancelled)
|
||||
}
|
||||
|
||||
var row struct {
|
||||
Status int
|
||||
Total int
|
||||
Success int
|
||||
Failed int
|
||||
}
|
||||
if err := db.Table("test_async_contract_task").Select("status, total_count AS total, success_count AS success, failed_count AS failed").Where("id = 1").Scan(&row).Error; err != nil {
|
||||
t.Fatalf("读取任务终态失败:%v", err)
|
||||
}
|
||||
if row.Status != contract.StatusCompleted || row.Total != 10 || row.Success != 7 || row.Failed != 3 {
|
||||
t.Fatalf("任务终态计数错误:%+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresTaskContractSupportsWholeFailureAndCancellation(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTaskContractTable(t, db)
|
||||
store, _ := storepkg.NewStore(db, taskDefinition())
|
||||
now := time.Now().UTC()
|
||||
if err := db.Exec("INSERT INTO test_async_contract_task (id, status, updated_at) VALUES (2, 1, ?), (3, 1, ?)", now, now).Error; err != nil {
|
||||
t.Fatalf("准备任务失败:%v", err)
|
||||
}
|
||||
claimed, _ := store.Claim(context.Background(), 2, "worker", now, time.Minute)
|
||||
if !claimed {
|
||||
t.Fatal("整体失败任务领取失败")
|
||||
}
|
||||
finished, err := store.Finish(context.Background(), 2, "worker", contract.TerminalResult{
|
||||
TaskID: "2", Status: contract.StatusFailed, ErrorCode: "FILE_PARSE_FAILED", ErrorSummary: "文件无法解析", UpdatedAt: now,
|
||||
}, now)
|
||||
if err != nil || !finished {
|
||||
t.Fatalf("整体失败终态更新失败:%v", err)
|
||||
}
|
||||
cancelled, err := store.Cancel(context.Background(), 3, now)
|
||||
if err != nil || !cancelled {
|
||||
t.Fatalf("待处理任务取消失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func taskDefinition() storepkg.Definition {
|
||||
return storepkg.Definition{
|
||||
Table: "test_async_contract_task", IDColumn: "id", StatusColumn: "status",
|
||||
LeaseOwnerColumn: "lease_owner", LeaseExpiresColumn: "lease_expires_at",
|
||||
TotalColumn: "total_count", SuccessColumn: "success_count", FailedColumn: "failed_count",
|
||||
ProgressColumn: "progress", ErrorCodeColumn: "error_code", ErrorSummaryColumn: "error_summary",
|
||||
StartedAtColumn: "started_at", CompletedAtColumn: "completed_at", UpdatedAtColumn: "updated_at",
|
||||
}
|
||||
}
|
||||
|
||||
func createTaskContractTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP TABLE test_async_contract_task (
|
||||
id bigint PRIMARY KEY, status integer NOT NULL, total_count integer NOT NULL DEFAULT 0,
|
||||
success_count integer NOT NULL DEFAULT 0, failed_count integer NOT NULL DEFAULT 0,
|
||||
progress integer NOT NULL DEFAULT 0, error_code varchar(100) NOT NULL DEFAULT '',
|
||||
error_summary varchar(500) NOT NULL DEFAULT '', lease_owner varchar(100), lease_expires_at timestamptz,
|
||||
started_at timestamptz, completed_at timestamptz, updated_at timestamptz NOT NULL
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建任务契约测试表失败:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// TestSecurityActionsRegistered 验证账号安全动作不会绕过注册表。
|
||||
func TestSecurityActionsRegistered(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
for _, code := range []string{
|
||||
constants.AuditActionAccountPasswordReset,
|
||||
constants.AuditActionAccountPasswordChanged,
|
||||
constants.AuditActionAccountWeComBound,
|
||||
constants.AuditActionAuthLogin,
|
||||
constants.AuditActionAuthLogout,
|
||||
constants.AuditActionAuthTokenRefreshed,
|
||||
} {
|
||||
action, ok := registry.Action(code)
|
||||
if !ok {
|
||||
t.Fatalf("安全动作未注册:%s", code)
|
||||
}
|
||||
if action.PrimaryResource != constants.AuditResourceAccount || action.Category != constants.AuditCategorySecurity {
|
||||
t.Fatalf("安全动作注册错误:%s", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityAuditRemovesCredentials 验证安全凭据不会进入审计 JSON。
|
||||
func TestSecurityAuditRemovesCredentials(t *testing.T) {
|
||||
encoded, err := safeObject(map[string]any{
|
||||
"password": "secret",
|
||||
"verification_code": "123456",
|
||||
"access_token": "token",
|
||||
"cookie": "session=value",
|
||||
"credentials_configured": true,
|
||||
"state": "changed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("清理审计 JSON 失败:%v", err)
|
||||
}
|
||||
var value map[string]any
|
||||
if err := sonic.Unmarshal(encoded, &value); err != nil {
|
||||
t.Fatalf("解析审计 JSON 失败:%v", err)
|
||||
}
|
||||
for _, field := range []string{"password", "verification_code", "access_token", "cookie"} {
|
||||
if _, exists := value[field]; exists {
|
||||
t.Fatalf("安全凭据未删除:%s", field)
|
||||
}
|
||||
}
|
||||
if value["credentials_configured"] != true || value["state"] != "changed" {
|
||||
t.Fatalf("安全业务事实被错误删除:%v", value)
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package outbox_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
)
|
||||
|
||||
func TestBusinessFactAndOutboxCommitAndRollbackTogether(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryOutboxTables(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("INSERT INTO test_foundation_business_fact (business_key) VALUES (?)", "fact-success").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := repository.Append(context.Background(), tx, newEnvelope("event-success", "fact-success"))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("提交业务事实和 Outbox 失败:%v", err)
|
||||
}
|
||||
assertTableCount(t, db, "test_foundation_business_fact", 1)
|
||||
assertTableCount(t, db, "tb_outbox_event", 1)
|
||||
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("INSERT INTO test_foundation_business_fact (business_key) VALUES (?)", "fact-rollback").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := repository.Append(context.Background(), tx, newEnvelope("event-rollback", "fact-rollback")); err != nil {
|
||||
return err
|
||||
}
|
||||
return stderrors.New("注入业务失败")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("注入业务失败时事务应回滚")
|
||||
}
|
||||
assertTableCount(t, db, "test_foundation_business_fact", 1)
|
||||
assertTableCount(t, db, "tb_outbox_event", 1)
|
||||
}
|
||||
|
||||
func TestOutboxUniqueFailureRollsBackBusinessFact(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryOutboxTables(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
_, err := repository.Append(context.Background(), tx, newEnvelope("event-duplicate", "first"))
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatalf("准备重复事件失败:%v", err)
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("INSERT INTO test_foundation_business_fact (business_key) VALUES (?)", "must-rollback").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
_, appendErr := repository.Append(context.Background(), tx, newEnvelope("event-duplicate", "second"))
|
||||
return appendErr
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("重复事件 ID 必须导致 Outbox 写入失败")
|
||||
}
|
||||
assertTableCount(t, db, "test_foundation_business_fact", 0)
|
||||
assertTableCount(t, db, "tb_outbox_event", 1)
|
||||
}
|
||||
|
||||
func newEnvelope(eventID, businessKey string) outbox.Envelope {
|
||||
return outbox.Envelope{
|
||||
EventID: eventID, EventType: "foundation.example.created", PayloadVersion: 1,
|
||||
AggregateType: "example", AggregateID: businessKey,
|
||||
ResourceType: "example", ResourceID: businessKey, BusinessKey: businessKey,
|
||||
RequestID: "request-1", CorrelationID: "correlation-1",
|
||||
Payload: struct {
|
||||
BusinessKey string `json:"business_key"`
|
||||
}{BusinessKey: businessKey},
|
||||
}
|
||||
}
|
||||
|
||||
func createTemporaryOutboxTables(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP TABLE test_foundation_business_fact (
|
||||
id bigserial PRIMARY KEY, business_key varchar(100) NOT NULL UNIQUE
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建业务事实测试表失败:%v", err)
|
||||
}
|
||||
testutil.CreateTemporaryOutboxTable(t, db)
|
||||
}
|
||||
|
||||
func assertTableCount(t *testing.T, db *gorm.DB, table string, expected int64) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := db.Table(table).Count(&count).Error; err != nil {
|
||||
t.Fatalf("统计表 %s 失败:%v", table, err)
|
||||
}
|
||||
if count != expected {
|
||||
t.Fatalf("表 %s 行数错误:得到 %d,期望 %d", table, count, expected)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package releasegate
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReportBlocksOnlyOnBlockingFindings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
report := Report{Phase: PhasePre, Findings: []Finding{{
|
||||
Code: "FOUNDATION_OBJECT_MISSING", Severity: SeverityInfo, Object: "tb_outbox_event",
|
||||
}}}
|
||||
if !report.Passed() {
|
||||
t.Fatal("迁移前公共对象尚未创建不应单独阻断发布")
|
||||
}
|
||||
report.Findings = append(report.Findings, Finding{
|
||||
Code: "OUTBOX_UNDELIVERED", Severity: SeverityBlock, Object: "tb_outbox_event", Count: 1,
|
||||
})
|
||||
if report.Passed() {
|
||||
t.Fatal("存在未投递事件时必须阻断发布")
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package releasegate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/releasegate"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
)
|
||||
|
||||
func TestPublicFoundationMigrationsOnEmptyAndCompatibleDatabase(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
schema := prepareMigrationSchema(t, db, 164)
|
||||
checker := releasegate.NewCheckerWithSchema(db, schema)
|
||||
|
||||
pre, err := checker.Run(context.Background(), releasegate.PhasePre)
|
||||
if err != nil {
|
||||
t.Fatalf("执行迁移前检查失败:%v", err)
|
||||
}
|
||||
if !pre.Passed() {
|
||||
t.Fatalf("空数据库迁移前检查不应阻断:%+v", pre.Findings)
|
||||
}
|
||||
if err := db.Exec("CREATE TABLE compatible_existing_data (id bigint PRIMARY KEY, value text NOT NULL)").Error; err != nil {
|
||||
t.Fatalf("准备兼容存量表失败:%v", err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO compatible_existing_data (id, value) VALUES (1, 'keep')").Error; err != nil {
|
||||
t.Fatalf("准备兼容存量数据失败:%v", err)
|
||||
}
|
||||
|
||||
executeMigration(t, db, "000165_create_public_outbox.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.up.sql")
|
||||
if err := db.Exec("UPDATE schema_migrations SET version = 166, dirty = false").Error; err != nil {
|
||||
t.Fatalf("更新隔离迁移版本失败:%v", err)
|
||||
}
|
||||
|
||||
post, err := checker.Run(context.Background(), releasegate.PhasePost)
|
||||
if err != nil {
|
||||
t.Fatalf("执行迁移后检查失败:%v", err)
|
||||
}
|
||||
if !post.Passed() {
|
||||
t.Fatalf("迁移后检查应通过:%+v", post.Findings)
|
||||
}
|
||||
postAgain, err := checker.Run(context.Background(), releasegate.PhasePost)
|
||||
if err != nil || !postAgain.Passed() {
|
||||
t.Fatalf("迁移后检查必须可重复执行:%v,结果:%+v", err, postAgain.Findings)
|
||||
}
|
||||
var compatibleCount int64
|
||||
if err := db.Table("compatible_existing_data").Count(&compatibleCount).Error; err != nil || compatibleCount != 1 {
|
||||
t.Fatalf("迁移破坏了兼容存量数据:%v,数量:%d", err, compatibleCount)
|
||||
}
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return executeMigrationWithError(tx, "000165_create_public_outbox.up.sql")
|
||||
}); err == nil {
|
||||
t.Fatal("公共对象已创建时不得通过重复 DDL 静默掩盖定义")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationGateBlocksAnomaliesWithoutDestructiveWrites(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
schema := prepareMigrationSchema(t, db, 166)
|
||||
executeMigration(t, db, "000165_create_public_outbox.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.up.sql")
|
||||
if err := db.Exec(`INSERT INTO tb_outbox_event
|
||||
(event_id, event_type, payload_version, aggregate_type, aggregate_id, resource_type, resource_id, payload)
|
||||
VALUES ('blocked-event', 'foundation.blocked', 1, 'example', '1', 'example', '1', '{}'::jsonb)`).Error; err != nil {
|
||||
t.Fatalf("准备未投递异常失败:%v", err)
|
||||
}
|
||||
if err := db.Exec("ALTER TABLE tb_outbox_event DROP CONSTRAINT ck_outbox_event_status").Error; err != nil {
|
||||
t.Fatalf("准备非法状态定义失败:%v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO tb_outbox_event
|
||||
(event_id, event_type, payload_version, aggregate_type, aggregate_id, resource_type, resource_id, payload, status)
|
||||
VALUES ('invalid-status', 'foundation.invalid', 1, 'example', '2', 'example', '2', '{}'::jsonb, 9)`).Error; err != nil {
|
||||
t.Fatalf("准备非法状态数据失败:%v", err)
|
||||
}
|
||||
|
||||
report, err := releasegate.NewCheckerWithSchema(db, schema).Run(context.Background(), releasegate.PhasePost)
|
||||
if err != nil {
|
||||
t.Fatalf("执行异常门禁失败:%v", err)
|
||||
}
|
||||
if report.Passed() || !hasFinding(report, "OUTBOX_UNDELIVERED") || !hasFinding(report, "OUTBOX_INVALID_STATUS") || !hasFinding(report, "FOUNDATION_CONSTRAINT_MISSING") {
|
||||
t.Fatalf("异常数据和定义未被完整阻断:%+v", report.Findings)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Table("tb_outbox_event").Count(&count).Error; err != nil || count != 2 {
|
||||
t.Fatalf("只读门禁修改了异常数据:%v,数量:%d", err, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownMigrationAllowsEmptyStructuresAndRejectsExistingFacts(t *testing.T) {
|
||||
t.Run("空结构可回滚", func(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
schema := prepareMigrationSchema(t, db, 166)
|
||||
executeMigration(t, db, "000165_create_public_outbox.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.down.sql")
|
||||
executeMigration(t, db, "000165_create_public_outbox.down.sql")
|
||||
for _, table := range []string{"tb_outbox_event", "tb_system_config"} {
|
||||
var exists bool
|
||||
if err := db.Raw("SELECT to_regclass(?) IS NOT NULL", schema+"."+table).Scan(&exists).Error; err != nil || exists {
|
||||
t.Fatalf("空结构回滚失败:%s,错误:%v", table, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("已有事实拒绝删表", func(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
_ = prepareMigrationSchema(t, db, 166)
|
||||
executeMigration(t, db, "000165_create_public_outbox.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.up.sql")
|
||||
if err := db.Exec(`INSERT INTO tb_system_config
|
||||
(config_key, config_value, value_type, module, description)
|
||||
VALUES ('foundation.test.fact', 'true', 'bool', 'foundation', '回滚保护测试')`).Error; err != nil {
|
||||
t.Fatalf("准备配置事实失败:%v", err)
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return executeMigrationWithError(tx, "000166_create_system_config.down.sql")
|
||||
}); err == nil || !strings.Contains(err.Error(), "禁止删表回滚") {
|
||||
t.Fatalf("已有事实时 down 迁移必须明确拒绝:%v", err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Table("tb_system_config").Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("拒绝回滚后配置事实丢失:%v,数量:%d", err, count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func prepareMigrationSchema(t *testing.T, db *gorm.DB, version uint) string {
|
||||
t.Helper()
|
||||
schema := "foundation_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
if err := db.Exec(fmt.Sprintf(`CREATE SCHEMA %s`, schema)).Error; err != nil {
|
||||
t.Fatalf("创建隔离迁移 schema 失败:%v", err)
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf(`SET LOCAL search_path TO %s`, schema)).Error; err != nil {
|
||||
t.Fatalf("切换隔离迁移 schema 失败:%v", err)
|
||||
}
|
||||
if err := db.Exec("CREATE TABLE schema_migrations (version bigint NOT NULL, dirty boolean NOT NULL)").Error; err != nil {
|
||||
t.Fatalf("创建隔离迁移版本表失败:%v", err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO schema_migrations (version, dirty) VALUES (?, false)", version).Error; err != nil {
|
||||
t.Fatalf("写入隔离迁移版本失败:%v", err)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func executeMigration(t *testing.T, db *gorm.DB, name string) {
|
||||
t.Helper()
|
||||
if err := executeMigrationWithError(db, name); err != nil {
|
||||
t.Fatalf("执行迁移 %s 失败:%v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func executeMigrationWithError(db *gorm.DB, name string) error {
|
||||
path := filepath.Join("..", "..", "..", "migrations", name)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, statement := range splitSQLStatements(string(content)) {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitSQLStatements(content string) []string {
|
||||
statements := make([]string, 0)
|
||||
start := 0
|
||||
inSingleQuote := false
|
||||
inDoubleQuote := false
|
||||
inDollarBlock := false
|
||||
for index := 0; index < len(content); index++ {
|
||||
if index+1 < len(content) && content[index:index+2] == "$$" && !inSingleQuote && !inDoubleQuote {
|
||||
inDollarBlock = !inDollarBlock
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if inDollarBlock {
|
||||
continue
|
||||
}
|
||||
switch content[index] {
|
||||
case '\'':
|
||||
if !inDoubleQuote {
|
||||
inSingleQuote = !inSingleQuote
|
||||
}
|
||||
case '"':
|
||||
if !inSingleQuote {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
}
|
||||
case ';':
|
||||
if !inSingleQuote && !inDoubleQuote {
|
||||
statement := strings.TrimSpace(content[start : index+1])
|
||||
if statement != "" {
|
||||
statements = append(statements, statement)
|
||||
}
|
||||
start = index + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if tail := strings.TrimSpace(content[start:]); tail != "" {
|
||||
statements = append(statements, tail)
|
||||
}
|
||||
return statements
|
||||
}
|
||||
|
||||
func hasFinding(report releasegate.Report, code string) bool {
|
||||
for _, finding := range report.Findings {
|
||||
if finding.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package systemconfig_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
"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 unavailableCache struct{}
|
||||
|
||||
func (unavailableCache) Get(context.Context, string) (string, error) {
|
||||
return "", stderrors.New("测试缓存不可用")
|
||||
}
|
||||
|
||||
func (unavailableCache) Set(context.Context, string, string, time.Duration) error {
|
||||
return stderrors.New("测试缓存不可用")
|
||||
}
|
||||
|
||||
func (unavailableCache) Delete(context.Context, string) error {
|
||||
return stderrors.New("测试缓存不可用")
|
||||
}
|
||||
|
||||
type alertRecorder struct {
|
||||
mu sync.Mutex
|
||||
codes []string
|
||||
}
|
||||
|
||||
func (r *alertRecorder) Warn(_ context.Context, code, _, _, _ string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.codes = append(r.codes, code)
|
||||
}
|
||||
|
||||
func TestReaderUsesRedisHitAndFallsBackToPostgresOnMiss(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
testutil.CreateTemporarySystemConfigTable(t, db)
|
||||
registry := systemconfig.NewRegistry()
|
||||
definition := systemconfig.Definition{
|
||||
Key: "foundation.reader.limit", Module: "foundation", ValueType: constants.SystemConfigTypeInt,
|
||||
DefaultValue: "10", Description: "读取器测试上限",
|
||||
}
|
||||
if err := registry.Register(definition); err != nil {
|
||||
t.Fatalf("注册系统配置失败:%v", err)
|
||||
}
|
||||
if err := db.Create(&model.SystemConfig{
|
||||
ConfigKey: definition.Key, ConfigValue: "20", ValueType: definition.ValueType,
|
||||
Module: definition.Module, Description: definition.Description,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("准备数据库配置失败:%v", err)
|
||||
}
|
||||
cacheKey := constants.RedisSystemConfigKey(definition.Key)
|
||||
t.Cleanup(func() { _ = redisClient.Del(context.Background(), cacheKey).Err() })
|
||||
if err := redisClient.Set(context.Background(), cacheKey, "30", constants.SystemConfigCacheTTL).Err(); err != nil {
|
||||
t.Fatalf("准备 Redis 缓存失败:%v", err)
|
||||
}
|
||||
reader := systemconfig.NewReader(db, registry, systemconfig.NewRedisCache(redisClient), nil)
|
||||
|
||||
value, err := reader.Get(context.Background(), definition.Key)
|
||||
if err != nil || value != "30" {
|
||||
t.Fatalf("Redis 命中结果错误:值=%q,错误=%v", value, err)
|
||||
}
|
||||
if err := redisClient.Del(context.Background(), cacheKey).Err(); err != nil {
|
||||
t.Fatalf("清理 Redis 缓存失败:%v", err)
|
||||
}
|
||||
value, err = reader.Get(context.Background(), definition.Key)
|
||||
if err != nil || value != "20" {
|
||||
t.Fatalf("Redis 未命中时未回退 PostgreSQL:值=%q,错误=%v", value, err)
|
||||
}
|
||||
cached, err := redisClient.Get(context.Background(), cacheKey).Result()
|
||||
if err != nil || cached != "20" {
|
||||
t.Fatalf("PostgreSQL 结果未回填 Redis:值=%q,错误=%v", cached, err)
|
||||
}
|
||||
ttl, err := redisClient.TTL(context.Background(), cacheKey).Result()
|
||||
if err != nil || ttl <= 0 || ttl > constants.SystemConfigCacheTTL {
|
||||
t.Fatalf("Redis 回填 TTL 不符合公共常量:TTL=%s,错误=%v", ttl, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderFallsBackToPostgresWhenCacheIsUnavailable(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
testutil.CreateTemporarySystemConfigTable(t, db)
|
||||
registry := systemconfig.NewRegistry()
|
||||
definition := systemconfig.Definition{
|
||||
Key: "foundation.reader.fallback", Module: "foundation", ValueType: constants.SystemConfigTypeString,
|
||||
DefaultValue: "default", Description: "缓存故障回退测试值",
|
||||
}
|
||||
if err := registry.Register(definition); err != nil {
|
||||
t.Fatalf("注册系统配置失败:%v", err)
|
||||
}
|
||||
if err := db.Create(&model.SystemConfig{
|
||||
ConfigKey: definition.Key, ConfigValue: "database", ValueType: definition.ValueType,
|
||||
Module: definition.Module, Description: definition.Description,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("准备数据库配置失败:%v", err)
|
||||
}
|
||||
alerts := &alertRecorder{}
|
||||
reader := systemconfig.NewReader(db, registry, unavailableCache{}, alerts)
|
||||
value, err := reader.Get(context.Background(), definition.Key)
|
||||
if err != nil || value != "database" {
|
||||
t.Fatalf("缓存不可用时未回退 PostgreSQL:值=%q,错误=%v", value, err)
|
||||
}
|
||||
alerts.mu.Lock()
|
||||
defer alerts.mu.Unlock()
|
||||
if len(alerts.codes) < 2 || alerts.codes[0] != "SYSTEM_CONFIG_CACHE_READ_FAILED" {
|
||||
t.Fatalf("缓存读写故障未产生安全告警:%v", alerts.codes)
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package systemconfig_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func TestRegistryRejectsDuplicateTypeConflictAndInvalidDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
registry := systemconfig.NewRegistry()
|
||||
definition := systemconfig.Definition{
|
||||
Key: "foundation.example.limit", Module: "foundation", ValueType: constants.SystemConfigTypeInt,
|
||||
DefaultValue: "10", Description: "示例限制",
|
||||
}
|
||||
if err := registry.Register(definition); err != nil {
|
||||
t.Fatalf("注册合法配置失败:%v", err)
|
||||
}
|
||||
if err := registry.Register(definition); err == nil {
|
||||
t.Fatal("重复 Key 必须被拒绝")
|
||||
}
|
||||
conflict := definition
|
||||
conflict.ValueType = constants.SystemConfigTypeString
|
||||
if err := registry.Register(conflict); err == nil {
|
||||
t.Fatal("同 Key 类型冲突必须被拒绝")
|
||||
}
|
||||
invalid := definition
|
||||
invalid.Key = "foundation.example.invalid"
|
||||
invalid.DefaultValue = "not-int"
|
||||
if err := registry.Register(invalid); err == nil {
|
||||
t.Fatal("非法默认值必须在注册阶段失败")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateValueSupportsFourControlledTypesAndBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
minimum, maximum := int64(1), int64(10)
|
||||
cases := []systemconfig.Definition{
|
||||
{Key: "a.b.string", Module: "a", ValueType: constants.SystemConfigTypeString, DefaultValue: "x", Description: "字符串"},
|
||||
{Key: "a.b.int", Module: "a", ValueType: constants.SystemConfigTypeInt, DefaultValue: "5", Description: "整数", Min: &minimum, Max: &maximum},
|
||||
{Key: "a.b.bool", Module: "a", ValueType: constants.SystemConfigTypeBool, DefaultValue: "true", Description: "布尔"},
|
||||
{Key: "a.b.json", Module: "a", ValueType: constants.SystemConfigTypeJSON, DefaultValue: `{"enabled":true}`, Description: "JSON"},
|
||||
}
|
||||
for _, definition := range cases {
|
||||
if err := systemconfig.ValidateValue(definition, definition.DefaultValue); err != nil {
|
||||
t.Fatalf("合法 %s 值未通过:%v", definition.ValueType, err)
|
||||
}
|
||||
}
|
||||
if err := systemconfig.ValidateValue(cases[1], "11"); err == nil {
|
||||
t.Fatal("越界整数必须被拒绝")
|
||||
}
|
||||
if err := systemconfig.ValidateValue(cases[2], "yes"); err == nil {
|
||||
t.Fatal("非法布尔值必须被拒绝")
|
||||
}
|
||||
if err := systemconfig.ValidateValue(cases[3], "{"); err == nil {
|
||||
t.Fatal("非法 JSON 必须被拒绝")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user