// 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) } } }