Files
sub2api/backend/internal/service/affiliate_service_test.go
shaw 4e1bb2b445 feat(affiliate): add feature toggle and per-user custom invite settings
- 在系统设置「功能开关」中新增邀请返利总开关,默认关闭;
  关闭态:菜单隐藏、注册忽略 aff、新充值不返利,但已有 quota 仍可转余额
- 支持管理员为指定用户设置专属邀请码(覆盖随机码,全局唯一)
- 支持管理员为指定用户设置专属返利比例(覆盖全局比例,可单条/批量调整)
- 在系统设置邀请返利卡片内嵌入专属用户管理表格(搜索/编辑/批量/删除),
  删除采用项目通用 ConfirmDialog,会同时清除专属比例并把邀请码重置为系统随机码
- /affiliate 用户页新增「我的返利比例」卡片与动态使用说明,让用户直观看到
  分享后能拿到多少(同源 resolveRebateRatePercent 计算,与实际充值一致)
- 新增数据库迁移 132 添加 aff_rebate_rate_percent 与 aff_code_custom 列
- 新增 admin 路由组 /api/v1/admin/affiliates/users/* 共 5 个端点
- AffiliateService 改为只依赖 *SettingService,去除冗余的 SettingRepository
- 邀请码格式校验放宽到 [A-Z0-9_-]{4,32},兼容旧 12 位系统码与新自定义码
- 补充单元测试与集成测试覆盖新方法、冲突路径与边界值
2026-04-25 20:22:07 +08:00

132 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build unit
package service
import (
"context"
"math"
"testing"
"github.com/stretchr/testify/require"
)
// TestResolveRebateRatePercent_PerUserOverride verifies that per-inviter
// AffRebateRatePercent overrides the global rate, that NULL falls back to the
// global rate, and that out-of-range exclusive rates are clamped silently.
//
// SettingService is left nil here so globalRebateRatePercent returns the
// documented default (AffiliateRebateRateDefault = 20%) — this exercises the
// fallback path without spinning up a settings stub.
func TestResolveRebateRatePercent_PerUserOverride(t *testing.T) {
t.Parallel()
svc := &AffiliateService{}
// nil exclusive rate → falls back to global default (20%)
require.InDelta(t, AffiliateRebateRateDefault,
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{}), 1e-9)
// exclusive rate set → overrides global
rate := 50.0
require.InDelta(t, 50.0,
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &rate}), 1e-9)
// exclusive rate 0 → returns 0 (no rebate, intentional)
zero := 0.0
require.InDelta(t, 0.0,
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &zero}), 1e-9)
// exclusive rate above max → clamped to Max
tooHigh := 250.0
require.InDelta(t, AffiliateRebateRateMax,
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &tooHigh}), 1e-9)
// exclusive rate below min → clamped to Min
tooLow := -5.0
require.InDelta(t, AffiliateRebateRateMin,
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &tooLow}), 1e-9)
}
// TestIsEnabled_NilSettingServiceReturnsDefault verifies that IsEnabled
// safely handles a nil settingService dependency by returning the default
// (off). This protects callers from nil-pointer crashes in misconfigured
// environments.
func TestIsEnabled_NilSettingServiceReturnsDefault(t *testing.T) {
t.Parallel()
svc := &AffiliateService{}
require.False(t, svc.IsEnabled(context.Background()))
require.Equal(t, AffiliateEnabledDefault, svc.IsEnabled(context.Background()))
}
// TestValidateExclusiveRate_BoundaryAndInvalid covers the validator used by
// admin-facing rate setters: nil is always valid (clear), in-range values
// are accepted, NaN/Inf and out-of-range values produce a typed BadRequest.
func TestValidateExclusiveRate_BoundaryAndInvalid(t *testing.T) {
t.Parallel()
require.NoError(t, validateExclusiveRate(nil))
for _, v := range []float64{0, 0.01, 50, 99.99, 100} {
v := v
require.NoError(t, validateExclusiveRate(&v), "value %v should be valid", v)
}
for _, v := range []float64{-0.01, 100.01, -100, 200} {
v := v
require.Error(t, validateExclusiveRate(&v), "value %v should be rejected", v)
}
nan := math.NaN()
require.Error(t, validateExclusiveRate(&nan))
posInf := math.Inf(1)
require.Error(t, validateExclusiveRate(&posInf))
negInf := math.Inf(-1)
require.Error(t, validateExclusiveRate(&negInf))
}
func TestMaskEmail(t *testing.T) {
t.Parallel()
require.Equal(t, "a***@g***.com", maskEmail("alice@gmail.com"))
require.Equal(t, "x***@d***", maskEmail("x@domain"))
require.Equal(t, "", maskEmail(""))
}
func TestIsValidAffiliateCodeFormat(t *testing.T) {
t.Parallel()
// 邀请码格式校验同时服务于:
// 1) 系统自动生成的 12 位随机码A-Z 去 I/O2-9 去 0/1
// 2) 管理员设置的自定义专属码(如 "VIP2026"、"NEW_USER-1"
// 因此校验放宽到 [A-Z0-9_-]{4,32}(要求调用方先 ToUpper
cases := []struct {
name string
in string
want bool
}{
{"valid canonical 12-char", "ABCDEFGHJKLM", true},
{"valid all digits 2-9", "234567892345", true},
{"valid mixed", "A2B3C4D5E6F7", true},
{"valid admin custom short", "VIP1", true},
{"valid admin custom with hyphen", "NEW-USER", true},
{"valid admin custom with underscore", "VIP_2026", true},
{"valid 32-char max", "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", true},
// Previously-excluded chars (I/O/0/1) are now allowed since admins may use them.
{"letter I now allowed", "IBCDEFGHJKLM", true},
{"letter O now allowed", "OBCDEFGHJKLM", true},
{"digit 0 now allowed", "0BCDEFGHJKLM", true},
{"digit 1 now allowed", "1BCDEFGHJKLM", true},
{"too short (3 chars)", "ABC", false},
{"too long (33 chars)", "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456", false},
{"lowercase rejected (caller must ToUpper first)", "abcdefghjklm", false},
{"empty", "", false},
{"utf8 non-ascii", "ÄÄÄÄÄÄ", false}, // bytes out of charset
{"ascii punctuation .", "ABCDEFGHJK.M", false},
{"whitespace", "ABCDEFGHJK M", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.want, isValidAffiliateCodeFormat(tc.in))
})
}
}