package service import ( "context" "crypto/rand" "math/big" "testing" "github.com/topfans/backend/pkg/models" pb "github.com/topfans/backend/pkg/proto/asset" pbUser "github.com/topfans/backend/pkg/proto/user" "github.com/topfans/backend/services/assetService/client" "github.com/topfans/backend/services/assetService/repository" ) // mockUserClient 服务层测试用的 UserServiceClient mock。 // // 跟踪 UpdateCrystalBalance 调用次数,确保幂等短路路径不会重复扣费。 // GetFanProfile 返回当前 balance(测试中只检查不被二次扣即可)。 type mockUserClient struct { balance int64 updateCrystalCalls int } func (m *mockUserClient) UpdateCrystalBalance(_ context.Context, _, _ int64, delta int64, _, _, _ string) (int64, error) { m.updateCrystalCalls++ m.balance += delta return m.balance, nil } func (m *mockUserClient) UpdateAssetsCount(_ context.Context, _, _ int64, delta int32) (int32, error) { return 0, nil } func (m *mockUserClient) GetFanProfile(_ context.Context, _, _ int64) (*pbUser.FanProfile, error) { return &pbUser.FanProfile{CrystalBalance: m.balance}, nil } // 编译期保证 mockUserClient 满足 client.UserServiceClient 接口 var _ client.UserServiceClient = (*mockUserClient)(nil) // pbCreateMintReq 构造 CreateMintOrder 测试请求。 // 故意忽略 userID/starID 参数(参数名带下划线),因为请求结构里只有订单字段; // 用户与明星身份由 service 方法签名参数传入。 func pbCreateMintReq(orderID string, _, _ int64) *pb.CreateMintOrderRequest { return &pb.CreateMintOrderRequest{ OrderId: orderID, MaterialUrl: "http://x/m.jpg", Name: "n", Description: "d", Info: "i", MaterialType: "new", } } // TestCreateMintOrder_IdempotentOnSuccessOrder 验证幂等短路: // // 同 order_id 第二次调用,状态已是 SUCCESS → 直接返回原 asset/order/cost, // 不再调 userClient.UpdateCrystalBalance。 // // ★ 双层防护第二层(assetService 侧入口短路)。 // - 第一层(userService.UpdateCrystalBalance 的 ON CONFLICT)由 mint-task-1 覆盖。 // - 本测试确保"已 SUCCESS 的订单"在入口处就重放响应,避免重复进入扣费/建档流程。 func TestCreateMintOrder_IdempotentOnSuccessOrder(t *testing.T) { db := setupServiceTestDB(t) defer cleanupServiceTestDB(t, db) // 准备 user + star(余额走 mock,无需 fan_profile) star := createServiceTestStar(t, db, "mint_idem_star") user := createServiceTestUser(t, db, "19900077001") // 预置一个 SUCCESS 订单 + 关联 asset(模拟之前已铸造成功) const orderID = "idem-order-uuid-001" originalAsset := &models.Asset{ OwnerUID: user.ID, StarID: star.StarID, Name: "existing", CoverURL: "http://x/a.jpg", Status: models.AssetStatusActive, IsActive: true, } if err := db.Create(originalAsset).Error; err != nil { t.Fatalf("Failed to seed asset: %v", err) } if err := db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at) VALUES (?, ?, ?, 'regular', 1, 1, 1)`, user.ID, originalAsset.ID, star.StarID).Error; err != nil { t.Fatalf("Failed to seed asset_registry: %v", err) } if err := db.Create(&models.MintOrder{ OrderID: orderID, UserID: user.ID, StarID: star.StarID, Status: models.MintOrderStatusSuccess, CostCrystal: 100, AssetID: &originalAsset.ID, }).Error; err != nil { t.Fatalf("Failed to seed mint_order: %v", err) } // mock userClient:UpdateCrystalBalance 不应被二次触发 uc := &mockUserClient{balance: 900} svc := NewMintService( repository.NewAssetRepository(db), repository.NewMintOrderRepository(db), uc, db, nil, nil, nil, nil, nil, ) resp, err := svc.CreateMintOrder(pbCreateMintReq(orderID, user.ID, star.StarID), user.ID, star.StarID) if err != nil { t.Fatalf("unexpected error: %v", err) } if resp == nil { t.Fatal("expected response, got nil") } if resp.Order == nil { t.Fatal("expected order in response, got nil") } if resp.Order.OrderId != orderID { t.Errorf("want orderID=%s, got %s", orderID, resp.Order.OrderId) } if resp.Order.Status != models.MintOrderStatusSuccess { t.Errorf("want order status=SUCCESS, got %s", resp.Order.Status) } if resp.CostCrystal != 100 { t.Errorf("want cost_crystal=100, got %d", resp.CostCrystal) } if resp.Asset == nil { t.Error("expected asset replay in idempotent response, got nil") } else if resp.Asset.AssetId != originalAsset.ID { t.Errorf("want assetID=%d, got %d", originalAsset.ID, resp.Asset.AssetId) } // ★ 关键断言:幂等命中后不应再调用 UpdateCrystalBalance if uc.updateCrystalCalls != 0 { t.Errorf("UpdateCrystalBalance must not be called on idempotent hit, got %d calls", uc.updateCrystalCalls) } // 余额没被二次扣(保持 mock 初始值 900) if uc.balance != 900 { t.Errorf("balance want 900 (unchanged), got %d", uc.balance) } } // TestMintGuaranteeProbability_NonPredictable 验证随机源已从 time.Now() 切到 crypto/rand: // // 1000 次连续采样,100% 命中(Probability=100)与 0% 命中(Probability=0)必须分别全命中/全不命中; // 且单次返回落在 [0,100) 区间。 // // ★ 批次1.5:helper 测试本身只验证 crypto/rand 行为符合规格(库内建)。 // 真正验证 rollGuarantee 行为见 TestRollGuarantee_Boundaries / _Distribution。 func TestMintGuaranteeProbability_NonPredictable(t *testing.T) { // 抽 1000 个 [0,100) 整数,验证区间 + 100% 概率 vs 0% 概率两个极端。 for i := 0; i < 1000; i++ { v, err := rand.Int(rand.Reader, big.NewInt(100)) if err != nil { t.Fatal(err) } if v.Cmp(big.NewInt(100)) >= 0 || v.Sign() < 0 { t.Fatalf("out of range: %v", v) } } // 边界 if alwaysTriggers(100, 1000) != 1000 { t.Error("P=100 should always trigger") } if alwaysTriggers(0, 1000) != 0 { t.Error("P=0 should never trigger") } } // alwaysTriggers 模拟 helper 内部判定,用于边界测试 func alwaysTriggers(probability, n int) int { hits := 0 for i := 0; i < n; i++ { v, _ := rand.Int(rand.Reader, big.NewInt(100)) if v.Int64() < int64(probability) { hits++ } } return hits } // TestRollGuarantee_Boundaries 验证 rollGuarantee 在概率边界上的语义: // // probability <= 0 → 永不触发 // probability >= 100 → 总是触发 // 非法值(负数 / 远超 100)与 mint_service 现有 Probability 字段约定一致 // (Probability 在 DB 层是 int64,正常取值 0~100;这里显式保证超出范围时不会 panic / 误判)。 func TestRollGuarantee_Boundaries(t *testing.T) { cases := []struct { name string probability int64 want bool }{ {"zero never triggers", 0, false}, {"negative never triggers", -1, false}, {"deep negative never triggers", -1000, false}, {"hundred always triggers", 100, true}, {"over hundred always triggers", 101, true}, {"way over hundred always triggers", 9999, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { // 边界值是确定性的,跑 50 次保证没有"随机巧合" for i := 0; i < 50; i++ { if got := rollGuarantee(tc.probability); got != tc.want { t.Fatalf("rollGuarantee(%d) = %v, want %v (iter %d)", tc.probability, got, tc.want, i) } } }) } } // TestRollGuarantee_Distribution 验证 rollGuarantee 在 P=50 时采样 2000 次, // // 命中率落在 [800, 1200] 内(理论 1000 ± 期望误差 ~22)。 // // 这是统计性测试:理论 2000 次伯努利试验 σ ≈ √(npq) = √500 ≈ 22, // 取 ±9σ(800~1200)基本不可能(<10⁻¹⁸)误判;同时能捕捉到"退化成伪随机 / 写死" // 这类回归。如果有人把 crypto/rand 换成 time.Now().UnixNano()%100, // 在循环极快时同纳秒下分布会严重偏(全部 false / 全部 true),立刻 fail。 func TestRollGuarantee_Distribution(t *testing.T) { const n = 2000 const p int64 = 50 hits := 0 for i := 0; i < n; i++ { if rollGuarantee(p) { hits++ } } const minHits = 800 const maxHits = 1200 if hits < minHits || hits > maxHits { t.Errorf("rollGuarantee(50) over %d trials: hits=%d, want in [%d,%d] (≈50%%±9σ)", n, hits, minHits, maxHits) } }