重置项目上下文与规范文档

This commit is contained in:
2026-08-07 16:18:07 +08:00
parent 6611ca5226
commit 79e2d9ff92
1900 changed files with 1552 additions and 348365 deletions

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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),
}
}

View File

@@ -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)
}
}

View File

@@ -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("存在未投递事件时必须阻断发布")
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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 必须被拒绝")
}
}