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%
253 lines
8.7 KiB
Go
253 lines
8.7 KiB
Go
// manual_claim_test.go — 单元测试三类人工奖 handler 的静态约束:
|
|
// - Type / IsAuto / Dispatch 契约
|
|
// - ClaimSchema 返回合法 JSON
|
|
// - ValidateClaim 正确/错误样本表驱动
|
|
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/perfect-panel/server/internal/model/lottery"
|
|
)
|
|
|
|
// ---- Crypto ---------------------------------------------------------------
|
|
|
|
func TestCryptoHandler_Contract(t *testing.T) {
|
|
h := NewCryptoHandler()
|
|
if h.Type() != lottery.PrizeTypeCrypto {
|
|
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypeCrypto)
|
|
}
|
|
if h.IsAuto() {
|
|
t.Fatal("IsAuto must be false for manual claim handler")
|
|
}
|
|
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
|
t.Fatalf("Dispatch on manual handler must return ErrDispatchNotSupported, got %v", err)
|
|
}
|
|
if h.ClaimSchema() == nil {
|
|
t.Fatal("ClaimSchema must not be nil for manual handler")
|
|
}
|
|
var schema map[string]any
|
|
if err := json.Unmarshal(h.ClaimSchema(), &schema); err != nil {
|
|
t.Fatalf("ClaimSchema must be valid JSON: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCryptoHandler_ValidateClaim(t *testing.T) {
|
|
h := NewCryptoHandler()
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
wantErr bool
|
|
errIsErr error
|
|
}{
|
|
{"empty", "", true, ErrClaimDataEmpty},
|
|
{"whitespace", " ", true, ErrClaimDataEmpty},
|
|
{"malformed json", `{"network"`, true, ErrClaimDataMalformed},
|
|
{"missing network", `{"address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, true, ErrClaimDataMalformed},
|
|
{"empty network", `{"network":"","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, true, ErrClaimDataMalformed},
|
|
{"address too short", `{"network":"BTC","address":"abc"}`, true, ErrClaimDataMalformed},
|
|
{"address bad chars", `{"network":"BTC","address":"bc1$$!!****"}`, true, ErrClaimDataMalformed},
|
|
{"valid BTC", `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, false, nil},
|
|
{"valid ETH", `{"network":"ETH","address":"0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1"}`, false, nil},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := h.ValidateClaim([]byte(tc.body))
|
|
if tc.wantErr && err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
if tc.errIsErr != nil && !errors.Is(err, tc.errIsErr) {
|
|
t.Fatalf("expected errors.Is %v, got %v", tc.errIsErr, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateCryptoNetwork(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
prizeConfig string
|
|
wantErr bool
|
|
}{
|
|
{"empty networks in cfg means allow-all", `{"network":"foo","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, `{"amount":"1","currency":"BTC"}`, false},
|
|
{"network in whitelist", `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, `{"networks":["BTC","ETH"]}`, false},
|
|
{"network NOT in whitelist", `{"network":"XRP","address":"rXYZQabcdefghijkxxxxxxxx"}`, `{"networks":["BTC","ETH"]}`, true},
|
|
{"empty body", "", `{"networks":["BTC"]}`, true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := ValidateCryptoNetwork([]byte(tc.body), tc.prizeConfig)
|
|
if tc.wantErr && err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildCryptoClaimSchema_InjectsNetworkEnum(t *testing.T) {
|
|
schema := BuildCryptoClaimSchema(`{"networks":["BTC","TRX"]}`)
|
|
s := string(schema)
|
|
if !strings.Contains(s, `"enum":["BTC","TRX"]`) {
|
|
t.Fatalf("expected schema to include enum with configured networks, got %s", s)
|
|
}
|
|
// 合法 JSON
|
|
var out map[string]any
|
|
if err := json.Unmarshal(schema, &out); err != nil {
|
|
t.Fatalf("built schema not valid JSON: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildCryptoClaimSchema_FallsBackWhenConfigInvalid(t *testing.T) {
|
|
// invalid JSON → fallback to generic schema without enum
|
|
schema := BuildCryptoClaimSchema(`not-json`)
|
|
var out map[string]any
|
|
if err := json.Unmarshal(schema, &out); err != nil {
|
|
t.Fatalf("fallback schema not valid JSON: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---- Physical -------------------------------------------------------------
|
|
|
|
func TestPhysicalHandler_Contract(t *testing.T) {
|
|
h := NewPhysicalHandler()
|
|
if h.Type() != lottery.PrizeTypePhysical {
|
|
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypePhysical)
|
|
}
|
|
if h.IsAuto() {
|
|
t.Fatal("IsAuto must be false")
|
|
}
|
|
if h.ClaimSchema() == nil {
|
|
t.Fatal("ClaimSchema must not be nil")
|
|
}
|
|
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
|
t.Fatalf("Dispatch must return ErrDispatchNotSupported, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPhysicalHandler_ValidateClaim(t *testing.T) {
|
|
h := NewPhysicalHandler()
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
wantErr bool
|
|
}{
|
|
{"empty", ``, true},
|
|
{"missing name", `{"phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":"文一路"}`, true},
|
|
{"bad phone", `{"name":"张三","phone":"abc","province":"浙江","city":"杭州","district":"西湖","detail":"文一路"}`, true},
|
|
{"missing detail", `{"name":"张三","phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":""}`, true},
|
|
{"valid CN", `{"name":"张三","phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":"文一路 XX 号"}`, false},
|
|
{"valid international", `{"name":"John","phone":"+1 415-555-0100","province":"CA","city":"SF","district":"SoMa","detail":"1 Market St"}`, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := h.ValidateClaim([]byte(tc.body))
|
|
if tc.wantErr && err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---- ManualOther ---------------------------------------------------------
|
|
|
|
func TestManualOtherHandler_Contract(t *testing.T) {
|
|
h := NewManualOtherHandler()
|
|
if h.Type() != lottery.PrizeTypeManualOther {
|
|
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypeManualOther)
|
|
}
|
|
if h.IsAuto() {
|
|
t.Fatal("IsAuto must be false")
|
|
}
|
|
if h.ClaimSchema() == nil {
|
|
t.Fatal("ClaimSchema must not be nil")
|
|
}
|
|
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
|
t.Fatalf("Dispatch must return ErrDispatchNotSupported, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestManualOtherHandler_ValidateClaim(t *testing.T) {
|
|
h := NewManualOtherHandler()
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
wantErr bool
|
|
}{
|
|
{"empty", ``, true},
|
|
{"unknown contact_type", `{"contact_type":"fax","contact_value":"1234"}`, true},
|
|
{"missing contact_value", `{"contact_type":"phone","contact_value":""}`, true},
|
|
{"valid phone", `{"contact_type":"phone","contact_value":"+8613800001234","remark":"下午联系"}`, false},
|
|
{"valid email", `{"contact_type":"email","contact_value":"user@example.com"}`, false},
|
|
{"valid tg", `{"contact_type":"tg","contact_value":"@handle"}`, false},
|
|
{"remark too long", `{"contact_type":"email","contact_value":"x@y.z","remark":"` + strings.Repeat("x", 513) + `"}`, true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := h.ValidateClaim([]byte(tc.body))
|
|
if tc.wantErr && err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Fatalf("expected nil, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---- 状态机常量约束 --------------------------------------------------------
|
|
|
|
func TestIsClaimStatusResubmittable(t *testing.T) {
|
|
cases := []struct {
|
|
status string
|
|
want bool
|
|
}{
|
|
{lottery.ClaimStatusPendingClaim, true},
|
|
{lottery.ClaimStatusRejected, true},
|
|
{lottery.ClaimStatusReviewing, false},
|
|
{lottery.ClaimStatusPaying, false},
|
|
{lottery.ClaimStatusPaid, false},
|
|
{lottery.ClaimStatusExpired, false},
|
|
{"", false},
|
|
{"unknown", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := lottery.IsClaimStatusResubmittable(tc.status); got != tc.want {
|
|
t.Errorf("IsClaimStatusResubmittable(%q) = %v, want %v", tc.status, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsPrizeTypeManualClaim(t *testing.T) {
|
|
cases := []struct {
|
|
prizeType string
|
|
want bool
|
|
}{
|
|
{lottery.PrizeTypeCrypto, true},
|
|
{lottery.PrizeTypePhysical, true},
|
|
{lottery.PrizeTypeManualOther, true},
|
|
{lottery.PrizeTypeVPNDuration, false},
|
|
{lottery.PrizeTypeCommission, false},
|
|
{lottery.PrizeTypeNone, false},
|
|
{"unknown", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := lottery.IsPrizeTypeManualClaim(tc.prizeType); got != tc.want {
|
|
t.Errorf("IsPrizeTypeManualClaim(%q) = %v, want %v", tc.prizeType, got, tc.want)
|
|
}
|
|
}
|
|
}
|