a51501909a
ci/woodpecker/tag/build Pipeline was successful
1. 预校验(避免无用查库)
原先任何非空字符串都会直接拿去查库。邀请码固定长度且只用
inviteCodeChars(去掉 0/O/1/L 后的 31 个字符),不合规的输入
可以无需查库就拒绝;用统一文案「邀请码无效」,不泄露额外信息。
2. 存量兼容(关键,差点造成线上事故)
最初把校验写成「必须 8 位」。查生产库发现:
6 位:551 个用户
8 位:1 个用户
即绝大多数用户的邀请码仍是 6 位旧码。若只接受 8 位,
这 551 人的码会被判无效,他们将无法被新用户填写邀请——
影响面很大。已改为同时接受 6 位与 8 位,字符集校验不变。
测试覆盖:8 位正常、6 位存量、5/7 位、空串、含易混淆字符
(0/O/1/I/L)、含小写、含符号、含中文。
205 lines
5.9 KiB
Go
205 lines
5.9 KiB
Go
package handler
|
||
|
||
import (
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// TestNewInviteCodeFormat 邀请码长度、字符集(不含易混淆的 0/O/1/I/L)
|
||
func TestNewInviteCodeFormat(t *testing.T) {
|
||
for i := 0; i < 2000; i++ {
|
||
code, err := newInviteCode()
|
||
if err != nil {
|
||
t.Fatalf("newInviteCode error: %v", err)
|
||
}
|
||
if len(code) != inviteCodeLength {
|
||
t.Fatalf("长度应为 %d,得到 %q", inviteCodeLength, code)
|
||
}
|
||
for _, ch := range code {
|
||
if !strings.ContainsRune(inviteCodeChars, ch) {
|
||
t.Fatalf("出现字符集外字符 %q(code=%q)", ch, code)
|
||
}
|
||
if strings.ContainsRune("0O1IL", ch) {
|
||
t.Fatalf("出现易混淆字符 %q(code=%q)", ch, code)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestNewInviteCodeRandomness 连续生成不应大量重复(旧实现按纳秒取模,快速连续调用会高度重复)
|
||
func TestNewInviteCodeRandomness(t *testing.T) {
|
||
const n = 5000
|
||
seen := make(map[string]struct{}, n)
|
||
for i := 0; i < n; i++ {
|
||
code, _ := newInviteCode()
|
||
seen[code] = struct{}{}
|
||
}
|
||
// 31^6 ≈ 8.9 亿空间,5000 个样本的期望碰撞数 ≈ 0.014;允许极少量
|
||
if dup := n - len(seen); dup > 2 {
|
||
t.Fatalf("5000 次生成出现 %d 个重复,随机性异常", dup)
|
||
}
|
||
}
|
||
|
||
func dupErr(key string) error {
|
||
return errors.New("Error 1062 (23000): Duplicate entry 'ABC123' for key '" + key + "'")
|
||
}
|
||
|
||
// TestWithUniqueInviteCodeRetriesOnConflict 邀请码唯一索引冲突时换码重试,成功返回最终写入的码
|
||
func TestWithUniqueInviteCodeRetriesOnConflict(t *testing.T) {
|
||
calls := 0
|
||
var tried []string
|
||
code, err := withUniqueInviteCode(func(c string) error {
|
||
calls++
|
||
tried = append(tried, c)
|
||
if calls < 3 {
|
||
return dupErr("sys_user.idx_sys_user_invite_code")
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("应重试成功,得到错误 %v", err)
|
||
}
|
||
if calls != 3 {
|
||
t.Fatalf("应调用 3 次,实际 %d", calls)
|
||
}
|
||
if code != tried[len(tried)-1] {
|
||
t.Fatalf("返回的码应为最后一次写入的码:got %q, want %q", code, tried[len(tried)-1])
|
||
}
|
||
}
|
||
|
||
// TestWithUniqueInviteCodeNoRetryOnOtherErrors 用户名冲突等其他错误不重试,原样返回
|
||
func TestWithUniqueInviteCodeNoRetryOnOtherErrors(t *testing.T) {
|
||
cases := []error{
|
||
dupErr("sys_user.idx_sys_user_username"),
|
||
errors.New("connection refused"),
|
||
}
|
||
for _, want := range cases {
|
||
calls := 0
|
||
_, err := withUniqueInviteCode(func(string) error { calls++; return want })
|
||
if calls != 1 {
|
||
t.Fatalf("非邀请码冲突不应重试(%v),实际调用 %d 次", want, calls)
|
||
}
|
||
if !errors.Is(err, want) && err.Error() != want.Error() {
|
||
t.Fatalf("应原样返回错误 %v,得到 %v", want, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestWithUniqueInviteCodeGivesUp 持续冲突时有上限,不会死循环
|
||
func TestWithUniqueInviteCodeGivesUp(t *testing.T) {
|
||
calls := 0
|
||
_, err := withUniqueInviteCode(func(string) error {
|
||
calls++
|
||
return dupErr("sys_user.idx_sys_user_invite_code")
|
||
})
|
||
if err == nil {
|
||
t.Fatal("持续冲突应返回错误")
|
||
}
|
||
if calls != inviteCodeAttempts {
|
||
t.Fatalf("应尝试 %d 次,实际 %d", inviteCodeAttempts, calls)
|
||
}
|
||
}
|
||
|
||
// TestGenerateRandomCode 短信登录占位密码等仍使用该函数:长度正确且不重复
|
||
func TestGenerateRandomCode(t *testing.T) {
|
||
a, b := generateRandomCode(32), generateRandomCode(32)
|
||
if len(a) != 32 || len(b) != 32 {
|
||
t.Fatalf("长度应为 32:%q %q", a, b)
|
||
}
|
||
if a == b {
|
||
t.Fatal("两次生成不应相同")
|
||
}
|
||
}
|
||
|
||
// 重置邀请码:新旧码必须不同,且新码格式合规。
|
||
func TestResetInviteCodeProducesNewCode(t *testing.T) {
|
||
old, err := newInviteCode()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
seen := map[string]bool{old: true}
|
||
for i := 0; i < 100; i++ {
|
||
code, err := newInviteCode()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(code) != inviteCodeLength {
|
||
t.Fatalf("新码长度应为 %d,实际 %q", inviteCodeLength, code)
|
||
}
|
||
if seen[code] {
|
||
t.Fatalf("生成重复码 %q", code)
|
||
}
|
||
seen[code] = true
|
||
}
|
||
}
|
||
|
||
// 8 位邀请码空间足够大:5000 次生成不应出现重复。
|
||
func TestInviteCodeLengthIsEight(t *testing.T) {
|
||
if inviteCodeLength != 8 {
|
||
t.Fatalf("邀请码长度应为 8,实际 %d", inviteCodeLength)
|
||
}
|
||
const n = 5000
|
||
seen := make(map[string]struct{}, n)
|
||
for i := 0; i < n; i++ {
|
||
code, err := newInviteCode()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
seen[code] = struct{}{}
|
||
}
|
||
// 31^8 ≈ 8.5e11,5000 个样本碰撞期望 ≈ 0
|
||
if dup := n - len(seen); dup > 0 {
|
||
t.Fatalf("8 位下 5000 次生成出现 %d 个重复", dup)
|
||
}
|
||
}
|
||
|
||
// resetInviteCode 的乐观锁:并发改动时返回 errInviteCodeChanged
|
||
func TestErrInviteCodeChangedIsDistinct(t *testing.T) {
|
||
if errors.Is(errInviteCodeChanged, errInviteCodeAlreadySet) {
|
||
t.Fatal("两个哨兵错误不应互相匹配")
|
||
}
|
||
if !errors.Is(errInviteCodeChanged, errInviteCodeChanged) {
|
||
t.Fatal("errors.Is 自反性失败")
|
||
}
|
||
}
|
||
|
||
// isValidInviteCodeShape:只校验形态(长度 + 字符集),不查库
|
||
func TestIsValidInviteCodeShape(t *testing.T) {
|
||
valid, err := newInviteCode()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
cases := []struct {
|
||
name string
|
||
code string
|
||
want bool
|
||
}{
|
||
{"正常 8 位", valid, true},
|
||
{"空串", "", false},
|
||
{"过短", "ABC", false},
|
||
{"过长", "ABCDEFGHJ", false},
|
||
{"含易混淆字符 0", "ABCD0EFG", false},
|
||
{"含易混淆字符 O", "ABCDOEFG", false},
|
||
{"含易混淆字符 1", "ABCD1EFG", false},
|
||
{"含易混淆字符 I", "ABCDIEFG", false},
|
||
{"含易混淆字符 L", "ABCDLEFG", false},
|
||
{"含小写", "abcdefgh", false},
|
||
{"含符号", "ABCD-EFG", false},
|
||
{"含中文", "邀请码邀请码邀请", false},
|
||
// 存量兼容:生产库仍有 551 个 6 位旧码,必须仍能正常绑定
|
||
{"存量 6 位码", "ABCDEF", true},
|
||
{"存量 6 位含易混淆字符", "ABCD0F", false},
|
||
{"5 位", "ABCDE", false},
|
||
{"7 位", "ABCDEFG", false},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
if got := isValidInviteCodeShape(c.code); got != c.want {
|
||
t.Fatalf("isValidInviteCodeShape(%q)=%v, want %v", c.code, got, c.want)
|
||
}
|
||
})
|
||
}
|
||
}
|