9933d34bdd
Closes HIF-3 Stage 1 骨架:7 张表迁移 + 门槛规则引擎 + 加权选奖 + 次数入账/消耗(幂等)+ 发奖 handler 抽象与注册表。 - 单测覆盖 75.5%(未覆盖行 = stub handler ErrNotImplemented,合理) - CI 全绿(构建/Vet/测试 + golangci-lint) - 骨架不接入真实业务,vpn_duration/commission handler 在 Dispatch 中返回 ErrNotImplemented;合并后线上零变更 架构师 review 通过,4 项决策已在 issue 上给出: 1. 邀请转化语义 = 首次付款激活 2. 佣金日志类型 = 新增 CommissionTypeLottery=339 3. 家庭组归属 = 穿透到 owner 4. 管理端 IP 白名单 = 不做(推到 nginx/ingress 层) 后续 PR B/C 补真实业务对接 + 用户 API + 后台 CRUD + 集成/并发/概率测试。
194 lines
5.3 KiB
Go
194 lines
5.3 KiB
Go
package lottery
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
)
|
|
|
|
func mustParams(t *testing.T, v any) json.RawMessage {
|
|
t.Helper()
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("marshal params: %v", err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func TestEvaluator_HasSubscription(t *testing.T) {
|
|
ev := NewRuleEvaluator()
|
|
tree := &EligibilityRule{
|
|
Type: RuleTypeHasSubscription,
|
|
Params: mustParams(t, map[string]int64{"min_days_remaining": 7}),
|
|
}
|
|
|
|
// 没订阅
|
|
ok, unmet, err := ev.Evaluate(context.Background(), tree, RuleContext{})
|
|
if err != nil {
|
|
t.Fatalf("eval err: %v", err)
|
|
}
|
|
if ok || len(unmet) != 1 || unmet[0].Rule != RuleTypeHasSubscription {
|
|
t.Fatalf("expected fail on no sub, got ok=%v unmet=%+v", ok, unmet)
|
|
}
|
|
|
|
// 有订阅但剩余不足
|
|
ok, unmet, err = ev.Evaluate(context.Background(), tree, RuleContext{
|
|
HasActiveSubscription: true,
|
|
SubscriptionExpiresIn: 3 * 86400,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("eval err: %v", err)
|
|
}
|
|
if ok {
|
|
t.Fatalf("expected fail on 3 days < 7 required, got pass")
|
|
}
|
|
if len(unmet) != 1 || unmet[0].Required != 7 || unmet[0].Current != 3 {
|
|
t.Fatalf("unmet mismatch: %+v", unmet)
|
|
}
|
|
|
|
// 有订阅剩余足够
|
|
ok, _, err = ev.Evaluate(context.Background(), tree, RuleContext{
|
|
HasActiveSubscription: true,
|
|
SubscriptionExpiresIn: 30 * 86400,
|
|
})
|
|
if err != nil || !ok {
|
|
t.Fatalf("expected pass on 30 days, got ok=%v err=%v", ok, err)
|
|
}
|
|
}
|
|
|
|
func TestEvaluator_InviteCount(t *testing.T) {
|
|
ev := NewRuleEvaluator()
|
|
tree := &EligibilityRule{
|
|
Type: RuleTypeInviteCount,
|
|
Params: mustParams(t, map[string]int64{"min": 3}),
|
|
}
|
|
ok, unmet, err := ev.Evaluate(context.Background(), tree, RuleContext{InviteCount: 1})
|
|
if err != nil || ok {
|
|
t.Fatalf("expected fail, got ok=%v err=%v", ok, err)
|
|
}
|
|
if unmet[0].Current != 1 || unmet[0].Required != 3 {
|
|
t.Fatalf("expected current=1 required=3, got %+v", unmet[0])
|
|
}
|
|
|
|
ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{InviteCount: 3})
|
|
if !ok {
|
|
t.Fatalf("expected pass with InviteCount=3")
|
|
}
|
|
}
|
|
|
|
func TestEvaluator_ANDShort(t *testing.T) {
|
|
ev := NewRuleEvaluator()
|
|
tree := &EligibilityRule{
|
|
Op: OpAND,
|
|
Children: []*EligibilityRule{
|
|
{Type: RuleTypeHasSubscription},
|
|
{Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 5})},
|
|
},
|
|
}
|
|
ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{
|
|
HasActiveSubscription: true,
|
|
InviteCount: 1,
|
|
})
|
|
if ok {
|
|
t.Fatalf("expected AND to fail")
|
|
}
|
|
// AND 需要收集全部未通过项(这里只有 1 条)
|
|
if len(unmet) != 1 || unmet[0].Rule != RuleTypeInviteCount {
|
|
t.Fatalf("expected 1 unmet (invite_count), got %+v", unmet)
|
|
}
|
|
}
|
|
|
|
func TestEvaluator_ORPassIgnoresChildUnmet(t *testing.T) {
|
|
ev := NewRuleEvaluator()
|
|
tree := &EligibilityRule{
|
|
Op: OpOR,
|
|
Children: []*EligibilityRule{
|
|
{Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 100})},
|
|
{Type: RuleTypeHasSubscription}, // 会通过
|
|
},
|
|
}
|
|
ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{
|
|
HasActiveSubscription: true,
|
|
InviteCount: 0,
|
|
})
|
|
if !ok {
|
|
t.Fatalf("expected OR to pass because one child passes")
|
|
}
|
|
if len(unmet) != 0 {
|
|
t.Fatalf("OR pass should hide child failures, got %+v", unmet)
|
|
}
|
|
}
|
|
|
|
func TestEvaluator_ORFailBubblesAllChildren(t *testing.T) {
|
|
ev := NewRuleEvaluator()
|
|
tree := &EligibilityRule{
|
|
Op: OpOR,
|
|
Children: []*EligibilityRule{
|
|
{Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 5})},
|
|
{Type: RuleTypeHasSubscription},
|
|
},
|
|
}
|
|
ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{
|
|
InviteCount: 1,
|
|
})
|
|
if ok {
|
|
t.Fatalf("expected OR to fail")
|
|
}
|
|
if len(unmet) != 2 {
|
|
t.Fatalf("expected 2 unmet reasons on OR fail, got %+v", unmet)
|
|
}
|
|
}
|
|
|
|
func TestEvaluator_SubscriptionType(t *testing.T) {
|
|
ev := NewRuleEvaluator()
|
|
tree := &EligibilityRule{
|
|
Type: RuleTypeSubscriptionType,
|
|
Params: mustParams(t, map[string][]int64{"plan_ids": {10, 20}}),
|
|
}
|
|
ok, _, _ := ev.Evaluate(context.Background(), tree, RuleContext{
|
|
SubscriptionPlanIds: []int64{20},
|
|
})
|
|
if !ok {
|
|
t.Fatalf("expected pass when user plan matches")
|
|
}
|
|
ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{
|
|
SubscriptionPlanIds: []int64{99},
|
|
})
|
|
if ok {
|
|
t.Fatalf("expected fail when user plan not in whitelist")
|
|
}
|
|
}
|
|
|
|
func TestEvaluator_UserTag(t *testing.T) {
|
|
ev := NewRuleEvaluator()
|
|
tree := &EligibilityRule{
|
|
Type: RuleTypeUserTag,
|
|
Params: mustParams(t, map[string][]string{"tags": {"vip", "beta"}}),
|
|
}
|
|
ok, _, _ := ev.Evaluate(context.Background(), tree, RuleContext{UserTags: []string{"beta"}})
|
|
if !ok {
|
|
t.Fatalf("expected pass when any tag matches")
|
|
}
|
|
ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{UserTags: []string{"foo"}})
|
|
if ok {
|
|
t.Fatalf("expected fail when no tags match")
|
|
}
|
|
}
|
|
|
|
func TestEvaluator_NilTreeAlwaysPasses(t *testing.T) {
|
|
ev := NewRuleEvaluator()
|
|
ok, unmet, err := ev.Evaluate(context.Background(), nil, RuleContext{})
|
|
if err != nil || !ok || len(unmet) != 0 {
|
|
t.Fatalf("nil tree should always pass; got ok=%v unmet=%+v err=%v", ok, unmet, err)
|
|
}
|
|
}
|
|
|
|
func TestEvaluator_UnknownRuleType(t *testing.T) {
|
|
ev := NewRuleEvaluator()
|
|
tree := &EligibilityRule{Type: "no_such_rule"}
|
|
_, _, err := ev.Evaluate(context.Background(), tree, RuleContext{})
|
|
if err == nil {
|
|
t.Fatalf("expected error on unknown rule type")
|
|
}
|
|
}
|