b5ab92a57e
来自 xhigh code review 的正确性/健壮性修复,覆盖全部五端: - server:鉴权 fail-closed、计量交叉校验与配额扣穿处理、WS 网关并发与关闭顺序、 billing 行锁、redis Lua 过期与设备槽刷新、config 解析 - desktop:会话 epoch 防串话、WS 重连与 401 处理、api 客户端复用、统一 usePoll 轮询 - android:握手时序、请求头封装、账户状态派生、按需重组 - ios:finalize 宽限、串行采集、错误文案服务端优先、删除死代码 CommitController Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
106 lines
3.1 KiB
Go
106 lines
3.1 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/alicebob/miniredis/v2"
|
||
"github.com/redis/go-redis/v9"
|
||
)
|
||
|
||
func rdb(t *testing.T) *redis.Client {
|
||
t.Helper()
|
||
mr := miniredis.RunT(t)
|
||
return redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||
}
|
||
|
||
func TestAllowSessionWindow(t *testing.T) {
|
||
r := rdb(t)
|
||
ctx := context.Background()
|
||
now := time.Now()
|
||
// 30 次放行,第 31 次拒绝
|
||
for i := 0; i < 30; i++ {
|
||
ok, err := AllowSession(ctx, r, "dev1", now)
|
||
if err != nil || !ok {
|
||
t.Fatalf("session %d should pass: ok=%v err=%v", i+1, ok, err)
|
||
}
|
||
}
|
||
if ok, _ := AllowSession(ctx, r, "dev1", now); ok {
|
||
t.Fatal("31st session should be rejected")
|
||
}
|
||
// 其他设备不受影响
|
||
if ok, _ := AllowSession(ctx, r, "dev2", now); !ok {
|
||
t.Fatal("other device should pass")
|
||
}
|
||
}
|
||
|
||
// TestAudioWindowExhausted 复用生产路径(AudioWindowExhausted 只查 + RecordAudioSeconds 记账)。
|
||
func TestAudioWindowExhausted(t *testing.T) {
|
||
r := rdb(t)
|
||
ctx := context.Background()
|
||
now := time.Now()
|
||
// 初始未满
|
||
if full, _ := AudioWindowExhausted(ctx, r, "dev1", now); full {
|
||
t.Fatal("fresh window should not be exhausted")
|
||
}
|
||
// 记满后窗口耗尽(AudioWindowExhausted 只查不记,val=0,sum>limit 才算满)
|
||
if err := RecordAudioSeconds(ctx, r, "dev1", 1801, now); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if full, _ := AudioWindowExhausted(ctx, r, "dev1", now.Add(time.Second)); !full {
|
||
t.Fatal("window should be exhausted after exceeding 1800s")
|
||
}
|
||
// 其他设备不受影响
|
||
if full, _ := AudioWindowExhausted(ctx, r, "dev2", now); full {
|
||
t.Fatal("other device window should not be exhausted")
|
||
}
|
||
}
|
||
|
||
func TestDeviceSlot(t *testing.T) {
|
||
r := rdb(t)
|
||
ctx := context.Background()
|
||
ok, err := AcquireDeviceSlot(ctx, r, "dev1", "s1")
|
||
if err != nil || !ok {
|
||
t.Fatalf("first acquire should pass: %v", err)
|
||
}
|
||
if ok, _ := AcquireDeviceSlot(ctx, r, "dev1", "s2"); ok {
|
||
t.Fatal("second concurrent acquire should fail")
|
||
}
|
||
// 非持有者释放无效
|
||
if err := ReleaseDeviceSlot(ctx, r, "dev1", "s2"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ok, _ := AcquireDeviceSlot(ctx, r, "dev1", "s3"); ok {
|
||
t.Fatal("slot should still be held by s1")
|
||
}
|
||
// 持有者释放后可再获取
|
||
if err := ReleaseDeviceSlot(ctx, r, "dev1", "s1"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ok, _ := AcquireDeviceSlot(ctx, r, "dev1", "s3"); !ok {
|
||
t.Fatal("acquire after release should pass")
|
||
}
|
||
}
|
||
|
||
// TestRefreshDeviceSlot 仅持有者能续期;非持有者续期无效(17D)。
|
||
func TestRefreshDeviceSlot(t *testing.T) {
|
||
r := rdb(t)
|
||
ctx := context.Background()
|
||
if ok, err := AcquireDeviceSlot(ctx, r, "dev1", "s1"); err != nil || !ok {
|
||
t.Fatalf("acquire should pass: %v", err)
|
||
}
|
||
// 持有者续期成功
|
||
if ok, err := RefreshDeviceSlot(ctx, r, "dev1", "s1"); err != nil || !ok {
|
||
t.Fatalf("holder refresh should succeed: ok=%v err=%v", ok, err)
|
||
}
|
||
// 非持有者续期失败
|
||
if ok, _ := RefreshDeviceSlot(ctx, r, "dev1", "s2"); ok {
|
||
t.Fatal("non-holder refresh should fail")
|
||
}
|
||
// 续期未释放槽位:他人仍抢不到
|
||
if ok, _ := AcquireDeviceSlot(ctx, r, "dev1", "s2"); ok {
|
||
t.Fatal("slot should still be held after refresh")
|
||
}
|
||
}
|