重置项目上下文与规范文档
This commit is contained in:
@@ -1,129 +0,0 @@
|
||||
package outbox_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
outboxapp "github.com/break/junhong_cmp_fiber/internal/application/outbox"
|
||||
infraoutbox "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
type databaseAuditWriter struct {
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (w *databaseAuditWriter) WriteRecovery(_ context.Context, tx *gorm.DB, audit outboxapp.RecoveryAudit) error {
|
||||
if w.fail {
|
||||
return stderrors.New("测试审计不可用")
|
||||
}
|
||||
return tx.Exec(`INSERT INTO test_outbox_recovery_audit (batch_id, operation_type, event_count)
|
||||
VALUES (?, ?, ?)`, audit.BatchID, audit.OperationType, len(audit.EventIDs)).Error
|
||||
}
|
||||
|
||||
func TestSelectiveReplayPreservesIdentityAndWritesAudit(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
testutil.CreateTemporaryOutboxTable(t, db)
|
||||
createTemporaryRecoveryAuditTable(t, db)
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
repository := infraoutbox.NewRepository()
|
||||
event, err := repository.Append(context.Background(), db, infraoutbox.Envelope{
|
||||
EventID: "recovery-stable", EventType: "example.recovery", AggregateType: "example", AggregateID: "1",
|
||||
ResourceType: "example", ResourceID: "1", CorrelationID: "correlation-stable",
|
||||
Payload: struct {
|
||||
Secret string `json:"secret"`
|
||||
}{Secret: "preserved-in-database-only"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("准备恢复事件失败:%v", err)
|
||||
}
|
||||
if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Updates(map[string]any{
|
||||
"status": constants.OutboxStatusFailed, "retry_count": 10, "last_error_code": "FINAL",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("设置最终失败状态失败:%v", err)
|
||||
}
|
||||
var storedBefore model.OutboxEvent
|
||||
if err := db.First(&storedBefore, event.ID).Error; err != nil {
|
||||
t.Fatalf("读取重放前事件失败:%v", err)
|
||||
}
|
||||
|
||||
service, err := outboxapp.NewRecoveryService(db, &databaseAuditWriter{}, func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatalf("创建恢复服务失败:%v", err)
|
||||
}
|
||||
batchID, err := service.Replay(context.Background(), outboxapp.Operator{
|
||||
ID: 7, SuperAdmin: true, RequestID: "request-recovery", CorrelationID: "correlation-recovery",
|
||||
}, []uint{event.ID}, "确认消费者已具备幂等保护")
|
||||
if err != nil || batchID == "" {
|
||||
t.Fatalf("选择性重放失败:%v", err)
|
||||
}
|
||||
var recovered model.OutboxEvent
|
||||
if err := db.First(&recovered, event.ID).Error; err != nil {
|
||||
t.Fatalf("读取重放事件失败:%v", err)
|
||||
}
|
||||
if recovered.Status != constants.OutboxStatusPending || recovered.EventID != event.EventID ||
|
||||
recovered.CorrelationID != event.CorrelationID || string(recovered.Payload) != string(storedBefore.Payload) {
|
||||
t.Fatalf("重放改变了事件身份或内容:%+v", recovered)
|
||||
}
|
||||
var auditCount int64
|
||||
if err := db.Table("test_outbox_recovery_audit").Where("batch_id = ?", batchID).Count(&auditCount).Error; err != nil || auditCount != 1 {
|
||||
t.Fatalf("恢复审计事实缺失:%v,数量:%d", err, auditCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryRejectsActiveLeaseUnauthorizedOperatorAndAuditFailure(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
testutil.CreateTemporaryOutboxTable(t, db)
|
||||
createTemporaryRecoveryAuditTable(t, db)
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
repository := infraoutbox.NewRepository()
|
||||
event, err := repository.Append(context.Background(), db, infraoutbox.Envelope{
|
||||
EventID: "active-lease", EventType: "example.recovery", AggregateType: "example", AggregateID: "2",
|
||||
ResourceType: "example", ResourceID: "2", Payload: struct {
|
||||
Visible bool `json:"visible"`
|
||||
}{Visible: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("准备有效租约事件失败:%v", err)
|
||||
}
|
||||
if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Updates(map[string]any{
|
||||
"status": constants.OutboxStatusDelivering, "lease_owner": "active-worker", "lease_expires_at": now.Add(time.Minute),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("设置有效租约失败:%v", err)
|
||||
}
|
||||
service, _ := outboxapp.NewRecoveryService(db, &databaseAuditWriter{}, func() time.Time { return now })
|
||||
if _, err := service.ReleaseExpiredLeases(context.Background(), outboxapp.Operator{ID: 7, SuperAdmin: true}, []uint{event.ID}, "测试释放"); err == nil {
|
||||
t.Fatal("不得释放仍然有效的租约")
|
||||
}
|
||||
if _, err := service.Replay(context.Background(), outboxapp.Operator{ID: 8, SuperAdmin: false}, []uint{event.ID}, "越权测试"); err == nil {
|
||||
t.Fatal("非超级管理员不得执行人工恢复")
|
||||
}
|
||||
|
||||
if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Update("status", constants.OutboxStatusFailed).Error; err != nil {
|
||||
t.Fatalf("设置失败事件失败:%v", err)
|
||||
}
|
||||
failClosed, _ := outboxapp.NewRecoveryService(db, &databaseAuditWriter{fail: true}, func() time.Time { return now })
|
||||
if _, err := failClosed.Replay(context.Background(), outboxapp.Operator{ID: 7, SuperAdmin: true}, []uint{event.ID}, "审计失败测试"); err == nil {
|
||||
t.Fatal("统一审计不可用时恢复事务必须失败")
|
||||
}
|
||||
var unchanged model.OutboxEvent
|
||||
if err := db.First(&unchanged, event.ID).Error; err != nil || unchanged.Status != constants.OutboxStatusFailed {
|
||||
t.Fatalf("审计失败后事件状态未回滚:%v,状态:%d", err, unchanged.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func createTemporaryRecoveryAuditTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP TABLE test_outbox_recovery_audit (
|
||||
id bigserial PRIMARY KEY, batch_id varchar(64) NOT NULL,
|
||||
operation_type varchar(100) NOT NULL, event_count integer NOT NULL
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建恢复审计测试表失败:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package systemconfig_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
systemconfiginfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
type transactionAuditWriter struct{}
|
||||
|
||||
func (transactionAuditWriter) WriteConfigChange(_ context.Context, tx *gorm.DB, audit systemconfigapp.ChangeAudit) error {
|
||||
before, _ := sonic.Marshal(audit.BeforeData)
|
||||
after, _ := sonic.Marshal(audit.AfterData)
|
||||
return tx.Exec(`INSERT INTO test_system_config_audit
|
||||
(config_key, operator_id, before_data, after_data) VALUES (?, ?, ?::jsonb, ?::jsonb)`,
|
||||
audit.ConfigKey, audit.OperatorID, string(before), string(after)).Error
|
||||
}
|
||||
|
||||
func TestConcurrentFirstSystemConfigUpdatesAreSerializedByPostgres(t *testing.T) {
|
||||
if os.Getenv("JUNHONG_DATABASE_HOST") == "" {
|
||||
t.Skip("未加载 .env.local,跳过依赖真实 PostgreSQL 的并发集成测试")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("加载测试配置失败:%v", err)
|
||||
}
|
||||
schema := "test_system_config_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
adminDB := openSchemaDatabase(t, &cfg.Database, "public")
|
||||
if err := adminDB.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatalf("创建隔离 schema 失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = adminDB.Exec("DROP SCHEMA " + schema + " CASCADE").Error
|
||||
closeDatabase(adminDB)
|
||||
})
|
||||
|
||||
firstDB := openSchemaDatabase(t, &cfg.Database, schema)
|
||||
secondDB := openSchemaDatabase(t, &cfg.Database, schema)
|
||||
t.Cleanup(func() {
|
||||
closeDatabase(firstDB)
|
||||
closeDatabase(secondDB)
|
||||
})
|
||||
createConcurrentConfigTables(t, firstDB)
|
||||
registry := systemconfiginfra.NewRegistry()
|
||||
definition := systemconfiginfra.Definition{
|
||||
Key: "foundation.concurrent.value", Module: "foundation", ValueType: constants.SystemConfigTypeString,
|
||||
DefaultValue: "default", Description: "并发配置测试值",
|
||||
}
|
||||
if err := registry.Register(definition); err != nil {
|
||||
t.Fatalf("注册并发测试配置失败:%v", err)
|
||||
}
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{UserID: 7, UserType: constants.UserTypeSuperAdmin})
|
||||
services := []*systemconfigapp.UpdateService{
|
||||
systemconfigapp.NewUpdateService(firstDB, registry, nil, transactionAuditWriter{}, nil, nil),
|
||||
systemconfigapp.NewUpdateService(secondDB, registry, nil, transactionAuditWriter{}, nil, nil),
|
||||
}
|
||||
values := []string{"first", "second"}
|
||||
start := make(chan struct{})
|
||||
errorsFound := make(chan error, len(services))
|
||||
var waitGroup sync.WaitGroup
|
||||
for index := range services {
|
||||
waitGroup.Add(1)
|
||||
go func(index int) {
|
||||
defer waitGroup.Done()
|
||||
<-start
|
||||
_, executeErr := services[index].Execute(ctx, definition.Key, dto.UpdateSystemConfigRequest{Value: values[index]})
|
||||
errorsFound <- executeErr
|
||||
}(index)
|
||||
}
|
||||
close(start)
|
||||
waitGroup.Wait()
|
||||
close(errorsFound)
|
||||
for executeErr := range errorsFound {
|
||||
if executeErr != nil {
|
||||
t.Fatalf("并发更新不应产生唯一键冲突:%v", executeErr)
|
||||
}
|
||||
}
|
||||
var stored model.SystemConfig
|
||||
if err := firstDB.Where("config_key = ?", definition.Key).First(&stored).Error; err != nil {
|
||||
t.Fatalf("读取并发更新结果失败:%v", err)
|
||||
}
|
||||
if stored.ConfigValue != values[0] && stored.ConfigValue != values[1] {
|
||||
t.Fatalf("并发更新保存了非法结果:%q", stored.ConfigValue)
|
||||
}
|
||||
var auditCount int64
|
||||
if err := firstDB.Table("test_system_config_audit").Where("config_key = ?", definition.Key).Count(&auditCount).Error; err != nil || auditCount != 2 {
|
||||
t.Fatalf("并发更新审计事实不完整:错误=%v,数量=%d", err, auditCount)
|
||||
}
|
||||
}
|
||||
|
||||
func openSchemaDatabase(t *testing.T, cfg *config.DatabaseConfig, schema string) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s search_path=%s TimeZone=Asia/Shanghai",
|
||||
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode, schema)
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{SkipDefaultTransaction: true})
|
||||
if err != nil {
|
||||
t.Fatalf("连接隔离 PostgreSQL 失败:%v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func closeDatabase(db *gorm.DB) {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func createConcurrentConfigTables(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TABLE tb_system_config (
|
||||
id bigserial PRIMARY KEY, config_key varchar(150) NOT NULL UNIQUE, config_value text NOT NULL,
|
||||
value_type varchar(20) NOT NULL, module varchar(100) NOT NULL, description varchar(500) NOT NULL,
|
||||
is_readonly boolean NOT NULL DEFAULT false, is_sensitive boolean NOT NULL DEFAULT false,
|
||||
creator bigint NOT NULL DEFAULT 0, updater bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(), updated_at timestamptz NOT NULL DEFAULT NOW()
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("创建隔离配置表失败:%v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE test_system_config_audit (
|
||||
id bigserial PRIMARY KEY, config_key varchar(150) NOT NULL, operator_id bigint NOT NULL,
|
||||
before_data jsonb NOT NULL, after_data jsonb NOT NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("创建隔离审计表失败:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package packagedomain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// TestResolveTermsSnapshot 验证默认、覆盖及两种周期规则。
|
||||
func TestResolveTermsSnapshot(t *testing.T) {
|
||||
overridePurchase := constants.PackageExpiryBaseFromPurchase
|
||||
overrideActivation := constants.PackageExpiryBaseFromActivation
|
||||
tests := []struct {
|
||||
name string
|
||||
pkg model.Package
|
||||
allocation *model.ShopPackageAllocation
|
||||
wantErr bool
|
||||
wantExpiryBase string
|
||||
}{
|
||||
{name: "自然月默认", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeNaturalMonth, DurationMonths: 12}, wantExpiryBase: constants.PackageExpiryBaseFromActivation},
|
||||
{name: "from_purchase 覆盖", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30}, allocation: &model.ShopPackageAllocation{ExpiryBaseOverride: &overridePurchase}, wantExpiryBase: constants.PackageExpiryBaseFromPurchase},
|
||||
{name: "from_activation 覆盖", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromPurchase, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 14}, allocation: &model.ShopPackageAllocation{ExpiryBaseOverride: &overrideActivation}, wantExpiryBase: constants.PackageExpiryBaseFromActivation},
|
||||
{name: "非法生效条件", pkg: model.Package{ExpiryBase: "invalid", CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30}, wantErr: true},
|
||||
{name: "非法按天时长", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromPurchase, CalendarType: constants.PackageCalendarTypeByDay}, wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := ResolveTermsSnapshot(&test.pkg, test.allocation)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("错误状态不符合预期:%v", err)
|
||||
}
|
||||
if !test.wantErr && !got.IsValid() {
|
||||
t.Fatalf("快照应有效:%+v", got)
|
||||
}
|
||||
if !test.wantErr && test.wantExpiryBase != "" && got.ExpiryBase != test.wantExpiryBase {
|
||||
t.Fatalf("生效条件不符预期:want=%s got=%s", test.wantExpiryBase, got.ExpiryBase)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package auditcoverage_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/governance/auditcoverage"
|
||||
)
|
||||
|
||||
func TestReviewedAuditCoverageManifestMatchesAllRegisteredEntrypoints(t *testing.T) {
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("无法定位覆盖门禁测试文件")
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "../../../"))
|
||||
actual, err := auditcoverage.Scan(root)
|
||||
if err != nil {
|
||||
t.Fatalf("扫描全系统入口失败:%v", err)
|
||||
}
|
||||
expected, err := auditcoverage.LoadManifest(filepath.Join(root, ".scratch/tech-global-audit/审计覆盖清单.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("读取经评审审计覆盖清单失败:%v", err)
|
||||
}
|
||||
if len(actual) == 0 {
|
||||
t.Fatal("入口扫描结果为空,覆盖门禁不可用")
|
||||
}
|
||||
if !reflect.DeepEqual(expected, actual) {
|
||||
t.Fatalf("源码入口与审计覆盖清单不一致:清单 %d 项,源码 %d 项;请重新分类并完成评审后更新清单", len(expected), len(actual))
|
||||
}
|
||||
for _, entry := range expected {
|
||||
if entry.AuditEvent == "" || entry.DomainLedger == "" || entry.IntegrationLog == "" || entry.Outbox == "" ||
|
||||
entry.Transaction == "" || entry.FailureStrategy == "" || entry.SensitivePolicy == "" ||
|
||||
entry.BeforeAfterPolicy == "" || entry.TestSeam == "" {
|
||||
t.Fatalf("入口 %s 存在未分类字段", entry.Key)
|
||||
}
|
||||
if entry.AuditEvent == "N/A" && entry.NAReason == "" {
|
||||
t.Fatalf("入口 %s 的 Audit Event 为 N/A 但缺少理由", entry.Key)
|
||||
}
|
||||
if entry.AuditEvent != "N/A" && (entry.ActionCode == "" || entry.ActionName == "" || entry.Category == "" ||
|
||||
entry.Risk == "" || entry.PrimaryResource == "") {
|
||||
t.Fatalf("入口 %s 缺少动作、风险或主要资源", entry.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestExchangeListValidatesCompleteRequest 验证列表 Handler 对全部查询字段执行统一校验。
|
||||
func TestExchangeListValidatesCompleteRequest(t *testing.T) {
|
||||
testCases := []string{
|
||||
"page=0", "page_size=0", "page_size=101", "status=0", "status=6", "flow_type=invalid",
|
||||
"old_asset_keyword=" + url.QueryEscape(strings.Repeat("旧", 101)),
|
||||
"new_asset_keyword=" + url.QueryEscape(strings.Repeat("新", 101)),
|
||||
"created_at_start=invalid",
|
||||
}
|
||||
for _, query := range testCases {
|
||||
t.Run(query, func(t *testing.T) {
|
||||
app, _ := newExchangeListTestApp(&exchangeListStub{})
|
||||
status, body := exchangeListRequest(t, app, "/api/admin/exchanges?"+query)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("期望 HTTP 400,实际 %d,响应:%s", status, body)
|
||||
}
|
||||
assertExchangeErrorResponse(t, body, errors.CodeInvalidParam, "参数验证失败")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeListPassesIndependentKeywordsAndReturnsUnifiedResponse 验证新旧关键词独立传入读取用例。
|
||||
func TestExchangeListPassesIndependentKeywordsAndReturnsUnifiedResponse(t *testing.T) {
|
||||
stub := &exchangeListStub{response: &dto.ExchangeListResponse{List: []*dto.ExchangeOrderResponse{}, Total: 0, Page: 2, PageSize: 7}}
|
||||
app, _ := newExchangeListTestApp(stub)
|
||||
status, body := exchangeListRequest(t, app, "/api/admin/exchanges?page=2&page_size=7&old_asset_keyword=old&new_asset_keyword=new")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("期望 HTTP 200,实际 %d,响应:%s", status, body)
|
||||
}
|
||||
if stub.request == nil || stub.request.OldAssetKeyword != "old" || stub.request.NewAssetKeyword != "new" {
|
||||
t.Fatalf("读取用例未收到独立关键词:%+v", stub.request)
|
||||
}
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Items []any `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析响应失败:%v", err)
|
||||
}
|
||||
if response.Code != errors.CodeSuccess || response.Data.Page != 2 || response.Data.Size != 7 || response.Data.Items == nil {
|
||||
t.Fatalf("统一分页响应不符合契约:%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeListSanitizesDatabaseErrors 验证数据库故障返回脱敏统一 500。
|
||||
func TestExchangeListSanitizesDatabaseErrors(t *testing.T) {
|
||||
stub := &exchangeListStub{err: errors.Wrap(errors.CodeDatabaseError, context.Canceled, "查询换货单数量失败")}
|
||||
app, _ := newExchangeListTestApp(stub)
|
||||
status, body := exchangeListRequest(t, app, "/api/admin/exchanges?old_asset_keyword=UR45")
|
||||
if status != http.StatusInternalServerError {
|
||||
t.Fatalf("期望 HTTP 500,实际 %d,响应:%s", status, body)
|
||||
}
|
||||
assertExchangeErrorResponse(t, body, errors.CodeDatabaseError, "数据库错误")
|
||||
if strings.Contains(string(body), "context canceled") || strings.Contains(string(body), "查询换货单数量失败") {
|
||||
t.Fatalf("数据库错误响应泄露内部细节:%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeListHTTPIntegratesSearchAndAccountScopes 验证真实 Query 的 HTTP 搜索组合和三类账号范围。
|
||||
func TestExchangeListHTTPIntegratesSearchAndAccountScopes(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
shopOne, shopTwo := uint(45201), uint(45202)
|
||||
oldCard := createExchangeHTTPCard(t, tx, 1, shopOne)
|
||||
newCard := createExchangeHTTPCard(t, tx, 2, shopOne)
|
||||
otherCard := createExchangeHTTPCard(t, tx, 3, shopTwo)
|
||||
createdAt := time.Now().Add(-time.Hour).Truncate(time.Second)
|
||||
visible := createExchangeHTTPOrder(t, tx, "UR45-HTTP-VISIBLE", oldCard, newCard, shopOne, createdAt)
|
||||
createExchangeHTTPOrder(t, tx, "UR45-HTTP-HIDDEN", otherCard, otherCard, shopTwo, createdAt.Add(time.Minute))
|
||||
|
||||
handler := NewExchangeHandler(nil, exchangeQuery.NewListQuery(tx), validator.New())
|
||||
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
|
||||
app.Get("/api/admin/exchanges", func(c *fiber.Ctx) error {
|
||||
ctx := c.UserContext()
|
||||
if c.Get("X-UR45-Account") == "agent" {
|
||||
ctx = context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{shopOne})
|
||||
}
|
||||
c.SetUserContext(ctx)
|
||||
return handler.List(c)
|
||||
})
|
||||
|
||||
start, end := createdAt.Add(-time.Minute).Format(time.RFC3339), createdAt.Add(time.Minute).Format(time.RFC3339)
|
||||
query := "old_asset_keyword=" + url.QueryEscape(oldCard.MSISDN) + "&new_asset_keyword=" + url.QueryEscape(newCard.VirtualNo) + "&status=4&flow_type=direct&created_at_start=" + url.QueryEscape(start) + "&created_at_end=" + url.QueryEscape(end)
|
||||
for _, account := range []string{"super_admin", "platform", "agent"} {
|
||||
t.Run(account, func(t *testing.T) {
|
||||
status, body := exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?"+query, account)
|
||||
page := decodeExchangeHTTPPage(t, body)
|
||||
if status != http.StatusOK || page.Total != 1 || len(page.Items) != 1 || uint(page.Items[0]["id"].(float64)) != visible.ID {
|
||||
t.Fatalf("账号范围或组合搜索错误:status=%d body=%s", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
status, body := exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?old_asset_keyword="+url.QueryEscape(oldCard.MSISDN), "platform")
|
||||
page := decodeExchangeHTTPPage(t, body)
|
||||
if status != http.StatusOK || page.Total != 1 || page.Items[0]["old_asset_identifier"] != "历史旧快照" {
|
||||
t.Fatalf("仅旧关键词或历史旧快照响应错误:status=%d body=%s", status, body)
|
||||
}
|
||||
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?new_asset_keyword="+url.QueryEscape(newCard.VirtualNo), "platform")
|
||||
page = decodeExchangeHTTPPage(t, body)
|
||||
if status != http.StatusOK || page.Total != 1 || page.Items[0]["new_asset_identifier"] != "历史新快照" {
|
||||
t.Fatalf("仅新关键词或历史新快照响应错误:status=%d body=%s", status, body)
|
||||
}
|
||||
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges", "platform")
|
||||
page = decodeExchangeHTTPPage(t, body)
|
||||
if status != http.StatusOK || !exchangeHTTPPageContains(page, "UR45-HTTP-VISIBLE") || !exchangeHTTPPageContains(page, "UR45-HTTP-HIDDEN") {
|
||||
t.Fatalf("空参数列表响应错误:status=%d body=%s", status, body)
|
||||
}
|
||||
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?old_asset_keyword="+url.QueryEscape(otherCard.ICCID), "agent")
|
||||
if page := decodeExchangeHTTPPage(t, body); status != http.StatusOK || page.Total != 0 || len(page.Items) != 0 {
|
||||
t.Fatalf("代理关键词绕过店铺范围:status=%d body=%s", status, body)
|
||||
}
|
||||
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?new_asset_keyword=不存在", "platform")
|
||||
if page := decodeExchangeHTTPPage(t, body); status != http.StatusOK || page.Total != 0 || len(page.Items) != 0 {
|
||||
t.Fatalf("无匹配应返回成功空分页:status=%d body=%s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
type exchangeListStub struct {
|
||||
request *dto.ExchangeListRequest
|
||||
response *dto.ExchangeListResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *exchangeListStub) List(_ context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error) {
|
||||
s.request = req
|
||||
if s.response == nil && s.err == nil {
|
||||
s.response = &dto.ExchangeListResponse{List: []*dto.ExchangeOrderResponse{}, Page: constants.DefaultPage, PageSize: constants.DefaultPageSize}
|
||||
}
|
||||
return s.response, s.err
|
||||
}
|
||||
|
||||
func newExchangeListTestApp(stub ExchangeLister) (*fiber.App, *ExchangeHandler) {
|
||||
handler := NewExchangeHandler(nil, stub, validator.New())
|
||||
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
|
||||
app.Get("/api/admin/exchanges", handler.List)
|
||||
return app, handler
|
||||
}
|
||||
|
||||
func exchangeListRequest(t *testing.T, app *fiber.App, path string) (int, []byte) {
|
||||
return exchangeListRequestWithAccount(t, app, path, "")
|
||||
}
|
||||
|
||||
func exchangeListRequestWithAccount(t *testing.T, app *fiber.App, path, account string) (int, []byte) {
|
||||
t.Helper()
|
||||
request, err := http.NewRequest(http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("创建请求失败:%v", err)
|
||||
}
|
||||
if account != "" {
|
||||
request.Header.Set("X-UR45-Account", account)
|
||||
}
|
||||
response, err := app.Test(request, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("执行请求失败:%v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("读取响应失败:%v", err)
|
||||
}
|
||||
return response.StatusCode, body
|
||||
}
|
||||
|
||||
type exchangeHTTPPage struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func decodeExchangeHTTPPage(t *testing.T, body []byte) exchangeHTTPPage {
|
||||
t.Helper()
|
||||
var response struct {
|
||||
Data exchangeHTTPPage `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析分页响应失败:%v", err)
|
||||
}
|
||||
return response.Data
|
||||
}
|
||||
|
||||
func exchangeHTTPPageContains(page exchangeHTTPPage, exchangeNo string) bool {
|
||||
for _, item := range page.Items {
|
||||
if item["exchange_no"] == exchangeNo {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func createExchangeHTTPCard(t *testing.T, tx *gorm.DB, suffix int, shopID uint) *model.IotCard {
|
||||
t.Helper()
|
||||
iccid := "8986222222222222000" + strconv.Itoa(suffix)
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], MSISDN: "1360000000" + strconv.Itoa(suffix), VirtualNo: "UR45-HTTP-CARD-" + strconv.Itoa(suffix), ShopID: &shopID, AssetStatus: constants.AssetStatusInStock}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建 HTTP 测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createExchangeHTTPOrder(t *testing.T, tx *gorm.DB, exchangeNo string, oldCard, newCard *model.IotCard, shopID uint, createdAt time.Time) *model.ExchangeOrder {
|
||||
t.Helper()
|
||||
newID := newCard.ID
|
||||
order := &model.ExchangeOrder{ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeDirect, OldAssetType: constants.ExchangeAssetTypeIotCard, OldAssetID: oldCard.ID, OldAssetIdentifier: "历史旧快照", NewAssetType: constants.ExchangeAssetTypeIotCard, NewAssetID: &newID, NewAssetIdentifier: "历史新快照", ExchangeReason: "UR45 HTTP 测试", Status: constants.ExchangeStatusCompleted, ShopID: &shopID}
|
||||
order.CreatedAt, order.UpdatedAt = createdAt, createdAt
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
t.Fatalf("创建 HTTP 测试换货单失败:%v", err)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func assertExchangeErrorResponse(t *testing.T, body []byte, expectedCode int, expectedMessage string) {
|
||||
t.Helper()
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析错误响应失败:%v", err)
|
||||
}
|
||||
if response.Code != expectedCode || response.Msg != expectedMessage || response.Data != nil || !strings.Contains(string(body), "timestamp") {
|
||||
t.Fatalf("错误响应不符合契约:%s", body)
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
batchAllocationService "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
|
||||
grantService "github.com/break/junhong_cmp_fiber/internal/service/shop_series_grant"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
|
||||
const testIDMask = 0x7fffffff
|
||||
|
||||
// TestBatchAllocatePackagesHTTPRequiresExplicitExpiryBase 验证批量分配显式选择并固化到全部记录。
|
||||
func TestBatchAllocatePackagesHTTPRequiresExplicitExpiryBase(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
field string
|
||||
expected *string
|
||||
}{
|
||||
{name: "跟随默认", field: "null"},
|
||||
{name: "购买即生效", field: `"from_purchase"`, expected: testutil.StringPointer(constants.PackageExpiryBaseFromPurchase)},
|
||||
{name: "实名即生效", field: `"from_activation"`, expected: testutil.StringPointer(constants.PackageExpiryBaseFromActivation)},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
tx, fixture := newExpiryBaseHTTPFixture(t)
|
||||
app := fixture.newApp(constants.UserTypeSuperAdmin, 0)
|
||||
body := `{"shop_id":` + strconv.FormatUint(uint64(fixture.shop.ID), 10) + `,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) + `,"expiry_base_override":` + testCase.field + `}`
|
||||
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-package-allocations/batch", body)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("批量分配失败:status=%d body=%s", status, responseBody)
|
||||
}
|
||||
var allocations []model.ShopPackageAllocation
|
||||
if err := tx.Where("shop_id = ?", fixture.shop.ID).Order("package_id").Find(&allocations).Error; err != nil {
|
||||
t.Fatalf("查询套餐分配失败:%v", err)
|
||||
}
|
||||
if len(allocations) != len(fixture.packages) {
|
||||
t.Fatalf("期望创建 %d 条分配,实际 %d", len(fixture.packages), len(allocations))
|
||||
}
|
||||
for _, allocation := range allocations {
|
||||
if !nullableStringEqual(allocation.ExpiryBaseOverride, testCase.expected) {
|
||||
t.Fatalf("分配 %d 的覆盖值错误:%v", allocation.ID, allocation.ExpiryBaseOverride)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchAllocatePackagesHTTPRejectsMissingAndInvalidExpiryBase 验证字段缺失与非法枚举统一拒绝且不写入。
|
||||
func TestBatchAllocatePackagesHTTPRejectsMissingAndInvalidExpiryBase(t *testing.T) {
|
||||
for _, bodySuffix := range []string{"", `,"expiry_base_override":"invalid"`} {
|
||||
t.Run(bodySuffix, func(t *testing.T) {
|
||||
tx, fixture := newExpiryBaseHTTPFixture(t)
|
||||
app := fixture.newApp(constants.UserTypeSuperAdmin, 0)
|
||||
body := `{"shop_id":` + strconv.FormatUint(uint64(fixture.shop.ID), 10) + `,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) + bodySuffix + `}`
|
||||
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-package-allocations/batch", body)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("期望参数错误,实际 status=%d body=%s", status, responseBody)
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.ShopPackageAllocation{}).Where("shop_id = ?", fixture.shop.ID).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("失败请求不应留下套餐分配:count=%d err=%v", count, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateAllocationExpiryBaseHTTP 验证 PATCH 的恢复默认、幂等、权限和历史快照隔离。
|
||||
func TestUpdateAllocationExpiryBaseHTTP(t *testing.T) {
|
||||
tx, fixture := newExpiryBaseHTTPFixture(t)
|
||||
override := constants.PackageExpiryBaseFromPurchase
|
||||
allocation := &model.ShopPackageAllocation{ShopID: fixture.shop.ID, PackageID: fixture.packages[0].ID, AllocatorShopID: 101, CostPrice: 1, RetailPrice: 2, ExpiryBaseOverride: &override, Status: constants.StatusEnabled, ShelfStatus: 1}
|
||||
if err := tx.Create(allocation).Error; err != nil {
|
||||
t.Fatalf("创建套餐分配失败:%v", err)
|
||||
}
|
||||
usage := completeExpiryBaseTestUsage(fixture.packages[0].ID)
|
||||
if err := tx.Create(usage).Error; err != nil {
|
||||
t.Fatalf("创建既有购买记录失败:%v", err)
|
||||
}
|
||||
app := fixture.newApp(constants.UserTypeSuperAdmin, 0)
|
||||
path := "/api/admin/shop-package-allocations/" + strconv.FormatUint(uint64(allocation.ID), 10) + "/expiry-base"
|
||||
|
||||
// 首次 PATCH 验证响应体包含 spec 要求的所有生效条件字段
|
||||
{
|
||||
status, respBody := expiryBaseHTTPRequest(t, app, http.MethodPatch, path, `{"expiry_base_override":"from_activation"}`)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("首次 PATCH 失败:status=%d body=%s", status, respBody)
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
DefaultExpiryBase string `json:"default_expiry_base"`
|
||||
DefaultExpiryBaseName string `json:"default_expiry_base_name"`
|
||||
ExpiryBaseOverride *string `json:"expiry_base_override"`
|
||||
ExpiryBaseOverrideName string `json:"expiry_base_override_name"`
|
||||
EffectiveExpiryBase string `json:"effective_expiry_base"`
|
||||
EffectiveExpiryBaseName string `json:"effective_expiry_base_name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(respBody, &resp); err != nil {
|
||||
t.Fatalf("解析 PATCH 响应失败:%v", err)
|
||||
}
|
||||
d := resp.Data
|
||||
if d.DefaultExpiryBase == "" || d.DefaultExpiryBaseName == "" ||
|
||||
d.ExpiryBaseOverride == nil || d.ExpiryBaseOverrideName == "" ||
|
||||
d.EffectiveExpiryBase == "" || d.EffectiveExpiryBaseName == "" {
|
||||
t.Fatalf("PATCH 响应缺少 spec 要求的生效条件字段:%+v", d)
|
||||
}
|
||||
}
|
||||
// 幂等 + 恢复默认
|
||||
for _, body := range []string{`{"expiry_base_override":"from_activation"}`, `{"expiry_base_override":null}`} {
|
||||
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPatch, path, body)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("修改覆盖值失败:status=%d body=%s", status, responseBody)
|
||||
}
|
||||
}
|
||||
var refreshed model.ShopPackageAllocation
|
||||
if err := tx.First(&refreshed, allocation.ID).Error; err != nil || refreshed.ExpiryBaseOverride != nil {
|
||||
t.Fatalf("显式 null 应恢复跟随默认:allocation=%+v err=%v", refreshed, err)
|
||||
}
|
||||
var refreshedUsage model.PackageUsage
|
||||
if err := tx.First(&refreshedUsage, usage.ID).Error; err != nil {
|
||||
t.Fatalf("查询既有购买记录失败:%v", err)
|
||||
}
|
||||
if refreshedUsage.ExpiryBaseSnapshot != usage.ExpiryBaseSnapshot || refreshedUsage.CalendarTypeSnapshot != usage.CalendarTypeSnapshot || refreshedUsage.DurationDaysSnapshot != usage.DurationDaysSnapshot {
|
||||
t.Fatalf("修改分配不应改变既有购买快照:before=%+v after=%+v", usage, refreshedUsage)
|
||||
}
|
||||
|
||||
for _, body := range []string{`{}`, `{"expiry_base_override":"invalid"}`} {
|
||||
status, _ := expiryBaseHTTPRequest(t, app, http.MethodPatch, path, body)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("字段缺失或非法值应返回 400,body=%s status=%d", body, status)
|
||||
}
|
||||
}
|
||||
forbiddenApp := fixture.newApp(constants.UserTypeAgent, 202)
|
||||
status, _ := expiryBaseHTTPRequest(t, forbiddenApp, http.MethodPatch, path, `{"expiry_base_override":null}`)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("越权修改应返回 403,实际 %d", status)
|
||||
}
|
||||
if err := tx.Delete(&refreshed).Error; err != nil {
|
||||
t.Fatalf("软删除分配失败:%v", err)
|
||||
}
|
||||
status, _ = expiryBaseHTTPRequest(t, app, http.MethodPatch, path, `{"expiry_base_override":null}`)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("软删除分配应使用统一 403 语义,实际 %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
type expiryBaseHTTPFixture struct {
|
||||
tx *gorm.DB
|
||||
shop *model.Shop
|
||||
series *model.PackageSeries
|
||||
packages []*model.Package
|
||||
}
|
||||
|
||||
func newExpiryBaseHTTPFixture(t *testing.T) (*gorm.DB, *expiryBaseHTTPFixture) {
|
||||
t.Helper()
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
shop := &model.Shop{ShopName: "UR55测试店铺", ShopCode: "UR55-SHOP-" + suffix, Status: constants.StatusEnabled}
|
||||
series := &model.PackageSeries{SeriesCode: "UR55-SERIES-" + suffix, SeriesName: "UR55测试系列", Status: constants.StatusEnabled}
|
||||
if err := tx.Create(shop).Error; err != nil {
|
||||
t.Fatalf("创建测试店铺失败:%v", err)
|
||||
}
|
||||
if err := tx.Create(series).Error; err != nil {
|
||||
t.Fatalf("创建测试系列失败:%v", err)
|
||||
}
|
||||
packages := make([]*model.Package, 0, 2)
|
||||
for index, expiryBase := range []string{constants.PackageExpiryBaseFromActivation, constants.PackageExpiryBaseFromPurchase} {
|
||||
pkg := &model.Package{PackageCode: "UR55-PKG-" + suffix + "-" + strconv.Itoa(index), PackageName: "UR55测试套餐", SeriesID: series.ID, PackageType: constants.PackageTypeFormal, DurationMonths: 1, DurationDays: 30, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, PriceConfigStatus: 2}
|
||||
if err := tx.Create(pkg).Error; err != nil {
|
||||
t.Fatalf("创建测试套餐失败:%v", err)
|
||||
}
|
||||
packages = append(packages, pkg)
|
||||
}
|
||||
seriesAllocation := &model.ShopSeriesAllocation{ShopID: shop.ID, SeriesID: series.ID, Status: constants.StatusEnabled}
|
||||
if err := tx.Create(seriesAllocation).Error; err != nil {
|
||||
t.Fatalf("创建系列授权失败:%v", err)
|
||||
}
|
||||
return tx, &expiryBaseHTTPFixture{tx: tx, shop: shop, series: series, packages: packages}
|
||||
}
|
||||
|
||||
func (f *expiryBaseHTTPFixture) newApp(userType int, shopID uint) *fiber.App {
|
||||
service := batchAllocationService.New(f.tx, postgres.NewPackageStore(f.tx), postgres.NewShopPackageAllocationStore(f.tx), postgres.NewShopSeriesAllocationStore(f.tx), postgres.NewShopStore(f.tx, nil), nil)
|
||||
handler := NewShopPackageBatchAllocationHandler(service)
|
||||
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
|
||||
setContext := func(c *fiber.Ctx) error {
|
||||
ctx := middleware.SetUserContext(c.UserContext(), &middleware.UserContextInfo{UserID: 9001, UserType: userType, Username: "UR55测试账号", ShopID: shopID})
|
||||
c.SetUserContext(ctx)
|
||||
return c.Next()
|
||||
}
|
||||
app.Post("/api/admin/shop-package-allocations/batch", setContext, handler.BatchAllocate)
|
||||
app.Patch("/api/admin/shop-package-allocations/:id/expiry-base", setContext, handler.UpdateExpiryBase)
|
||||
return app
|
||||
}
|
||||
|
||||
func expiryBaseHTTPRequest(t *testing.T, app *fiber.App, method, path, body string) (int, []byte) {
|
||||
t.Helper()
|
||||
request, err := http.NewRequestWithContext(context.Background(), method, path, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
t.Fatalf("创建 HTTP 请求失败:%v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := app.Test(request, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("执行 HTTP 请求失败:%v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 HTTP 响应失败:%v", err)
|
||||
}
|
||||
return response.StatusCode, responseBody
|
||||
}
|
||||
|
||||
func completeExpiryBaseTestUsage(packageID uint) *model.PackageUsage {
|
||||
unique := uint(time.Now().UnixNano() & testIDMask)
|
||||
return &model.PackageUsage{OrderID: unique, OrderNo: "UR55-PATCH-USAGE", PackageID: packageID, UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: unique, DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: 1, PackageName: "UR55测试套餐", Generation: 1, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
|
||||
}
|
||||
|
||||
func nullableStringEqual(left, right *string) bool {
|
||||
return left == nil && right == nil || left != nil && right != nil && *left == *right
|
||||
}
|
||||
|
||||
// TestSeriesGrantCreateHTTPRequiresExplicitExpiryBase 验证系列首次授权(含套餐)固化生效条件到分配记录。
|
||||
func TestSeriesGrantCreateHTTPRequiresExplicitExpiryBase(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
field string
|
||||
expected *string
|
||||
}{
|
||||
{name: "跟随默认", field: "null"},
|
||||
{name: "购买即生效", field: `"from_purchase"`, expected: testutil.StringPointer(constants.PackageExpiryBaseFromPurchase)},
|
||||
{name: "实名即生效", field: `"from_activation"`, expected: testutil.StringPointer(constants.PackageExpiryBaseFromActivation)},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
tx, fixture := newExpiryBaseHTTPFixture(t)
|
||||
targetShop := &model.Shop{ShopName: "UR55授权目标店铺", ShopCode: "UR55-TARGET-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.StatusEnabled}
|
||||
if err := tx.Create(targetShop).Error; err != nil {
|
||||
t.Fatalf("创建目标店铺失败:%v", err)
|
||||
}
|
||||
app := fixture.newGrantApp(constants.UserTypeSuperAdmin, 0, tx)
|
||||
pkg := fixture.packages[0]
|
||||
body := `{"shop_id":` + strconv.FormatUint(uint64(targetShop.ID), 10) +
|
||||
`,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) +
|
||||
`,"packages":[{"package_id":` + strconv.FormatUint(uint64(pkg.ID), 10) + `,"cost_price":100}]` +
|
||||
`,"expiry_base_override":` + testCase.field + `}`
|
||||
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-series-grants", body)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("系列授权创建失败:status=%d body=%s", status, responseBody)
|
||||
}
|
||||
var alloc model.ShopPackageAllocation
|
||||
if err := tx.Where("shop_id = ? AND package_id = ?", targetShop.ID, pkg.ID).First(&alloc).Error; err != nil {
|
||||
t.Fatalf("查询套餐分配失败:%v", err)
|
||||
}
|
||||
if !nullableStringEqual(alloc.ExpiryBaseOverride, testCase.expected) {
|
||||
t.Fatalf("系列授权覆盖值错误:got=%v want=%v", alloc.ExpiryBaseOverride, testCase.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeriesGrantCreateHTTPRejectsMissingAndInvalidExpiryBase 验证字段缺失与非法枚举拒绝系列授权创建。
|
||||
func TestSeriesGrantCreateHTTPRejectsMissingAndInvalidExpiryBase(t *testing.T) {
|
||||
for _, bodySuffix := range []string{"", `,"expiry_base_override":"invalid"`} {
|
||||
t.Run(bodySuffix, func(t *testing.T) {
|
||||
tx, fixture := newExpiryBaseHTTPFixture(t)
|
||||
targetShop := &model.Shop{ShopName: "UR55授权目标店铺", ShopCode: "UR55-TARGET-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.StatusEnabled}
|
||||
if err := tx.Create(targetShop).Error; err != nil {
|
||||
t.Fatalf("创建目标店铺失败:%v", err)
|
||||
}
|
||||
app := fixture.newGrantApp(constants.UserTypeSuperAdmin, 0, tx)
|
||||
pkg := fixture.packages[0]
|
||||
body := `{"shop_id":` + strconv.FormatUint(uint64(targetShop.ID), 10) +
|
||||
`,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) +
|
||||
`,"packages":[{"package_id":` + strconv.FormatUint(uint64(pkg.ID), 10) + `,"cost_price":100}]` +
|
||||
bodySuffix + `}`
|
||||
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-series-grants", body)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 status=%d body=%s", status, responseBody)
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.ShopPackageAllocation{}).Where("shop_id = ?", targetShop.ID).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("失败请求不应留下套餐分配:count=%d err=%v", count, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeriesGrantManagePackagesHTTPRequiresExplicitExpiryBase 验证后续追加套餐固化生效条件到新分配记录。
|
||||
func TestSeriesGrantManagePackagesHTTPRequiresExplicitExpiryBase(t *testing.T) {
|
||||
tx, fixture := newExpiryBaseHTTPFixture(t)
|
||||
targetShop := &model.Shop{ShopName: "UR55追加目标店铺", ShopCode: "UR55-MANAGE-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.StatusEnabled}
|
||||
if err := tx.Create(targetShop).Error; err != nil {
|
||||
t.Fatalf("创建目标店铺失败:%v", err)
|
||||
}
|
||||
// 先创建不含套餐的系列授权
|
||||
seriesAlloc := &model.ShopSeriesAllocation{ShopID: targetShop.ID, SeriesID: fixture.series.ID, AllocatorShopID: 0, Status: constants.StatusEnabled}
|
||||
if err := tx.Create(seriesAlloc).Error; err != nil {
|
||||
t.Fatalf("创建系列授权失败:%v", err)
|
||||
}
|
||||
app := fixture.newGrantApp(constants.UserTypeSuperAdmin, 0, tx)
|
||||
path := "/api/admin/shop-series-grants/" + strconv.FormatUint(uint64(seriesAlloc.ID), 10) + "/packages"
|
||||
pkg := fixture.packages[0]
|
||||
|
||||
// 有效追加(from_purchase)
|
||||
body := `{"packages":[{"package_id":` + strconv.FormatUint(uint64(pkg.ID), 10) + `,"cost_price":50}],"expiry_base_override":"from_purchase"}`
|
||||
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPut, path, body)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("追加套餐失败:status=%d body=%s", status, responseBody)
|
||||
}
|
||||
var alloc model.ShopPackageAllocation
|
||||
if err := tx.Where("shop_id = ? AND package_id = ?", targetShop.ID, pkg.ID).First(&alloc).Error; err != nil {
|
||||
t.Fatalf("查询新建套餐分配失败:%v", err)
|
||||
}
|
||||
expected := constants.PackageExpiryBaseFromPurchase
|
||||
if !nullableStringEqual(alloc.ExpiryBaseOverride, &expected) {
|
||||
t.Fatalf("追加套餐覆盖值错误:%v", alloc.ExpiryBaseOverride)
|
||||
}
|
||||
|
||||
// 缺失 expiry_base_override 字段应返回 400
|
||||
for _, badBody := range []string{
|
||||
`{"packages":[{"package_id":` + strconv.FormatUint(uint64(fixture.packages[1].ID), 10) + `,"cost_price":50}]}`,
|
||||
`{"packages":[{"package_id":` + strconv.FormatUint(uint64(fixture.packages[1].ID), 10) + `,"cost_price":50}],"expiry_base_override":"invalid"}`,
|
||||
} {
|
||||
s, _ := expiryBaseHTTPRequest(t, app, http.MethodPut, path, badBody)
|
||||
if s != http.StatusBadRequest {
|
||||
t.Fatalf("缺失/非法 expiry_base_override 应返回 400,实际 %d", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *expiryBaseHTTPFixture) newGrantApp(userType int, shopID uint, tx *gorm.DB) *fiber.App {
|
||||
svc := grantService.New(
|
||||
tx,
|
||||
postgres.NewShopSeriesAllocationStore(tx),
|
||||
postgres.NewShopPackageAllocationStore(tx),
|
||||
postgres.NewShopPackageAllocationPriceHistoryStore(tx),
|
||||
postgres.NewShopStore(tx, nil),
|
||||
postgres.NewPackageStore(tx),
|
||||
postgres.NewPackageSeriesStore(tx),
|
||||
zap.NewNop(),
|
||||
)
|
||||
handler := NewShopSeriesGrantHandler(svc)
|
||||
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
|
||||
setContext := func(c *fiber.Ctx) error {
|
||||
ctx := middleware.SetUserContext(c.UserContext(), &middleware.UserContextInfo{UserID: 9001, UserType: userType, Username: "UR55测试账号", ShopID: shopID})
|
||||
c.SetUserContext(ctx)
|
||||
return c.Next()
|
||||
}
|
||||
app.Post("/api/admin/shop-series-grants", setContext, handler.Create)
|
||||
app.Put("/api/admin/shop-series-grants/:id/packages", setContext, handler.ManagePackages)
|
||||
return app
|
||||
}
|
||||
|
||||
var _ = errors.CodeSuccess
|
||||
@@ -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 必须被拒绝")
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestUpdateShopCreditLimitRequestDoesNotExposeVersion 验证乐观锁版本不属于前端调额契约。
|
||||
func TestUpdateShopCreditLimitRequestDoesNotExposeVersion(t *testing.T) {
|
||||
typeOfRequest := reflect.TypeOf(UpdateShopCreditLimitRequest{})
|
||||
for i := 0; i < typeOfRequest.NumField(); i++ {
|
||||
jsonName := strings.Split(typeOfRequest.Field(i).Tag.Get("json"), ",")[0]
|
||||
if jsonName == "version" {
|
||||
t.Fatal("调额请求不应暴露由服务端管理的乐观锁版本")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,435 +0,0 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// TestExchangeTraceQueryProjectsPreviousAsset 验证卡和设备前代均使用换货快照,并始终返回稳定对象。
|
||||
func TestExchangeTraceQueryProjectsPreviousAsset(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
query := NewExchangeTraceQuery(tx, zap.NewNop())
|
||||
|
||||
card := createTraceCard(t, tx, 1, nil)
|
||||
trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, card.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询无换货关系资产失败:%v", err)
|
||||
}
|
||||
if trace == nil || trace.PreviousAsset != nil || trace.NextAsset != nil {
|
||||
t.Fatalf("无关系时应返回稳定空对象:%+v", trace)
|
||||
}
|
||||
|
||||
oldCard := createTraceCard(t, tx, 2, nil)
|
||||
createTraceOrder(t, tx, "UR86-PREV-CARD", constants.ExchangeAssetTypeIotCard, oldCard.ID, "历史卡快照", constants.ExchangeAssetTypeIotCard, card.ID, "新卡快照", constants.ExchangeStatusCompleted, time.Now())
|
||||
trace, err = query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, card.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询卡前代失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, oldCard.ID, "历史卡快照", "UR86-PREV-CARD", true)
|
||||
|
||||
oldDevice := createTraceDevice(t, tx, 3, nil)
|
||||
newDevice := createTraceDevice(t, tx, 4, nil)
|
||||
createTraceOrder(t, tx, "UR86-PREV-DEVICE", constants.ExchangeAssetTypeDevice, oldDevice.ID, "历史设备快照", constants.ExchangeAssetTypeDevice, newDevice.ID, "新设备快照", constants.ExchangeStatusCompleted, time.Now())
|
||||
trace, err = query.Resolve(context.Background(), constants.ExchangeAssetTypeDevice, newDevice.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询设备前代失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeDevice, oldDevice.ID, "历史设备快照", "UR86-PREV-DEVICE", true)
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryHidesInvisiblePreviousAsset 验证关系查询不受权限过滤,但关联资产 ID 按当前权限隐藏。
|
||||
func TestExchangeTraceQueryHidesInvisiblePreviousAsset(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
visibleShop, hiddenShop := uint(86101), uint(86102)
|
||||
oldCard := createTraceCard(t, tx, 11, &hiddenShop)
|
||||
newCard := createTraceCard(t, tx, 12, &visibleShop)
|
||||
createTraceOrder(t, tx, "UR86-PREV-HIDDEN", constants.ExchangeAssetTypeIotCard, oldCard.ID, "不可见历史快照", constants.ExchangeAssetTypeIotCard, newCard.ID, "当前资产快照", constants.ExchangeStatusCompleted, time.Now())
|
||||
|
||||
ctx := context.WithValue(context.Background(), constants.ContextKeySubordinateShopIDs, []uint{visibleShop})
|
||||
trace, err := NewExchangeTraceQuery(tx, zap.NewNop()).Resolve(ctx, constants.ExchangeAssetTypeIotCard, newCard.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询不可见前代失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, 0, "不可见历史快照", "UR86-PREV-HIDDEN", false)
|
||||
|
||||
emptyScope := context.WithValue(context.Background(), constants.ContextKeySubordinateShopIDs, []uint{})
|
||||
trace, err = NewExchangeTraceQuery(tx, zap.NewNop()).Resolve(emptyScope, constants.ExchangeAssetTypeIotCard, newCard.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询空权限范围前代失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, 0, "不可见历史快照", "UR86-PREV-HIDDEN", false)
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryIgnoresIncompleteAndDeletedPreviousRelations 验证仅未软删除的已完成换货形成前代。
|
||||
func TestExchangeTraceQueryIgnoresIncompleteAndDeletedPreviousRelations(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
query := NewExchangeTraceQuery(tx, zap.NewNop())
|
||||
oldCard := createTraceCard(t, tx, 21, nil)
|
||||
statuses := []int{
|
||||
constants.ExchangeStatusPendingInfo,
|
||||
constants.ExchangeStatusPendingShip,
|
||||
constants.ExchangeStatusShipped,
|
||||
constants.ExchangeStatusCancelled,
|
||||
}
|
||||
for index, status := range statuses {
|
||||
newCard := createTraceCard(t, tx, 22+index, nil)
|
||||
createTraceOrder(t, tx, fmt.Sprintf("UR86-PREV-STATUS-%d", status), constants.ExchangeAssetTypeIotCard, oldCard.ID, "旧快照", constants.ExchangeAssetTypeIotCard, newCard.ID, "新快照", status, time.Now())
|
||||
trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, newCard.ID)
|
||||
if err != nil || trace.PreviousAsset != nil {
|
||||
t.Fatalf("状态 %d 不应形成前代:trace=%+v err=%v", status, trace, err)
|
||||
}
|
||||
}
|
||||
|
||||
deletedNew := createTraceCard(t, tx, 30, nil)
|
||||
deletedOrder := createTraceOrder(t, tx, "UR86-PREV-DELETED", constants.ExchangeAssetTypeIotCard, oldCard.ID, "旧快照", constants.ExchangeAssetTypeIotCard, deletedNew.ID, "新快照", constants.ExchangeStatusCompleted, time.Now())
|
||||
if err := tx.Delete(deletedOrder).Error; err != nil {
|
||||
t.Fatalf("软删除换货单失败:%v", err)
|
||||
}
|
||||
trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, deletedNew.ID)
|
||||
if err != nil || trace.PreviousAsset != nil {
|
||||
t.Fatalf("软删除换货单不应形成前代:trace=%+v err=%v", trace, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeTraceQuerySelectsLatestPreviousRelationAndLogsAnomaly 验证重复完成记录按完成时间和主键确定性选择。
|
||||
func TestExchangeTraceQuerySelectsLatestPreviousRelationAndLogsAnomaly(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
query := NewExchangeTraceQuery(tx, zap.New(core))
|
||||
newCard := createTraceCard(t, tx, 41, nil)
|
||||
oldOne := createTraceCard(t, tx, 42, nil)
|
||||
oldTwo := createTraceCard(t, tx, 43, nil)
|
||||
completedAt := time.Now().Truncate(time.Second)
|
||||
createTraceOrder(t, tx, "UR86-PREV-OLDER", constants.ExchangeAssetTypeIotCard, oldOne.ID, "较旧快照", constants.ExchangeAssetTypeIotCard, newCard.ID, "新快照", constants.ExchangeStatusCompleted, completedAt)
|
||||
createTraceOrder(t, tx, "UR86-PREV-LATEST", constants.ExchangeAssetTypeIotCard, oldTwo.ID, "最新快照", constants.ExchangeAssetTypeIotCard, newCard.ID, "新快照", constants.ExchangeStatusCompleted, completedAt)
|
||||
|
||||
trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, newCard.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询重复完成记录失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, oldTwo.ID, "最新快照", "UR86-PREV-LATEST", true)
|
||||
if logs.FilterMessage("检测到同方向多条已完成换货记录").Len() != 1 {
|
||||
t.Fatalf("应记录重复换货异常日志:%v", logs.All())
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryWrapsDatabaseErrors 验证查询故障不会降级为空关系。
|
||||
func TestExchangeTraceQueryWrapsDatabaseErrors(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
callbackName := "ur86:force_trace_query_error"
|
||||
if err := tx.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
|
||||
db.AddError(fmt.Errorf("UR86 模拟数据库故障"))
|
||||
}); err != nil {
|
||||
t.Fatalf("注册数据库故障回调失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = tx.Callback().Query().Remove(callbackName) })
|
||||
|
||||
_, err := NewExchangeTraceQuery(tx, zap.NewNop()).Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, 1)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
if !ok || appErr.Code != errors.CodeDatabaseError {
|
||||
t.Fatalf("数据库故障应转换为统一错误,实际:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryProjectsBidirectionalChain 验证 A→B→C 中间资产同时返回前代和后代。
|
||||
func TestExchangeTraceQueryProjectsBidirectionalChain(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
query := NewExchangeTraceQuery(tx, zap.NewNop())
|
||||
a := createTraceCard(t, tx, 51, nil)
|
||||
b := createTraceCard(t, tx, 52, nil)
|
||||
c := createTraceCard(t, tx, 53, nil)
|
||||
createTraceOrder(t, tx, "UR86-CHAIN-AB", constants.ExchangeAssetTypeIotCard, a.ID, "快照A", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeStatusCompleted, time.Now().Add(-time.Minute))
|
||||
createTraceOrder(t, tx, "UR86-CHAIN-BC", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeAssetTypeIotCard, c.ID, "快照C", constants.ExchangeStatusCompleted, time.Now())
|
||||
|
||||
trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, b.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询双向换货链失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, a.ID, "快照A", "UR86-CHAIN-AB", true)
|
||||
assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeIotCard, c.ID, "快照C", "UR86-CHAIN-BC", true)
|
||||
|
||||
trace, err = query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, a.ID)
|
||||
if err != nil || trace.PreviousAsset != nil {
|
||||
t.Fatalf("链首不应有前代:trace=%+v err=%v", trace, err)
|
||||
}
|
||||
assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeIotCard, b.ID, "快照B", "UR86-CHAIN-AB", true)
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryHidesInvisibleNextAsset 验证后代不可见时保留快照但隐藏 ID。
|
||||
func TestExchangeTraceQueryHidesInvisibleNextAsset(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
visibleShop, hiddenShop := uint(86201), uint(86202)
|
||||
oldDevice := createTraceDevice(t, tx, 61, &visibleShop)
|
||||
newDevice := createTraceDevice(t, tx, 62, &hiddenShop)
|
||||
createTraceOrder(t, tx, "UR86-NEXT-HIDDEN", constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧设备快照", constants.ExchangeAssetTypeDevice, newDevice.ID, "不可见新设备快照", constants.ExchangeStatusCompleted, time.Now())
|
||||
ctx := context.WithValue(context.Background(), constants.ContextKeySubordinateShopIDs, []uint{visibleShop})
|
||||
|
||||
trace, err := NewExchangeTraceQuery(tx, zap.NewNop()).Resolve(ctx, constants.ExchangeAssetTypeDevice, oldDevice.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询不可见后代失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeDevice, 0, "不可见新设备快照", "UR86-NEXT-HIDDEN", false)
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryKeepsNextSnapshotWhenAssetIDMissing 验证历史完成单缺少新资产 ID 时仍保留后代文本。
|
||||
func TestExchangeTraceQueryKeepsNextSnapshotWhenAssetIDMissing(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
oldCard := createTraceCard(t, tx, 63, nil)
|
||||
completedAt := time.Now()
|
||||
order := &model.ExchangeOrder{
|
||||
ExchangeNo: "UR86-NEXT-MISSING-ID", FlowType: constants.ExchangeFlowTypeDirect,
|
||||
OldAssetType: constants.ExchangeAssetTypeIotCard, OldAssetID: oldCard.ID, OldAssetIdentifier: "旧快照",
|
||||
NewAssetType: constants.ExchangeAssetTypeIotCard, NewAssetIdentifier: "缺失 ID 的后代快照",
|
||||
ExchangeReason: "UR86 历史异常测试", Status: constants.ExchangeStatusCompleted, CompletedAt: &completedAt,
|
||||
}
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
t.Fatalf("创建缺少新资产 ID 的历史换货单失败:%v", err)
|
||||
}
|
||||
|
||||
trace, err := NewExchangeTraceQuery(tx, zap.New(core)).Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, oldCard.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询缺少新资产 ID 的后代失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeIotCard, 0, "缺失 ID 的后代快照", "UR86-NEXT-MISSING-ID", false)
|
||||
if logs.FilterMessage("已完成换货记录缺少新资产 ID").Len() != 1 {
|
||||
t.Fatalf("应记录历史换货单缺少新资产 ID 的异常:%v", logs.All())
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryProvidesBatchRelationshipQueries 验证前代和后代关系支持批量查询。
|
||||
func TestExchangeTraceQueryProvidesBatchRelationshipQueries(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
a := createTraceCard(t, tx, 64, nil)
|
||||
b := createTraceCard(t, tx, 65, nil)
|
||||
c := createTraceDevice(t, tx, 66, nil)
|
||||
d := createTraceDevice(t, tx, 67, nil)
|
||||
createTraceOrder(t, tx, "UR86-BATCH-CARD", constants.ExchangeAssetTypeIotCard, a.ID, "快照A", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeStatusCompleted, time.Now())
|
||||
createTraceOrder(t, tx, "UR86-BATCH-DEVICE", constants.ExchangeAssetTypeDevice, c.ID, "快照C", constants.ExchangeAssetTypeDevice, d.ID, "快照D", constants.ExchangeStatusCompleted, time.Now())
|
||||
query := NewExchangeTraceQuery(tx, zap.NewNop())
|
||||
|
||||
previousRefs := []ExchangeTraceAssetRef{{AssetType: constants.AssetResolveTypeCard, AssetID: b.ID}, {AssetType: constants.ExchangeAssetTypeDevice, AssetID: d.ID}}
|
||||
previous, err := query.FindPreviousCompleted(context.Background(), previousRefs)
|
||||
if err != nil || len(previous[ExchangeTraceAssetRef{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: b.ID}]) != 1 || len(previous[previousRefs[1]]) != 1 {
|
||||
t.Fatalf("批量前代查询错误:result=%+v err=%v", previous, err)
|
||||
}
|
||||
nextRefs := []ExchangeTraceAssetRef{{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: a.ID}, {AssetType: constants.ExchangeAssetTypeDevice, AssetID: c.ID}}
|
||||
next, err := query.FindNextCompleted(context.Background(), nextRefs)
|
||||
if err != nil || len(next[nextRefs[0]]) != 1 || len(next[nextRefs[1]]) != 1 {
|
||||
t.Fatalf("批量后代查询错误:result=%+v err=%v", next, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryIgnoresIncompleteDeletedAndSelectsLatestNextRelation 验证后代过滤、确定性选择和异常日志。
|
||||
func TestExchangeTraceQueryIgnoresIncompleteDeletedAndSelectsLatestNextRelation(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
query := NewExchangeTraceQuery(tx, zap.New(core))
|
||||
oldDevice := createTraceDevice(t, tx, 71, nil)
|
||||
statuses := []int{constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, constants.ExchangeStatusShipped, constants.ExchangeStatusCancelled}
|
||||
for index, status := range statuses {
|
||||
candidate := createTraceDevice(t, tx, 72+index, nil)
|
||||
createTraceOrder(t, tx, fmt.Sprintf("UR86-NEXT-STATUS-%d", status), constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧快照", constants.ExchangeAssetTypeDevice, candidate.ID, "候选快照", status, time.Now())
|
||||
}
|
||||
deletedCandidate := createTraceDevice(t, tx, 80, nil)
|
||||
deletedOrder := createTraceOrder(t, tx, "UR86-NEXT-DELETED", constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧快照", constants.ExchangeAssetTypeDevice, deletedCandidate.ID, "软删除快照", constants.ExchangeStatusCompleted, time.Now())
|
||||
if err := tx.Delete(deletedOrder).Error; err != nil {
|
||||
t.Fatalf("软删除后代换货单失败:%v", err)
|
||||
}
|
||||
|
||||
trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeDevice, oldDevice.ID)
|
||||
if err != nil || trace.NextAsset != nil {
|
||||
t.Fatalf("非完成和软删除记录不应形成后代:trace=%+v err=%v", trace, err)
|
||||
}
|
||||
|
||||
newOne := createTraceDevice(t, tx, 81, nil)
|
||||
newTwo := createTraceDevice(t, tx, 82, nil)
|
||||
completedAt := time.Now().Truncate(time.Second)
|
||||
createTraceOrder(t, tx, "UR86-NEXT-OLDER", constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧快照", constants.ExchangeAssetTypeDevice, newOne.ID, "较旧后代快照", constants.ExchangeStatusCompleted, completedAt)
|
||||
createTraceOrder(t, tx, "UR86-NEXT-LATEST", constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧快照", constants.ExchangeAssetTypeDevice, newTwo.ID, "最新后代快照", constants.ExchangeStatusCompleted, completedAt)
|
||||
trace, err = query.Resolve(context.Background(), constants.ExchangeAssetTypeDevice, oldDevice.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询重复后代记录失败:%v", err)
|
||||
}
|
||||
assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeDevice, newTwo.ID, "最新后代快照", "UR86-NEXT-LATEST", true)
|
||||
if logs.FilterMessage("检测到同方向多条已完成换货记录").Len() != 1 {
|
||||
t.Fatalf("应记录后代重复换货异常日志:%v", logs.All())
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeTraceQueryUsesFixedQueriesAndMeetsPerformanceTargets 验证 SQL 次数固定且读取性能满足项目目标。
|
||||
func TestExchangeTraceQueryUsesFixedQueriesAndMeetsPerformanceTargets(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
a := createTraceCard(t, tx, 91, nil)
|
||||
b := createTraceCard(t, tx, 92, nil)
|
||||
c := createTraceCard(t, tx, 93, nil)
|
||||
createTraceOrder(t, tx, "UR86-PERF-AB", constants.ExchangeAssetTypeIotCard, a.ID, "快照A", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeStatusCompleted, time.Now().Add(-time.Minute))
|
||||
createTraceOrder(t, tx, "UR86-PERF-BC", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeAssetTypeIotCard, c.ID, "快照C", constants.ExchangeStatusCompleted, time.Now())
|
||||
counter := &traceQueryCounter{Interface: tx.Logger}
|
||||
query := NewExchangeTraceQuery(tx.Session(&gorm.Session{Logger: counter}), zap.NewNop())
|
||||
|
||||
durations := make([]time.Duration, 30)
|
||||
for index := range durations {
|
||||
startedAt := time.Now()
|
||||
trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, b.ID)
|
||||
durations[index] = time.Since(startedAt)
|
||||
if err != nil || trace.PreviousAsset == nil || trace.NextAsset == nil {
|
||||
t.Fatalf("第 %d 次性能查询失败:trace=%+v err=%v", index+1, trace, err)
|
||||
}
|
||||
}
|
||||
if counter.count.Load() != 90 {
|
||||
t.Fatalf("卡链每次应固定执行 3 条 SQL,实际总数:%d", counter.count.Load())
|
||||
}
|
||||
sort.Slice(durations, func(left, right int) bool { return durations[left] < durations[right] })
|
||||
p95, p99 := durations[28], durations[29]
|
||||
if p95 >= 200*time.Millisecond || p99 >= 500*time.Millisecond {
|
||||
t.Fatalf("换货链 Query 超过 API 性能目标:p95=%s p99=%s", p95, p99)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeTraceIndexDefinitionAndPlans 验证双向部分索引定义及代表性查询计划。
|
||||
func TestExchangeTraceIndexDefinitionAndPlans(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
indexNames := []string{"idx_exchange_trace_new_asset", "idx_exchange_trace_old_asset"}
|
||||
for _, indexName := range indexNames {
|
||||
var count int64
|
||||
if err := tx.Raw("SELECT COUNT(*) FROM pg_class WHERE relname = ?", indexName).Scan(&count).Error; err != nil {
|
||||
t.Fatalf("查询换货链索引数量失败:%v", err)
|
||||
}
|
||||
if os.Getenv("UR86_EXPECT_INDEX_ABSENT") == "1" {
|
||||
if count != 0 {
|
||||
t.Fatalf("回滚后索引 %s 仍然存在", indexName)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("换货链索引 %s 数量异常:%d", indexName, count)
|
||||
}
|
||||
|
||||
var index struct {
|
||||
AccessMethod string `gorm:"column:access_method"`
|
||||
IsUnique bool `gorm:"column:is_unique"`
|
||||
Definition string `gorm:"column:definition"`
|
||||
Predicate string `gorm:"column:predicate"`
|
||||
}
|
||||
err := tx.Raw(`
|
||||
SELECT am.amname AS access_method,
|
||||
ix.indisunique AS is_unique,
|
||||
pg_get_indexdef(ix.indexrelid) AS definition,
|
||||
pg_get_expr(ix.indpred, ix.indrelid) AS predicate
|
||||
FROM pg_index ix
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
JOIN pg_am am ON am.oid = i.relam
|
||||
WHERE i.relname = ?
|
||||
`, indexName).Scan(&index).Error
|
||||
if err != nil {
|
||||
t.Fatalf("查询换货链索引定义失败:%v", err)
|
||||
}
|
||||
if index.AccessMethod != "btree" || index.IsUnique || !strings.Contains(index.Definition, "completed_at DESC NULLS LAST") || !strings.Contains(index.Definition, "id DESC") || !strings.Contains(index.Predicate, "status = 4") || !strings.Contains(index.Predicate, "deleted_at IS NULL") {
|
||||
t.Fatalf("换货链索引定义不符合契约:%+v", index)
|
||||
}
|
||||
}
|
||||
if os.Getenv("UR86_EXPECT_INDEX_ABSENT") == "1" {
|
||||
return
|
||||
}
|
||||
|
||||
for direction, sql := range map[string]string{
|
||||
"previous": "SELECT * FROM tb_exchange_order WHERE new_asset_type = 'iot_card' AND new_asset_id = 1 AND status = 4 AND deleted_at IS NULL ORDER BY completed_at DESC NULLS LAST, id DESC LIMIT 1",
|
||||
"next": "SELECT * FROM tb_exchange_order WHERE old_asset_type = 'iot_card' AND old_asset_id = 1 AND status = 4 AND deleted_at IS NULL ORDER BY completed_at DESC NULLS LAST, id DESC LIMIT 1",
|
||||
} {
|
||||
var planLines []string
|
||||
if err := tx.Exec("SET LOCAL enable_seqscan = off").Error; err != nil {
|
||||
t.Fatalf("配置查询计划测试失败:%v", err)
|
||||
}
|
||||
if err := tx.Raw("EXPLAIN " + sql).Scan(&planLines).Error; err != nil {
|
||||
t.Fatalf("记录 %s 查询计划失败:%v", direction, err)
|
||||
}
|
||||
plan := strings.Join(planLines, "\n")
|
||||
expectedIndex := "idx_exchange_trace_old_asset"
|
||||
if direction == "previous" {
|
||||
expectedIndex = "idx_exchange_trace_new_asset"
|
||||
}
|
||||
if !strings.Contains(plan, expectedIndex) {
|
||||
t.Fatalf("%s 查询计划未使用预期索引 %s:%s", direction, expectedIndex, plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createTraceCard(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.IotCard {
|
||||
t.Helper()
|
||||
iccid := fmt.Sprintf("8986222222222222%04d", suffix)
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], VirtualNo: fmt.Sprintf("UR86-CARD-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建换货链测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createTraceDevice(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.Device {
|
||||
t.Helper()
|
||||
device := &model.Device{VirtualNo: fmt.Sprintf("UR86-DEVICE-%04d", suffix), IMEI: fmt.Sprintf("86222222222%04d", suffix), SN: fmt.Sprintf("UR86-SN-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock}
|
||||
if err := tx.Create(device).Error; err != nil {
|
||||
t.Fatalf("创建换货链测试设备失败:%v", err)
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
||||
func createTraceOrder(t *testing.T, tx *gorm.DB, exchangeNo, oldType string, oldID uint, oldIdentifier, newType string, newID uint, newIdentifier string, status int, completedAt time.Time) *model.ExchangeOrder {
|
||||
t.Helper()
|
||||
order := &model.ExchangeOrder{
|
||||
ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeDirect,
|
||||
OldAssetType: oldType, OldAssetID: oldID, OldAssetIdentifier: oldIdentifier,
|
||||
NewAssetType: newType, NewAssetID: &newID, NewAssetIdentifier: newIdentifier,
|
||||
ExchangeReason: "UR86 换货链测试", Status: status,
|
||||
}
|
||||
if status == constants.ExchangeStatusCompleted {
|
||||
order.CompletedAt = &completedAt
|
||||
}
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
t.Fatalf("创建换货链测试单失败:%v", err)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func assertTraceAsset(t *testing.T, actual *dto.AssetExchangeTraceItem, assetType string, assetID uint, identifier, exchangeNo string, canView bool) {
|
||||
t.Helper()
|
||||
if actual == nil {
|
||||
t.Fatal("换货关联项不应为空")
|
||||
}
|
||||
if actual.AssetType != assetType || actual.Identifier != identifier || actual.ExchangeNo != exchangeNo || actual.CanView != canView {
|
||||
t.Fatalf("换货关联项错误:%+v", actual)
|
||||
}
|
||||
if canView {
|
||||
if actual.AssetID == nil || *actual.AssetID != assetID {
|
||||
t.Fatalf("可见关联资产应返回真实 ID:%+v", actual)
|
||||
}
|
||||
} else if actual.AssetID != nil {
|
||||
t.Fatalf("不可见关联资产不应返回 ID:%+v", actual)
|
||||
}
|
||||
}
|
||||
|
||||
type traceQueryCounter struct {
|
||||
logger.Interface
|
||||
count atomic.Int64
|
||||
}
|
||||
|
||||
func (l *traceQueryCounter) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
l.count.Add(1)
|
||||
l.Interface.Trace(ctx, begin, fc, err)
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// TestListQuerySearchesOldAndNewAssetsIndependently 验证六类资产标识、新旧独立和双条件 AND。
|
||||
func TestListQuerySearchesOldAndNewAssetsIndependently(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
query := NewListQuery(tx)
|
||||
oldCard := createListTestCard(t, tx, 1, nil)
|
||||
newCard := createListTestCard(t, tx, 2, nil)
|
||||
oldDevice := createListTestDevice(t, tx, 1, nil)
|
||||
newDevice := createListTestDevice(t, tx, 2, nil)
|
||||
|
||||
cardOrder := createListTestOrder(t, tx, "UR45-Q-CARD", oldCard.ID, constants.ExchangeAssetTypeIotCard, newCard.ID, constants.ExchangeAssetTypeIotCard, time.Now().Add(-time.Hour))
|
||||
deviceOrder := createListTestOrder(t, tx, "UR45-Q-DEVICE", oldDevice.ID, constants.ExchangeAssetTypeDevice, newDevice.ID, constants.ExchangeAssetTypeDevice, time.Now())
|
||||
|
||||
for name, keyword := range map[string]string{"卡ICCID": oldCard.ICCID, "卡接入号": oldCard.MSISDN, "卡虚拟号": oldCard.VirtualNo} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: keyword}, []uint{cardOrder.ID})
|
||||
})
|
||||
}
|
||||
for name, keyword := range map[string]string{"设备虚拟号": newDevice.VirtualNo, "设备IMEI": newDevice.IMEI, "设备SN": newDevice.SN} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assertListQueryIDs(t, query, &dto.ExchangeListRequest{NewAssetKeyword: keyword}, []uint{deviceOrder.ID})
|
||||
})
|
||||
}
|
||||
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: oldCard.MSISDN, NewAssetKeyword: newCard.VirtualNo}, []uint{cardOrder.ID})
|
||||
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: oldCard.ICCID, NewAssetKeyword: newDevice.IMEI}, nil)
|
||||
}
|
||||
|
||||
// TestListQueryCombinesFiltersAndPreservesHistoricalSnapshots 验证组合条件、历史快照和空结果契约。
|
||||
func TestListQueryCombinesFiltersAndPreservesHistoricalSnapshots(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
query := NewListQuery(tx)
|
||||
oldCard := createListTestCard(t, tx, 11, nil)
|
||||
newCard := createListTestCard(t, tx, 12, nil)
|
||||
createdAt := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
|
||||
order := createListTestOrder(t, tx, "UR45-Q-HISTORY", oldCard.ID, constants.ExchangeAssetTypeIotCard, newCard.ID, constants.ExchangeAssetTypeIotCard, createdAt)
|
||||
if err := tx.Model(order).Updates(map[string]any{
|
||||
"old_asset_identifier": "历史旧快照", "new_asset_identifier": "历史新快照",
|
||||
"status": constants.ExchangeStatusCompleted, "flow_type": constants.ExchangeFlowTypeDirect,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("更新历史快照失败:%v", err)
|
||||
}
|
||||
status := constants.ExchangeStatusCompleted
|
||||
start, end := createdAt.Add(-time.Minute), createdAt.Add(time.Minute)
|
||||
result, err := query.List(context.Background(), &dto.ExchangeListRequest{
|
||||
OldAssetKeyword: oldCard.VirtualNo, NewAssetKeyword: newCard.MSISDN,
|
||||
Status: &status, FlowType: constants.ExchangeFlowTypeDirect, CreatedAtStart: &start, CreatedAtEnd: &end,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("查询历史换货单失败:%v", err)
|
||||
}
|
||||
if result.Total != 1 || len(result.List) != 1 || result.List[0].OldAssetIdentifier != "历史旧快照" || result.List[0].NewAssetIdentifier != "历史新快照" {
|
||||
t.Fatalf("历史快照查询结果错误:%+v", result)
|
||||
}
|
||||
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: "不存在"}, nil)
|
||||
}
|
||||
|
||||
// TestListQueryExcludesDeletedAssetsAndAppliesShopScope 验证候选软删除和最终换货单店铺范围。
|
||||
func TestListQueryExcludesDeletedAssetsAndAppliesShopScope(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
query := NewListQuery(tx)
|
||||
shopOne, shopTwo := uint(45101), uint(45102)
|
||||
cardOne := createListTestCard(t, tx, 21, &shopOne)
|
||||
cardTwo := createListTestCard(t, tx, 22, &shopTwo)
|
||||
orderOne := createListTestOrder(t, tx, "UR45-Q-SCOPE-1", cardOne.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now())
|
||||
orderTwo := createListTestOrder(t, tx, "UR45-Q-SCOPE-2", cardTwo.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now().Add(time.Second))
|
||||
if err := tx.Model(orderOne).Update("shop_id", shopOne).Error; err != nil {
|
||||
t.Fatalf("更新店铺范围失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(orderTwo).Update("shop_id", shopTwo).Error; err != nil {
|
||||
t.Fatalf("更新店铺范围失败:%v", err)
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), constants.ContextKeySubordinateShopIDs, []uint{shopOne})
|
||||
result, err := query.List(ctx, &dto.ExchangeListRequest{OldAssetKeyword: "UR45-Q-CARD"})
|
||||
if err != nil {
|
||||
t.Fatalf("按店铺范围查询失败:%v", err)
|
||||
}
|
||||
if result.Total != 1 || result.List[0].ID != orderOne.ID {
|
||||
t.Fatalf("店铺范围被关键词绕过:%+v", result)
|
||||
}
|
||||
if err := tx.Delete(cardOne).Error; err != nil {
|
||||
t.Fatalf("软删除测试卡失败:%v", err)
|
||||
}
|
||||
result, err = query.List(ctx, &dto.ExchangeListRequest{OldAssetKeyword: cardOne.ICCID})
|
||||
if err != nil || result.Total != 0 {
|
||||
t.Fatalf("软删除候选资产不应命中:result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListQueryUsesFixedQueriesAndConsistentPagination 验证大结果集仅执行计数和分页两条 SQL。
|
||||
func TestListQueryUsesFixedQueriesAndConsistentPagination(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createListTestCard(t, tx, 31, nil)
|
||||
for index := 0; index < 120; index++ {
|
||||
createListTestOrder(t, tx, fmt.Sprintf("UR45-Q-PERF-%03d", index), card.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now().Add(time.Duration(index)*time.Second))
|
||||
}
|
||||
counter := &queryCounter{Interface: tx.Logger}
|
||||
query := NewListQuery(tx.Session(&gorm.Session{Logger: counter}))
|
||||
page, pageSize := 2, 20
|
||||
req := &dto.ExchangeListRequest{OldAssetKeyword: card.ICCID, Page: &page, PageSize: &pageSize}
|
||||
durations := make([]time.Duration, 100)
|
||||
var result *dto.ExchangeListResponse
|
||||
for index := range durations {
|
||||
startedAt := time.Now()
|
||||
var err error
|
||||
result, err = query.List(context.Background(), req)
|
||||
durations[index] = time.Since(startedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 次查询大结果集失败:%v", index+1, err)
|
||||
}
|
||||
}
|
||||
if counter.count.Load() != 200 || result.Total != 120 || len(result.List) != 20 || result.List[0].ExchangeNo != "UR45-Q-PERF-099" {
|
||||
t.Fatalf("查询次数或分页不一致:queries=%d total=%d items=%d first=%s", counter.count.Load(), result.Total, len(result.List), result.List[0].ExchangeNo)
|
||||
}
|
||||
sort.Slice(durations, func(left, right int) bool { return durations[left] < durations[right] })
|
||||
p95, p99 := durations[94], durations[98]
|
||||
if p95 >= 200*time.Millisecond || p99 >= 500*time.Millisecond {
|
||||
t.Fatalf("列表查询超过性能目标:p95=%s p99=%s", p95, p99)
|
||||
}
|
||||
planSQL := query.db.ToSQL(func(db *gorm.DB) *gorm.DB {
|
||||
return applyListFilters(db.Model(&model.ExchangeOrder{}), req).Order("created_at DESC").Offset(20).Limit(20).Find(&[]*model.ExchangeOrder{})
|
||||
})
|
||||
var planLines []string
|
||||
if err := tx.Raw("EXPLAIN " + planSQL).Scan(&planLines).Error; err != nil || len(planLines) == 0 {
|
||||
t.Fatalf("记录查询计划失败:plan=%v err=%v", planLines, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListQueryWrapsDatabaseErrors 验证数据库故障不会降级为空结果。
|
||||
func TestListQueryWrapsDatabaseErrors(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
callbackName := "ur45:force_query_error"
|
||||
if err := tx.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
|
||||
db.AddError(fmt.Errorf("UR45 模拟数据库故障"))
|
||||
}); err != nil {
|
||||
t.Fatalf("注册数据库故障回调失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = tx.Callback().Query().Remove(callbackName) })
|
||||
_, err := NewListQuery(tx).List(context.Background(), &dto.ExchangeListRequest{})
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
if !ok || appErr.Code != errors.CodeDatabaseError {
|
||||
t.Fatalf("数据库故障应转换为统一错误,实际:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type queryCounter struct {
|
||||
logger.Interface
|
||||
count atomic.Int64
|
||||
}
|
||||
|
||||
func (l *queryCounter) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
l.count.Add(1)
|
||||
l.Interface.Trace(ctx, begin, fc, err)
|
||||
}
|
||||
|
||||
func createListTestCard(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.IotCard {
|
||||
t.Helper()
|
||||
iccid := fmt.Sprintf("8986111111111111%04d", suffix)
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], MSISDN: fmt.Sprintf("1370000%04d", suffix), VirtualNo: fmt.Sprintf("UR45-Q-CARD-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createListTestDevice(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.Device {
|
||||
t.Helper()
|
||||
device := &model.Device{VirtualNo: fmt.Sprintf("UR45-Q-DEVICE-%04d", suffix), IMEI: fmt.Sprintf("86111111111%04d", suffix), SN: fmt.Sprintf("UR45-Q-SN-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock}
|
||||
if err := tx.Create(device).Error; err != nil {
|
||||
t.Fatalf("创建设备失败:%v", err)
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
||||
func createListTestOrder(t *testing.T, tx *gorm.DB, exchangeNo string, oldID uint, oldType string, newID uint, newType string, createdAt time.Time) *model.ExchangeOrder {
|
||||
t.Helper()
|
||||
order := &model.ExchangeOrder{ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeShipping, OldAssetType: oldType, OldAssetID: oldID, OldAssetIdentifier: "历史旧快照", NewAssetType: newType, NewAssetIdentifier: "历史新快照", ExchangeReason: "UR45 查询测试", Status: constants.ExchangeStatusPendingInfo}
|
||||
if newID > 0 {
|
||||
order.NewAssetID = &newID
|
||||
}
|
||||
order.CreatedAt, order.UpdatedAt = createdAt, createdAt
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
t.Fatalf("创建换货单失败:%v", err)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func assertListQueryIDs(t *testing.T, query *ListQuery, req *dto.ExchangeListRequest, expected []uint) {
|
||||
t.Helper()
|
||||
result, err := query.List(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("查询换货单失败:%v", err)
|
||||
}
|
||||
if result.Total != int64(len(expected)) || len(result.List) != len(expected) {
|
||||
t.Fatalf("命中数量错误:total=%d items=%d expected=%d", result.Total, len(result.List), len(expected))
|
||||
}
|
||||
for index, id := range expected {
|
||||
if result.List[index].ID != id {
|
||||
t.Fatalf("命中换货单错误:期望 %d,实际 %d", id, result.List[index].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package outbox_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
outboxquery "github.com/break/junhong_cmp_fiber/internal/query/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func TestMetricsCoverBacklogRetriesFailuresAndThresholdAlerts(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
testutil.CreateTemporaryOutboxTable(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
states := []struct {
|
||||
id string
|
||||
eventType string
|
||||
status int
|
||||
retries int
|
||||
}{
|
||||
{id: "metrics-pending", eventType: "example.a", status: constants.OutboxStatusPending},
|
||||
{id: "metrics-delivering", eventType: "example.a", status: constants.OutboxStatusDelivering, retries: 1},
|
||||
{id: "metrics-delivered", eventType: "example.b", status: constants.OutboxStatusDelivered},
|
||||
{id: "metrics-failed", eventType: "example.b", status: constants.OutboxStatusFailed, retries: 3},
|
||||
}
|
||||
for _, state := range states {
|
||||
event, err := repository.Append(context.Background(), db, outbox.Envelope{
|
||||
EventID: state.id, EventType: state.eventType, AggregateType: "example", AggregateID: state.id,
|
||||
ResourceType: "example", ResourceID: state.id, Payload: struct {
|
||||
Visible bool `json:"visible"`
|
||||
}{Visible: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("准备指标事件失败:%v", err)
|
||||
}
|
||||
updates := map[string]any{"status": state.status, "retry_count": state.retries, "created_at": now.Add(-2 * time.Minute), "updated_at": now}
|
||||
if state.status == constants.OutboxStatusDelivering {
|
||||
updates["lease_owner"] = "dead-worker"
|
||||
updates["lease_expires_at"] = now.Add(-time.Minute)
|
||||
}
|
||||
if state.status == constants.OutboxStatusDelivered {
|
||||
updates["delivered_at"] = now.Add(-time.Minute)
|
||||
}
|
||||
if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Updates(updates).Error; err != nil {
|
||||
t.Fatalf("设置指标事件状态失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
query := outboxquery.NewQuery(db, func() time.Time { return now })
|
||||
metrics, err := query.GetMetrics(context.Background(), time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("查询 Outbox 指标失败:%v", err)
|
||||
}
|
||||
if metrics.PendingCount != 1 || metrics.DeliveringCount != 1 || metrics.ExpiredLeaseCount != 1 ||
|
||||
metrics.DeliveredInWindow != 1 || metrics.FinalFailedCount != 1 || metrics.OldestPendingAgeSecs != 120 {
|
||||
t.Fatalf("Outbox 指标不完整:%+v", metrics)
|
||||
}
|
||||
if len(metrics.RetryDistribution) != 2 || len(metrics.BacklogByEventType) != 2 {
|
||||
t.Fatalf("重试分布或事件类型积压不完整:%+v", metrics)
|
||||
}
|
||||
alerts := outboxquery.EvaluateAlerts(metrics, outboxquery.Thresholds{
|
||||
PendingCount: 1, OldestPendingAge: time.Minute, ExpiredLeaseCount: 1, FinalFailedCount: 1,
|
||||
})
|
||||
if len(alerts) != 4 {
|
||||
t.Fatalf("阈值告警数量错误:%+v", alerts)
|
||||
}
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
package packageexpiry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// testIndexCardID 是索引执行计划测试使用的卡资产 ID。
|
||||
testIndexCardID = 970001
|
||||
// testIndexDeviceID 是索引执行计划测试使用的设备资产 ID。
|
||||
testIndexDeviceID = 970002
|
||||
// testQueryPerformanceLimitMS 是项目数据库查询耗时上限。
|
||||
testQueryPerformanceLimitMS = 50
|
||||
)
|
||||
|
||||
func TestCalculateBasicStatuses(t *testing.T) {
|
||||
location, now, currentExpiry := calculationTimes()
|
||||
assertCalculation(t, nil, nil, now, constants.PackageExpiryEstimateStatusNone, nil, nil)
|
||||
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
current.ExpiresAt = ¤tExpiry
|
||||
assertCalculation(t, []*model.PackageUsage{current}, nil, now, constants.PackageExpiryEstimateStatusExact, ¤tExpiry, intPointer(8))
|
||||
waiting := newCalculationUsage(2, constants.PackageUsageStatusPending, 1)
|
||||
waiting.PendingRealnameActivation = true
|
||||
assertCalculation(t, []*model.PackageUsage{waiting}, nil, now, constants.PackageExpiryEstimateStatusWaitingActivation, nil, nil)
|
||||
expiredAt := time.Date(2026, 7, 20, 23, 59, 59, 0, location)
|
||||
expiredCurrent := newCalculationUsage(3, constants.PackageUsageStatusActive, 1)
|
||||
expiredCurrent.ExpiresAt = &expiredAt
|
||||
assertCalculation(t, []*model.PackageUsage{expiredCurrent}, nil, now, constants.PackageExpiryEstimateStatusExact, &expiredAt, intPointer(-3))
|
||||
duplicateCurrent := newCalculationUsage(4, constants.PackageUsageStatusDepleted, 2)
|
||||
duplicateCurrent.ExpiresAt = ¤tExpiry
|
||||
assertCalculation(t, []*model.PackageUsage{current, duplicateCurrent}, nil, now, constants.PackageExpiryEstimateStatusInvalidData, nil, nil)
|
||||
}
|
||||
|
||||
func TestCalculateQueueAndCalendarBoundaries(t *testing.T) {
|
||||
location, now, currentExpiry := calculationTimes()
|
||||
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
current.ExpiresAt = ¤tExpiry
|
||||
firstQueued := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||
secondQueued := newCalculationUsage(3, constants.PackageUsageStatusPending, 3)
|
||||
assertCalculation(t, []*model.PackageUsage{current, firstQueued, secondQueued}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 8, 8, 23, 59, 59, 0, location)), intPointer(16))
|
||||
naturalMonth := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||
naturalMonth.CalendarTypeSnapshot, naturalMonth.DurationDaysSnapshot, naturalMonth.DurationMonthsSnapshot = constants.PackageCalendarTypeNaturalMonth, 0, 1
|
||||
assertCalculation(t, []*model.PackageUsage{current, naturalMonth}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 9, 30, 23, 59, 59, 0, location)), intPointer(69))
|
||||
crossYearExpiry := time.Date(2026, 12, 31, 23, 59, 59, 0, location)
|
||||
crossYearCurrent := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
crossYearCurrent.ExpiresAt = &crossYearExpiry
|
||||
oneDay := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||
oneDay.DurationDaysSnapshot = 1
|
||||
assertCalculation(t, []*model.PackageUsage{crossYearCurrent, oneDay}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2027, 1, 2, 23, 59, 59, 0, location)), nil)
|
||||
assertDuplicatePriorityOrder(t, location, now)
|
||||
}
|
||||
|
||||
func TestCalculateFallbackAndExclusions(t *testing.T) {
|
||||
location, now, currentExpiry := calculationTimes()
|
||||
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
current.ExpiresAt = ¤tExpiry
|
||||
historical := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||
historical.ExpiryBaseSnapshot, historical.CalendarTypeSnapshot, historical.DurationDaysSnapshot = "", "", 0
|
||||
packages := map[uint]*model.Package{2: {ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 3}}
|
||||
assertCalculation(t, []*model.PackageUsage{current, historical}, packages, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 8, 4, 23, 59, 59, 0, location)), intPointer(12))
|
||||
invalid := newCalculationUsage(3, constants.PackageUsageStatusPending, 2)
|
||||
invalid.CalendarTypeSnapshot = "bad"
|
||||
assertCalculation(t, []*model.PackageUsage{current, invalid}, nil, now, constants.PackageExpiryEstimateStatusInvalidData, nil, nil)
|
||||
assertExcludedUsages(t, now)
|
||||
}
|
||||
|
||||
// TestCalculateExpiringBoundaries 验证临期标记包含 0 和 15 天,并排除 16 天及已过期结果。
|
||||
func TestCalculateExpiringBoundaries(t *testing.T) {
|
||||
location, now, _ := calculationTimes()
|
||||
for _, testCase := range []struct {
|
||||
days int
|
||||
want bool
|
||||
}{{days: -1, want: false}, {days: 0, want: true}, {days: 15, want: true}, {days: 16, want: false}} {
|
||||
usage := newCalculationUsage(uint(testCase.days+2), constants.PackageUsageStatusActive, 1)
|
||||
expiresAt := now.AddDate(0, 0, testCase.days).In(location)
|
||||
usage.ExpiresAt = &expiresAt
|
||||
result := Calculate([]*model.PackageUsage{usage}, nil, now)
|
||||
if result.IsExpiring != testCase.want {
|
||||
t.Fatalf("临期边界错误:days=%d want=%v result=%+v", testCase.days, testCase.want, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func calculationTimes() (*time.Location, time.Time, time.Time) {
|
||||
location := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
return location, time.Date(2026, 7, 23, 12, 0, 0, 0, location), time.Date(2026, 7, 31, 23, 59, 59, 0, location)
|
||||
}
|
||||
|
||||
func newCalculationUsage(id uint, status, priority int) *model.PackageUsage {
|
||||
createdAt := time.Date(2026, 7, 1, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
return &model.PackageUsage{Model: modelBase(id, createdAt.Add(time.Duration(id)*time.Second)), PackageID: id, Status: status, Priority: priority, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 3}
|
||||
}
|
||||
|
||||
func assertCalculation(t *testing.T, usages []*model.PackageUsage, packages map[uint]*model.Package, now time.Time, wantStatus string, wantDate *time.Time, wantDays *int) {
|
||||
t.Helper()
|
||||
got := Calculate(usages, packages, now)
|
||||
if got.ExpiryEstimateStatus != wantStatus || wantDate != nil && (got.EstimatedFinalExpiresAt == nil || !got.EstimatedFinalExpiresAt.Equal(*wantDate)) || wantDays != nil && (got.DaysUntilFinalExpiry == nil || *got.DaysUntilFinalExpiry != *wantDays) {
|
||||
t.Fatalf("推算结果错误:status=%s date=%v days=%v result=%+v", wantStatus, wantDate, wantDays, got)
|
||||
}
|
||||
if wantStatus != constants.PackageExpiryEstimateStatusExact && (got.EstimatedFinalExpiresAt != nil || got.DaysUntilFinalExpiry != nil) {
|
||||
t.Fatalf("非精确状态必须返回 null:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertDuplicatePriorityOrder(t *testing.T, location *time.Location, now time.Time) {
|
||||
t.Helper()
|
||||
expiresAt := time.Date(2026, 1, 30, 23, 59, 59, 0, location)
|
||||
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
current.ExpiresAt = &expiresAt
|
||||
naturalMonth := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||
naturalMonth.CalendarTypeSnapshot, naturalMonth.DurationDaysSnapshot, naturalMonth.DurationMonthsSnapshot = constants.PackageCalendarTypeNaturalMonth, 0, 1
|
||||
byDay := newCalculationUsage(3, constants.PackageUsageStatusPending, 2)
|
||||
byDay.DurationDaysSnapshot = 5
|
||||
assertCalculation(t, []*model.PackageUsage{byDay, current, naturalMonth}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 3, 6, 23, 59, 59, 0, location)), nil)
|
||||
}
|
||||
|
||||
func assertExcludedUsages(t *testing.T, now time.Time) {
|
||||
t.Helper()
|
||||
expired := newCalculationUsage(1, constants.PackageUsageStatusExpired, 1)
|
||||
invalidated := newCalculationUsage(2, constants.PackageUsageStatusInvalidated, 2)
|
||||
refundID, masterID := uint(3), uint(1)
|
||||
refunded := newCalculationUsage(3, constants.PackageUsageStatusActive, 3)
|
||||
refunded.RefundID = &refundID
|
||||
deleted := newCalculationUsage(4, constants.PackageUsageStatusActive, 4)
|
||||
deleted.DeletedAt = gorm.DeletedAt{Time: now, Valid: true}
|
||||
addon := newCalculationUsage(5, constants.PackageUsageStatusActive, 5)
|
||||
addon.MasterUsageID = &masterID
|
||||
assertCalculation(t, []*model.PackageUsage{expired, invalidated, refunded, deleted, addon}, nil, now, constants.PackageExpiryEstimateStatusNone, nil, nil)
|
||||
}
|
||||
|
||||
// TestCalculateUsesShanghaiCalendarDays 验证剩余天数按上海自然日而非不足 24 小时取整。
|
||||
func TestCalculateUsesShanghaiCalendarDays(t *testing.T) {
|
||||
location := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
now := time.Date(2026, 7, 23, 23, 30, 0, 0, location)
|
||||
expiresAt := time.Date(2026, 7, 24, 0, 30, 0, 0, location)
|
||||
usage := &model.PackageUsage{Model: modelBase(1, now), Status: constants.PackageUsageStatusActive, Priority: 1, ExpiresAt: &expiresAt}
|
||||
result := Calculate([]*model.PackageUsage{usage}, nil, now)
|
||||
if result.DaysUntilFinalExpiry == nil || *result.DaysUntilFinalExpiry != 1 {
|
||||
t.Fatalf("上海自然日差错误:want=1 got=%v", result.DaysUntilFinalExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryResolveBatchUsesOneBatchLoad 验证卡和设备各 100 个资产只执行一次套餐读取。
|
||||
func TestQueryResolveBatchUsesOneBatchLoad(t *testing.T) {
|
||||
for _, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
|
||||
t.Run(assetType, func(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
assetIDs := make([]uint, 0, 100)
|
||||
for i := 1; i <= 100; i++ {
|
||||
assetID := uint(970000 + i)
|
||||
assetIDs = append(assetIDs, assetID)
|
||||
expiresAt := now.AddDate(0, 0, i%16)
|
||||
usage := newQueryConsistencyUsage(assetType, assetID, constants.PackageUsageStatusActive, 1, now)
|
||||
usage.ExpiresAt = &expiresAt
|
||||
usage.OrderNo = "UR46-BATCH"
|
||||
createQueryConsistencyUsage(t, tx, usage)
|
||||
}
|
||||
counter := &queryCounterLogger{Interface: tx.Logger}
|
||||
query := NewQuery(tx.Session(&gorm.Session{Logger: counter}))
|
||||
query.now = func() time.Time { return now }
|
||||
results, err := query.ResolveBatch(context.Background(), assetType, assetIDs)
|
||||
if err != nil {
|
||||
t.Fatalf("批量查询失败:%v", err)
|
||||
}
|
||||
if len(results) != len(assetIDs) || counter.count != 1 {
|
||||
t.Fatalf("100 个资产批量读取错误:results=%d queries=%d", len(results), counter.count)
|
||||
}
|
||||
for _, assetID := range assetIDs {
|
||||
if results[assetID].ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact {
|
||||
t.Fatalf("资产 %d 应得到精确结果:%+v", assetID, results[assetID])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type queryCounterLogger struct {
|
||||
logger.Interface
|
||||
count int
|
||||
}
|
||||
|
||||
func (l *queryCounterLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
l.count++
|
||||
l.Interface.Trace(ctx, begin, fc, err)
|
||||
}
|
||||
|
||||
// TestQueryResolveAndResolveBatchReturnSameResult 验证单资产与批量入口共享同一结果口径。
|
||||
func TestQueryResolveAndResolveBatchReturnSameResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
assetType string
|
||||
scenario string
|
||||
}{
|
||||
{name: "卡历史回退", assetType: constants.AssetTypeIotCard, scenario: "historical"},
|
||||
{name: "卡等待激活", assetType: constants.AssetTypeIotCard, scenario: "waiting"},
|
||||
{name: "卡异常数据", assetType: constants.AssetTypeIotCard, scenario: "invalid"},
|
||||
{name: "卡负数天数", assetType: constants.AssetTypeIotCard, scenario: "expired"},
|
||||
{name: "设备历史回退", assetType: constants.AssetTypeDevice, scenario: "historical"},
|
||||
{name: "设备等待激活", assetType: constants.AssetTypeDevice, scenario: "waiting"},
|
||||
{name: "设备异常数据", assetType: constants.AssetTypeDevice, scenario: "invalid"},
|
||||
{name: "设备负数天数", assetType: constants.AssetTypeDevice, scenario: "expired"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
assetID := uint(980001)
|
||||
createQueryConsistencyFixture(t, tx, tt.assetType, assetID, tt.scenario, now)
|
||||
query := NewQuery(tx)
|
||||
query.now = func() time.Time { return now }
|
||||
single, err := query.Resolve(context.Background(), tt.assetType, assetID)
|
||||
if err != nil {
|
||||
t.Fatalf("单资产查询失败:%v", err)
|
||||
}
|
||||
batch, err := query.ResolveBatch(context.Background(), tt.assetType, []uint{assetID})
|
||||
if err != nil {
|
||||
t.Fatalf("批量查询失败:%v", err)
|
||||
}
|
||||
assertEstimateEqual(t, single, batch[assetID])
|
||||
assertScenarioEstimate(t, single, tt.scenario, now)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertScenarioEstimate 验证一致性夹具不仅两入口相同,也符合各场景业务结果。
|
||||
func assertScenarioEstimate(t *testing.T, estimate dto.PackageExpiryEstimate, scenario string, now time.Time) {
|
||||
t.Helper()
|
||||
switch scenario {
|
||||
case "historical":
|
||||
currentExpiry := now.AddDate(0, 0, 5)
|
||||
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact || estimate.EstimatedFinalExpiresAt == nil || !estimate.EstimatedFinalExpiresAt.After(currentExpiry) {
|
||||
t.Fatalf("历史快照回退未参与套餐接续:%+v", estimate)
|
||||
}
|
||||
case "waiting":
|
||||
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusWaitingActivation {
|
||||
t.Fatalf("等待激活场景错误:%+v", estimate)
|
||||
}
|
||||
case "invalid":
|
||||
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusInvalidData {
|
||||
t.Fatalf("异常数据场景错误:%+v", estimate)
|
||||
}
|
||||
case "expired":
|
||||
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact || estimate.DaysUntilFinalExpiry == nil || *estimate.DaysUntilFinalExpiry != -3 {
|
||||
t.Fatalf("负数天数场景错误:%+v", estimate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createQueryConsistencyFixture 按场景创建单资产与批量入口共享的数据库夹具。
|
||||
func createQueryConsistencyFixture(t *testing.T, tx *gorm.DB, assetType string, assetID uint, scenario string, now time.Time) {
|
||||
t.Helper()
|
||||
usage := newQueryConsistencyUsage(assetType, assetID, constants.PackageUsageStatusActive, 1, now)
|
||||
switch scenario {
|
||||
case "waiting":
|
||||
usage.Status = constants.PackageUsageStatusPending
|
||||
usage.PendingRealnameActivation = true
|
||||
usage.ExpiresAt = nil
|
||||
createQueryConsistencyUsage(t, tx, usage)
|
||||
case "invalid":
|
||||
usage.ExpiresAt = nil
|
||||
createQueryConsistencyUsage(t, tx, usage)
|
||||
case "expired":
|
||||
expiresAt := now.AddDate(0, 0, -3)
|
||||
usage.ExpiresAt = &expiresAt
|
||||
createQueryConsistencyUsage(t, tx, usage)
|
||||
case "historical":
|
||||
expiresAt := now.AddDate(0, 0, 5)
|
||||
usage.ExpiresAt = &expiresAt
|
||||
createQueryConsistencyUsage(t, tx, usage)
|
||||
pkg := &model.Package{PackageCode: "UR46-FALLBACK-" + assetType, PackageName: "UR46历史回退套餐", PackageType: constants.PackageTypeFormal, DurationDays: 3, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: constants.PackageExpiryBaseFromActivation, Status: constants.StatusEnabled, ShelfStatus: 1}
|
||||
if err := tx.Create(pkg).Error; err != nil {
|
||||
t.Fatalf("创建历史回退套餐失败:%v", err)
|
||||
}
|
||||
queued := newQueryConsistencyUsage(assetType, assetID, constants.PackageUsageStatusPending, 2, now)
|
||||
queued.PackageID = pkg.ID
|
||||
queued.OrderID++
|
||||
queued.OrderNo = "UR46-CONSISTENCY-Q"
|
||||
queued.ExpiryBaseSnapshot = ""
|
||||
queued.CalendarTypeSnapshot = ""
|
||||
queued.DurationDaysSnapshot = 0
|
||||
// 历史套餐允许缺少快照;仅在测试事务内临时关闭新数据校验触发器,事务结束后状态自动恢复。
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("禁用历史快照触发器失败:%v", err)
|
||||
}
|
||||
createQueryConsistencyUsage(t, tx, queued)
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage ENABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("恢复历史快照触发器失败:%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newQueryConsistencyUsage(assetType string, assetID uint, status, priority int, now time.Time) *model.PackageUsage {
|
||||
usage := &model.PackageUsage{OrderID: assetID, OrderNo: "UR46-CONSISTENCY", PackageID: assetID, UsageType: assetType, DataLimitMB: 1, Status: status, Priority: priority, ActivatedAt: &now, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
|
||||
if assetType == constants.AssetTypeDevice {
|
||||
usage.DeviceID = assetID
|
||||
} else {
|
||||
usage.IotCardID = assetID
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func createQueryConsistencyUsage(t *testing.T, tx *gorm.DB, usage *model.PackageUsage) {
|
||||
t.Helper()
|
||||
desiredStatus := usage.Status
|
||||
pendingRealname := usage.PendingRealnameActivation
|
||||
if err := tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
|
||||
t.Fatalf("创建一致性套餐记录失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(usage).Updates(map[string]any{"status": desiredStatus, "pending_realname_activation": pendingRealname}).Error; err != nil {
|
||||
t.Fatalf("更新一致性套餐状态失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEstimateEqual(t *testing.T, left, right dto.PackageExpiryEstimate) {
|
||||
t.Helper()
|
||||
if left.ExpiryEstimateStatus != right.ExpiryEstimateStatus || left.ExpiryEstimateStatusName != right.ExpiryEstimateStatusName || left.IsExpiring != right.IsExpiring || !timePointersEqual(left.EstimatedFinalExpiresAt, right.EstimatedFinalExpiresAt) || !intPointersEqual(left.DaysUntilFinalExpiry, right.DaysUntilFinalExpiry) {
|
||||
t.Fatalf("单资产与批量结果不一致:single=%+v batch=%+v", left, right)
|
||||
}
|
||||
}
|
||||
|
||||
func timePointersEqual(left, right *time.Time) bool {
|
||||
return left == nil && right == nil || left != nil && right != nil && left.Equal(*right)
|
||||
}
|
||||
|
||||
func intPointersEqual(left, right *int) bool {
|
||||
return left == nil && right == nil || left != nil && right != nil && *left == *right
|
||||
}
|
||||
|
||||
// TestQueryIndexesExplainAnalyze 验证 UR46 部分索引定义及代表性查询执行时间。
|
||||
func TestQueryIndexesExplainAnalyze(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
createIndexPlanFixtures(t, tx)
|
||||
var indexDefinitions []string
|
||||
if err := tx.Raw("SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname IN (?, ?) ORDER BY indexname", "idx_package_usage_expiry_iot_card_queue", "idx_package_usage_expiry_device_queue").Scan(&indexDefinitions).Error; err != nil {
|
||||
t.Fatalf("查询 UR46 索引定义失败:%v", err)
|
||||
}
|
||||
if len(indexDefinitions) != 2 {
|
||||
t.Fatalf("UR46 部分索引数量错误:want=2 got=%d", len(indexDefinitions))
|
||||
}
|
||||
for _, definition := range indexDefinitions {
|
||||
if !strings.Contains(definition, "master_usage_id IS NULL") || !strings.Contains(definition, "refund_id IS NULL") || !strings.Contains(definition, "status = ANY") {
|
||||
t.Fatalf("UR46 部分索引条件不完整:%s", definition)
|
||||
}
|
||||
}
|
||||
if err := tx.Exec("ANALYZE tb_package_usage").Error; err != nil {
|
||||
t.Fatalf("更新代表性数据统计信息失败:%v", err)
|
||||
}
|
||||
queries := []struct {
|
||||
sql string
|
||||
indexName string
|
||||
}{
|
||||
{sql: "EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE deleted_at IS NULL AND iot_card_id = 970001 AND master_usage_id IS NULL AND refund_id IS NULL AND status IN (0, 1, 2) ORDER BY priority ASC, created_at ASC, id ASC", indexName: "idx_package_usage_expiry_iot_card_queue"},
|
||||
{sql: "EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE deleted_at IS NULL AND device_id = 970002 AND master_usage_id IS NULL AND refund_id IS NULL AND status IN (0, 1, 2) ORDER BY priority ASC, created_at ASC, id ASC", indexName: "idx_package_usage_expiry_device_queue"},
|
||||
}
|
||||
for _, query := range queries {
|
||||
var lines []string
|
||||
if err := tx.Raw(query.sql).Scan(&lines).Error; err != nil {
|
||||
t.Fatalf("执行 EXPLAIN ANALYZE 失败:%v", err)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
t.Fatal("EXPLAIN ANALYZE 未返回执行计划")
|
||||
}
|
||||
plan := strings.Join(lines, "\n")
|
||||
if !strings.Contains(plan, query.indexName) {
|
||||
t.Fatalf("代表性查询未命中 UR46 部分索引 %s:%s", query.indexName, plan)
|
||||
}
|
||||
match := regexp.MustCompile(`Execution Time: ([0-9.]+) ms`).FindStringSubmatch(plan)
|
||||
if len(match) != 2 {
|
||||
t.Fatalf("执行计划缺少执行时间:%v", lines)
|
||||
}
|
||||
executionMS, err := strconv.ParseFloat(match[1], 64)
|
||||
if err != nil || executionMS >= testQueryPerformanceLimitMS {
|
||||
t.Fatalf("代表性套餐队列查询超过 50ms:execution_ms=%v err=%v", executionMS, err)
|
||||
}
|
||||
t.Logf("%s", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// createIndexPlanFixtures 创建高基数背景数据和目标套餐队列,供正常规划器评估索引选择。
|
||||
func createIndexPlanFixtures(t *testing.T, tx *gorm.DB) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
expiresAt := now.AddDate(0, 0, 30)
|
||||
usages := make([]model.PackageUsage, 0, 2020)
|
||||
for index := 0; index < 1010; index++ {
|
||||
cardID, deviceID := uint(971000+index), uint(972500+index)
|
||||
if index < 10 {
|
||||
cardID, deviceID = testIndexCardID, testIndexDeviceID
|
||||
}
|
||||
card := newQueryConsistencyUsage(constants.AssetTypeIotCard, cardID, constants.PackageUsageStatusActive, index+1, now)
|
||||
card.OrderID, card.PackageID, card.OrderNo, card.ExpiresAt = uint(800000+index), uint(800000+index), "UR46-INDEX-CARD-"+strconv.Itoa(index), &expiresAt
|
||||
device := newQueryConsistencyUsage(constants.AssetTypeDevice, deviceID, constants.PackageUsageStatusActive, index+1, now)
|
||||
device.OrderID, device.PackageID, device.OrderNo, device.ExpiresAt = uint(900000+index), uint(900000+index), "UR46-INDEX-DEVICE-"+strconv.Itoa(index), &expiresAt
|
||||
usages = append(usages, *card, *device)
|
||||
}
|
||||
if err := tx.CreateInBatches(usages, 200).Error; err != nil {
|
||||
t.Fatalf("创建索引代表性数据失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func modelBase(id uint, createdAt time.Time) gorm.Model {
|
||||
return gorm.Model{ID: id, CreatedAt: createdAt}
|
||||
}
|
||||
|
||||
func intPointer(value int) *int { return &value }
|
||||
|
||||
func timePointer(value time.Time) *time.Time { return &value }
|
||||
@@ -1,954 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
|
||||
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
||||
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||
iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/database"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
const (
|
||||
// testListPageSize 是列表批量验收的代表性每页资产数量。
|
||||
testListPageSize = 100
|
||||
// testHTTPPerformanceLimit 是单次集成请求可验证的项目 API P99 响应耗时上限。
|
||||
testHTTPPerformanceLimit = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace 验证真实认证、权限、资产解析和双向换货投影。
|
||||
func TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace(t *testing.T) {
|
||||
env := newAssetTraceHTTPEnv(t)
|
||||
visibleShop := env.createShop(t, "UR86 可见店铺", nil, 1)
|
||||
hiddenShop := env.createShop(t, "UR86 不可见店铺", nil, 1)
|
||||
oldCard := env.createCard(t, 1, &hiddenShop.ID)
|
||||
middleCard := env.createCard(t, 2, &visibleShop.ID)
|
||||
nextCard := env.createCard(t, 3, &visibleShop.ID)
|
||||
env.createOrder(t, "UR86-HTTP-PREV", oldCard, middleCard, "HTTP 历史前代快照", "HTTP 中间快照", time.Now().Add(-time.Minute))
|
||||
env.createOrder(t, "UR86-HTTP-NEXT", middleCard, nextCard, "HTTP 中间快照", "HTTP 历史后代快照", time.Now())
|
||||
token := env.newAgentToken(t, visibleShop.ID)
|
||||
|
||||
status, body := env.request(t, "/api/admin/assets/resolve/"+middleCard.ICCID, token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("查询中间资产失败,状态 %d:%s", status, body)
|
||||
}
|
||||
var success struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
AssetID uint `json:"asset_id"`
|
||||
ExchangeTrace struct {
|
||||
PreviousAsset *struct {
|
||||
AssetID *uint `json:"asset_id"`
|
||||
Identifier string `json:"identifier"`
|
||||
ExchangeNo string `json:"exchange_no"`
|
||||
CanView bool `json:"can_view"`
|
||||
} `json:"previous_asset"`
|
||||
NextAsset *struct {
|
||||
AssetID *uint `json:"asset_id"`
|
||||
Identifier string `json:"identifier"`
|
||||
ExchangeNo string `json:"exchange_no"`
|
||||
CanView bool `json:"can_view"`
|
||||
} `json:"next_asset"`
|
||||
} `json:"exchange_trace"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &success); err != nil {
|
||||
t.Fatalf("解析资产详情响应失败:%v", err)
|
||||
}
|
||||
if success.Code != errors.CodeSuccess || success.Data.AssetID != middleCard.ID || success.Data.ExchangeTrace.PreviousAsset == nil || success.Data.ExchangeTrace.NextAsset == nil {
|
||||
t.Fatalf("双向换货响应不完整:%s", body)
|
||||
}
|
||||
previous := success.Data.ExchangeTrace.PreviousAsset
|
||||
if previous.CanView || previous.AssetID != nil || previous.Identifier != "HTTP 历史前代快照" || previous.ExchangeNo != "UR86-HTTP-PREV" {
|
||||
t.Fatalf("不可见前代未按契约隐藏 ID:%s", body)
|
||||
}
|
||||
next := success.Data.ExchangeTrace.NextAsset
|
||||
if !next.CanView || next.AssetID == nil || *next.AssetID != nextCard.ID || next.Identifier != "HTTP 历史后代快照" || next.ExchangeNo != "UR86-HTTP-NEXT" {
|
||||
t.Fatalf("可见后代未按契约返回:%s", body)
|
||||
}
|
||||
|
||||
noTraceCard := env.createCard(t, 4, &visibleShop.ID)
|
||||
status, body = env.request(t, "/api/admin/assets/resolve/"+noTraceCard.ICCID, token)
|
||||
assertHTTPTraceDirections(t, status, body, false, false)
|
||||
|
||||
previousOnlyOld := env.createCard(t, 5, &visibleShop.ID)
|
||||
previousOnlyNew := env.createCard(t, 6, &visibleShop.ID)
|
||||
env.createOrder(t, "UR86-HTTP-PREV-ONLY", previousOnlyOld, previousOnlyNew, "仅前代旧快照", "仅前代新快照", time.Now())
|
||||
status, body = env.request(t, "/api/admin/assets/resolve/"+previousOnlyNew.ICCID, token)
|
||||
assertHTTPTraceDirections(t, status, body, true, false)
|
||||
|
||||
nextOnlyOld := env.createCard(t, 7, &visibleShop.ID)
|
||||
nextOnlyNew := env.createCard(t, 8, &visibleShop.ID)
|
||||
env.createOrder(t, "UR86-HTTP-NEXT-ONLY", nextOnlyOld, nextOnlyNew, "仅后代旧快照", "仅后代新快照", time.Now())
|
||||
status, body = env.request(t, "/api/admin/assets/resolve/"+nextOnlyOld.ICCID, token)
|
||||
assertHTTPTraceDirections(t, status, body, false, true)
|
||||
|
||||
status, body = env.request(t, "/api/admin/assets/resolve/"+oldCard.ICCID, token)
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("当前资产无权限时应维持防枚举响应,状态 %d:%s", status, body)
|
||||
}
|
||||
var denied struct {
|
||||
Code int `json:"code"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &denied); err != nil || denied.Code != errors.CodeNotFound || denied.Data != nil {
|
||||
t.Fatalf("当前资产无权限响应不符合原契约:%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssetResolveHTTPReturnsPackageExpiryEstimate 验证后台详情通过真实 JWT、Redis、Fiber 与 PostgreSQL 返回预计最终到期字段。
|
||||
func TestAssetResolveHTTPReturnsPackageExpiryEstimate(t *testing.T) {
|
||||
env := newAssetTraceHTTPEnv(t)
|
||||
shop := env.createShop(t, "UR46详情店铺", nil, 1)
|
||||
card := env.createCard(t, 46, &shop.ID)
|
||||
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
expiresAt := now.AddDate(0, 0, 5)
|
||||
usage := &model.PackageUsage{OrderID: card.ID, OrderNo: "UR46-HTTP", PackageID: card.ID, UsageType: constants.AssetTypeIotCard, IotCardID: card.ID, DataLimitMB: 1, Status: constants.PackageUsageStatusActive, Priority: 1, ActivatedAt: &now, ExpiresAt: &expiresAt, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
|
||||
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
|
||||
t.Fatalf("创建详情套餐使用记录失败:%v", err)
|
||||
}
|
||||
assertPackageUsagePersisted(t, env.db, usage.ID, constants.PackageUsageStatusActive)
|
||||
status, body := env.request(t, "/api/admin/assets/resolve/"+card.ICCID, env.newAgentToken(t, shop.ID))
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("查询资产详情失败:%d %s", status, body)
|
||||
}
|
||||
var response struct {
|
||||
Data struct {
|
||||
ExpiresAt *time.Time `json:"estimated_final_expires_at"`
|
||||
Days *int `json:"days_until_final_expiry"`
|
||||
Status string `json:"expiry_estimate_status"`
|
||||
Name string `json:"expiry_estimate_status_name"`
|
||||
Expiring bool `json:"is_expiring"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析预计到期响应失败:%v", err)
|
||||
}
|
||||
if response.Data.ExpiresAt == nil || response.Data.Days == nil || response.Data.Status != constants.PackageExpiryEstimateStatusExact || response.Data.Name == "" || !response.Data.Expiring {
|
||||
t.Fatalf("预计到期字段不完整:%s", body)
|
||||
}
|
||||
|
||||
device := env.createDevice(t, 46, &shop.ID)
|
||||
deviceUsage := newExpiryHTTPUsage(device.ID, constants.AssetTypeDevice, constants.PackageUsageStatusActive, &expiresAt, false, now)
|
||||
if err := createExpiryHTTPUsage(env.db, deviceUsage); err != nil {
|
||||
t.Fatalf("创建设备详情套餐使用记录失败:%v", err)
|
||||
}
|
||||
assertPackageUsagePersisted(t, env.db, deviceUsage.ID, constants.PackageUsageStatusActive)
|
||||
status, body = env.request(t, "/api/admin/assets/resolve/"+device.VirtualNo, env.newAgentToken(t, shop.ID))
|
||||
assertExpiryHTTPStatus(t, status, body, constants.PackageExpiryEstimateStatusExact, true)
|
||||
}
|
||||
|
||||
// TestAssetResolveHTTPReturnsAllPackageExpiryStatuses 验证后台详情完整返回四种状态及 nullable 语义。
|
||||
func TestAssetResolveHTTPReturnsAllPackageExpiryStatuses(t *testing.T) {
|
||||
env := newAssetTraceHTTPEnv(t)
|
||||
shop := env.createShop(t, "UR46四状态店铺", nil, 1)
|
||||
token := env.newAgentToken(t, shop.ID)
|
||||
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
for typeIndex, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
|
||||
for statusIndex, estimateStatus := range []string{constants.PackageExpiryEstimateStatusNone, constants.PackageExpiryEstimateStatusWaitingActivation, constants.PackageExpiryEstimateStatusInvalidData} {
|
||||
name := assetType + "/" + estimateStatus
|
||||
t.Run(name, func(t *testing.T) {
|
||||
asset := createAdminExpiryTestAsset(t, env, assetType, 60+typeIndex*10+statusIndex, &shop.ID)
|
||||
if estimateStatus != constants.PackageExpiryEstimateStatusNone {
|
||||
usageStatus, waiting := constants.PackageUsageStatusActive, false
|
||||
if estimateStatus == constants.PackageExpiryEstimateStatusWaitingActivation {
|
||||
usageStatus, waiting = constants.PackageUsageStatusPending, true
|
||||
}
|
||||
usage := newExpiryHTTPUsage(asset.assetID, assetType, usageStatus, nil, waiting, now)
|
||||
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
|
||||
t.Fatalf("创建状态套餐失败:%v", err)
|
||||
}
|
||||
assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
|
||||
}
|
||||
status, body := env.request(t, "/api/admin/assets/resolve/"+asset.identifier, token)
|
||||
assertExpiryHTTPStatus(t, status, body, estimateStatus, false)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createAdminExpiryTestAsset(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int, shopID *uint) clientExpiryTestAsset {
|
||||
t.Helper()
|
||||
if assetType == constants.AssetTypeDevice {
|
||||
device := env.createDevice(t, suffix, shopID)
|
||||
return clientExpiryTestAsset{assetType: assetType, assetID: device.ID, virtualNo: device.VirtualNo, identifier: device.VirtualNo}
|
||||
}
|
||||
card := env.createCard(t, suffix, shopID)
|
||||
return clientExpiryTestAsset{assetType: assetType, assetID: card.ID, virtualNo: card.VirtualNo, identifier: card.ICCID}
|
||||
}
|
||||
|
||||
// TestAssetResolveHTTPReflectsQueueChangesImmediately 验证新增、退款和失效队列后无需刷新资产快照。
|
||||
func TestAssetResolveHTTPReflectsQueueChangesImmediately(t *testing.T) {
|
||||
env := newAssetTraceHTTPEnv(t)
|
||||
shop := env.createShop(t, "UR46实时变化店铺", nil, 1)
|
||||
for index, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
|
||||
t.Run(assetType, func(t *testing.T) {
|
||||
asset := createAdminExpiryTestAsset(t, env, assetType, 63+index, &shop.ID)
|
||||
verifyAdminQueueChanges(t, env, asset, shop.ID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// verifyAdminQueueChanges 验证后台详情实时反映指定资产的套餐队列变化。
|
||||
func verifyAdminQueueChanges(t *testing.T, env *assetTraceHTTPEnv, asset clientExpiryTestAsset, shopID uint) {
|
||||
t.Helper()
|
||||
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
currentExpiry := now.AddDate(0, 0, 5)
|
||||
current := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusActive, ¤tExpiry, false, now)
|
||||
if err := createExpiryHTTPUsage(env.db, current); err != nil {
|
||||
t.Fatalf("创建当前套餐失败:%v", err)
|
||||
}
|
||||
assertPackageUsagePersisted(t, env.db, current.ID, constants.PackageUsageStatusActive)
|
||||
token := env.newAgentToken(t, shopID)
|
||||
initial := requestExpiryEstimate(t, env, asset.identifier, token)
|
||||
queued := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusPending, nil, false, now)
|
||||
queued.Priority = 2
|
||||
queued.PackageID = asset.assetID + 1
|
||||
queued.OrderNo = fmt.Sprintf("UR46-Q-%s-%d", asset.assetType, asset.assetID)
|
||||
if err := createExpiryHTTPUsage(env.db, queued); err != nil {
|
||||
t.Fatalf("创建排队套餐失败:%v", err)
|
||||
}
|
||||
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusPending)
|
||||
extended := requestExpiryEstimate(t, env, asset.identifier, token)
|
||||
if initial.ExpiresAt == nil || extended.ExpiresAt == nil || !extended.ExpiresAt.After(*initial.ExpiresAt) {
|
||||
t.Fatalf("新增排队套餐后预计到期未延长:initial=%v extended=%v", initial.ExpiresAt, extended.ExpiresAt)
|
||||
}
|
||||
refundID := uint(990001)
|
||||
if err := env.db.Model(queued).Update("refund_id", refundID).Error; err != nil {
|
||||
t.Fatalf("设置排队套餐退款状态失败:%v", err)
|
||||
}
|
||||
assertPackageUsageRefunded(t, env.db, queued.ID, refundID)
|
||||
refunded := requestExpiryEstimate(t, env, asset.identifier, token)
|
||||
if refunded.ExpiresAt == nil || !refunded.ExpiresAt.Equal(*initial.ExpiresAt) {
|
||||
t.Fatalf("退款后预计到期未实时恢复:initial=%v refunded=%v", initial.ExpiresAt, refunded.ExpiresAt)
|
||||
}
|
||||
if err := env.db.Model(queued).Updates(map[string]any{"refund_id": nil, "status": constants.PackageUsageStatusInvalidated}).Error; err != nil {
|
||||
t.Fatalf("设置排队套餐失效状态失败:%v", err)
|
||||
}
|
||||
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusInvalidated)
|
||||
invalidated := requestExpiryEstimate(t, env, asset.identifier, token)
|
||||
if invalidated.ExpiresAt == nil || !invalidated.ExpiresAt.Equal(*initial.ExpiresAt) {
|
||||
t.Fatalf("失效后预计到期未实时恢复:initial=%v invalidated=%v", initial.ExpiresAt, invalidated.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
type expiryHTTPResult struct {
|
||||
ExpiresAt *time.Time `json:"estimated_final_expires_at"`
|
||||
Days *int `json:"days_until_final_expiry"`
|
||||
Status string `json:"expiry_estimate_status"`
|
||||
Name string `json:"expiry_estimate_status_name"`
|
||||
Expiring bool `json:"is_expiring"`
|
||||
}
|
||||
|
||||
// requestExpiryEstimate 请求后台统一资产详情并验证预计到期字段的 JSON 契约。
|
||||
func requestExpiryEstimate(t *testing.T, env *assetTraceHTTPEnv, identifier, token string) expiryHTTPResult {
|
||||
t.Helper()
|
||||
status, body := env.request(t, "/api/admin/assets/resolve/"+identifier, token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("查询预计到期失败:%d %s", status, body)
|
||||
}
|
||||
var response struct {
|
||||
Data expiryHTTPResult `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析预计到期响应失败:%v", err)
|
||||
}
|
||||
assertExpiryJSONNullable(t, body, response.Data.Status == constants.PackageExpiryEstimateStatusExact)
|
||||
return response.Data
|
||||
}
|
||||
|
||||
func newExpiryHTTPUsage(assetID uint, assetType string, status int, expiresAt *time.Time, waiting bool, now time.Time) *model.PackageUsage {
|
||||
usage := &model.PackageUsage{OrderID: assetID, OrderNo: fmt.Sprintf("UR46-STATUS-%s-%d", assetType, assetID), PackageID: assetID, UsageType: assetType, DataLimitMB: 1, Status: status, Priority: 1, ActivatedAt: &now, ExpiresAt: expiresAt, PendingRealnameActivation: waiting, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
|
||||
if assetType == constants.AssetTypeDevice {
|
||||
usage.DeviceID = assetID
|
||||
} else {
|
||||
usage.IotCardID = assetID
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func createExpiryHTTPUsage(db *gorm.DB, usage *model.PackageUsage) error {
|
||||
desiredStatus := usage.Status
|
||||
pendingRealname := usage.PendingRealnameActivation
|
||||
if err := db.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
usage.Status = desiredStatus
|
||||
usage.PendingRealnameActivation = pendingRealname
|
||||
return db.Model(usage).Updates(map[string]any{"status": desiredStatus, "pending_realname_activation": pendingRealname}).Error
|
||||
}
|
||||
|
||||
// assertExpiryHTTPStatus 验证详情状态、中文名称、临期标记及 nullable 语义。
|
||||
func assertExpiryHTTPStatus(t *testing.T, status int, body []byte, expected string, exact bool) {
|
||||
t.Helper()
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("预计到期接口失败:%d %s", status, body)
|
||||
}
|
||||
var response struct {
|
||||
Data struct {
|
||||
ExpiresAt *time.Time `json:"estimated_final_expires_at"`
|
||||
Days *int `json:"days_until_final_expiry"`
|
||||
Status string `json:"expiry_estimate_status"`
|
||||
Name string `json:"expiry_estimate_status_name"`
|
||||
Expiring bool `json:"is_expiring"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析预计到期响应失败:%v", err)
|
||||
}
|
||||
if response.Data.Status != expected || response.Data.Name == "" {
|
||||
t.Fatalf("预计到期状态错误:want=%s body=%s", expected, body)
|
||||
}
|
||||
if exact != (response.Data.ExpiresAt != nil && response.Data.Days != nil) {
|
||||
t.Fatalf("预计到期 nullable 语义错误:body=%s", body)
|
||||
}
|
||||
if response.Data.Expiring != exact {
|
||||
t.Fatalf("预计到期临期标记错误:exact=%v body=%s", exact, body)
|
||||
}
|
||||
assertExpiryJSONNullable(t, body, exact)
|
||||
}
|
||||
|
||||
// assertExpiryJSONNullable 验证日期和天数字段必须存在,并按状态显式返回值或 null。
|
||||
func assertExpiryJSONNullable(t *testing.T, body []byte, exact bool) {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("解析预计到期原始字段失败:%v", err)
|
||||
}
|
||||
expiresAt, expiresAtExists := payload.Data["estimated_final_expires_at"]
|
||||
days, daysExists := payload.Data["days_until_final_expiry"]
|
||||
if !expiresAtExists || !daysExists || exact != (expiresAt != nil && days != nil) {
|
||||
t.Fatalf("预计到期字段必须存在并显式返回值或 null:exact=%v body=%s", exact, body)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPackageUsagePersisted(t *testing.T, db *gorm.DB, usageID uint, expectedStatus int) {
|
||||
t.Helper()
|
||||
var usage model.PackageUsage
|
||||
if err := db.First(&usage, usageID).Error; err != nil || usage.Status != expectedStatus {
|
||||
t.Fatalf("套餐使用记录数据库状态错误:id=%d status=%d err=%v", usageID, usage.Status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPackageUsageRefunded(t *testing.T, db *gorm.DB, usageID, expectedRefundID uint) {
|
||||
t.Helper()
|
||||
var usage model.PackageUsage
|
||||
if err := db.First(&usage, usageID).Error; err != nil || usage.RefundID == nil || *usage.RefundID != expectedRefundID {
|
||||
t.Fatalf("套餐退款数据库状态错误:id=%d refund_id=%v err=%v", usageID, usage.RefundID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientAssetInfoHTTPReturnsAllPackageExpiryStatuses 验证 C 端卡、设备四状态与后台口径一致。
|
||||
func TestClientAssetInfoHTTPReturnsAllPackageExpiryStatuses(t *testing.T) {
|
||||
env := newAssetTraceHTTPEnv(t)
|
||||
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
assets := append(createClientExpiryStatusAssets(t, env, constants.AssetTypeIotCard, 70, now), createClientExpiryStatusAssets(t, env, constants.AssetTypeDevice, 80, now)...)
|
||||
adminToken := env.newAgentToken(t, 0)
|
||||
for _, asset := range assets {
|
||||
client := requestClientExpiryEstimate(t, env, asset.assetType, asset.assetID, asset.virtualNo, asset.identifier)
|
||||
admin := requestExpiryEstimate(t, env, asset.identifier, adminToken)
|
||||
if client.Status != asset.status || client.Status != admin.Status || client.Name == "" || client.Expiring != admin.Expiring || !timePointersEqualRoute(client.ExpiresAt, admin.ExpiresAt) || !intPointersEqualRoute(client.Days, admin.Days) {
|
||||
t.Fatalf("C 端与后台预计到期不一致:asset=%s client=%+v admin=%+v", asset.identifier, client, admin)
|
||||
}
|
||||
exact := asset.status == constants.PackageExpiryEstimateStatusExact
|
||||
if exact != (client.ExpiresAt != nil && client.Days != nil) || exact != client.Expiring {
|
||||
t.Fatalf("C 端 nullable 或临期标记错误:asset=%s result=%+v", asset.identifier, client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type clientExpiryTestAsset struct {
|
||||
assetType string
|
||||
assetID uint
|
||||
virtualNo string
|
||||
identifier string
|
||||
status string
|
||||
}
|
||||
|
||||
// createClientExpiryStatusAssets 为指定资产类型创建预计到期四状态夹具。
|
||||
func createClientExpiryStatusAssets(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int, now time.Time) []clientExpiryTestAsset {
|
||||
t.Helper()
|
||||
statuses := []string{constants.PackageExpiryEstimateStatusExact, constants.PackageExpiryEstimateStatusNone, constants.PackageExpiryEstimateStatusWaitingActivation, constants.PackageExpiryEstimateStatusInvalidData}
|
||||
assets := make([]clientExpiryTestAsset, 0, len(statuses))
|
||||
for index, estimateStatus := range statuses {
|
||||
asset := createClientExpiryTestAsset(t, env, assetType, suffix+index)
|
||||
asset.status = estimateStatus
|
||||
if estimateStatus != constants.PackageExpiryEstimateStatusNone {
|
||||
usageStatus, waiting := constants.PackageUsageStatusActive, false
|
||||
var expiresAt *time.Time
|
||||
if estimateStatus == constants.PackageExpiryEstimateStatusExact {
|
||||
value := now.AddDate(0, 0, 5)
|
||||
expiresAt = &value
|
||||
} else if estimateStatus == constants.PackageExpiryEstimateStatusWaitingActivation {
|
||||
usageStatus, waiting = constants.PackageUsageStatusPending, true
|
||||
}
|
||||
usage := newExpiryHTTPUsage(asset.assetID, assetType, usageStatus, expiresAt, waiting, now)
|
||||
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
|
||||
t.Fatalf("创建 C 端状态套餐失败:%v", err)
|
||||
}
|
||||
assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
|
||||
}
|
||||
assets = append(assets, asset)
|
||||
}
|
||||
return assets
|
||||
}
|
||||
|
||||
func createClientExpiryTestAsset(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int) clientExpiryTestAsset {
|
||||
t.Helper()
|
||||
if assetType == constants.AssetTypeDevice {
|
||||
device := env.createDevice(t, suffix, nil)
|
||||
return clientExpiryTestAsset{assetType: assetType, assetID: device.ID, virtualNo: device.VirtualNo, identifier: device.VirtualNo}
|
||||
}
|
||||
card := env.createCard(t, suffix, nil)
|
||||
return clientExpiryTestAsset{assetType: assetType, assetID: card.ID, virtualNo: card.VirtualNo, identifier: card.ICCID}
|
||||
}
|
||||
|
||||
// TestClientAssetInfoHTTPReflectsQueueChangesImmediately 验证 C 端实时反映套餐新增、退款和失效。
|
||||
func TestClientAssetInfoHTTPReflectsQueueChangesImmediately(t *testing.T) {
|
||||
env := newAssetTraceHTTPEnv(t)
|
||||
for index, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
|
||||
t.Run(assetType, func(t *testing.T) {
|
||||
asset := createClientExpiryTestAsset(t, env, assetType, 90+index)
|
||||
verifyClientQueueChanges(t, env, asset)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// verifyClientQueueChanges 验证 C 端详情实时反映指定资产的套餐队列变化。
|
||||
func verifyClientQueueChanges(t *testing.T, env *assetTraceHTTPEnv, asset clientExpiryTestAsset) {
|
||||
t.Helper()
|
||||
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
currentExpiry := now.AddDate(0, 0, 5)
|
||||
current := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusActive, ¤tExpiry, false, now)
|
||||
if err := createExpiryHTTPUsage(env.db, current); err != nil {
|
||||
t.Fatalf("创建 C 端当前套餐失败:%v", err)
|
||||
}
|
||||
assertPackageUsagePersisted(t, env.db, current.ID, constants.PackageUsageStatusActive)
|
||||
request := func() expiryHTTPResult {
|
||||
return requestClientExpiryEstimate(t, env, asset.assetType, asset.assetID, asset.virtualNo, asset.identifier)
|
||||
}
|
||||
initial := request()
|
||||
queued := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusPending, nil, false, now)
|
||||
queued.PackageID = asset.assetID + 1
|
||||
queued.OrderNo = fmt.Sprintf("UR46-CQ-%s-%d", asset.assetType, asset.assetID)
|
||||
queued.Priority = 2
|
||||
if err := createExpiryHTTPUsage(env.db, queued); err != nil {
|
||||
t.Fatalf("创建 C 端排队套餐失败:%v", err)
|
||||
}
|
||||
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusPending)
|
||||
extended := request()
|
||||
if initial.ExpiresAt == nil || extended.ExpiresAt == nil || !extended.ExpiresAt.After(*initial.ExpiresAt) {
|
||||
t.Fatalf("C 端新增套餐后预计到期未延长:initial=%v extended=%v", initial.ExpiresAt, extended.ExpiresAt)
|
||||
}
|
||||
refundID := uint(990002)
|
||||
if err := env.db.Model(queued).Update("refund_id", refundID).Error; err != nil {
|
||||
t.Fatalf("设置 C 端排队套餐退款失败:%v", err)
|
||||
}
|
||||
assertPackageUsageRefunded(t, env.db, queued.ID, refundID)
|
||||
refunded := request()
|
||||
if refunded.ExpiresAt == nil || !refunded.ExpiresAt.Equal(*initial.ExpiresAt) {
|
||||
t.Fatalf("C 端退款后预计到期未实时恢复:initial=%v refunded=%v", initial.ExpiresAt, refunded.ExpiresAt)
|
||||
}
|
||||
if err := env.db.Model(queued).Updates(map[string]any{"refund_id": nil, "status": constants.PackageUsageStatusInvalidated}).Error; err != nil {
|
||||
t.Fatalf("设置 C 端排队套餐失效失败:%v", err)
|
||||
}
|
||||
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusInvalidated)
|
||||
invalidated := request()
|
||||
if invalidated.ExpiresAt == nil || !invalidated.ExpiresAt.Equal(*initial.ExpiresAt) {
|
||||
t.Fatalf("C 端失效后预计到期未实时恢复:initial=%v invalidated=%v", initial.ExpiresAt, invalidated.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// requestClientExpiryEstimate 创建真实客户认证和绑定后请求 C 端资产信息。
|
||||
func requestClientExpiryEstimate(t *testing.T, env *assetTraceHTTPEnv, assetType string, assetID uint, virtualNo, identifier string) expiryHTTPResult {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
customer := &model.PersonalCustomer{WxOpenID: fmt.Sprintf("ur46-client-%d-%d", assetID, time.Now().UnixNano()), WxUnionID: fmt.Sprintf("ur46-union-%d", assetID), Status: constants.StatusEnabled}
|
||||
if err := env.db.Create(customer).Error; err != nil {
|
||||
t.Fatalf("创建 C 端客户失败:%v", err)
|
||||
}
|
||||
binding := &model.PersonalCustomerDevice{CustomerID: customer.ID, VirtualNo: virtualNo, BindAt: now, Status: constants.StatusEnabled}
|
||||
if err := env.db.Create(binding).Error; err != nil {
|
||||
t.Fatalf("创建 C 端资产绑定失败:%v", err)
|
||||
}
|
||||
jwtManager := auth.NewJWTManager("ur46-client-status-secret", time.Hour)
|
||||
token, err := jwtManager.GeneratePersonalCustomerToken(customer.ID, "13800000000", assetType, assetID)
|
||||
if err != nil {
|
||||
t.Fatalf("生成 C 端 JWT 失败:%v", err)
|
||||
}
|
||||
if err := env.redis.Set(context.Background(), constants.RedisPersonalCustomerTokenKey(customer.ID), token, time.Hour).Err(); err != nil {
|
||||
t.Fatalf("写入 C 端 Token 失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = env.redis.Del(context.Background(), constants.RedisPersonalCustomerTokenKey(customer.ID)).Err()
|
||||
})
|
||||
cardStore := postgres.NewIotCardStore(env.db, env.redis)
|
||||
deviceStore := postgres.NewDeviceStore(env.db, env.redis)
|
||||
handler := apphandler.NewClientAssetHandler(env.assetService, customerBinding.New(env.db, cardStore, deviceStore), postgres.NewAssetWalletStore(env.db, env.redis), postgres.NewPackageStore(env.db), postgres.NewShopPackageAllocationStore(env.db), cardStore, deviceStore, env.db, zap.NewNop())
|
||||
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
|
||||
app.Get("/api/c/v1/asset/info", internalMiddleware.NewPersonalAuthMiddleware(jwtManager, env.redis, zap.NewNop()).Authenticate(), handler.GetAssetInfo)
|
||||
request, err := http.NewRequest(http.MethodGet, "/api/c/v1/asset/info?identifier="+identifier, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("创建 C 端请求失败:%v", err)
|
||||
}
|
||||
request.Header.Set(fiber.HeaderAuthorization, "Bearer "+token)
|
||||
response, err := app.Test(request, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("执行 C 端请求失败:%v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("C 端预计到期响应失败:%d %s", response.StatusCode, body)
|
||||
}
|
||||
var payload struct {
|
||||
Data expiryHTTPResult `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("解析 C 端预计到期响应失败:%v", err)
|
||||
}
|
||||
assertExpiryJSONNullable(t, body, payload.Data.Status == constants.PackageExpiryEstimateStatusExact)
|
||||
return payload.Data
|
||||
}
|
||||
|
||||
func timePointersEqualRoute(left, right *time.Time) bool {
|
||||
return left == nil && right == nil || left != nil && right != nil && left.Equal(*right)
|
||||
}
|
||||
|
||||
func intPointersEqualRoute(left, right *int) bool {
|
||||
return left == nil && right == nil || left != nil && right != nil && *left == *right
|
||||
}
|
||||
|
||||
type routeSQLCounterLogger struct {
|
||||
logger.Interface
|
||||
queries []string
|
||||
}
|
||||
|
||||
func (l *routeSQLCounterLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
sql, rows := fc()
|
||||
l.queries = append(l.queries, sql)
|
||||
l.Interface.Trace(ctx, begin, func() (string, int64) { return sql, rows }, err)
|
||||
}
|
||||
|
||||
func (l *routeSQLCounterLogger) Reset() {
|
||||
l.queries = nil
|
||||
}
|
||||
|
||||
func (l *routeSQLCounterLogger) Total() int {
|
||||
return len(l.queries)
|
||||
}
|
||||
|
||||
func (l *routeSQLCounterLogger) CountContaining(fragment string) int {
|
||||
count := 0
|
||||
for _, query := range l.queries {
|
||||
if strings.Contains(query, fragment) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// TestAssetListHTTPReturnsPackageExpiryEstimate 验证卡、设备列表在真实 JWT、Redis、Fiber 下批量返回统一预计到期字段。
|
||||
func TestAssetListHTTPReturnsPackageExpiryEstimate(t *testing.T) {
|
||||
env := newAssetTraceHTTPEnv(t)
|
||||
counter := &routeSQLCounterLogger{Interface: env.db.Logger}
|
||||
db := env.db.Session(&gorm.Session{Logger: counter})
|
||||
registerPackageExpiryListRoutes(env, db)
|
||||
fixture := createPackageExpiryListFixtures(t, env)
|
||||
token := env.newAgentToken(t, 0)
|
||||
for _, testCase := range []packageExpiryListCase{
|
||||
{path: "/api/admin/iot-cards/standalone", assetType: constants.AssetTypeIotCard, identifier: fixture.lastCardIdentifier, orderedIDs: fixture.cardIDs, packageQueries: 1},
|
||||
{path: "/api/admin/devices", assetType: constants.AssetTypeDevice, identifier: fixture.lastDeviceIdentifier, orderedIDs: fixture.deviceIDs, packageQueries: 2},
|
||||
} {
|
||||
t.Run(testCase.assetType, func(t *testing.T) {
|
||||
verifyPackageExpiryList(t, env, counter, fixture, testCase, token)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type packageExpiryListFixture struct {
|
||||
batchNo string
|
||||
cardIDs []uint
|
||||
deviceIDs []uint
|
||||
statusIDs map[string]map[string]uint
|
||||
lastCardIdentifier string
|
||||
lastDeviceIdentifier string
|
||||
}
|
||||
|
||||
type packageExpiryListCase struct {
|
||||
path string
|
||||
assetType string
|
||||
identifier string
|
||||
orderedIDs []uint
|
||||
packageQueries int
|
||||
}
|
||||
|
||||
type packageExpiryListItem struct {
|
||||
ID uint `json:"id"`
|
||||
ExpiresAt *time.Time `json:"estimated_final_expires_at"`
|
||||
Days *int `json:"days_until_final_expiry"`
|
||||
Status string `json:"expiry_estimate_status"`
|
||||
StatusName string `json:"expiry_estimate_status_name"`
|
||||
IsExpiring bool `json:"is_expiring"`
|
||||
fieldsExist bool
|
||||
fieldsNull bool
|
||||
}
|
||||
|
||||
type packageExpiryListPayload struct {
|
||||
Data struct {
|
||||
Items []packageExpiryListItem `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// registerPackageExpiryListRoutes 使用计数数据库会话注册卡和设备列表路由。
|
||||
func registerPackageExpiryListRoutes(env *assetTraceHTTPEnv, db *gorm.DB) {
|
||||
shopStore := postgres.NewShopStore(db, env.redis)
|
||||
cardStore := postgres.NewIotCardStore(db, nil)
|
||||
deviceStore := postgres.NewDeviceStore(db, nil)
|
||||
seriesStore := postgres.NewPackageSeriesStore(db)
|
||||
allocationStore := postgres.NewAssetAllocationRecordStore(db, env.redis)
|
||||
cardSvc := iotCardService.New(db, cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(db), postgres.NewShopSeriesAllocationStore(db), seriesStore, nil, zap.NewNop(), nil)
|
||||
deviceSvc := deviceService.New(db, nil, deviceStore, postgres.NewDeviceSimBindingStore(db, nil), cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(db), postgres.NewShopSeriesAllocationStore(db), seriesStore, nil, postgres.NewAssetIdentifierStore(db), nil, postgres.NewEnterpriseDeviceAuthorizationStore(db, nil), postgres.NewEnterpriseStore(db, nil))
|
||||
packageExpiry := packageExpiryQuery.NewQuery(db)
|
||||
cardSvc.SetPackageExpiryQuery(packageExpiry)
|
||||
deviceSvc.SetPackageExpiryQuery(packageExpiry)
|
||||
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
|
||||
info, err := env.tokenManager.ValidateAccessToken(context.Background(), token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pkgMiddleware.UserContextInfo{UserID: info.UserID, UserType: info.UserType, ShopID: info.ShopID}, nil
|
||||
}, ShopStore: shopStore})
|
||||
env.app.Get("/api/admin/iot-cards/standalone", authMiddleware, admin.NewIotCardHandler(cardSvc).ListStandalone)
|
||||
env.app.Get("/api/admin/devices", authMiddleware, admin.NewDeviceHandler(deviceSvc).List)
|
||||
}
|
||||
|
||||
// createPackageExpiryListFixtures 创建卡和设备各 100 条的四状态列表数据。
|
||||
func createPackageExpiryListFixtures(t *testing.T, env *assetTraceHTTPEnv) packageExpiryListFixture {
|
||||
t.Helper()
|
||||
fixture := packageExpiryListFixture{batchNo: "UR46-LIST-" + strconv.FormatInt(time.Now().UnixNano(), 10), cardIDs: make([]uint, 0, 100), deviceIDs: make([]uint, 0, 100), statusIDs: map[string]map[string]uint{constants.AssetTypeIotCard: {}, constants.AssetTypeDevice: {}}}
|
||||
now := time.Now()
|
||||
expiresAt := now.AddDate(0, 0, 5)
|
||||
for index := 0; index < 100; index++ {
|
||||
card := env.createCard(t, 1000+index, nil)
|
||||
device := env.createDevice(t, 1000+index, nil)
|
||||
if err := env.db.Model(card).Updates(map[string]any{"is_standalone": true, "batch_no": fixture.batchNo}).Error; err != nil {
|
||||
t.Fatalf("设置卡列表 fixture 失败:%v", err)
|
||||
}
|
||||
if err := env.db.Model(device).Update("batch_no", fixture.batchNo).Error; err != nil {
|
||||
t.Fatalf("设置设备列表 fixture 失败:%v", err)
|
||||
}
|
||||
fixture.cardIDs = append(fixture.cardIDs, card.ID)
|
||||
fixture.deviceIDs = append(fixture.deviceIDs, device.ID)
|
||||
fixture.lastCardIdentifier, fixture.lastDeviceIdentifier = card.ICCID, device.VirtualNo
|
||||
createPackageExpiryListUsage(t, env, constants.AssetTypeIotCard, card.ID, index, now, expiresAt, fixture.statusIDs)
|
||||
createPackageExpiryListUsage(t, env, constants.AssetTypeDevice, device.ID, index, now, expiresAt, fixture.statusIDs)
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
|
||||
// createPackageExpiryListUsage 按资产序号分配精确、无套餐、待激活和异常状态。
|
||||
func createPackageExpiryListUsage(t *testing.T, env *assetTraceHTTPEnv, assetType string, assetID uint, index int, now, expiresAt time.Time, statusIDs map[string]map[string]uint) {
|
||||
t.Helper()
|
||||
estimateStatus := constants.PackageExpiryEstimateStatusExact
|
||||
usageStatus := constants.PackageUsageStatusActive
|
||||
usageExpiry := &expiresAt
|
||||
waiting := false
|
||||
switch index {
|
||||
case 1:
|
||||
estimateStatus = constants.PackageExpiryEstimateStatusNone
|
||||
statusIDs[assetType][estimateStatus] = assetID
|
||||
return
|
||||
case 2:
|
||||
estimateStatus = constants.PackageExpiryEstimateStatusWaitingActivation
|
||||
usageStatus, usageExpiry, waiting = constants.PackageUsageStatusPending, nil, true
|
||||
case 3:
|
||||
estimateStatus = constants.PackageExpiryEstimateStatusInvalidData
|
||||
usageExpiry = nil
|
||||
}
|
||||
statusIDs[assetType][estimateStatus] = assetID
|
||||
usage := newExpiryHTTPUsage(assetID, assetType, usageStatus, usageExpiry, waiting, now)
|
||||
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
|
||||
t.Fatalf("创建列表套餐失败:%v", err)
|
||||
}
|
||||
assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
|
||||
}
|
||||
|
||||
// verifyPackageExpiryList 验证列表响应、固定查询数、性能、筛选、排序和详情一致性。
|
||||
func verifyPackageExpiryList(t *testing.T, env *assetTraceHTTPEnv, counter *routeSQLCounterLogger, fixture packageExpiryListFixture, testCase packageExpiryListCase, token string) {
|
||||
t.Helper()
|
||||
baseQuery := "?page=1&page_size=100&batch_no=" + fixture.batchNo
|
||||
counter.Reset()
|
||||
oneStatus, oneBody := env.request(t, testCase.path+strings.Replace(baseQuery, "page_size=100", "page_size=1", 1), token)
|
||||
oneAssetQueries := counter.Total()
|
||||
if oneStatus != http.StatusOK {
|
||||
t.Fatalf("单资产分页请求失败:path=%s status=%d body=%s", testCase.path, oneStatus, oneBody)
|
||||
}
|
||||
counter.Reset()
|
||||
startedAt := time.Now()
|
||||
status, body := env.request(t, testCase.path+baseQuery, token)
|
||||
requestDuration := time.Since(startedAt)
|
||||
hundredAssetQueries := counter.Total()
|
||||
if status != http.StatusOK || oneAssetQueries != hundredAssetQueries || counter.CountContaining("tb_package_usage") != testCase.packageQueries || requestDuration >= testHTTPPerformanceLimit {
|
||||
t.Fatalf("列表批量查询或性能错误:path=%s status=%d one=%d hundred=%d package_queries=%d duration=%v", testCase.path, status, oneAssetQueries, hundredAssetQueries, counter.CountContaining("tb_package_usage"), requestDuration)
|
||||
}
|
||||
payload := decodePackageExpiryList(t, body)
|
||||
if payload.Data.Total != testListPageSize || payload.Data.Page != 1 || payload.Data.Size != testListPageSize || len(payload.Data.Items) != testListPageSize || payload.Data.Items[0].ID != testCase.orderedIDs[testListPageSize-1] {
|
||||
t.Fatalf("列表分页或排序错误:path=%s payload=%+v", testCase.path, payload.Data)
|
||||
}
|
||||
assertPackageExpiryListStatuses(t, payload.Data.Items, fixture.statusIDs[testCase.assetType])
|
||||
detail := requestExpiryEstimate(t, env, testCase.identifier, token)
|
||||
first := payload.Data.Items[0]
|
||||
if first.Status != detail.Status || !timePointersEqualRoute(first.ExpiresAt, detail.ExpiresAt) || !intPointersEqualRoute(first.Days, detail.Days) {
|
||||
t.Fatalf("列表与详情预计到期不一致:list=%+v detail=%+v", first, detail)
|
||||
}
|
||||
status, pageTwoBody := env.request(t, testCase.path+"?page=2&page_size=50&batch_no="+fixture.batchNo, token)
|
||||
pageTwo := decodePackageExpiryList(t, pageTwoBody)
|
||||
if status != http.StatusOK || pageTwo.Data.Total != 100 || len(pageTwo.Data.Items) != 50 || pageTwo.Data.Items[0].ID != testCase.orderedIDs[49] {
|
||||
t.Fatalf("列表翻页不稳定:path=%s status=%d payload=%+v", testCase.path, status, pageTwo.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// decodePackageExpiryList 同时解析类型字段和原始 JSON,以区分字段缺失与显式 null。
|
||||
func decodePackageExpiryList(t *testing.T, body []byte) packageExpiryListPayload {
|
||||
t.Helper()
|
||||
var payload packageExpiryListPayload
|
||||
if err := sonic.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("解析列表分页响应失败:%v", err)
|
||||
}
|
||||
var raw struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &raw); err != nil || len(raw.Data.Items) != len(payload.Data.Items) {
|
||||
t.Fatalf("解析列表原始字段失败:%v", err)
|
||||
}
|
||||
for index, item := range raw.Data.Items {
|
||||
expiresAt, expiresAtExists := item["estimated_final_expires_at"]
|
||||
days, daysExists := item["days_until_final_expiry"]
|
||||
payload.Data.Items[index].fieldsExist = expiresAtExists && daysExists
|
||||
payload.Data.Items[index].fieldsNull = expiresAt == nil && days == nil
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// assertPackageExpiryListStatuses 逐项验证列表四状态、字段存在性和 nullable 语义。
|
||||
func assertPackageExpiryListStatuses(t *testing.T, items []packageExpiryListItem, expectedIDs map[string]uint) {
|
||||
t.Helper()
|
||||
byID := make(map[uint]packageExpiryListItem, len(items))
|
||||
for _, item := range items {
|
||||
byID[item.ID] = item
|
||||
}
|
||||
for status, id := range expectedIDs {
|
||||
item, ok := byID[id]
|
||||
if !ok || item.Status != status || item.StatusName == "" {
|
||||
t.Fatalf("列表缺少状态投影:status=%s id=%d item=%+v", status, id, item)
|
||||
}
|
||||
exact := status == constants.PackageExpiryEstimateStatusExact
|
||||
if !item.fieldsExist || exact == item.fieldsNull || exact != (item.ExpiresAt != nil && item.Days != nil) || exact != item.IsExpiring {
|
||||
t.Fatalf("列表 nullable 或临期标记错误:status=%s item=%+v", status, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertHTTPTraceDirections(t *testing.T, status int, body []byte, hasPrevious, hasNext bool) {
|
||||
t.Helper()
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("资产详情状态错误,实际 %d:%s", status, body)
|
||||
}
|
||||
var response struct {
|
||||
Data struct {
|
||||
ExchangeTrace struct {
|
||||
PreviousAsset any `json:"previous_asset"`
|
||||
NextAsset any `json:"next_asset"`
|
||||
} `json:"exchange_trace"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析换货方向响应失败:%v", err)
|
||||
}
|
||||
if (response.Data.ExchangeTrace.PreviousAsset != nil) != hasPrevious || (response.Data.ExchangeTrace.NextAsset != nil) != hasNext {
|
||||
t.Fatalf("换货方向空值语义错误:previous=%v next=%v body=%s", hasPrevious, hasNext, body)
|
||||
}
|
||||
}
|
||||
|
||||
type assetTraceHTTPEnv struct {
|
||||
app *fiber.App
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
tokenManager *auth.TokenManager
|
||||
assetService *assetService.Service
|
||||
}
|
||||
|
||||
func newAssetTraceHTTPEnv(t *testing.T) *assetTraceHTTPEnv {
|
||||
t.Helper()
|
||||
if os.Getenv("JUNHONG_DATABASE_HOST") == "" || os.Getenv("JUNHONG_REDIS_ADDRESS") == "" {
|
||||
t.Skip("未加载 .env.local,跳过依赖真实 PostgreSQL 和 Redis 的资产换货链 HTTP 集成测试")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("加载测试配置失败:%v", err)
|
||||
}
|
||||
log := zap.NewNop()
|
||||
db, err := database.InitPostgreSQL(&cfg.Database, log)
|
||||
if err != nil {
|
||||
t.Fatalf("连接 PostgreSQL 失败:%v", err)
|
||||
}
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("开启资产换货链测试事务失败:%v", tx.Error)
|
||||
}
|
||||
redisClient, err := database.NewRedisClient(database.RedisConfig{
|
||||
Address: cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port), Password: cfg.Redis.Password, DB: cfg.Redis.DB,
|
||||
PoolSize: cfg.Redis.PoolSize, MinIdleConns: cfg.Redis.MinIdleConns,
|
||||
DialTimeout: cfg.Redis.DialTimeout, ReadTimeout: cfg.Redis.ReadTimeout, WriteTimeout: cfg.Redis.WriteTimeout,
|
||||
}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("连接 Redis 失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = tx.Rollback().Error
|
||||
_ = redisClient.Close()
|
||||
if sqlDB, dbErr := db.DB(); dbErr == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
shopStore := postgres.NewShopStore(tx, redisClient)
|
||||
deviceStore := postgres.NewDeviceStore(tx, redisClient)
|
||||
cardStore := postgres.NewIotCardStore(tx, redisClient)
|
||||
assetSvc := assetService.New(
|
||||
tx, deviceStore, cardStore,
|
||||
postgres.NewPackageUsageStore(tx, redisClient), postgres.NewPackageStore(tx), postgres.NewPackageSeriesStore(tx),
|
||||
postgres.NewDeviceSimBindingStore(tx, redisClient), shopStore, redisClient, nil, nil,
|
||||
postgres.NewAssetIdentifierStore(tx), postgres.NewOrderStore(tx, redisClient), postgres.NewOrderItemStore(tx, redisClient),
|
||||
postgres.NewExchangeOrderStore(tx), nil,
|
||||
)
|
||||
assetSvc.SetPackageExpiryQuery(packageExpiryQuery.NewQuery(tx))
|
||||
handler := admin.NewAssetHandler(assetSvc, nil, nil, nil, nil, nil, assetQuery.NewExchangeTraceQuery(tx, log))
|
||||
tokenManager := auth.NewTokenManager(redisClient, cfg.JWT.AccessTokenTTL, cfg.JWT.RefreshTokenTTL)
|
||||
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{
|
||||
TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
|
||||
info, validateErr := tokenManager.ValidateAccessToken(context.Background(), token)
|
||||
if validateErr != nil {
|
||||
return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期")
|
||||
}
|
||||
return &pkgMiddleware.UserContextInfo{UserID: info.UserID, UserType: info.UserType, Username: info.Username, ShopID: info.ShopID}, nil
|
||||
},
|
||||
ShopStore: shopStore,
|
||||
})
|
||||
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(log)})
|
||||
RegisterAdminRoutes(app.Group("/api/admin"), &bootstrap.Handlers{Asset: handler}, &bootstrap.Middlewares{AdminAuth: authMiddleware}, nil, "/api/admin")
|
||||
return &assetTraceHTTPEnv{app: app, db: tx, redis: redisClient, tokenManager: tokenManager, assetService: assetSvc}
|
||||
}
|
||||
|
||||
func (e *assetTraceHTTPEnv) createShop(t *testing.T, name string, parentID *uint, level int) *model.Shop {
|
||||
t.Helper()
|
||||
shop := &model.Shop{ShopName: name, ShopCode: fmt.Sprintf("UR86-%d", time.Now().UnixNano()), ParentID: parentID, Level: level, Status: constants.ShopStatusEnabled}
|
||||
if err := e.db.Create(shop).Error; err != nil {
|
||||
t.Fatalf("创建资产换货链测试店铺失败:%v", err)
|
||||
}
|
||||
return shop
|
||||
}
|
||||
|
||||
func (e *assetTraceHTTPEnv) createCard(t *testing.T, suffix int, shopID *uint) *model.IotCard {
|
||||
t.Helper()
|
||||
iccid := fmt.Sprintf("8986333333333333%04d", suffix)
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], VirtualNo: fmt.Sprintf("UR86-HTTP-CARD-%04d", suffix), ShopID: shopID, Status: constants.IotCardStatusInStock, AssetStatus: constants.AssetStatusInStock}
|
||||
if err := e.db.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建资产换货链 HTTP 测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func (e *assetTraceHTTPEnv) createDevice(t *testing.T, suffix int, shopID *uint) *model.Device {
|
||||
t.Helper()
|
||||
device := &model.Device{VirtualNo: fmt.Sprintf("UR46-HTTP-DEVICE-%04d-%d", suffix, time.Now().UnixNano()), ShopID: shopID, Status: constants.DeviceStatusInStock, AssetStatus: constants.AssetStatusInStock, MaxSimSlots: 4}
|
||||
if err := e.db.Create(device).Error; err != nil {
|
||||
t.Fatalf("创建 UR46 HTTP 测试设备失败:%v", err)
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
||||
func (e *assetTraceHTTPEnv) createOrder(t *testing.T, exchangeNo string, oldCard, newCard *model.IotCard, oldSnapshot, newSnapshot string, completedAt time.Time) {
|
||||
t.Helper()
|
||||
newID := newCard.ID
|
||||
order := &model.ExchangeOrder{
|
||||
ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeDirect,
|
||||
OldAssetType: constants.ExchangeAssetTypeIotCard, OldAssetID: oldCard.ID, OldAssetIdentifier: oldSnapshot,
|
||||
NewAssetType: constants.ExchangeAssetTypeIotCard, NewAssetID: &newID, NewAssetIdentifier: newSnapshot,
|
||||
ExchangeReason: "UR86 HTTP 测试", Status: constants.ExchangeStatusCompleted, CompletedAt: &completedAt,
|
||||
}
|
||||
if err := e.db.Create(order).Error; err != nil {
|
||||
t.Fatalf("创建资产换货链 HTTP 测试单失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *assetTraceHTTPEnv) newAgentToken(t *testing.T, shopID uint) string {
|
||||
t.Helper()
|
||||
info := &auth.TokenInfo{UserID: uint(time.Now().UnixNano() % 1_000_000_000), UserType: constants.UserTypeAgent, ShopID: shopID, Username: "UR86测试代理"}
|
||||
accessToken, refreshToken, err := e.tokenManager.GenerateTokenPair(context.Background(), info)
|
||||
if err != nil {
|
||||
t.Fatalf("创建资产换货链测试令牌失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = e.tokenManager.RevokeToken(context.Background(), accessToken)
|
||||
_ = e.tokenManager.RevokeToken(context.Background(), refreshToken)
|
||||
_ = e.redis.Del(context.Background(), constants.RedisUserTokensKey(info.UserID)).Err()
|
||||
})
|
||||
return accessToken
|
||||
}
|
||||
|
||||
func (e *assetTraceHTTPEnv) request(t *testing.T, path, token string) (int, []byte) {
|
||||
t.Helper()
|
||||
request, err := http.NewRequest(http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("创建资产换货链 HTTP 请求失败:%v", err)
|
||||
}
|
||||
request.Header.Set(fiber.HeaderAuthorization, "Bearer "+token)
|
||||
response, err := e.app.Test(request, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("执行资产换货链 HTTP 请求失败:%v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("读取资产换货链 HTTP 响应失败:%v", err)
|
||||
}
|
||||
return response.StatusCode, body
|
||||
}
|
||||
@@ -1,637 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
shopService "github.com/break/junhong_cmp_fiber/internal/service/shop"
|
||||
shopCommissionService "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/database"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
type shopTestResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data shopTestPageData `json:"data"`
|
||||
}
|
||||
|
||||
type shopTestPageData struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
type shopTestEnv struct {
|
||||
app *fiber.App
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
tokenManager *auth.TokenManager
|
||||
}
|
||||
|
||||
// TestShopListValidatesAllQueryParameters 验证店铺列表会完整校验所有查询参数。
|
||||
func TestShopListValidatesAllQueryParameters(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
token := env.newToken(t, auth.TokenInfo{
|
||||
UserID: uniqueTestID(),
|
||||
UserType: constants.UserTypeSuperAdmin,
|
||||
Username: "ur60-super-admin",
|
||||
})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{name: "页码小于一", query: "page=0"},
|
||||
{name: "每页数量小于一", query: "page_size=0"},
|
||||
{name: "每页数量超过上限", query: "page_size=101"},
|
||||
{name: "店铺层级非法", query: "level=8"},
|
||||
{name: "店铺状态非法", query: "status=2"},
|
||||
{name: "店铺名称过长", query: "shop_name=" + strings.Repeat("店", 101)},
|
||||
{name: "店铺编号过长", query: "shop_code=" + strings.Repeat("A", 51)},
|
||||
{name: "参数解析失败", query: "parent_id=invalid"},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
status, body := env.request(t, http.MethodGet, "/api/admin/shops?"+testCase.query, token)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("期望 HTTP 400,实际为 %d,响应:%s", status, body)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析响应失败:%v", err)
|
||||
}
|
||||
if response.Code != errors.CodeInvalidParam || response.Msg != "参数验证失败" || response.Data != nil {
|
||||
t.Fatalf("参数错误响应不符合契约:%s", body)
|
||||
}
|
||||
if !strings.Contains(string(body), "timestamp") {
|
||||
t.Fatalf("参数错误响应缺少时间戳:%s", body)
|
||||
}
|
||||
if strings.Contains(string(body), "validation") || strings.Contains(string(body), "strconv") {
|
||||
t.Fatalf("参数错误响应泄露底层细节:%s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShopListUsesNormalizedAndExplicitPagination 验证默认分页归一化且显式分页保持不变。
|
||||
func TestShopListUsesNormalizedAndExplicitPagination(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
token := env.newToken(t, auth.TokenInfo{
|
||||
UserID: uniqueTestID(),
|
||||
UserType: constants.UserTypeSuperAdmin,
|
||||
Username: "ur60-super-admin",
|
||||
})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
query string
|
||||
expectedPage int
|
||||
expectedSize int
|
||||
}{
|
||||
{name: "默认分页", expectedPage: constants.DefaultPage, expectedSize: constants.DefaultPageSize},
|
||||
{name: "显式分页", query: "?page=2&page_size=7&shop_name=ur60-no-match", expectedPage: 2, expectedSize: 7},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
status, body := env.request(t, http.MethodGet, "/api/admin/shops"+testCase.query, token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("期望 HTTP 200,实际为 %d,响应:%s", status, body)
|
||||
}
|
||||
|
||||
var response shopTestResponse
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析响应失败:%v", err)
|
||||
}
|
||||
if response.Code != errors.CodeSuccess || response.Data.Page != testCase.expectedPage || response.Data.Size != testCase.expectedSize {
|
||||
t.Fatalf("分页响应不符合契约:%s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShopListKeepsAuthenticationContract 验证店铺列表保持既有认证错误契约。
|
||||
func TestShopListKeepsAuthenticationContract(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
token string
|
||||
}{
|
||||
{name: "未认证"},
|
||||
{name: "无效令牌", token: "ur60-invalid-token"},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
status, body := env.request(t, http.MethodGet, "/api/admin/shops", testCase.token)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Fatalf("期望 HTTP 401,实际为 %d,响应:%s", status, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "timestamp") {
|
||||
t.Fatalf("认证错误响应缺少时间戳:%s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnterpriseCannotAccessCoreShopManagementRoutes 验证企业账号无法访问五个核心店铺管理入口。
|
||||
func TestEnterpriseCannotAccessCoreShopManagementRoutes(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
token := env.newToken(t, auth.TokenInfo{
|
||||
UserID: uniqueTestID(),
|
||||
UserType: constants.UserTypeEnterprise,
|
||||
EnterpriseID: uniqueTestID(),
|
||||
Username: "ur60-enterprise",
|
||||
})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{name: "店铺列表", method: http.MethodGet, path: "/api/admin/shops"},
|
||||
{name: "创建店铺", method: http.MethodPost, path: "/api/admin/shops"},
|
||||
{name: "更新店铺", method: http.MethodPut, path: "/api/admin/shops/1"},
|
||||
{name: "删除店铺", method: http.MethodDelete, path: "/api/admin/shops/1"},
|
||||
{name: "联级查询", method: http.MethodGet, path: "/api/admin/shops/cascade"},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
status, body := env.request(t, testCase.method, testCase.path, token)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("期望 HTTP 403,实际为 %d,响应:%s", status, body)
|
||||
}
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析响应失败:%v", err)
|
||||
}
|
||||
if response.Code != errors.CodeForbidden || response.Msg != constants.ShopManagementForbiddenMessage || response.Data != nil {
|
||||
t.Fatalf("企业账号禁止响应不符合契约:%s", body)
|
||||
}
|
||||
if !strings.Contains(string(body), "timestamp") {
|
||||
t.Fatalf("企业账号禁止响应缺少时间戳:%s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoreShopRestrictionDoesNotAffectOtherShopRoutes 验证核心店铺权限限制不影响其他店铺前缀路由。
|
||||
func TestCoreShopRestrictionDoesNotAffectOtherShopRoutes(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
token := env.newToken(t, auth.TokenInfo{
|
||||
UserID: uniqueTestID(),
|
||||
UserType: constants.UserTypeEnterprise,
|
||||
EnterpriseID: uniqueTestID(),
|
||||
Username: "ur60-enterprise",
|
||||
})
|
||||
|
||||
paths := []string{
|
||||
"/api/admin/shops/1/roles",
|
||||
"/api/admin/shops/fund-summary",
|
||||
"/api/admin/shops/1/withdrawal-requests",
|
||||
"/api/admin/shops/1/commission-records",
|
||||
"/api/admin/shops/1/main-wallet/transactions",
|
||||
}
|
||||
for _, path := range paths {
|
||||
status, body := env.request(t, http.MethodGet, path, token)
|
||||
if status == http.StatusForbidden && strings.Contains(string(body), constants.ShopManagementForbiddenMessage) {
|
||||
t.Fatalf("核心店铺管理拦截误伤独立路由 %s:%s", path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonEnterpriseAccountsKeepCoreShopListAccess 验证非企业账号保留核心店铺列表访问能力。
|
||||
func TestNonEnterpriseAccountsKeepCoreShopListAccess(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
testCases := []struct {
|
||||
name string
|
||||
userType int
|
||||
shopID uint
|
||||
}{
|
||||
{name: "超级管理员", userType: constants.UserTypeSuperAdmin},
|
||||
{name: "平台账号", userType: constants.UserTypePlatform},
|
||||
{name: "代理账号", userType: constants.UserTypeAgent, shopID: 1},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
token := env.newToken(t, auth.TokenInfo{
|
||||
UserID: uniqueTestID(),
|
||||
UserType: testCase.userType,
|
||||
ShopID: testCase.shopID,
|
||||
Username: "ur60-allowed-user",
|
||||
})
|
||||
status, body := env.request(t, http.MethodGet, "/api/admin/shops?page=1&page_size=1", token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("期望保留列表访问能力,实际 HTTP %d,响应:%s", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShopListFiltersContactPhoneExactly 验证联系电话精确匹配、AND 组合、重复号码、排序及软删除语义。
|
||||
func TestShopListFiltersContactPhoneExactly(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
phone := "13800000000"
|
||||
parent := env.createShop(t, "UR60父店", phone, nil, 1, time.Now().Add(-4*time.Minute))
|
||||
newer := env.createShop(t, "UR60目标新店", phone, &parent.ID, 2, time.Now().Add(-time.Minute))
|
||||
older := env.createShop(t, "UR60目标旧店", phone, &parent.ID, 2, time.Now().Add(-2*time.Minute))
|
||||
env.createShop(t, "UR60相似号码", "13800000001", &parent.ID, 2, time.Now())
|
||||
deleted := env.createShop(t, "UR60软删除店", phone, &parent.ID, 2, time.Now().Add(time.Minute))
|
||||
if err := env.db.Delete(deleted).Error; err != nil {
|
||||
t.Fatalf("软删除测试店铺失败:%v", err)
|
||||
}
|
||||
|
||||
token := env.newToken(t, auth.TokenInfo{
|
||||
UserID: uniqueTestID(),
|
||||
UserType: constants.UserTypeSuperAdmin,
|
||||
Username: "ur60-super-admin",
|
||||
})
|
||||
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone="+phone+"&shop_name=UR60目标&parent_id="+strconv.FormatUint(uint64(parent.ID), 10)+"&level=2&status=1", token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("联系电话组合查询失败,HTTP %d:%s", status, body)
|
||||
}
|
||||
|
||||
var response shopTestResponse
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析联系电话查询响应失败:%v", err)
|
||||
}
|
||||
if response.Data.Total != 2 || len(response.Data.Items) != 2 {
|
||||
t.Fatalf("联系电话组合查询应返回两条可见记录:%s", body)
|
||||
}
|
||||
if uint(response.Data.Items[0]["id"].(float64)) != newer.ID || uint(response.Data.Items[1]["id"].(float64)) != older.ID {
|
||||
t.Fatalf("联系电话查询未保持 created_at DESC:%s", body)
|
||||
}
|
||||
|
||||
status, body = env.request(t, http.MethodGet, "/api/admin/shops?contact_phone=13800000001&shop_code="+newer.ShopCode, token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("联系电话精确性查询失败,HTTP %d:%s", status, body)
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析联系电话精确性响应失败:%v", err)
|
||||
}
|
||||
if response.Data.Total != 0 {
|
||||
t.Fatalf("联系电话必须与其他条件按 AND 精确匹配:%s", body)
|
||||
}
|
||||
|
||||
platformToken := env.newToken(t, auth.TokenInfo{
|
||||
UserID: uniqueTestID(),
|
||||
UserType: constants.UserTypePlatform,
|
||||
Username: "ur60-platform",
|
||||
})
|
||||
status, body = env.request(t, http.MethodGet, "/api/admin/shops?contact_phone="+phone+"&shop_name=UR60目标", platformToken)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("平台账号联系电话查询失败,HTTP %d:%s", status, body)
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析平台账号联系电话响应失败:%v", err)
|
||||
}
|
||||
if response.Data.Total != 2 {
|
||||
t.Fatalf("平台账号联系电话查询结果不符合契约:%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShopListContactPhoneKeepsEmptyAndPaginationSemantics 验证空电话、空结果和显式分页契约。
|
||||
func TestShopListContactPhoneKeepsEmptyAndPaginationSemantics(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
token := env.newToken(t, auth.TokenInfo{UserID: uniqueTestID(), UserType: constants.UserTypeSuperAdmin, Username: "ur60-super-admin"})
|
||||
|
||||
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone=&page=2&page_size=3", token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("空联系电话不应改变列表语义,HTTP %d:%s", status, body)
|
||||
}
|
||||
var response shopTestResponse
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析空联系电话响应失败:%v", err)
|
||||
}
|
||||
if response.Data.Page != 2 || response.Data.Size != 3 {
|
||||
t.Fatalf("联系电话筛选不应重置显式分页:%s", body)
|
||||
}
|
||||
|
||||
status, body = env.request(t, http.MethodGet, "/api/admin/shops?contact_phone=19999999999", token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("无匹配联系电话应返回成功空分页,HTTP %d:%s", status, body)
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析空结果响应失败:%v", err)
|
||||
}
|
||||
if response.Data.Total != 0 || len(response.Data.Items) != 0 {
|
||||
t.Fatalf("无匹配联系电话响应不符合空分页契约:%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShopListRejectsInvalidContactPhoneBeforeQuery 验证非法联系电话在执行店铺查询前被拒绝。
|
||||
func TestShopListRejectsInvalidContactPhoneBeforeQuery(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
token := env.newToken(t, auth.TokenInfo{UserID: uniqueTestID(), UserType: constants.UserTypeSuperAdmin, Username: "ur60-super-admin"})
|
||||
var shopQueryCount atomic.Int64
|
||||
callbackName := fmt.Sprintf("ur60:count_shop_queries:%d", time.Now().UnixNano())
|
||||
if err := env.db.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
|
||||
if db.Statement != nil && db.Statement.Table == (model.Shop{}).TableName() {
|
||||
shopQueryCount.Add(1)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("注册店铺查询计数回调失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = env.db.Callback().Query().Remove(callbackName) })
|
||||
|
||||
invalidPhones := []string{
|
||||
"1380000000", "138000000000", "1380000000A", "+8613800000000",
|
||||
"138-0000-0000", " 13800000000", "13800000000",
|
||||
}
|
||||
for _, phone := range invalidPhones {
|
||||
shopQueryCount.Store(0)
|
||||
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone="+url.QueryEscape(phone), token)
|
||||
if status != http.StatusBadRequest || shopQueryCount.Load() != 0 {
|
||||
t.Fatalf("非法联系电话应在查询前返回 400,phone=%q, queries=%d, body=%s", phone, shopQueryCount.Load(), body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentContactPhoneSearchKeepsShopScope 验证代理账号无法通过联系电话越过店铺层级范围。
|
||||
func TestAgentContactPhoneSearchKeepsShopScope(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
phone := "13700000000"
|
||||
root := env.createShop(t, "UR60代理根店", phone, nil, 1, time.Now().Add(-3*time.Minute))
|
||||
child := env.createShop(t, "UR60代理下级", phone, &root.ID, 2, time.Now().Add(-2*time.Minute))
|
||||
env.createShop(t, "UR60范围外店铺", phone, nil, 1, time.Now().Add(-time.Minute))
|
||||
token := env.newToken(t, auth.TokenInfo{UserID: uniqueTestID(), UserType: constants.UserTypeAgent, ShopID: root.ID, Username: "ur60-agent"})
|
||||
|
||||
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone="+phone, token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("代理联系电话查询失败,HTTP %d:%s", status, body)
|
||||
}
|
||||
var response shopTestResponse
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析代理联系电话响应失败:%v", err)
|
||||
}
|
||||
if response.Data.Total != 2 {
|
||||
t.Fatalf("代理联系电话查询应只返回自身及下级:%s", body)
|
||||
}
|
||||
returned := map[uint]bool{}
|
||||
for _, item := range response.Data.Items {
|
||||
returned[uint(item["id"].(float64))] = true
|
||||
}
|
||||
if !returned[root.ID] || !returned[child.ID] {
|
||||
t.Fatalf("代理联系电话查询缺少范围内店铺:%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShopListDatabaseFailureIsSanitized 验证数据库错误通过统一 500 响应脱敏。
|
||||
func TestShopListDatabaseFailureIsSanitized(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
token := env.newToken(t, auth.TokenInfo{UserID: uniqueTestID(), UserType: constants.UserTypeSuperAdmin, Username: "ur60-super-admin"})
|
||||
sqlDB, err := env.db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("获取测试数据库连接失败:%v", err)
|
||||
}
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Fatalf("关闭测试数据库连接失败:%v", err)
|
||||
}
|
||||
|
||||
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone=13800000000", token)
|
||||
if status != http.StatusInternalServerError {
|
||||
t.Fatalf("数据库失败应返回 HTTP 500,实际 %d:%s", status, body)
|
||||
}
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析数据库错误响应失败:%v", err)
|
||||
}
|
||||
if response.Code != errors.CodeInternalError || response.Msg != "内部服务器错误" || response.Data != nil {
|
||||
t.Fatalf("数据库错误响应不符合脱敏契约:%s", body)
|
||||
}
|
||||
for _, leaked := range []string{"sql", "postgres", "host", "driver", "database is closed"} {
|
||||
if strings.Contains(strings.ToLower(string(body)), leaked) {
|
||||
t.Fatalf("数据库错误响应泄露底层信息 %q:%s", leaked, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShopContactPhoneIndexDefinition 验证联系电话索引的类型、唯一性和部分条件。
|
||||
func TestShopContactPhoneIndexDefinition(t *testing.T) {
|
||||
env := newShopTestEnv(t)
|
||||
var indexCount int64
|
||||
if err := env.db.Raw(`
|
||||
SELECT COUNT(*)
|
||||
FROM pg_class
|
||||
WHERE relname = ?
|
||||
`, "idx_shop_contact_phone").Scan(&indexCount).Error; err != nil {
|
||||
t.Fatalf("查询联系电话索引数量失败:%v", err)
|
||||
}
|
||||
if os.Getenv("UR60_EXPECT_INDEX_ABSENT") == "1" {
|
||||
if indexCount != 0 {
|
||||
t.Fatalf("回滚后联系电话索引仍然存在")
|
||||
}
|
||||
return
|
||||
}
|
||||
if indexCount != 1 {
|
||||
t.Fatalf("联系电话索引数量异常:%d", indexCount)
|
||||
}
|
||||
|
||||
var index struct {
|
||||
AccessMethod string `gorm:"column:access_method"`
|
||||
IsUnique bool `gorm:"column:is_unique"`
|
||||
Definition string `gorm:"column:definition"`
|
||||
Predicate string `gorm:"column:predicate"`
|
||||
}
|
||||
err := env.db.Raw(`
|
||||
SELECT am.amname AS access_method,
|
||||
ix.indisunique AS is_unique,
|
||||
pg_get_indexdef(ix.indexrelid) AS definition,
|
||||
pg_get_expr(ix.indpred, ix.indrelid) AS predicate
|
||||
FROM pg_index ix
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
JOIN pg_am am ON am.oid = i.relam
|
||||
WHERE i.relname = ?
|
||||
`, "idx_shop_contact_phone").Scan(&index).Error
|
||||
if err != nil {
|
||||
t.Fatalf("查询联系电话索引定义失败:%v", err)
|
||||
}
|
||||
if index.AccessMethod != "btree" || index.IsUnique || !strings.Contains(index.Definition, "contact_phone") || !strings.Contains(index.Predicate, "deleted_at IS NULL") {
|
||||
t.Fatalf("联系电话索引定义不符合契约:%+v", index)
|
||||
}
|
||||
}
|
||||
|
||||
func newShopTestEnv(t *testing.T) *shopTestEnv {
|
||||
t.Helper()
|
||||
if os.Getenv("JUNHONG_DATABASE_HOST") == "" || os.Getenv("JUNHONG_REDIS_ADDRESS") == "" {
|
||||
t.Skip("未加载 .env.local,跳过依赖真实 PostgreSQL 和 Redis 的店铺 HTTP 集成测试")
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("加载测试配置失败:%v", err)
|
||||
}
|
||||
logger := zap.NewNop()
|
||||
db, err := database.InitPostgreSQL(&cfg.Database, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("连接 PostgreSQL 失败:%v", err)
|
||||
}
|
||||
redisClient, err := database.NewRedisClient(database.RedisConfig{
|
||||
Address: cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port),
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
PoolSize: cfg.Redis.PoolSize,
|
||||
MinIdleConns: cfg.Redis.MinIdleConns,
|
||||
DialTimeout: cfg.Redis.DialTimeout,
|
||||
ReadTimeout: cfg.Redis.ReadTimeout,
|
||||
WriteTimeout: cfg.Redis.WriteTimeout,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("连接 Redis 失败:%v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = redisClient.Close()
|
||||
if sqlDB, dbErr := db.DB(); dbErr == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
shopStore := postgres.NewShopStore(db, redisClient)
|
||||
service := shopService.New(shopStore, nil, nil, nil)
|
||||
shopCommission := shopCommissionService.New(
|
||||
shopStore,
|
||||
postgres.NewAccountStore(db, redisClient),
|
||||
postgres.NewAgentWalletStore(db, redisClient),
|
||||
postgres.NewCommissionWithdrawalRequestStore(db, redisClient),
|
||||
postgres.NewCommissionWithdrawalSettingStore(db, redisClient),
|
||||
postgres.NewCommissionRecordStore(db, redisClient),
|
||||
postgres.NewAgentWalletTransactionStore(db, redisClient),
|
||||
db,
|
||||
logger,
|
||||
)
|
||||
tokenManager := auth.NewTokenManager(redisClient, cfg.JWT.AccessTokenTTL, cfg.JWT.RefreshTokenTTL)
|
||||
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{
|
||||
TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
|
||||
info, validateErr := tokenManager.ValidateAccessToken(context.Background(), token)
|
||||
if validateErr != nil {
|
||||
return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期")
|
||||
}
|
||||
return &pkgMiddleware.UserContextInfo{
|
||||
UserID: info.UserID,
|
||||
UserType: info.UserType,
|
||||
Username: info.Username,
|
||||
ShopID: info.ShopID,
|
||||
EnterpriseID: info.EnterpriseID,
|
||||
}, nil
|
||||
},
|
||||
ShopStore: shopStore,
|
||||
})
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
JSONEncoder: sonic.Marshal,
|
||||
JSONDecoder: sonic.Unmarshal,
|
||||
ErrorHandler: internalMiddleware.ErrorHandler(logger),
|
||||
})
|
||||
RegisterAdminRoutes(app.Group("/api/admin"), &bootstrap.Handlers{
|
||||
Shop: admin.NewShopHandler(service, validator.New()),
|
||||
ShopRole: admin.NewShopRoleHandler(service),
|
||||
ShopCommission: admin.NewShopCommissionHandler(shopCommission),
|
||||
}, &bootstrap.Middlewares{AdminAuth: authMiddleware}, nil, "/api/admin")
|
||||
|
||||
return &shopTestEnv{app: app, db: db, redis: redisClient, tokenManager: tokenManager}
|
||||
}
|
||||
|
||||
func (e *shopTestEnv) createShop(t *testing.T, name, phone string, parentID *uint, level int, createdAt time.Time) *model.Shop {
|
||||
t.Helper()
|
||||
shop := &model.Shop{
|
||||
ShopName: name,
|
||||
ShopCode: fmt.Sprintf("UR60-%d", time.Now().UnixNano()),
|
||||
ParentID: parentID,
|
||||
Level: level,
|
||||
ContactPhone: phone,
|
||||
Status: constants.ShopStatusEnabled,
|
||||
}
|
||||
shop.CreatedAt = createdAt
|
||||
shop.UpdatedAt = createdAt
|
||||
shop.Creator = uniqueTestID()
|
||||
shop.Updater = shop.Creator
|
||||
if err := e.db.Create(shop).Error; err != nil {
|
||||
t.Fatalf("创建测试店铺失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = e.db.Unscoped().Delete(shop).Error })
|
||||
return shop
|
||||
}
|
||||
|
||||
func (e *shopTestEnv) newToken(t *testing.T, info auth.TokenInfo) string {
|
||||
t.Helper()
|
||||
accessToken, refreshToken, err := e.tokenManager.GenerateTokenPair(context.Background(), &info)
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试令牌失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = e.tokenManager.RevokeToken(context.Background(), accessToken)
|
||||
_ = e.tokenManager.RevokeToken(context.Background(), refreshToken)
|
||||
_ = e.redis.Del(context.Background(), constants.RedisUserTokensKey(info.UserID)).Err()
|
||||
})
|
||||
return accessToken
|
||||
}
|
||||
|
||||
func (e *shopTestEnv) request(t *testing.T, method, path, token string) (int, []byte) {
|
||||
t.Helper()
|
||||
request, err := http.NewRequest(method, path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("创建 HTTP 请求失败:%v", err)
|
||||
}
|
||||
if token != "" {
|
||||
request.Header.Set(fiber.HeaderAuthorization, "Bearer "+token)
|
||||
}
|
||||
response, err := e.app.Test(request, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("执行 HTTP 请求失败:%v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 HTTP 响应失败:%v", err)
|
||||
}
|
||||
return response.StatusCode, body
|
||||
}
|
||||
|
||||
func uniqueTestID() uint {
|
||||
return uint(time.Now().UnixNano() % 1_000_000_000)
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
type configAuditWriter struct {
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (w *configAuditWriter) WriteConfigChange(_ context.Context, tx *gorm.DB, audit systemConfigApp.ChangeAudit) error {
|
||||
if w.fail {
|
||||
return stderrors.New("测试审计写入失败")
|
||||
}
|
||||
before, _ := sonic.Marshal(audit.BeforeData)
|
||||
after, _ := sonic.Marshal(audit.AfterData)
|
||||
return tx.Exec(`INSERT INTO test_system_config_audit
|
||||
(config_key, operator_id, request_id, before_data, after_data)
|
||||
VALUES (?, ?, ?, ?::jsonb, ?::jsonb)`, audit.ConfigKey, audit.OperatorID, audit.RequestID, string(before), string(after)).Error
|
||||
}
|
||||
|
||||
type recordingAlerts struct {
|
||||
mu sync.Mutex
|
||||
codes []string
|
||||
}
|
||||
|
||||
func (a *recordingAlerts) Warn(_ context.Context, code, _, _, _ string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.codes = append(a.codes, code)
|
||||
}
|
||||
|
||||
type failingCache struct{}
|
||||
|
||||
func (failingCache) Get(context.Context, string) (string, error) {
|
||||
return "", stderrors.New("缓存不可用")
|
||||
}
|
||||
func (failingCache) Set(context.Context, string, string, time.Duration) error {
|
||||
return stderrors.New("缓存不可用")
|
||||
}
|
||||
func (failingCache) Delete(context.Context, string) error { return stderrors.New("缓存不可用") }
|
||||
|
||||
func TestSystemConfigFiberListAndUpdateUseRealPostgresAndRedis(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
testutil.CreateTemporarySystemConfigTable(t, db)
|
||||
createTemporarySystemConfigAuditTable(t, db)
|
||||
registry := newSystemConfigTestRegistry(t)
|
||||
cache := systemConfigInfra.NewRedisCache(redisClient)
|
||||
alerts := &recordingAlerts{}
|
||||
reader := systemConfigInfra.NewReader(db, registry, cache, alerts)
|
||||
handler := admin.NewSystemConfigHandler(
|
||||
systemConfigQuery.NewListQuery(reader),
|
||||
systemConfigApp.NewUpdateService(db, registry, cache, &configAuditWriter{}, alerts, nil),
|
||||
)
|
||||
|
||||
if err := db.Create(&model.SystemConfig{
|
||||
ConfigKey: "legacy.unknown.value", ConfigValue: "legacy", ValueType: constants.SystemConfigTypeString,
|
||||
Module: "legacy", Description: "未注册遗留配置", IsSensitive: false,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("准备未注册配置失败:%v", err)
|
||||
}
|
||||
if err := redisClient.Set(context.Background(), constants.RedisSystemConfigKey("foundation.demo.int"), "7", constants.SystemConfigCacheTTL).Err(); err != nil {
|
||||
t.Fatalf("准备 Redis 配置缓存失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, definition := range registry.List() {
|
||||
_ = redisClient.Del(context.Background(), constants.RedisSystemConfigKey(definition.Key)).Err()
|
||||
}
|
||||
})
|
||||
|
||||
app := newSystemConfigTestApp(handler, constants.UserTypeSuperAdmin)
|
||||
listResponse, err := app.Test(httptest.NewRequest("GET", "/api/admin/system-configs?module=foundation&page=1&page_size=20", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("查询系统配置接口失败:%v", err)
|
||||
}
|
||||
if listResponse.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("查询系统配置状态码错误:%d", listResponse.StatusCode)
|
||||
}
|
||||
var listBody struct {
|
||||
Code int `json:"code"`
|
||||
Data dto.SystemConfigListResponse `json:"data"`
|
||||
}
|
||||
if err := sonic.ConfigDefault.NewDecoder(listResponse.Body).Decode(&listBody); err != nil {
|
||||
t.Fatalf("解析系统配置列表响应失败:%v", err)
|
||||
}
|
||||
_ = listResponse.Body.Close()
|
||||
if listBody.Code != 0 || listBody.Data.Total != 4 || len(listBody.Data.List) != 4 {
|
||||
t.Fatalf("四种注册类型未完整返回:%+v", listBody)
|
||||
}
|
||||
for _, item := range listBody.Data.List {
|
||||
if item.Sensitive && item.Value != "[已配置]" {
|
||||
t.Fatalf("敏感配置未脱敏:%+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
updateRequest := httptest.NewRequest("PUT", "/api/admin/system-configs/foundation.demo.int", strings.NewReader(`{"value":"8"}`))
|
||||
updateRequest.Header.Set("Content-Type", "application/json")
|
||||
updateResponse, err := app.Test(updateRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("更新系统配置接口失败:%v", err)
|
||||
}
|
||||
if updateResponse.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("更新系统配置状态码错误:%d", updateResponse.StatusCode)
|
||||
}
|
||||
_ = updateResponse.Body.Close()
|
||||
var stored model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "foundation.demo.int").First(&stored).Error; err != nil || stored.ConfigValue != "8" {
|
||||
t.Fatalf("系统配置数据库事实错误:%v,记录:%+v", err, stored)
|
||||
}
|
||||
var auditCount int64
|
||||
if err := db.Table("test_system_config_audit").Where("config_key = ? AND operator_id = ?", stored.ConfigKey, 7).Count(&auditCount).Error; err != nil || auditCount != 1 {
|
||||
t.Fatalf("系统配置审计事实错误:%v,数量:%d", err, auditCount)
|
||||
}
|
||||
if exists, err := redisClient.Exists(context.Background(), constants.RedisSystemConfigKey(stored.ConfigKey)).Result(); err != nil || exists != 0 {
|
||||
t.Fatalf("更新后缓存未失效:%v,存在:%d", err, exists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemConfigPermissionsValidationRollbackAndCacheFailure(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
testutil.CreateTemporarySystemConfigTable(t, db)
|
||||
createTemporarySystemConfigAuditTable(t, db)
|
||||
registry := newSystemConfigTestRegistry(t)
|
||||
alerts := &recordingAlerts{}
|
||||
reader := systemConfigInfra.NewReader(db, registry, failingCache{}, alerts)
|
||||
service := systemConfigApp.NewUpdateService(db, registry, failingCache{}, &configAuditWriter{}, alerts, nil)
|
||||
handler := admin.NewSystemConfigHandler(systemConfigQuery.NewListQuery(reader), service)
|
||||
|
||||
forbiddenApp := newSystemConfigTestApp(handler, constants.UserTypePlatform)
|
||||
response, err := forbiddenApp.Test(httptest.NewRequest("GET", "/api/admin/system-configs", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("执行无权限查询失败:%v", err)
|
||||
}
|
||||
if response.StatusCode != fiber.StatusForbidden {
|
||||
t.Fatalf("权限不足必须返回 403,得到:%d", response.StatusCode)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{UserID: 7, UserType: constants.UserTypeSuperAdmin})
|
||||
invalidCases := []struct {
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
{key: "foundation.demo.int", value: "not-int"},
|
||||
{key: "foundation.demo.bool", value: "yes"},
|
||||
{key: "foundation.demo.json", value: "{"},
|
||||
{key: "foundation.unknown.value", value: "x"},
|
||||
{key: "foundation.demo.readonly", value: "x"},
|
||||
}
|
||||
for _, item := range invalidCases {
|
||||
if _, err := service.Execute(ctx, item.key, dto.UpdateSystemConfigRequest{Value: item.value}); err == nil {
|
||||
t.Fatalf("非法配置更新应失败:%s=%s", item.key, item.value)
|
||||
}
|
||||
}
|
||||
|
||||
failClosed := systemConfigApp.NewUpdateService(db, registry, failingCache{}, &configAuditWriter{fail: true}, alerts, nil)
|
||||
if _, err := failClosed.Execute(ctx, "foundation.demo.string", dto.UpdateSystemConfigRequest{Value: "must-rollback"}); err == nil {
|
||||
t.Fatal("审计写入失败时配置事务必须回滚")
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.SystemConfig{}).Where("config_key = ?", "foundation.demo.string").Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("审计失败后配置事实未回滚:%v,数量:%d", err, count)
|
||||
}
|
||||
|
||||
if _, err := service.Execute(ctx, "foundation.demo.string", dto.UpdateSystemConfigRequest{Value: "committed"}); err != nil {
|
||||
t.Fatalf("缓存失效失败不应回滚数据库事实:%v", err)
|
||||
}
|
||||
if len(alerts.codes) == 0 {
|
||||
t.Fatal("缓存故障必须产生可观察告警")
|
||||
}
|
||||
}
|
||||
|
||||
func newSystemConfigTestRegistry(t *testing.T) *systemConfigInfra.Registry {
|
||||
t.Helper()
|
||||
registry := systemConfigInfra.NewRegistry()
|
||||
minimum, maximum := int64(1), int64(10)
|
||||
definitions := []systemConfigInfra.Definition{
|
||||
{Key: "foundation.demo.string", Module: "foundation", ValueType: constants.SystemConfigTypeString, DefaultValue: "default", Description: "字符串示例", Control: "input"},
|
||||
{Key: "foundation.demo.int", Module: "foundation", ValueType: constants.SystemConfigTypeInt, DefaultValue: "5", Description: "整数示例", Control: "number", Min: &minimum, Max: &maximum},
|
||||
{Key: "foundation.demo.bool", Module: "foundation", ValueType: constants.SystemConfigTypeBool, DefaultValue: "true", Description: "布尔示例", Control: "switch"},
|
||||
{Key: "foundation.demo.json", Module: "foundation", ValueType: constants.SystemConfigTypeJSON, DefaultValue: `{"enabled":true}`, Description: "JSON 示例", Control: "structured", Sensitive: true},
|
||||
{Key: "foundation.demo.readonly", Module: "foundation-readonly", ValueType: constants.SystemConfigTypeString, DefaultValue: "fixed", Description: "只读示例", Control: "readonly", Readonly: true},
|
||||
}
|
||||
for _, definition := range definitions {
|
||||
if err := registry.Register(definition); err != nil {
|
||||
t.Fatalf("注册测试配置失败:%v", err)
|
||||
}
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func newSystemConfigTestApp(handler *admin.SystemConfigHandler, userType int) *fiber.App {
|
||||
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
|
||||
api := app.Group("/api/admin", func(c *fiber.Ctx) error {
|
||||
ctx := middleware.SetUserContext(c.UserContext(), &middleware.UserContextInfo{UserID: 7, UserType: userType})
|
||||
ctx = context.WithValue(ctx, constants.ContextKeyRequestID, "request-system-config")
|
||||
c.SetUserContext(ctx)
|
||||
return c.Next()
|
||||
})
|
||||
registerSystemConfigRoutes(api, handler, nil, "/api/admin")
|
||||
return app
|
||||
}
|
||||
|
||||
func createTemporarySystemConfigAuditTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP TABLE test_system_config_audit (
|
||||
id bigserial PRIMARY KEY, config_key varchar(150) NOT NULL,
|
||||
operator_id bigint NOT NULL, request_id varchar(100) NOT NULL,
|
||||
before_data jsonb NOT NULL, after_data jsonb NOT NULL
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建系统配置审计测试表失败:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
)
|
||||
|
||||
func TestComputeUsageSummary_EmptyList(t *testing.T) {
|
||||
totalUsed, totalRemaining := computeUsageSummary(nil)
|
||||
if totalUsed != 0.0 {
|
||||
t.Errorf("期望 totalUsed=0.0,实际=%v", totalUsed)
|
||||
}
|
||||
if totalRemaining != 0.0 {
|
||||
t.Errorf("期望 totalRemaining=0.0,实际=%v", totalRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeUsageSummary_SingleVirtualPackage(t *testing.T) {
|
||||
// 启用虚流量:真实总量=100MB,虚拟总量=200MB,倍率=0.5
|
||||
// 真实已用=40MB → 虚拟已用=min(40*0.5, 100)=20MB
|
||||
// 虚拟剩余=200-20=180MB
|
||||
usage := &model.PackageUsage{
|
||||
DataLimitMB: 100,
|
||||
DataUsageMB: 40,
|
||||
VirtualTotalMBSnapshot: 200,
|
||||
DisplayGainRatioSnapshot: 0.5,
|
||||
EnableVirtualDataSnapshot: true,
|
||||
}
|
||||
totalUsed, totalRemaining := computeUsageSummary([]*model.PackageUsage{usage})
|
||||
if totalUsed != 20.0 {
|
||||
t.Errorf("期望 totalUsed=20.0,实际=%v", totalUsed)
|
||||
}
|
||||
if totalRemaining != 180.0 {
|
||||
t.Errorf("期望 totalRemaining=180.0,实际=%v", totalRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeUsageSummary_SingleNonVirtualPackage(t *testing.T) {
|
||||
// 未启用虚流量:退化为真实值
|
||||
// 真实总量=100MB,真实已用=40MB → 虚拟已用=40MB,虚拟剩余=60MB
|
||||
usage := &model.PackageUsage{
|
||||
DataLimitMB: 100,
|
||||
DataUsageMB: 40,
|
||||
EnableVirtualDataSnapshot: false,
|
||||
}
|
||||
totalUsed, totalRemaining := computeUsageSummary([]*model.PackageUsage{usage})
|
||||
if totalUsed != 40.0 {
|
||||
t.Errorf("期望 totalUsed=40.0,实际=%v", totalUsed)
|
||||
}
|
||||
if totalRemaining != 60.0 {
|
||||
t.Errorf("期望 totalRemaining=60.0,实际=%v", totalRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeUsageSummary_MultiplePackages(t *testing.T) {
|
||||
// 套餐1(启用虚流量):VirtualUsed=20, VirtualTotal=200
|
||||
// 套餐2(未启用虚流量):VirtualUsed=10, VirtualTotal=50
|
||||
// 汇总:totalUsed=30, totalRemaining=(200+50)-30=220
|
||||
usages := []*model.PackageUsage{
|
||||
{
|
||||
DataLimitMB: 100,
|
||||
DataUsageMB: 40,
|
||||
VirtualTotalMBSnapshot: 200,
|
||||
DisplayGainRatioSnapshot: 0.5,
|
||||
EnableVirtualDataSnapshot: true,
|
||||
},
|
||||
{
|
||||
DataLimitMB: 50,
|
||||
DataUsageMB: 10,
|
||||
EnableVirtualDataSnapshot: false,
|
||||
},
|
||||
}
|
||||
totalUsed, totalRemaining := computeUsageSummary(usages)
|
||||
if totalUsed != 30.0 {
|
||||
t.Errorf("期望 totalUsed=30.0,实际=%v", totalUsed)
|
||||
}
|
||||
if totalRemaining != 220.0 {
|
||||
t.Errorf("期望 totalRemaining=220.0,实际=%v", totalRemaining)
|
||||
}
|
||||
}
|
||||
@@ -1,715 +0,0 @@
|
||||
package customer_binding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
)
|
||||
|
||||
// ---- 测试工具 ----
|
||||
|
||||
// bindKey 构建 mock 中使用的查找键
|
||||
func bindKey(customerID uint, key string) string {
|
||||
return fmt.Sprintf("%d:%s", customerID, key)
|
||||
}
|
||||
|
||||
// ---- mock 实现 ----
|
||||
|
||||
type mockCardReader struct {
|
||||
cards map[uint]*model.IotCard
|
||||
}
|
||||
|
||||
func (m *mockCardReader) GetByID(_ context.Context, id uint) (*model.IotCard, error) {
|
||||
if c, ok := m.cards[id]; ok {
|
||||
return c, nil
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
type mockDeviceReader struct {
|
||||
devices map[uint]*model.Device
|
||||
}
|
||||
|
||||
func (m *mockDeviceReader) GetByID(_ context.Context, id uint) (*model.Device, error) {
|
||||
if d, ok := m.devices[id]; ok {
|
||||
return d, nil
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// mockPCDOps 模拟 tb_personal_customer_device 操作
|
||||
type mockPCDOps struct {
|
||||
// bindings["customerID:virtualNo"] = true 表示有效(status=1)绑定
|
||||
bindings map[string]bool
|
||||
created []*model.PersonalCustomerDevice
|
||||
counts map[string]int64 // virtualNo → count(用于首绑判断)
|
||||
allRecords []*model.PersonalCustomerDevice // GetByDeviceNo 和 UpdateStatus/UpdateVirtualNo 使用
|
||||
}
|
||||
|
||||
func (m *mockPCDOps) ExistsByCustomerAndDevice(_ context.Context, customerID uint, deviceNo string) (bool, error) {
|
||||
return m.bindings[bindKey(customerID, deviceNo)], nil
|
||||
}
|
||||
|
||||
func (m *mockPCDOps) Create(_ context.Context, record *model.PersonalCustomerDevice) error {
|
||||
m.created = append(m.created, record)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPCDOps) CountByVirtualNo(_ context.Context, virtualNo string) (int64, error) {
|
||||
if m.counts != nil {
|
||||
return m.counts[virtualNo], nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockPCDOps) GetByDeviceNo(_ context.Context, deviceNo string) ([]*model.PersonalCustomerDevice, error) {
|
||||
var result []*model.PersonalCustomerDevice
|
||||
for _, r := range m.allRecords {
|
||||
if r.VirtualNo == deviceNo {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockPCDOps) UpdateStatus(_ context.Context, id uint, status int) error {
|
||||
for _, r := range m.allRecords {
|
||||
if r.ID == id {
|
||||
r.Status = status
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPCDOps) UpdateVirtualNo(_ context.Context, id uint, newVirtualNo string) error {
|
||||
for _, r := range m.allRecords {
|
||||
if r.ID == id {
|
||||
r.VirtualNo = newVirtualNo
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockPCIOps 模拟 tb_personal_customer_iccid 操作
|
||||
type mockPCIOps struct {
|
||||
bindings map[string]bool
|
||||
created []*model.PersonalCustomerICCID
|
||||
counts map[string]int64 // iccid → count
|
||||
allRecords []*model.PersonalCustomerICCID // GetByICCID 和 UpdateStatus 使用
|
||||
}
|
||||
|
||||
func (m *mockPCIOps) ExistsByCustomerAndICCID(_ context.Context, customerID uint, iccid string) (bool, error) {
|
||||
return m.bindings[bindKey(customerID, iccid)], nil
|
||||
}
|
||||
|
||||
func (m *mockPCIOps) Create(_ context.Context, record *model.PersonalCustomerICCID) error {
|
||||
m.created = append(m.created, record)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPCIOps) CountByICCID(_ context.Context, iccid string) (int64, error) {
|
||||
if m.counts != nil {
|
||||
return m.counts[iccid], nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockPCIOps) GetByICCID(_ context.Context, iccid string) ([]*model.PersonalCustomerICCID, error) {
|
||||
var result []*model.PersonalCustomerICCID
|
||||
for _, r := range m.allRecords {
|
||||
if r.ICCID == iccid {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockPCIOps) UpdateStatus(_ context.Context, id uint, status int) error {
|
||||
for _, r := range m.allRecords {
|
||||
if r.ID == id {
|
||||
r.Status = status
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 测试 Service 构造器 ----
|
||||
|
||||
// soldCalls 记录 markAsSold 调用
|
||||
type soldCalls struct {
|
||||
items []string
|
||||
}
|
||||
|
||||
func (s *soldCalls) mark(_ context.Context, _ *gorm.DB, assetType string, _ uint) error {
|
||||
s.items = append(s.items, assetType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestService(cards cardReader, devices deviceReader, pcd pcdOps, pci pciOps) (*Service, *soldCalls) {
|
||||
sold := &soldCalls{}
|
||||
return &Service{
|
||||
cards: cards,
|
||||
devices: devices,
|
||||
readCard: func(ctx context.Context, _ *gorm.DB, id uint) (*model.IotCard, error) {
|
||||
return cards.GetByID(ctx, id)
|
||||
},
|
||||
readDevice: func(ctx context.Context, _ *gorm.DB, id uint) (*model.Device, error) {
|
||||
return devices.GetByID(ctx, id)
|
||||
},
|
||||
makePCD: func(_ *gorm.DB) pcdOps { return pcd },
|
||||
makePCI: func(_ *gorm.DB) pciOps { return pci },
|
||||
markAsSold: sold.mark,
|
||||
}, sold
|
||||
}
|
||||
|
||||
// ---- OwnsAsset 测试 ----
|
||||
|
||||
// 验证:有虚拟号卡 + 客户有有效绑定 → true(tracer bullet)
|
||||
func TestOwnsAsset_有虚拟号卡_有效绑定_返回true(t *testing.T) {
|
||||
card := &model.IotCard{VirtualNo: "VN001"}
|
||||
card.ID = 1
|
||||
|
||||
pcd := &mockPCDOps{
|
||||
bindings: map[string]bool{bindKey(10, "VN001"): true},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{1: card}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 1)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if !owned {
|
||||
t.Fatal("期望返回 true,实际返回 false")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:有虚拟号卡 + status=0 的绑定 → false(修复安全缺口)
|
||||
func TestOwnsAsset_有虚拟号卡_禁用绑定_返回false(t *testing.T) {
|
||||
card := &model.IotCard{VirtualNo: "VN002"}
|
||||
card.ID = 2
|
||||
|
||||
pcd := &mockPCDOps{
|
||||
// status=0 的绑定:在 ExistsByCustomerAndDevice 中会过滤掉(bindings 中不存在)
|
||||
bindings: map[string]bool{},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{2: card}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 2)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if owned {
|
||||
t.Fatal("期望返回 false(status=0),实际返回 true")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:有虚拟号卡 + 无绑定 → false
|
||||
func TestOwnsAsset_有虚拟号卡_无绑定_返回false(t *testing.T) {
|
||||
card := &model.IotCard{VirtualNo: "VN003"}
|
||||
card.ID = 3
|
||||
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{3: card}},
|
||||
&mockDeviceReader{},
|
||||
&mockPCDOps{bindings: map[string]bool{}},
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 3)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if owned {
|
||||
t.Fatal("期望返回 false(无绑定),实际返回 true")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:assetType="card"(来自 assetService.Resolve)等价于 "iot_card"
|
||||
func TestOwnsAsset_assetType_card_等价iot_card(t *testing.T) {
|
||||
card := &model.IotCard{VirtualNo: "VN_CARD"}
|
||||
card.ID = 9
|
||||
|
||||
pcd := &mockPCDOps{
|
||||
bindings: map[string]bool{bindKey(10, "VN_CARD"): true},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{9: card}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
owned, err := svc.OwnsAsset(context.Background(), 10, "card", 9)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if !owned {
|
||||
t.Fatal("期望 card 类型等价 iot_card 返回 true,实际 false")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:设备资产 + 有效绑定 → true
|
||||
func TestOwnsAsset_设备_有效绑定_返回true(t *testing.T) {
|
||||
device := &model.Device{VirtualNo: "DEV001"}
|
||||
device.ID = 5
|
||||
|
||||
pcd := &mockPCDOps{
|
||||
bindings: map[string]bool{bindKey(10, "DEV001"): true},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{},
|
||||
&mockDeviceReader{devices: map[uint]*model.Device{5: device}},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
owned, err := svc.OwnsAsset(context.Background(), 10, "device", 5)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if !owned {
|
||||
t.Fatal("期望返回 true,实际返回 false")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Bind 测试 ----
|
||||
|
||||
// 验证:首次绑定有虚拟号卡 → 写入 pcd 记录 + 触发 markAssetAsSold
|
||||
func TestBind_有虚拟号卡_首次绑定_创建记录并标记已售(t *testing.T) {
|
||||
card := &model.IotCard{VirtualNo: "VN010"}
|
||||
card.ID = 10
|
||||
|
||||
pcd := &mockPCDOps{
|
||||
bindings: map[string]bool{},
|
||||
counts: map[string]int64{"VN010": 0}, // 首次绑定
|
||||
}
|
||||
svc, sold := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{10: card}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
err := svc.Bind(context.Background(), nil, 20, "iot_card", 10)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if len(pcd.created) != 1 {
|
||||
t.Fatalf("期望创建 1 条 pcd 记录,实际: %d", len(pcd.created))
|
||||
}
|
||||
if pcd.created[0].VirtualNo != "VN010" {
|
||||
t.Errorf("期望 VirtualNo=VN010,实际: %s", pcd.created[0].VirtualNo)
|
||||
}
|
||||
if pcd.created[0].CustomerID != 20 {
|
||||
t.Errorf("期望 CustomerID=20,实际: %d", pcd.created[0].CustomerID)
|
||||
}
|
||||
if len(sold.items) != 1 || sold.items[0] != "iot_card" {
|
||||
t.Errorf("期望触发 markAssetAsSold(iot_card),实际: %v", sold.items)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:重复绑定 → 不创建新记录
|
||||
func TestBind_有虚拟号卡_已有绑定_不重复创建(t *testing.T) {
|
||||
card := &model.IotCard{VirtualNo: "VN011"}
|
||||
card.ID = 11
|
||||
|
||||
pcd := &mockPCDOps{
|
||||
bindings: map[string]bool{bindKey(20, "VN011"): true},
|
||||
counts: map[string]int64{"VN011": 1},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{11: card}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
err := svc.Bind(context.Background(), nil, 20, "iot_card", 11)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if len(pcd.created) != 0 {
|
||||
t.Fatalf("期望不创建新记录,实际创建了 %d 条", len(pcd.created))
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:非首次绑定(已有其他客户绑定)→ 创建记录但不触发 markAssetAsSold
|
||||
func TestBind_有虚拟号卡_非首次绑定_创建记录不标记已售(t *testing.T) {
|
||||
card := &model.IotCard{VirtualNo: "VN012"}
|
||||
card.ID = 12
|
||||
|
||||
pcd := &mockPCDOps{
|
||||
bindings: map[string]bool{},
|
||||
counts: map[string]int64{"VN012": 1}, // 已有其他绑定
|
||||
}
|
||||
svc, sold := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{12: card}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
err := svc.Bind(context.Background(), nil, 30, "iot_card", 12)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if len(pcd.created) != 1 {
|
||||
t.Fatalf("期望创建 1 条记录,实际: %d", len(pcd.created))
|
||||
}
|
||||
if len(sold.items) != 0 {
|
||||
t.Errorf("期望不触发 markAssetAsSold,实际触发了: %v", sold.items)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Issue 02: 无虚拟号卡 pci 路径测试 ----
|
||||
|
||||
// 验证:无虚拟号卡 + 客户有有效 PCI 绑定 → true
|
||||
func TestOwnsAsset_无虚拟号卡_有效PCI绑定_返回true(t *testing.T) {
|
||||
card := &model.IotCard{ICCID: "89860000000000000001"} // VirtualNo 为空
|
||||
card.ID = 20
|
||||
|
||||
pci := &mockPCIOps{
|
||||
bindings: map[string]bool{bindKey(10, "89860000000000000001"): true},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{20: card}},
|
||||
&mockDeviceReader{},
|
||||
&mockPCDOps{bindings: map[string]bool{}},
|
||||
pci,
|
||||
)
|
||||
|
||||
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 20)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if !owned {
|
||||
t.Fatal("期望返回 true,实际返回 false")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:无虚拟号卡 + 无 PCI 绑定 → false
|
||||
func TestOwnsAsset_无虚拟号卡_无PCI绑定_返回false(t *testing.T) {
|
||||
card := &model.IotCard{ICCID: "89860000000000000002"} // VirtualNo 为空
|
||||
card.ID = 21
|
||||
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{21: card}},
|
||||
&mockDeviceReader{},
|
||||
&mockPCDOps{bindings: map[string]bool{}},
|
||||
&mockPCIOps{bindings: map[string]bool{}},
|
||||
)
|
||||
|
||||
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 21)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if owned {
|
||||
t.Fatal("期望返回 false(无 PCI 绑定),实际返回 true")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:无虚拟号卡首次绑定 → 写入 pci 记录 + 触发 markAssetAsSold
|
||||
func TestBind_无虚拟号卡_首次绑定_创建PCI记录并标记已售(t *testing.T) {
|
||||
card := &model.IotCard{ICCID: "89860000000000000010"} // VirtualNo 为空
|
||||
card.ID = 30
|
||||
|
||||
pci := &mockPCIOps{
|
||||
bindings: map[string]bool{},
|
||||
counts: map[string]int64{"89860000000000000010": 0}, // 首次绑定
|
||||
}
|
||||
svc, sold := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{30: card}},
|
||||
&mockDeviceReader{},
|
||||
&mockPCDOps{bindings: map[string]bool{}},
|
||||
pci,
|
||||
)
|
||||
|
||||
err := svc.Bind(context.Background(), nil, 50, "iot_card", 30)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if len(pci.created) != 1 {
|
||||
t.Fatalf("期望创建 1 条 pci 记录,实际: %d", len(pci.created))
|
||||
}
|
||||
if pci.created[0].ICCID != "89860000000000000010" {
|
||||
t.Errorf("期望 ICCID=89860000000000000010,实际: %s", pci.created[0].ICCID)
|
||||
}
|
||||
if pci.created[0].CustomerID != 50 {
|
||||
t.Errorf("期望 CustomerID=50,实际: %d", pci.created[0].CustomerID)
|
||||
}
|
||||
if len(sold.items) != 1 || sold.items[0] != "iot_card" {
|
||||
t.Errorf("期望触发 markAssetAsSold(iot_card),实际: %v", sold.items)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:无虚拟号卡已有绑定 → 不重复创建
|
||||
func TestBind_无虚拟号卡_已有绑定_不重复创建(t *testing.T) {
|
||||
card := &model.IotCard{ICCID: "89860000000000000011"} // VirtualNo 为空
|
||||
card.ID = 31
|
||||
|
||||
pci := &mockPCIOps{
|
||||
bindings: map[string]bool{bindKey(50, "89860000000000000011"): true},
|
||||
counts: map[string]int64{"89860000000000000011": 1},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{31: card}},
|
||||
&mockDeviceReader{},
|
||||
&mockPCDOps{bindings: map[string]bool{}},
|
||||
pci,
|
||||
)
|
||||
|
||||
err := svc.Bind(context.Background(), nil, 50, "iot_card", 31)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if len(pci.created) != 0 {
|
||||
t.Fatalf("期望不创建新记录,实际创建了 %d 条", len(pci.created))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Issue 03: Migrate 迁移测试 ----
|
||||
|
||||
// newMockPCDRecord 构建一条带 ID 的 pcd 记录(用于迁移测试)
|
||||
func newMockPCDRecord(id, customerID uint, virtualNo string, status int) *model.PersonalCustomerDevice {
|
||||
r := &model.PersonalCustomerDevice{
|
||||
CustomerID: customerID,
|
||||
VirtualNo: virtualNo,
|
||||
Status: status,
|
||||
}
|
||||
r.ID = id
|
||||
return r
|
||||
}
|
||||
|
||||
// newMockPCIRecord 构建一条带 ID 的 pci 记录(用于迁移测试)
|
||||
func newMockPCIRecord(id, customerID uint, iccid string, status int) *model.PersonalCustomerICCID {
|
||||
r := &model.PersonalCustomerICCID{
|
||||
CustomerID: customerID,
|
||||
ICCID: iccid,
|
||||
Status: status,
|
||||
}
|
||||
r.ID = id
|
||||
return r
|
||||
}
|
||||
|
||||
// 验证:旧卡有虚拟号 + pcd 有绑定 + 新卡有虚拟号 → pcd.virtual_no 更新为新卡虚拟号
|
||||
func TestMigrate_有虚拟号旧卡_有绑定_换有虚拟号新卡_更新VirtualNo(t *testing.T) {
|
||||
oldCard := &model.IotCard{VirtualNo: "OLD_VN"}
|
||||
oldCard.ID = 100
|
||||
newCard := &model.IotCard{VirtualNo: "NEW_VN"}
|
||||
newCard.ID = 101
|
||||
|
||||
existing := newMockPCDRecord(1, 50, "OLD_VN", 1)
|
||||
pcd := &mockPCDOps{
|
||||
bindings: map[string]bool{},
|
||||
allRecords: []*model.PersonalCustomerDevice{existing},
|
||||
}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{100: oldCard, 101: newCard}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
&mockPCIOps{},
|
||||
)
|
||||
|
||||
err := svc.Migrate(context.Background(), nil, "iot_card", 100, "iot_card", 101)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if existing.VirtualNo != "NEW_VN" {
|
||||
t.Errorf("期望 VirtualNo 更新为 NEW_VN,实际: %s", existing.VirtualNo)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:旧卡有虚拟号 + pcd 有绑定 + 新卡无虚拟号 → 旧 pcd status=0,新 pci 创建
|
||||
func TestMigrate_有虚拟号旧卡_有绑定_换无虚拟号新卡_迁移到PCI(t *testing.T) {
|
||||
oldCard := &model.IotCard{VirtualNo: "OLD_VN2"}
|
||||
oldCard.ID = 110
|
||||
newCard := &model.IotCard{ICCID: "89860000000000000099"} // 无虚拟号
|
||||
newCard.ID = 111
|
||||
|
||||
existing := newMockPCDRecord(2, 60, "OLD_VN2", 1)
|
||||
pcd := &mockPCDOps{
|
||||
allRecords: []*model.PersonalCustomerDevice{existing},
|
||||
}
|
||||
pci := &mockPCIOps{}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{110: oldCard, 111: newCard}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
pci,
|
||||
)
|
||||
|
||||
err := svc.Migrate(context.Background(), nil, "iot_card", 110, "iot_card", 111)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if existing.Status != 0 {
|
||||
t.Errorf("期望旧 pcd 记录 status=0,实际: %d", existing.Status)
|
||||
}
|
||||
if len(pci.created) != 1 {
|
||||
t.Fatalf("期望创建 1 条 pci 记录,实际: %d", len(pci.created))
|
||||
}
|
||||
if pci.created[0].CustomerID != 60 {
|
||||
t.Errorf("期望 pci CustomerID=60,实际: %d", pci.created[0].CustomerID)
|
||||
}
|
||||
if pci.created[0].ICCID != "89860000000000000099" {
|
||||
t.Errorf("期望 pci ICCID=89860000000000000099,实际: %s", pci.created[0].ICCID)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:旧卡有虚拟号 + pcd 无绑定 + 新卡无虚拟号 → 跳过,无写入
|
||||
func TestMigrate_有虚拟号旧卡_无绑定_换无虚拟号新卡_跳过(t *testing.T) {
|
||||
oldCard := &model.IotCard{VirtualNo: "OLD_VN3"}
|
||||
oldCard.ID = 120
|
||||
newCard := &model.IotCard{ICCID: "89860000000000000088"} // 无虚拟号
|
||||
newCard.ID = 121
|
||||
|
||||
pcd := &mockPCDOps{allRecords: []*model.PersonalCustomerDevice{}} // 空
|
||||
pci := &mockPCIOps{}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{120: oldCard, 121: newCard}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
pci,
|
||||
)
|
||||
|
||||
err := svc.Migrate(context.Background(), nil, "iot_card", 120, "iot_card", 121)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if len(pci.created) != 0 {
|
||||
t.Fatalf("期望无写入,实际创建了 %d 条 pci 记录", len(pci.created))
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:旧卡无虚拟号 + pci 有绑定 + 新卡有虚拟号 → 旧 pci status=0,新 pcd 创建
|
||||
func TestMigrate_无虚拟号旧卡_有绑定_换有虚拟号新卡_迁移到PCD(t *testing.T) {
|
||||
oldCard := &model.IotCard{ICCID: "89860000000000000077"} // 无虚拟号
|
||||
oldCard.ID = 130
|
||||
newCard := &model.IotCard{VirtualNo: "NEW_VN3"}
|
||||
newCard.ID = 131
|
||||
|
||||
existingPCI := newMockPCIRecord(3, 70, "89860000000000000077", 1)
|
||||
pci := &mockPCIOps{allRecords: []*model.PersonalCustomerICCID{existingPCI}}
|
||||
pcd := &mockPCDOps{}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{130: oldCard, 131: newCard}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
pci,
|
||||
)
|
||||
|
||||
err := svc.Migrate(context.Background(), nil, "iot_card", 130, "iot_card", 131)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if existingPCI.Status != 0 {
|
||||
t.Errorf("期望旧 pci 记录 status=0,实际: %d", existingPCI.Status)
|
||||
}
|
||||
if len(pcd.created) != 1 {
|
||||
t.Fatalf("期望创建 1 条 pcd 记录,实际: %d", len(pcd.created))
|
||||
}
|
||||
if pcd.created[0].CustomerID != 70 {
|
||||
t.Errorf("期望 pcd CustomerID=70,实际: %d", pcd.created[0].CustomerID)
|
||||
}
|
||||
if pcd.created[0].VirtualNo != "NEW_VN3" {
|
||||
t.Errorf("期望 pcd VirtualNo=NEW_VN3,实际: %s", pcd.created[0].VirtualNo)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:旧卡无虚拟号 + pci 有绑定 + 新卡也无虚拟号 → 旧 pci status=0,新 pci 创建新 ICCID
|
||||
func TestMigrate_无虚拟号旧卡_有绑定_换无虚拟号新卡_迁移PCI(t *testing.T) {
|
||||
oldCard := &model.IotCard{ICCID: "89860000000000000066"} // 无虚拟号
|
||||
oldCard.ID = 140
|
||||
newCard := &model.IotCard{ICCID: "89860000000000000055"} // 无虚拟号
|
||||
newCard.ID = 141
|
||||
|
||||
existingPCI := newMockPCIRecord(4, 80, "89860000000000000066", 1)
|
||||
pci := &mockPCIOps{allRecords: []*model.PersonalCustomerICCID{existingPCI}}
|
||||
pcd := &mockPCDOps{}
|
||||
svc, _ := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{140: oldCard, 141: newCard}},
|
||||
&mockDeviceReader{},
|
||||
pcd,
|
||||
pci,
|
||||
)
|
||||
|
||||
err := svc.Migrate(context.Background(), nil, "iot_card", 140, "iot_card", 141)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if existingPCI.Status != 0 {
|
||||
t.Errorf("期望旧 pci 记录 status=0,实际: %d", existingPCI.Status)
|
||||
}
|
||||
if len(pci.created) != 1 {
|
||||
t.Fatalf("期望创建 1 条新 pci 记录,实际: %d", len(pci.created))
|
||||
}
|
||||
if pci.created[0].CustomerID != 80 {
|
||||
t.Errorf("期望 pci CustomerID=80,实际: %d", pci.created[0].CustomerID)
|
||||
}
|
||||
if pci.created[0].ICCID != "89860000000000000055" {
|
||||
t.Errorf("期望 pci ICCID=89860000000000000055,实际: %s", pci.created[0].ICCID)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证:无虚拟号卡非首次绑定(已有其他客户)→ 创建记录但不触发 markAssetAsSold
|
||||
func TestBind_无虚拟号卡_非首次绑定_创建记录不标记已售(t *testing.T) {
|
||||
card := &model.IotCard{ICCID: "89860000000000000012"} // VirtualNo 为空
|
||||
card.ID = 32
|
||||
|
||||
pci := &mockPCIOps{
|
||||
bindings: map[string]bool{},
|
||||
counts: map[string]int64{"89860000000000000012": 1}, // 已有其他客户绑定
|
||||
}
|
||||
svc, sold := newTestService(
|
||||
&mockCardReader{cards: map[uint]*model.IotCard{32: card}},
|
||||
&mockDeviceReader{},
|
||||
&mockPCDOps{bindings: map[string]bool{}},
|
||||
pci,
|
||||
)
|
||||
|
||||
err := svc.Bind(context.Background(), nil, 60, "iot_card", 32)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("期望无错误,实际: %v", err)
|
||||
}
|
||||
if len(pci.created) != 1 {
|
||||
t.Fatalf("期望创建 1 条记录,实际: %d", len(pci.created))
|
||||
}
|
||||
if len(sold.items) != 0 {
|
||||
t.Errorf("期望不触发 markAssetAsSold,实际触发了: %v", sold.items)
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestResolveAssetByIdentifierUsesAuthoritativeSnapshot 验证任意受支持标识都生成权威换货快照。
|
||||
func TestResolveAssetByIdentifierUsesAuthoritativeSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
service := &Service{
|
||||
iotCardStore: postgres.NewIotCardStore(tx, nil),
|
||||
deviceStore: postgres.NewDeviceStore(tx, nil),
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
card := &model.IotCard{ICCID: "89860012345678901234", ICCID19: "8986001234567890123", MSISDN: "13800138000", VirtualNo: "UR45-CARD", AssetStatus: constants.AssetStatusInStock}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建测试卡失败:%v", err)
|
||||
}
|
||||
for _, identifier := range []string{card.ICCID, card.MSISDN, card.VirtualNo} {
|
||||
asset, err := service.resolveAssetByIdentifier(ctx, constants.ExchangeAssetTypeIotCard, identifier)
|
||||
if err != nil {
|
||||
t.Fatalf("通过 %s 解析测试卡失败:%v", identifier, err)
|
||||
}
|
||||
if asset.Identifier != card.ICCID {
|
||||
t.Fatalf("卡快照应为 ICCID,输入 %s,实际 %s", identifier, asset.Identifier)
|
||||
}
|
||||
}
|
||||
|
||||
device := &model.Device{VirtualNo: "UR45-DEVICE", IMEI: "860000000000001", SN: "UR45-SN-1", AssetStatus: constants.AssetStatusInStock}
|
||||
if err := tx.Create(device).Error; err != nil {
|
||||
t.Fatalf("创建设备失败:%v", err)
|
||||
}
|
||||
asset, err := service.resolveAssetByIdentifier(ctx, constants.ExchangeAssetTypeDevice, device.IMEI)
|
||||
if err != nil {
|
||||
t.Fatalf("解析设备失败:%v", err)
|
||||
}
|
||||
if asset.Identifier != device.VirtualNo {
|
||||
t.Fatalf("设备应优先保存虚拟号,实际 %s", asset.Identifier)
|
||||
}
|
||||
for _, testCase := range []struct {
|
||||
device *model.Device
|
||||
expected string
|
||||
}{
|
||||
{device: &model.Device{IMEI: "860000000000002", SN: "UR45-SN-2"}, expected: "860000000000002"},
|
||||
{device: &model.Device{SN: "UR45-SN-3"}, expected: "UR45-SN-3"},
|
||||
} {
|
||||
if actual := newResolvedDeviceAsset(testCase.device).Identifier; actual != testCase.expected {
|
||||
t.Fatalf("设备快照优先级错误,期望 %s,实际 %s", testCase.expected, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeWriteEntrypointsPersistAuthoritativeCardSnapshots 验证三个写入入口持久化卡的权威 ICCID。
|
||||
func TestExchangeWriteEntrypointsPersistAuthoritativeCardSnapshots(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
identifier func(*model.IotCard) string
|
||||
}{
|
||||
{name: "ICCID", identifier: func(card *model.IotCard) string { return card.ICCID }},
|
||||
{name: "接入号", identifier: func(card *model.IotCard) string { return card.MSISDN }},
|
||||
{name: "虚拟号", identifier: func(card *model.IotCard) string { return card.VirtualNo }},
|
||||
}
|
||||
|
||||
for index, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
service := newExchangeSnapshotTestService(tx)
|
||||
oldCard := createExchangeSnapshotCard(t, tx, index*10+1)
|
||||
newCard := createExchangeSnapshotCard(t, tx, index*10+2)
|
||||
|
||||
direct, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
|
||||
OldAssetType: constants.ExchangeAssetTypeIotCard,
|
||||
OldIdentifier: testCase.identifier(oldCard),
|
||||
FlowType: constants.ExchangeFlowTypeDirect,
|
||||
NewIdentifier: testCase.identifier(newCard),
|
||||
ExchangeReason: "UR45 卡快照测试",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建直接换货失败:%v", err)
|
||||
}
|
||||
if direct.OldAssetIdentifier != oldCard.ICCID || direct.NewAssetIdentifier != newCard.ICCID {
|
||||
t.Fatalf("直接换货卡快照错误:old=%s new=%s", direct.OldAssetIdentifier, direct.NewAssetIdentifier)
|
||||
}
|
||||
|
||||
shippingOld := createExchangeSnapshotCard(t, tx, index*10+3)
|
||||
shippingNew := createExchangeSnapshotCard(t, tx, index*10+4)
|
||||
shipping, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
|
||||
OldAssetType: constants.ExchangeAssetTypeIotCard,
|
||||
OldIdentifier: testCase.identifier(shippingOld),
|
||||
FlowType: constants.ExchangeFlowTypeShipping,
|
||||
ExchangeReason: "UR45 物流换货快照测试",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建物流换货失败:%v", err)
|
||||
}
|
||||
if shipping.OldAssetIdentifier != shippingOld.ICCID {
|
||||
t.Fatalf("物流换货旧卡快照应为 ICCID,实际 %s", shipping.OldAssetIdentifier)
|
||||
}
|
||||
if err = tx.Model(&model.ExchangeOrder{}).Where("id = ?", shipping.ID).Updates(map[string]any{
|
||||
"status": constants.ExchangeStatusPendingShip, "recipient_name": "测试用户",
|
||||
"recipient_phone": "13800138000", "recipient_address": "测试地址",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("建立待发货测试前置状态失败:%v", err)
|
||||
}
|
||||
shipped, err := service.Ship(context.Background(), shipping.ID, &dto.ExchangeShipRequest{
|
||||
ExpressCompany: "测试快递", ExpressNo: "UR45-EXPRESS", NewIdentifier: testCase.identifier(shippingNew),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("物流换货发货失败:%v", err)
|
||||
}
|
||||
if shipped.NewAssetIdentifier != shippingNew.ICCID {
|
||||
t.Fatalf("物流换货新卡快照应为 ICCID,实际 %s", shipped.NewAssetIdentifier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeWriteEntrypointsPersistPreferredDeviceSnapshots 验证设备输入标识不影响稳定快照优先级。
|
||||
func TestExchangeWriteEntrypointsPersistPreferredDeviceSnapshots(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
identifier func(*model.Device) string
|
||||
}{
|
||||
{name: "虚拟号", identifier: func(device *model.Device) string { return device.VirtualNo }},
|
||||
{name: "IMEI", identifier: func(device *model.Device) string { return device.IMEI }},
|
||||
{name: "SN", identifier: func(device *model.Device) string { return device.SN }},
|
||||
}
|
||||
|
||||
for index, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
service := newExchangeSnapshotTestService(tx)
|
||||
oldDevice := createExchangeSnapshotDevice(t, tx, index*10+1, true, true)
|
||||
newDevice := createExchangeSnapshotDevice(t, tx, index*10+2, true, true)
|
||||
order, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
|
||||
OldAssetType: constants.ExchangeAssetTypeDevice,
|
||||
OldIdentifier: testCase.identifier(oldDevice),
|
||||
FlowType: constants.ExchangeFlowTypeDirect,
|
||||
NewIdentifier: testCase.identifier(newDevice),
|
||||
ExchangeReason: "UR45 设备快照测试",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建设备直接换货失败:%v", err)
|
||||
}
|
||||
if order.OldAssetIdentifier != oldDevice.VirtualNo || order.NewAssetIdentifier != newDevice.VirtualNo {
|
||||
t.Fatalf("设备快照应优先使用虚拟号:old=%s new=%s", order.OldAssetIdentifier, order.NewAssetIdentifier)
|
||||
}
|
||||
persisted, err := service.Get(context.Background(), order.ID)
|
||||
if err != nil || persisted.OldAssetIdentifier != oldDevice.VirtualNo || persisted.NewAssetIdentifier != newDevice.VirtualNo {
|
||||
t.Fatalf("详情未读回设备权威快照:order=%+v err=%v", persisted, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for index, testCase := range testCases {
|
||||
t.Run("物流"+testCase.name, func(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
service := newExchangeSnapshotTestService(tx)
|
||||
oldDevice := createExchangeSnapshotDevice(t, tx, 80+index*10+1, true, true)
|
||||
newDevice := createExchangeSnapshotDevice(t, tx, 80+index*10+2, true, true)
|
||||
shipping, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
|
||||
OldAssetType: constants.ExchangeAssetTypeDevice, OldIdentifier: testCase.identifier(oldDevice),
|
||||
FlowType: constants.ExchangeFlowTypeShipping, ExchangeReason: "UR45 设备物流快照测试",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建设备物流换货失败:%v", err)
|
||||
}
|
||||
if shipping.OldAssetIdentifier != oldDevice.VirtualNo {
|
||||
t.Fatalf("物流创建设备旧快照应使用虚拟号,实际 %s", shipping.OldAssetIdentifier)
|
||||
}
|
||||
if err = tx.Model(&model.ExchangeOrder{}).Where("id = ?", shipping.ID).Update("status", constants.ExchangeStatusPendingShip).Error; err != nil {
|
||||
t.Fatalf("建立待发货状态失败:%v", err)
|
||||
}
|
||||
shipped, err := service.Ship(context.Background(), shipping.ID, &dto.ExchangeShipRequest{
|
||||
ExpressCompany: "测试快递", ExpressNo: "UR45-DEVICE-EXPRESS", NewIdentifier: testCase.identifier(newDevice),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("设备物流换货发货失败:%v", err)
|
||||
}
|
||||
if shipped.OldAssetIdentifier != oldDevice.VirtualNo || shipped.NewAssetIdentifier != newDevice.VirtualNo {
|
||||
t.Fatalf("设备物流快照应使用虚拟号:old=%s new=%s", shipped.OldAssetIdentifier, shipped.NewAssetIdentifier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newExchangeSnapshotTestService(tx *gorm.DB) *Service {
|
||||
iotCardStore := postgres.NewIotCardStore(tx, nil)
|
||||
deviceStore := postgres.NewDeviceStore(tx, nil)
|
||||
return New(
|
||||
tx,
|
||||
postgres.NewExchangeOrderStore(tx),
|
||||
iotCardStore,
|
||||
deviceStore,
|
||||
postgres.NewAssetWalletStore(tx, nil),
|
||||
postgres.NewAssetWalletTransactionStore(tx, nil),
|
||||
postgres.NewPackageUsageStore(tx, nil),
|
||||
postgres.NewPackageUsageDailyRecordStore(tx, nil),
|
||||
postgres.NewResourceTagStore(tx),
|
||||
customerBindingSvc.New(tx, iotCardStore, deviceStore),
|
||||
zap.NewNop(),
|
||||
)
|
||||
}
|
||||
|
||||
func createExchangeSnapshotCard(t *testing.T, tx *gorm.DB, suffix int) *model.IotCard {
|
||||
t.Helper()
|
||||
iccid := fmt.Sprintf("8986000000000000%04d", suffix)
|
||||
card := &model.IotCard{
|
||||
ICCID: iccid, ICCID19: iccid[:19], MSISDN: fmt.Sprintf("1390000%04d", suffix),
|
||||
VirtualNo: fmt.Sprintf("UR45-CARD-%04d", suffix), AssetStatus: constants.AssetStatusInStock,
|
||||
}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createExchangeSnapshotDevice(t *testing.T, tx *gorm.DB, suffix int, withVirtualNo, withIMEI bool) *model.Device {
|
||||
t.Helper()
|
||||
device := &model.Device{SN: fmt.Sprintf("UR45-SN-%04d", suffix), AssetStatus: constants.AssetStatusInStock}
|
||||
if withVirtualNo {
|
||||
device.VirtualNo = fmt.Sprintf("UR45-DEVICE-%04d", suffix)
|
||||
}
|
||||
if withIMEI {
|
||||
device.IMEI = fmt.Sprintf("86000000000%04d", suffix)
|
||||
}
|
||||
if err := tx.Create(device).Error; err != nil {
|
||||
t.Fatalf("创建设备失败:%v", err)
|
||||
}
|
||||
return device
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package iot_card
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsRiskGatewayExtend 验证网关扩展状态的风险判断逻辑
|
||||
func TestIsRiskGatewayExtend(t *testing.T) {
|
||||
cases := []struct {
|
||||
extend string
|
||||
want bool
|
||||
}{
|
||||
{"风险停机", true},
|
||||
{"已销户", true},
|
||||
{"机卡分离停机", false},
|
||||
{"待激活", false},
|
||||
{"", false},
|
||||
{" 风险停机 ", true}, // 含空白字符
|
||||
{"已注销", false}, // 非目标状态
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := isRiskGatewayExtend(tc.extend)
|
||||
if got != tc.want {
|
||||
t.Errorf("isRiskGatewayExtend(%q) = %v, 期望 %v", tc.extend, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestAssetWalletOrderReservationLifecycle 验证个人钱包订单冻结、超额拦截、支付核销和历史订单兼容。
|
||||
func TestAssetWalletOrderReservationLifecycle(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
resourceID := uint(time.Now().UnixNano() & testIDMask)
|
||||
wallet := &model.AssetWallet{
|
||||
ResourceType: constants.AssetWalletResourceTypeIotCard,
|
||||
ResourceID: resourceID,
|
||||
Balance: 1000,
|
||||
Status: 1,
|
||||
}
|
||||
if err := tx.Create(wallet).Error; err != nil {
|
||||
t.Fatalf("创建测试资产钱包失败:%v", err)
|
||||
}
|
||||
service := &Service{}
|
||||
order := &model.Order{
|
||||
OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal,
|
||||
PaymentMethod: model.PaymentMethodWallet, TotalAmount: 700, IotCardID: &resourceID,
|
||||
}
|
||||
if err := service.freezeAssetWalletForOrder(context.Background(), tx, order); err != nil {
|
||||
t.Fatalf("冻结订单金额失败:%v", err)
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 1000, 700)
|
||||
if order.AssetWalletReservationWalletID == nil || *order.AssetWalletReservationWalletID != wallet.ID || order.AssetWalletReservedAmount != 700 {
|
||||
t.Fatalf("订单预占快照错误:wallet_id=%v amount=%d", order.AssetWalletReservationWalletID, order.AssetWalletReservedAmount)
|
||||
}
|
||||
|
||||
overdrawOrder := &model.Order{
|
||||
OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal,
|
||||
PaymentMethod: model.PaymentMethodWallet, TotalAmount: 400, IotCardID: &resourceID,
|
||||
}
|
||||
if err := service.freezeAssetWalletForOrder(context.Background(), tx, overdrawOrder); err == nil {
|
||||
t.Fatal("可用余额不足时必须拒绝第二笔冻结")
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 1000, 700)
|
||||
|
||||
if _, err := service.deductAssetWalletForOrder(context.Background(), tx, order, constants.AssetWalletResourceTypeIotCard, resourceID); err != nil {
|
||||
t.Fatalf("核销订单预占失败:%v", err)
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 300, 0)
|
||||
|
||||
if err := tx.Model(&model.AssetWallet{}).Where("id = ?", wallet.ID).
|
||||
Updates(map[string]any{"balance": 300, "frozen_balance": 200, "version": 10}).Error; err != nil {
|
||||
t.Fatalf("准备历史订单场景失败:%v", err)
|
||||
}
|
||||
legacyOrder := &model.Order{
|
||||
OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal,
|
||||
PaymentMethod: model.PaymentMethodWallet, TotalAmount: 150, IotCardID: &resourceID,
|
||||
}
|
||||
if err := service.releaseAssetWalletReservation(context.Background(), tx, legacyOrder); err != nil {
|
||||
t.Fatalf("历史订单取消不应释放其他订单冻结额:%v", err)
|
||||
}
|
||||
if _, err := service.deductAssetWalletForOrder(context.Background(), tx, legacyOrder, constants.AssetWalletResourceTypeIotCard, resourceID); err == nil {
|
||||
t.Fatal("历史订单不得占用其他订单的冻结余额")
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 300, 200)
|
||||
}
|
||||
|
||||
func assertAssetWalletFunds(t *testing.T, tx *gorm.DB, walletID uint, balance, frozenBalance int64) {
|
||||
t.Helper()
|
||||
var wallet model.AssetWallet
|
||||
if err := tx.First(&wallet, walletID).Error; err != nil {
|
||||
t.Fatalf("查询资产钱包失败:%v", err)
|
||||
}
|
||||
if wallet.Balance != balance || wallet.FrozenBalance != frozenBalance {
|
||||
t.Fatalf("资产钱包金额错误:balance=%d frozen=%d,期望 balance=%d frozen=%d", wallet.Balance, wallet.FrozenBalance, balance, frozenBalance)
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
|
||||
const testIDMask = 0x7fffffff
|
||||
|
||||
// TestSynchronousPurchasePersistsImmutableTermsSnapshots 验证同步主套餐和加油包固化购买时计时条款。
|
||||
func TestSynchronousPurchasePersistsImmutableTermsSnapshots(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusNotVerified)
|
||||
formal := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromActivation, 45)
|
||||
addon := createOrderTermsPackage(t, tx, constants.PackageTypeAddon, constants.PackageExpiryBaseFromActivation, 10)
|
||||
shopID := uint(time.Now().UnixNano() & testIDMask)
|
||||
override := constants.PackageExpiryBaseFromPurchase
|
||||
for _, packageID := range []uint{formal.ID, addon.ID} {
|
||||
allocation := &model.ShopPackageAllocation{ShopID: shopID, PackageID: packageID, CostPrice: 1, RetailPrice: 2, Status: constants.StatusEnabled, ShelfStatus: 1, ExpiryBaseOverride: &override}
|
||||
if err := tx.Create(allocation).Error; err != nil {
|
||||
t.Fatalf("创建套餐覆盖配置失败:%v", err)
|
||||
}
|
||||
}
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
now := time.Date(2026, 7, 22, 10, 0, 0, 0, time.Local)
|
||||
formalOrder := newOrderTermsOrder(formal.ID, card.ID, &shopID)
|
||||
if err := service.activateMainPackage(context.Background(), tx, formalOrder, formal, constants.AssetWalletResourceTypeIotCard, card.ID, now); err != nil {
|
||||
t.Fatalf("同步创建主套餐失败:%v", err)
|
||||
}
|
||||
addonOrder := newOrderTermsOrder(addon.ID, card.ID, &shopID)
|
||||
if err := service.activateAddonPackage(context.Background(), tx, addonOrder, addon, constants.AssetWalletResourceTypeIotCard, card.ID, now.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("同步创建加油包失败:%v", err)
|
||||
}
|
||||
|
||||
for _, item := range []struct {
|
||||
orderID uint
|
||||
packageID uint
|
||||
durationDay int
|
||||
}{
|
||||
{formalOrder.ID, formal.ID, 45},
|
||||
{addonOrder.ID, addon.ID, 10},
|
||||
} {
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", item.orderID, item.packageID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询套餐使用记录失败:%v", err)
|
||||
}
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.CalendarTypeSnapshot != constants.PackageCalendarTypeByDay || usage.DurationDaysSnapshot != item.durationDay {
|
||||
t.Fatalf("同步购买快照错误:%+v", usage)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Package{}).Where("id IN ?", []uint{formal.ID, addon.ID}).Updates(map[string]any{"expiry_base": constants.PackageExpiryBaseFromActivation, "duration_days": 99}).Error; err != nil {
|
||||
t.Fatalf("修改套餐当前配置失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(&model.ShopPackageAllocation{}).Where("shop_id = ?", shopID).Update("expiry_base_override", nil).Error; err != nil {
|
||||
t.Fatalf("修改分配当前配置失败:%v", err)
|
||||
}
|
||||
var usages []model.PackageUsage
|
||||
if err := tx.Where("order_id IN ?", []uint{formalOrder.ID, addonOrder.ID}).Order("order_id").Find(&usages).Error; err != nil {
|
||||
t.Fatalf("重新查询购买快照失败:%v", err)
|
||||
}
|
||||
if len(usages) != 2 || usages[0].ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usages[1].ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usages[0].DurationDaysSnapshot == 99 || usages[1].DurationDaysSnapshot == 99 {
|
||||
t.Fatalf("购买后配置变化不应修改快照:%+v", usages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSynchronousPurchaseRejectsInvalidTermsWithoutUsage 验证非法配置不会留下空快照使用记录。
|
||||
func TestSynchronousPurchaseRejectsInvalidTermsWithoutUsage(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, "invalid", 30)
|
||||
order := newOrderTermsOrder(pkg.ID, card.ID, nil)
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err == nil {
|
||||
t.Fatal("非法计时条款必须拒绝同步创建")
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.PackageUsage{}).Where("order_id = ?", order.ID).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("失败路径不应留下使用记录:count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCEndPurchasePersistsSnapshotAndActivatesImmediately 验证 C 端购买无论 expiry_base 如何均写入快照并立即激活。
|
||||
func TestCEndPurchasePersistsSnapshotAndActivatesImmediately(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
// C 端购买前已做实名前置检查;测试用未实名卡以区分"C 端即时激活"与"实名才激活"
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusNotVerified)
|
||||
pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromActivation, 20)
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
now := time.Now()
|
||||
order := &model.Order{
|
||||
Model: gorm.Model{ID: uint(time.Now().UnixNano() & testIDMask)},
|
||||
OrderNo: "UR55-CEND-" + strconv.FormatInt(time.Now().UnixNano(), 10),
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
BuyerType: model.BuyerTypePersonal,
|
||||
IotCardID: &card.ID,
|
||||
TotalAmount: 100,
|
||||
Generation: 1,
|
||||
}
|
||||
if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, now); err != nil {
|
||||
t.Fatalf("C 端购买创建主套餐失败:%v", err)
|
||||
}
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询 C 端购买使用记录失败:%v", err)
|
||||
}
|
||||
// 快照必须完整
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromActivation || usage.CalendarTypeSnapshot != constants.PackageCalendarTypeByDay || usage.DurationDaysSnapshot != 20 {
|
||||
t.Fatalf("C 端购买快照错误:%+v", usage)
|
||||
}
|
||||
// C 端购买不等实名,必须立即激活
|
||||
if usage.Status != constants.PackageUsageStatusActive || usage.PendingRealnameActivation {
|
||||
t.Fatalf("C 端购买必须立即激活:status=%d pendingRealname=%v", usage.Status, usage.PendingRealnameActivation)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformPurchaseWithoutAllocationPersistsPackageDefaultSnapshot 验证无分配配置时使用套餐默认值写入快照。
|
||||
func TestPlatformPurchaseWithoutAllocationPersistsPackageDefaultSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 15)
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
// sellerShopID=nil 模拟平台后台代购(无代理分配)
|
||||
order := newOrderTermsOrder(pkg.ID, card.ID, nil)
|
||||
if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err != nil {
|
||||
t.Fatalf("平台代购创建主套餐失败:%v", err)
|
||||
}
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询平台代购使用记录失败:%v", err)
|
||||
}
|
||||
// 无分配时快照应取套餐默认值 from_purchase
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.DurationDaysSnapshot != 15 {
|
||||
t.Fatalf("平台代购快照应使用套餐默认值:%+v", usage)
|
||||
}
|
||||
}
|
||||
|
||||
func createOrderTermsCard(t *testing.T, tx *gorm.DB, realnameStatus int) *model.IotCard {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano()%100000000000000000, 10)
|
||||
iccid := "89" + suffix
|
||||
if len(iccid) < 19 {
|
||||
iccid += "0000000000000000000"[:19-len(iccid)]
|
||||
}
|
||||
iccid = iccid[:19]
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid, CarrierID: 1, RealNameStatus: realnameStatus, AssetStatus: constants.AssetStatusInStock, Generation: 1}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createOrderTermsPackage(t *testing.T, tx *gorm.DB, packageType, expiryBase string, durationDays int) *model.Package {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
pkg := &model.Package{PackageCode: "UR55-ORDER-" + suffix, PackageName: "UR55同步购买测试套餐", PackageType: packageType, DurationMonths: 1, DurationDays: durationDays, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, DataResetCycle: "monthly"}
|
||||
if err := tx.Create(pkg).Error; err != nil {
|
||||
t.Fatalf("创建测试套餐失败:%v", err)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func newOrderTermsOrder(packageID, cardID uint, sellerShopID *uint) *model.Order {
|
||||
orderID := uint(time.Now().UnixNano() & testIDMask)
|
||||
return &model.Order{Model: gorm.Model{ID: orderID}, OrderNo: "UR55-ORDER-" + strconv.FormatUint(uint64(orderID), 10), OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypeAgent, IotCardID: &cardID, SellerShopID: sellerShopID, TotalAmount: 100, Generation: 1}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
|
||||
const testIDMask = 0x7fffffff
|
||||
|
||||
// TestActivateByRealnameUsesPurchasedSnapshot 验证实名激活只使用购买快照计算起止时间。
|
||||
func TestActivateByRealnameUsesPurchasedSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 99)
|
||||
purchasedAt := time.Now().Add(-48 * time.Hour).Truncate(time.Second)
|
||||
usage := createActivationTermsUsage(t, tx, pkg.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 10, true, purchasedAt)
|
||||
service := NewActivationService(tx, nil, nil, nil, nil, zap.NewNop())
|
||||
if err := service.ActivateByRealname(context.Background(), constants.AssetTypeIotCard, card.ID); err != nil {
|
||||
t.Fatalf("实名激活失败:%v", err)
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, usage.ID).Error; err != nil {
|
||||
t.Fatalf("查询激活结果失败:%v", err)
|
||||
}
|
||||
expectedExpiry := CalculateExpiryTime(constants.PackageCalendarTypeByDay, refreshed.CreatedAt, 0, 10)
|
||||
if refreshed.ActivatedAt == nil || refreshed.ExpiresAt == nil || refreshed.ActivatedAt.Unix() != refreshed.CreatedAt.Unix() || refreshed.ExpiresAt.Unix() != expectedExpiry.Unix() {
|
||||
t.Fatalf("实名激活未使用购买快照:%+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivateNextPendingMainPackageUsesQueueSnapshotAndIsIdempotent 验证连续排队按优先级和各自快照接续。
|
||||
func TestActivateNextPendingMainPackageUsesQueueSnapshotAndIsIdempotent(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
firstPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 90)
|
||||
secondPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 90)
|
||||
first := createActivationTermsUsage(t, tx, firstPackage.ID, card.ID, 1, constants.PackageExpiryBaseFromActivation, 3, false, time.Now().Add(-time.Hour))
|
||||
second := createActivationTermsUsage(t, tx, secondPackage.ID, card.ID, 2, constants.PackageExpiryBaseFromActivation, 4, false, time.Now())
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
activated, err := service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || !activated {
|
||||
t.Fatalf("激活队首套餐失败:activated=%v err=%v", activated, err)
|
||||
}
|
||||
activated, err = service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || activated {
|
||||
t.Fatalf("已有生效主套餐时重复接续应幂等:activated=%v err=%v", activated, err)
|
||||
}
|
||||
if err := tx.Model(&model.PackageUsage{}).Where("id = ?", first.ID).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
|
||||
t.Fatalf("结束队首套餐失败:%v", err)
|
||||
}
|
||||
activated, err = service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || !activated {
|
||||
t.Fatalf("激活第二个排队套餐失败:activated=%v err=%v", activated, err)
|
||||
}
|
||||
var refreshed []model.PackageUsage
|
||||
if err := tx.Where("id IN ?", []uint{first.ID, second.ID}).Order("priority").Find(&refreshed).Error; err != nil {
|
||||
t.Fatalf("查询队列接续结果失败:%v", err)
|
||||
}
|
||||
if len(refreshed) != 2 || refreshed[1].ExpiresAt == nil || refreshed[1].ActivatedAt == nil || !refreshed[1].ExpiresAt.Equal(CalculateExpiryTime(constants.PackageCalendarTypeByDay, *refreshed[1].ActivatedAt, 0, 4)) {
|
||||
t.Fatalf("第二个排队套餐未使用自身快照:%+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundFollowUpActivatesNextPackageFromSnapshot 验证退款失效后下一套餐按购买快照接续。
|
||||
func TestRefundFollowUpActivatesNextPackageFromSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
currentPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 60)
|
||||
nextPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 60)
|
||||
current := createActivationTermsUsage(t, tx, currentPackage.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 30, false, time.Now().Add(-time.Hour))
|
||||
now := time.Now()
|
||||
if err := tx.Model(current).Updates(map[string]any{"status": constants.PackageUsageStatusActive, "activated_at": now, "expires_at": now.AddDate(0, 0, 30)}).Error; err != nil {
|
||||
t.Fatalf("设置当前生效套餐失败:%v", err)
|
||||
}
|
||||
next := createActivationTermsUsage(t, tx, nextPackage.ID, card.ID, 2, constants.PackageExpiryBaseFromActivation, 6, false, time.Now())
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
if err := service.InvalidatePackagesForRefund(context.Background(), constants.AssetTypeIotCard, card.ID, current.OrderID, 77, "UR55-REFUND", ¤t.ID); err != nil {
|
||||
t.Fatalf("退款失效当前套餐失败:%v", err)
|
||||
}
|
||||
activated, err := service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || !activated {
|
||||
t.Fatalf("退款后接续下一套餐失败:activated=%v err=%v", activated, err)
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, next.ID).Error; err != nil {
|
||||
t.Fatalf("查询退款接续结果失败:%v", err)
|
||||
}
|
||||
if refreshed.ExpiresAt == nil || refreshed.ActivatedAt == nil || !refreshed.ExpiresAt.Equal(CalculateExpiryTime(constants.PackageCalendarTypeByDay, *refreshed.ActivatedAt, 0, 6)) {
|
||||
t.Fatalf("退款接续未使用下一套餐快照:%+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFromPurchaseActivatesImmediatelyAtPurchaseTime 验证 from_purchase 快照在购买时直接激活,不等实名。
|
||||
func TestFromPurchaseActivatesImmediatelyAtPurchaseTime(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusNotVerified)
|
||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 7)
|
||||
usage := createActivationTermsUsage(t, tx, pkg.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 7, false, time.Now())
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
if err := service.ActivateSpecificPackage(context.Background(), usage.ID); err != nil {
|
||||
t.Fatalf("from_purchase 直接激活失败:%v", err)
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, usage.ID).Error; err != nil {
|
||||
t.Fatalf("查询激活结果失败:%v", err)
|
||||
}
|
||||
if refreshed.Status != constants.PackageUsageStatusActive || refreshed.ActivatedAt == nil || refreshed.ExpiresAt == nil {
|
||||
t.Fatalf("from_purchase 套餐应已激活:status=%d activatedAt=%v expiresAt=%v", refreshed.Status, refreshed.ActivatedAt, refreshed.ExpiresAt)
|
||||
}
|
||||
if refreshed.PendingRealnameActivation {
|
||||
t.Fatal("from_purchase 不应标记待实名")
|
||||
}
|
||||
expectedExpiry := CalculateExpiryTime(constants.PackageCalendarTypeByDay, *refreshed.ActivatedAt, 0, 7)
|
||||
if !refreshed.ExpiresAt.Equal(expectedExpiry) {
|
||||
t.Fatalf("to期时间不符:want=%v got=%v", expectedExpiry, refreshed.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoricalFallbackFiresWarningAndIncrementsCounter 验证历史记录缺少快照时回退套餐当前配置并递增计数器。
|
||||
func TestHistoricalFallbackFiresWarningAndIncrementsCounter(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromActivation, 21)
|
||||
// 仅在会回滚的测试事务内禁用触发器,以构造 UR#55 上线前的历史空快照记录。
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("禁用快照校验触发器失败:%v", err)
|
||||
}
|
||||
// 历史记录:四个快照字段全为空值/零值
|
||||
unique := uint(time.Now().UnixNano() & testIDMask)
|
||||
usage := &model.PackageUsage{
|
||||
OrderID: unique, OrderNo: "UR55-HIST-" + strconv.FormatUint(uint64(unique), 10),
|
||||
PackageID: pkg.ID, PackageName: "UR55历史测试套餐",
|
||||
UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: card.ID,
|
||||
DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: 1, Generation: 1,
|
||||
// 快照字段均留空,模拟 UR#55 上线前的旧数据
|
||||
}
|
||||
if err := tx.Omit("status").Create(usage).Error; err != nil {
|
||||
t.Fatalf("创建历史测试使用记录失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(usage).Update("status", constants.PackageUsageStatusPending).Error; err != nil {
|
||||
t.Fatalf("设置历史测试状态失败:%v", err)
|
||||
}
|
||||
before := HistoricalTermsFallbackCount()
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
if err := service.ActivateSpecificPackage(context.Background(), usage.ID); err != nil {
|
||||
t.Fatalf("历史记录兼容激活失败:%v", err)
|
||||
}
|
||||
after := HistoricalTermsFallbackCount()
|
||||
if after <= before {
|
||||
t.Fatalf("历史回退计数器未递增:before=%d after=%d", before, after)
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, usage.ID).Error; err != nil || refreshed.Status != constants.PackageUsageStatusActive {
|
||||
t.Fatalf("历史记录应兼容激活成功:status=%d err=%v", refreshed.Status, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivateSpecificPackageRejectsPartialSnapshot 验证新记录非法快照不会静默回退当前套餐配置。
|
||||
func TestActivateSpecificPackageRejectsPartialSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 30)
|
||||
usage := createActivationTermsUsage(t, tx, pkg.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 30, false, time.Now())
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("禁用快照校验触发器失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(usage).Update("calendar_type_snapshot", "invalid").Error; err != nil {
|
||||
t.Fatalf("构造非法快照失败:%v", err)
|
||||
}
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage ENABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("恢复快照校验触发器失败:%v", err)
|
||||
}
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
if err := service.ActivateSpecificPackage(context.Background(), usage.ID); err == nil {
|
||||
t.Fatal("非法部分快照必须拒绝激活")
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, usage.ID).Error; err != nil || refreshed.Status != constants.PackageUsageStatusPending {
|
||||
t.Fatalf("非法快照失败后状态必须保持待生效:status=%d err=%v", refreshed.Status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createActivationTermsCard(t *testing.T, tx *gorm.DB, realnameStatus int) *model.IotCard {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano()%100000000000000000, 10)
|
||||
iccid := "87" + suffix
|
||||
if len(iccid) < 19 {
|
||||
iccid += "0000000000000000000"[:19-len(iccid)]
|
||||
}
|
||||
iccid = iccid[:19]
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid, CarrierID: 1, RealNameStatus: realnameStatus, AssetStatus: constants.AssetStatusInStock, Generation: 1}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建激活测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createActivationTermsPackage(t *testing.T, tx *gorm.DB, expiryBase string, durationDays int) *model.Package {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
pkg := &model.Package{PackageCode: "UR55-ACTIVATE-" + suffix, PackageName: "UR55激活测试套餐", PackageType: constants.PackageTypeFormal, DurationMonths: 1, DurationDays: durationDays, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, DataResetCycle: "monthly"}
|
||||
if err := tx.Create(pkg).Error; err != nil {
|
||||
t.Fatalf("创建激活测试套餐失败:%v", err)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func createActivationTermsUsage(t *testing.T, tx *gorm.DB, packageID, cardID uint, priority int, expiryBase string, durationDays int, pendingRealname bool, createdAt time.Time) *model.PackageUsage {
|
||||
t.Helper()
|
||||
unique := uint(time.Now().UnixNano() & testIDMask)
|
||||
usage := &model.PackageUsage{Model: gorm.Model{CreatedAt: createdAt}, OrderID: unique, OrderNo: "UR55-ACTIVATE-" + strconv.FormatUint(uint64(unique), 10), PackageID: packageID, PackageName: "UR55激活测试套餐", UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: cardID, DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: priority, PendingRealnameActivation: pendingRealname, Generation: 1, ExpiryBaseSnapshot: expiryBase, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: durationDays}
|
||||
if err := tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
|
||||
t.Fatalf("创建激活测试使用记录失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(usage).Updates(map[string]any{"status": constants.PackageUsageStatusPending, "pending_realname_activation": pendingRealname}).Error; err != nil {
|
||||
t.Fatalf("设置激活测试状态失败:%v", err)
|
||||
}
|
||||
return usage
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// TestValidateExpiryBaseOverride 验证覆盖字段必须显式提交且仅接受既定枚举。
|
||||
func TestValidateExpiryBaseOverride(t *testing.T) {
|
||||
fromPurchase := constants.PackageExpiryBaseFromPurchase
|
||||
invalid := "from_realname"
|
||||
tests := []struct {
|
||||
name string
|
||||
value *string
|
||||
submitted bool
|
||||
wantValue *string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "字段缺失", wantErr: true},
|
||||
{name: "跟随默认", submitted: true},
|
||||
{name: "购买即生效", value: &fromPurchase, submitted: true, wantValue: &fromPurchase},
|
||||
{name: "非法枚举", value: &invalid, submitted: true, wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := ValidateExpiryBaseOverride(test.value, test.submitted)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("错误状态不符合预期:%v", err)
|
||||
}
|
||||
if test.wantValue != nil && (got == nil || *got != *test.wantValue) {
|
||||
t.Fatalf("覆盖值不符合预期:%v", got)
|
||||
}
|
||||
if test.wantValue == nil && !test.wantErr && got != nil {
|
||||
t.Fatalf("期望跟随默认,实际为:%v", *got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveExpiryBase 验证覆盖优先于套餐默认值。
|
||||
func TestEffectiveExpiryBase(t *testing.T) {
|
||||
pkg := &model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation}
|
||||
if got := EffectiveExpiryBase(pkg, nil); got != constants.PackageExpiryBaseFromActivation {
|
||||
t.Fatalf("无覆盖时应使用套餐默认值,实际为 %s", got)
|
||||
}
|
||||
override := constants.PackageExpiryBaseFromPurchase
|
||||
allocation := &model.ShopPackageAllocation{ExpiryBaseOverride: &override}
|
||||
if got := EffectiveExpiryBase(pkg, allocation); got != constants.PackageExpiryBaseFromPurchase {
|
||||
t.Fatalf("有覆盖时应使用覆盖值,实际为 %s", got)
|
||||
}
|
||||
pkg.ExpiryBase = constants.PackageExpiryBaseFromPurchase
|
||||
if got := EffectiveExpiryBase(pkg, allocation); got != constants.PackageExpiryBaseFromPurchase {
|
||||
t.Fatalf("套餐默认值变化不应改变覆盖结果,实际为 %s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TestResolveUsageTerms 验证快照优先、历史完整空值回退和部分缺失拒绝。
|
||||
func TestResolveUsageTerms(t *testing.T) {
|
||||
pkg := &model.Package{
|
||||
ExpiryBase: constants.PackageExpiryBaseFromActivation,
|
||||
CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30,
|
||||
}
|
||||
usage := &model.PackageUsage{
|
||||
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase,
|
||||
CalendarTypeSnapshot: constants.PackageCalendarTypeNaturalMonth,
|
||||
DurationMonthsSnapshot: 12,
|
||||
}
|
||||
terms, err := ResolveUsageTerms(usage, pkg, zap.NewNop())
|
||||
if err != nil || terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase || terms.DurationMonths != 12 {
|
||||
t.Fatalf("应优先读取不可变快照:%+v, %v", terms, err)
|
||||
}
|
||||
|
||||
before := HistoricalTermsFallbackCount()
|
||||
historical := &model.PackageUsage{Model: usage.Model}
|
||||
terms, err = ResolveUsageTerms(historical, pkg, zap.NewNop())
|
||||
if err != nil || terms.DurationDays != 30 || HistoricalTermsFallbackCount() != before+1 {
|
||||
t.Fatalf("历史空快照应可观测回退:%+v, %v", terms, err)
|
||||
}
|
||||
|
||||
broken := &model.PackageUsage{ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase}
|
||||
if _, err = ResolveUsageTerms(broken, pkg, zap.NewNop()); err == nil {
|
||||
t.Fatal("部分缺失快照必须拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveUsageTermsKeepsPurchasedTerms 验证购买后修改套餐配置不改变已有使用记录语义。
|
||||
func TestResolveUsageTermsKeepsPurchasedTerms(t *testing.T) {
|
||||
usage := &model.PackageUsage{
|
||||
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase,
|
||||
CalendarTypeSnapshot: constants.PackageCalendarTypeByDay,
|
||||
DurationDaysSnapshot: 90,
|
||||
}
|
||||
pkg := &model.Package{
|
||||
ExpiryBase: constants.PackageExpiryBaseFromActivation,
|
||||
CalendarType: constants.PackageCalendarTypeNaturalMonth,
|
||||
DurationMonths: 1,
|
||||
}
|
||||
terms, err := ResolveUsageTerms(usage, pkg, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("读取购买快照失败:%v", err)
|
||||
}
|
||||
if terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase || terms.CalendarType != constants.PackageCalendarTypeByDay || terms.DurationDays != 90 {
|
||||
t.Fatalf("套餐当前配置不应覆盖购买快照:%+v", terms)
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// TestPackageUsageRejectsNewEmptyTermsSnapshot 验证迁移约束拒绝上线后新增空计时快照。
|
||||
func TestPackageUsageRejectsNewEmptyTermsSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
usage := newTermsSnapshotTestUsage()
|
||||
if err := tx.Create(usage).Error; err == nil {
|
||||
t.Fatal("新增套餐使用记录缺少计时快照时必须被数据库拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPackageUsageAcceptsCompleteTermsSnapshot 验证完整计时快照可与使用记录一次性落库。
|
||||
func TestPackageUsageAcceptsCompleteTermsSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
usage := newTermsSnapshotTestUsage()
|
||||
usage.ExpiryBaseSnapshot = constants.PackageExpiryBaseFromPurchase
|
||||
usage.CalendarTypeSnapshot = constants.PackageCalendarTypeByDay
|
||||
usage.DurationDaysSnapshot = 30
|
||||
if err := tx.Create(usage).Error; err != nil {
|
||||
t.Fatalf("完整计时快照应可落库:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoricalPackageUsageWithoutTermsSnapshotCanUpdateStatus 验证历史空快照可继续状态流转。
|
||||
func TestHistoricalPackageUsageWithoutTermsSnapshotCanUpdateStatus(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("禁用快照校验触发器失败:%v", err)
|
||||
}
|
||||
usage := newTermsSnapshotTestUsage()
|
||||
if err := tx.Create(usage).Error; err != nil {
|
||||
t.Fatalf("构造历史空快照记录失败:%v", err)
|
||||
}
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage ENABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("恢复快照校验触发器失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(&usage).Update("status", usage.Status).Error; err != nil {
|
||||
t.Fatalf("历史空快照记录应可继续更新状态:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTermsSnapshotTestUsage() *model.PackageUsage {
|
||||
unique := uint(time.Now().UnixNano() & 0x7fffffff)
|
||||
return &model.PackageUsage{
|
||||
OrderID: unique, OrderNo: "UR55-SNAPSHOT-TEST", PackageID: unique,
|
||||
UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: unique,
|
||||
DataLimitMB: 1, Status: constants.PackageUsageStatusPending,
|
||||
Priority: 1, PackageName: "UR55测试套餐", Generation: 1,
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
|
||||
const testIDMask = 0x7fffffff
|
||||
|
||||
// TestAutoPurchasePersistsTermsSnapshotsAndRealnameDecision 验证自动购包复用快照并遵守实名起算规则。
|
||||
func TestAutoPurchasePersistsTermsSnapshotsAndRealnameDecision(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
defaultBase string
|
||||
override *string
|
||||
realnameStatus int
|
||||
expectedBase string
|
||||
expectedStatus int
|
||||
expectedPending bool
|
||||
}{
|
||||
{name: "跟随默认等待实名", defaultBase: constants.PackageExpiryBaseFromActivation, realnameStatus: constants.RealNameStatusNotVerified, expectedBase: constants.PackageExpiryBaseFromActivation, expectedStatus: constants.PackageUsageStatusPending, expectedPending: true},
|
||||
{name: "覆盖购买即生效", defaultBase: constants.PackageExpiryBaseFromActivation, override: testutil.StringPointer(constants.PackageExpiryBaseFromPurchase), realnameStatus: constants.RealNameStatusNotVerified, expectedBase: constants.PackageExpiryBaseFromPurchase, expectedStatus: constants.PackageUsageStatusActive},
|
||||
{name: "覆盖实名但已实名", defaultBase: constants.PackageExpiryBaseFromPurchase, override: testutil.StringPointer(constants.PackageExpiryBaseFromActivation), realnameStatus: constants.RealNameStatusVerified, expectedBase: constants.PackageExpiryBaseFromActivation, expectedStatus: constants.PackageUsageStatusActive},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createAutoPurchaseCard(t, tx, testCase.realnameStatus)
|
||||
pkg := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, testCase.defaultBase, 31)
|
||||
shopID := uint(time.Now().UnixNano() & testIDMask)
|
||||
if testCase.override != nil {
|
||||
allocation := &model.ShopPackageAllocation{ShopID: shopID, PackageID: pkg.ID, CostPrice: 1, RetailPrice: 2, Status: constants.StatusEnabled, ShelfStatus: 1, ExpiryBaseOverride: testCase.override}
|
||||
if err := tx.Create(allocation).Error; err != nil {
|
||||
t.Fatalf("创建套餐覆盖配置失败:%v", err)
|
||||
}
|
||||
}
|
||||
handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
order := newAutoPurchaseOrder(card.ID, &shopID)
|
||||
if err := handler.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err != nil {
|
||||
t.Fatalf("自动购包创建主套餐失败:%v", err)
|
||||
}
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询自动购包记录失败:%v", err)
|
||||
}
|
||||
if usage.ExpiryBaseSnapshot != testCase.expectedBase || usage.CalendarTypeSnapshot != constants.PackageCalendarTypeByDay || usage.DurationDaysSnapshot != 31 || usage.Status != testCase.expectedStatus || usage.PendingRealnameActivation != testCase.expectedPending {
|
||||
t.Fatalf("自动购包快照或实名决策错误:%+v", usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoPurchaseDefaultFromPurchaseActivatesImmediatelyWithoutRealname 验证套餐默认为 from_purchase 时自动购包不需要实名即激活。
|
||||
func TestAutoPurchaseDefaultFromPurchaseActivatesImmediatelyWithoutRealname(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
// 卡未实名,但套餐默认 from_purchase,应立即激活
|
||||
card := createAutoPurchaseCard(t, tx, constants.RealNameStatusNotVerified)
|
||||
pkg := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 14)
|
||||
handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
order := newAutoPurchaseOrder(card.ID, nil)
|
||||
if err := handler.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err != nil {
|
||||
t.Fatalf("from_purchase 默认值自动购包失败:%v", err)
|
||||
}
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询自动购包记录失败:%v", err)
|
||||
}
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.DurationDaysSnapshot != 14 {
|
||||
t.Fatalf("from_purchase 默认值快照错误:%+v", usage)
|
||||
}
|
||||
if usage.Status != constants.PackageUsageStatusActive || usage.PendingRealnameActivation {
|
||||
t.Fatalf("from_purchase 默认值未实名仍应立即激活:status=%d pendingRealname=%v", usage.Status, usage.PendingRealnameActivation)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoPurchaseMainAndAddonAreIdempotentByBusinessFact 验证主套餐、加油包和重复消费依赖数据库事实去重。
|
||||
func TestAutoPurchaseMainAndAddonAreIdempotentByBusinessFact(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createAutoPurchaseCard(t, tx, constants.RealNameStatusVerified)
|
||||
formal := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 30)
|
||||
addon := createAutoPurchasePackage(t, tx, constants.PackageTypeAddon, constants.PackageExpiryBaseFromActivation, 7)
|
||||
handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
formalOrder := newAutoPurchaseOrder(card.ID, nil)
|
||||
addonOrder := newAutoPurchaseOrder(card.ID, nil)
|
||||
now := time.Now()
|
||||
if err := handler.activatePackages(context.Background(), tx, formalOrder, []*model.Package{formal}, now); err != nil {
|
||||
t.Fatalf("首次自动创建主套餐失败:%v", err)
|
||||
}
|
||||
if err := handler.activatePackages(context.Background(), tx, formalOrder, []*model.Package{formal}, now); err != nil {
|
||||
t.Fatalf("重复消费应按业务事实幂等:%v", err)
|
||||
}
|
||||
if err := handler.activatePackages(context.Background(), tx, addonOrder, []*model.Package{addon}, now.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("自动创建加油包失败:%v", err)
|
||||
}
|
||||
var usages []model.PackageUsage
|
||||
if err := tx.Where("order_id IN ?", []uint{formalOrder.ID, addonOrder.ID}).Order("order_id").Find(&usages).Error; err != nil {
|
||||
t.Fatalf("查询自动购包记录失败:%v", err)
|
||||
}
|
||||
if len(usages) != 2 {
|
||||
t.Fatalf("重复消费只应形成一组记录,实际 %d", len(usages))
|
||||
}
|
||||
for _, usage := range usages {
|
||||
if usage.ExpiryBaseSnapshot == "" || usage.CalendarTypeSnapshot == "" {
|
||||
t.Fatalf("自动购包不得产生空快照:%+v", usage)
|
||||
}
|
||||
}
|
||||
if usages[1].MasterUsageID == nil {
|
||||
t.Fatalf("加油包必须关联主套餐:%+v", usages[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoPurchaseTransactionRollbackOnInvalidAddonTerms 验证同一事务中后续套餐失败时不留下部分使用记录。
|
||||
func TestAutoPurchaseTransactionRollbackOnInvalidAddonTerms(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createAutoPurchaseCard(t, tx, constants.RealNameStatusVerified)
|
||||
formal := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 30)
|
||||
invalidAddon := createAutoPurchasePackage(t, tx, constants.PackageTypeAddon, "invalid", 7)
|
||||
handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
order := newAutoPurchaseOrder(card.ID, nil)
|
||||
err := tx.Transaction(func(inner *gorm.DB) error {
|
||||
return handler.activatePackages(context.Background(), inner, order, []*model.Package{formal, invalidAddon}, time.Now())
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("非法加油包计时条款必须使业务事务失败")
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.PackageUsage{}).Where("order_id = ?", order.ID).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("事务失败不应留下部分主套餐:count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoPurchaseConfigChangeDoesNotModifySnapshot 验证自动购包后修改套餐配置不改变已有使用记录的计时快照。
|
||||
func TestAutoPurchaseConfigChangeDoesNotModifySnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createAutoPurchaseCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 30)
|
||||
handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
order := newAutoPurchaseOrder(card.ID, nil)
|
||||
if err := handler.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err != nil {
|
||||
t.Fatalf("自动购包创建主套餐失败:%v", err)
|
||||
}
|
||||
// 购买后修改套餐的生效条件和时长
|
||||
if err := tx.Model(pkg).Updates(map[string]any{"expiry_base": constants.PackageExpiryBaseFromActivation, "duration_days": 99}).Error; err != nil {
|
||||
t.Fatalf("修改套餐配置失败:%v", err)
|
||||
}
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询自动购包记录失败:%v", err)
|
||||
}
|
||||
// 快照应保留购买时的值,不随套餐修改而变化
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.DurationDaysSnapshot != 30 {
|
||||
t.Fatalf("套餐配置变更不应影响已购买快照:expiryBase=%s days=%d", usage.ExpiryBaseSnapshot, usage.DurationDaysSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func createAutoPurchaseCard(t *testing.T, tx *gorm.DB, realnameStatus int) *model.IotCard {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano()%100000000000000000, 10)
|
||||
iccid := "88" + suffix
|
||||
if len(iccid) < 19 {
|
||||
iccid += "0000000000000000000"[:19-len(iccid)]
|
||||
}
|
||||
iccid = iccid[:19]
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid, CarrierID: 1, RealNameStatus: realnameStatus, AssetStatus: constants.AssetStatusInStock, Generation: 1}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建自动购包测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createAutoPurchasePackage(t *testing.T, tx *gorm.DB, packageType, expiryBase string, durationDays int) *model.Package {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
pkg := &model.Package{PackageCode: "UR55-AUTO-" + suffix, PackageName: "UR55自动购包测试套餐", PackageType: packageType, DurationMonths: 1, DurationDays: durationDays, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, DataResetCycle: "monthly"}
|
||||
if err := tx.Create(pkg).Error; err != nil {
|
||||
t.Fatalf("创建自动购包测试套餐失败:%v", err)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func newAutoPurchaseOrder(cardID uint, sellerShopID *uint) *model.Order {
|
||||
orderID := uint(time.Now().UnixNano() & testIDMask)
|
||||
return &model.Order{Model: gorm.Model{ID: orderID}, OrderNo: "UR55-AUTO-" + strconv.FormatUint(uint64(orderID), 10), OrderType: model.OrderTypeSingleCard, IotCardID: &cardID, SellerShopID: sellerShopID, TotalAmount: 100, Generation: 1}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// TestShouldStopPolling 验证风险状态独立卡的轮询停止判断逻辑
|
||||
func TestShouldStopPolling(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
isStandalone bool
|
||||
gatewayExtend string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "独立卡+风险停机 -> 停止轮询",
|
||||
isStandalone: true,
|
||||
gatewayExtend: constants.GatewayCardExtendRiskStop,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "独立卡+已销户 -> 停止轮询",
|
||||
isStandalone: true,
|
||||
gatewayExtend: constants.GatewayCardExtendCancelled,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "非独立卡+风险停机 -> 不停止轮询",
|
||||
isStandalone: false,
|
||||
gatewayExtend: constants.GatewayCardExtendRiskStop,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "独立卡+机卡分离停机 -> 不停止轮询",
|
||||
isStandalone: true,
|
||||
gatewayExtend: constants.GatewayCardExtendMachineSeparated,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "独立卡+空扩展 -> 不停止轮询",
|
||||
isStandalone: true,
|
||||
gatewayExtend: "",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
card := &model.IotCard{
|
||||
IsStandalone: tc.isStandalone,
|
||||
GatewayExtend: tc.gatewayExtend,
|
||||
}
|
||||
got := shouldStopPollingForRisk(card, tc.gatewayExtend)
|
||||
if got != tc.want {
|
||||
t.Errorf("shouldStopPollingForRisk(standalone=%v, extend=%q) = %v, 期望 %v",
|
||||
tc.isStandalone, tc.gatewayExtend, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// Package testutil 提供项目集成测试共用的真实基础设施连接。
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/database"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NewPostgresTransaction 创建自动回滚的 PostgreSQL 测试事务。
|
||||
func NewPostgresTransaction(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db := NewPostgresDatabase(t)
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("开启测试事务失败:%v", tx.Error)
|
||||
}
|
||||
t.Cleanup(func() { _ = tx.Rollback().Error })
|
||||
return tx
|
||||
}
|
||||
|
||||
// NewPostgresDatabase 创建自动关闭、可使用多个连接的 PostgreSQL 测试数据库。
|
||||
// 仅在需要验证真实并发条件更新时使用;普通集成测试仍优先使用自动回滚事务。
|
||||
func NewPostgresDatabase(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
if os.Getenv("JUNHONG_DATABASE_HOST") == "" {
|
||||
t.Skip("未加载 .env.local,跳过依赖真实 PostgreSQL 的集成测试")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("加载测试配置失败:%v", err)
|
||||
}
|
||||
db, err := database.InitPostgreSQL(&cfg.Database, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("连接 PostgreSQL 失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, dbErr := db.DB(); dbErr == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
// NewRedisClient 创建真实 Redis 测试客户端并在测试结束时关闭。
|
||||
func NewRedisClient(t *testing.T) *redis.Client {
|
||||
t.Helper()
|
||||
if os.Getenv("JUNHONG_REDIS_ADDRESS") == "" {
|
||||
t.Skip("未加载 .env.local,跳过依赖真实 Redis 的集成测试")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("加载测试配置失败:%v", err)
|
||||
}
|
||||
client, err := database.NewRedisClient(database.RedisConfig{
|
||||
Address: cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port), Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB, PoolSize: cfg.Redis.PoolSize, MinIdleConns: cfg.Redis.MinIdleConns,
|
||||
DialTimeout: cfg.Redis.DialTimeout, ReadTimeout: cfg.Redis.ReadTimeout, WriteTimeout: cfg.Redis.WriteTimeout,
|
||||
}, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("连接 Redis 失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
return client
|
||||
}
|
||||
|
||||
// StringPointer 返回字符串值的指针,供测试用例构造 nullable string 参数。
|
||||
func StringPointer(s string) *string { return &s }
|
||||
@@ -1,27 +0,0 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CreateTemporaryOutboxTable 创建事务内自动回滚的公共 Outbox 测试表。
|
||||
func CreateTemporaryOutboxTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP TABLE tb_outbox_event (
|
||||
id bigserial PRIMARY KEY, event_id varchar(64) NOT NULL UNIQUE,
|
||||
event_type varchar(150) NOT NULL, payload_version integer NOT NULL,
|
||||
aggregate_type varchar(100) NOT NULL, aggregate_id varchar(100) NOT NULL,
|
||||
resource_type varchar(100) NOT NULL, resource_id varchar(100) NOT NULL,
|
||||
business_key varchar(150) NOT NULL DEFAULT '', request_id varchar(100) NOT NULL DEFAULT '',
|
||||
correlation_id varchar(100) NOT NULL DEFAULT '', payload jsonb NOT NULL,
|
||||
status integer NOT NULL, retry_count integer NOT NULL DEFAULT 0,
|
||||
max_retries integer NOT NULL, next_attempt_at timestamptz NOT NULL,
|
||||
lease_owner varchar(100), lease_expires_at timestamptz,
|
||||
last_error_code varchar(100) NOT NULL DEFAULT '', last_error_summary varchar(500) NOT NULL DEFAULT '',
|
||||
delivered_at timestamptz, created_at timestamptz NOT NULL DEFAULT NOW(), updated_at timestamptz NOT NULL DEFAULT NOW()
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建 Outbox 测试表失败:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CreateTemporarySystemConfigTable 创建事务内自动回滚的系统配置测试表。
|
||||
func CreateTemporarySystemConfigTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP TABLE tb_system_config (
|
||||
id bigserial PRIMARY KEY, config_key varchar(150) NOT NULL UNIQUE,
|
||||
config_value text NOT NULL, value_type varchar(20) NOT NULL,
|
||||
module varchar(100) NOT NULL, description varchar(500) NOT NULL,
|
||||
is_readonly boolean NOT NULL DEFAULT false, is_sensitive boolean NOT NULL DEFAULT false,
|
||||
creator bigint NOT NULL DEFAULT 0, updater bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(), updated_at timestamptz NOT NULL DEFAULT NOW()
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建系统配置测试表失败:%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user