Files
hi-server/internal/logic/admin/lottery/admin_claims_test.go
T
shanshanzhong147 07409eb602 新功能(#4): 抽奖 Stage 2 人工奖领奖工单(crypto / physical / manual_other)
Closes HIF-4

Stage 2 交付:人工奖领奖工单完整闭环。crypto / physical / manual_other 三类奖品从抽中到 mark-paid 的全流程可用。

- 迁移 02159_lottery_claim:UNIQUE(draw_id) + 3 支持索引,状态机 pending_claim→reviewing→paying→paid,rejected 可复活,超时 expired
- 3 个 PrizeHandler:Dispatch→ErrDispatchNotSupported 兜底、ClaimSchema 各自形态、ValidateClaim 表驱动
- BuildCryptoClaimSchema:抽中时按奖品 config.networks 注入 enum,前端下拉直接可用
- Draw service dispatchOrEnqueueClaim:人工奖同 tx 插 pending_claim(回滚双清),nonce 重放回读 ExpiresAt + ClaimFormSchema
- POST /claim 实装:ownership 校验 → prize 类型校验 → handler.ValidateClaim → crypto network 白名单二次校验 → tx CAS status IN (pending_claim, rejected) AND expires_at > now
- Admin CRUD 5 接口:list(IN 批拉 snap + user,无 N+1)、summary(GROUP BY 一次拿计数 + overdue 单查)、approve/reject/mark-paid 全走 CAS + audit
- Scheduler @every 1h 扫过期,级联 lottery_draw.dispatch_state → expired
- 新增错误码 100005-100011(already_submitted / invalid_claim_data / draw_not_found / not_your_draw / claim_expired / claim_state_invalid)
- Rebase 后 Stage 2 测试主动 reuse PR E 的 unmetReasonsNotEmpty + evaluatedAtNotZero matcher,人工奖分支若绕过守卫会立即挂
- Stage 1 全部 4 处 guardrail 后端 rebase 时自检过:UnmetReasons、EvaluatedAt、GrantLedger.Payload、AdminMetaMiddleware 全保留

CI 全绿;28 files, +2442/-126;覆盖率 handler 78.9% / model.lottery 74.3% / draw 68.7% / queue/lottery 76.9%
2026-07-10 04:01:34 -07:00

123 lines
4.2 KiB
Go

// admin_claims_test.go — 单元测试 Stage 2 claim 状态机的纯函数校验。
// 数据库集成留给 stage2 QA curl 脚本;单测只覆盖纯逻辑分支:
// - validateMarkPaidByType 的三个奖品类型 x 凭证字段组合
// - bucketByType 的类型 → 状态桶映射
// - claimToAdminView 的 nullable 字段渲染
package lottery
import (
"testing"
"time"
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
"github.com/perfect-panel/server/internal/types"
)
func TestValidateMarkPaidByType(t *testing.T) {
cases := []struct {
name string
prizeType string
txHash string
deliveryRef string
wantErr bool
}{
{"crypto with tx_hash", modelLottery.PrizeTypeCrypto, "0xdeadbeef", "", false},
{"crypto missing tx_hash", modelLottery.PrizeTypeCrypto, "", "", true},
{"crypto ignores delivery_ref alone", modelLottery.PrizeTypeCrypto, "", "SF123", true},
{"physical with delivery_ref", modelLottery.PrizeTypePhysical, "", "SF123456", false},
{"physical missing delivery_ref", modelLottery.PrizeTypePhysical, "", "", true},
{"manual_other with tx_hash", modelLottery.PrizeTypeManualOther, "0xabc", "", false},
{"manual_other with delivery_ref", modelLottery.PrizeTypeManualOther, "", "SF00", false},
{"manual_other with both", modelLottery.PrizeTypeManualOther, "0xabc", "SF00", false},
{"manual_other with none", modelLottery.PrizeTypeManualOther, "", "", true},
{"unknown type", "auto_hallucinated", "", "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateMarkPaidByType(tc.prizeType, tc.txHash, tc.deliveryRef)
if tc.wantErr && err == nil {
t.Fatal("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("expected nil, got %v", err)
}
})
}
}
func TestBucketByType(t *testing.T) {
s := &types.AdminLotteryClaimsSummary{}
if bucketByType(s, modelLottery.PrizeTypeCrypto) != &s.Crypto {
t.Fatal("crypto bucket mismatch")
}
if bucketByType(s, modelLottery.PrizeTypePhysical) != &s.Physical {
t.Fatal("physical bucket mismatch")
}
if bucketByType(s, modelLottery.PrizeTypeManualOther) != &s.ManualOther {
t.Fatal("manual_other bucket mismatch")
}
if bucketByType(s, "unknown") != nil {
t.Fatal("unknown type must return nil")
}
}
func TestClaimToAdminView_RendersNullableFields(t *testing.T) {
submittedAt := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
paidAt := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
claim := modelLottery.Claim{
Id: 42,
DrawId: 1234,
UserId: 88,
ActivityId: 100,
PrizeType: modelLottery.PrizeTypeCrypto,
Status: modelLottery.ClaimStatusPaid,
ClaimData: `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`,
SubmittedAt: &submittedAt,
ExpiresAt: submittedAt.Add(24 * time.Hour),
TxHash: "0xabcdef",
PaidAt: &paidAt,
}
snap := modelLottery.PrizeSnapshot{
Type: modelLottery.PrizeTypeCrypto,
Name: "1 BTC",
Config: `{"amount":"1","currency":"BTC","networks":["BTC"]}`,
}
view := claimToAdminView(claim, snap, "user@example.com")
if view.Id != 42 || view.DrawId != 1234 || view.User.Id != 88 {
t.Fatalf("view IDs wrong: %+v", view)
}
if view.User.Email != "user@example.com" {
t.Fatalf("Email = %q", view.User.Email)
}
if view.Status != modelLottery.ClaimStatusPaid {
t.Fatalf("Status = %q", view.Status)
}
if view.SubmittedAt != submittedAt.Unix() {
t.Fatalf("SubmittedAt = %d, want %d", view.SubmittedAt, submittedAt.Unix())
}
if view.PaidAt != paidAt.Unix() {
t.Fatalf("PaidAt = %d, want %d", view.PaidAt, paidAt.Unix())
}
if view.Prize.Type != modelLottery.PrizeTypeCrypto {
t.Fatalf("Prize.Type = %q", view.Prize.Type)
}
if len(view.ClaimData) == 0 {
t.Fatal("ClaimData must be included when non-empty")
}
}
func TestClaimToAdminView_NoSnapshotFallsBackToClaimPrizeType(t *testing.T) {
claim := modelLottery.Claim{
Id: 1,
DrawId: 2,
PrizeType: modelLottery.PrizeTypePhysical,
Status: modelLottery.ClaimStatusPendingClaim,
ExpiresAt: time.Now().Add(time.Hour),
}
view := claimToAdminView(claim, modelLottery.PrizeSnapshot{}, "")
if view.Prize.Type != modelLottery.PrizeTypePhysical {
t.Fatalf("Prize.Type fallback = %q", view.Prize.Type)
}
}