07409eb602
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%
123 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|