Files
hi-server/internal/model/lottery/rule_evaluator_test.go
T
shanshanzhong147 9933d34bdd 新功能(#3): 抽奖活动 Stage 1 骨架(DB + 规则引擎 + Handler 抽象)
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 + 集成/并发/概率测试。
2026-07-08 20:05:05 -07:00

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