d88c1ae647
手动合并 tsk_x7wrlA87orsY(设备管理 + 订阅校验中间件)到 main:
冲突解决:
- server/internal/apierr/apierr.go:保留 tsk_GXDoc3Cs07Rn 版本(New/StatusFor/
Middleware/ErrConflict/改善文档),并入 tsk_x7wrlA87orsY 新增的 ErrAccountBanned
及对应 StatusFor case(→ 403)。
新增文件(来自 tsk_x7wrlA87orsY):
- server/internal/devices/doc.go package 文档(替换占位 stub)
- server/internal/devices/context.go CtxKeyUserID / Plan / WithPlan / PlanFromCtx
- server/internal/devices/handler.go GET /v1/me/devices · DELETE /v1/me/devices/{id}
- server/internal/devices/middleware.go SubscriptionMiddleware · CheckDeviceQuota · RequirePaidTier
- server/internal/devices/service.go RegisterIfAbsent / DeleteDevice / ResolvePlan + 纯函数 resolveEffectivePlan
- server/internal/devices/store.go MySQL 数据访问层
- server/internal/devices/service_test.go 15 个单测(全通过)
- server/internal/devices/devices_integration_test.go testcontainers 集成测试
OpenAPI 更新(来自 tsk_x7wrlA87orsY):
- server/api/openapi.yaml:SubscriptionInfo.source 枚举补 free
- design/server/openapi.yaml:SubscriptionInfo.source 枚举补 admin, free
测试:go build ./... ✓;go test ./internal/apierr/... ✓(8 tests);
go test ./internal/devices/... ✓(15 tests)。
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
257 lines
8.0 KiB
Go
257 lines
8.0 KiB
Go
package devices
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// freePlan is the standard free fallback used in resolver tests
|
|
// (numbers per design/CLAUDE.md §7).
|
|
func freePlan() Plan {
|
|
m := 10
|
|
return Plan{PlanCode: "free", MaxDevices: 1, DailyMinutes: &m, AdGate: true, Source: "free"}
|
|
}
|
|
|
|
func mkSub(code string, maxDevices int, expiresAt time.Time, source string) effSub {
|
|
return effSub{PlanCode: code, MaxDevices: maxDevices, ExpiresAt: expiresAt, Source: source}
|
|
}
|
|
|
|
func TestResolveEffectivePlan_TrialActiveGivesPro(t *testing.T) {
|
|
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
|
|
subs := []effSub{mkSub("pro", 5, now.Add(24*time.Hour), "trial")}
|
|
|
|
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
|
|
if apiErr != nil {
|
|
t.Fatalf("unexpected error: %v", apiErr)
|
|
}
|
|
if got.PlanCode != "pro" || got.MaxDevices != 5 {
|
|
t.Fatalf("want pro/5, got %s/%d", got.PlanCode, got.MaxDevices)
|
|
}
|
|
if got.Source != "trial" {
|
|
t.Errorf("want source trial, got %s", got.Source)
|
|
}
|
|
if got.ExpiresAt == nil {
|
|
t.Fatal("expected non-nil expires_at")
|
|
}
|
|
}
|
|
|
|
func TestResolveEffectivePlan_ExpiredFallsBackToFree(t *testing.T) {
|
|
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
|
|
// pro trial expired one second ago.
|
|
subs := []effSub{mkSub("pro", 5, now.Add(-time.Second), "trial")}
|
|
|
|
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
|
|
if apiErr != nil {
|
|
t.Fatalf("unexpected error: %v", apiErr)
|
|
}
|
|
if got.PlanCode != "free" {
|
|
t.Fatalf("want free, got %s", got.PlanCode)
|
|
}
|
|
if got.ExpiresAt != nil {
|
|
t.Errorf("free fallback should have nil expires_at, got %v", *got.ExpiresAt)
|
|
}
|
|
if got.MaxDevices != 1 {
|
|
t.Errorf("want free max_devices 1, got %d", got.MaxDevices)
|
|
}
|
|
}
|
|
|
|
func TestResolveEffectivePlan_BannedRejected(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
subs := []effSub{mkSub("pro", 5, now.Add(time.Hour), "trial")}
|
|
|
|
_, apiErr := resolveEffectivePlan(now, "banned", subs, freePlan())
|
|
if apiErr == nil {
|
|
t.Fatal("expected banned to be rejected")
|
|
}
|
|
if apiErr.Code != "ACCOUNT_BANNED" {
|
|
t.Errorf("want ACCOUNT_BANNED, got %s", apiErr.Code)
|
|
}
|
|
}
|
|
|
|
func TestResolveEffectivePlan_HighestTierWins(t *testing.T) {
|
|
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
|
|
subs := []effSub{
|
|
mkSub("pro", 5, now.Add(100*24*time.Hour), "code"), // longer pro
|
|
mkSub("team", 10, now.Add(24*time.Hour), "code"), // shorter team
|
|
mkSub("free", 1, now.Add(50*24*time.Hour), "trial"),
|
|
}
|
|
|
|
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
|
|
if apiErr != nil {
|
|
t.Fatalf("unexpected error: %v", apiErr)
|
|
}
|
|
// Highest tier (team) wins even though pro expires later.
|
|
if got.PlanCode != "team" || got.MaxDevices != 10 {
|
|
t.Fatalf("want team/10, got %s/%d", got.PlanCode, got.MaxDevices)
|
|
}
|
|
}
|
|
|
|
func TestResolveEffectivePlan_SameTierLatestExpiryWins(t *testing.T) {
|
|
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
|
|
early := now.Add(24 * time.Hour)
|
|
late := now.Add(48 * time.Hour)
|
|
subs := []effSub{
|
|
mkSub("pro", 5, early, "code"),
|
|
mkSub("pro", 5, late, "code"),
|
|
}
|
|
|
|
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
|
|
if apiErr != nil {
|
|
t.Fatalf("unexpected error: %v", apiErr)
|
|
}
|
|
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(late) {
|
|
t.Fatalf("want latest expiry %v, got %v", late, got.ExpiresAt)
|
|
}
|
|
}
|
|
|
|
func TestResolveEffectivePlan_UTCBoundaryStrict(t *testing.T) {
|
|
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
|
|
// expires_at exactly == now must count as expired (strict >).
|
|
subs := []effSub{mkSub("pro", 5, now, "trial")}
|
|
|
|
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
|
|
if apiErr != nil {
|
|
t.Fatalf("unexpected error: %v", apiErr)
|
|
}
|
|
if got.PlanCode != "free" {
|
|
t.Fatalf("expires_at == now should be expired; want free, got %s", got.PlanCode)
|
|
}
|
|
|
|
// One microsecond after now is still active.
|
|
subs2 := []effSub{mkSub("pro", 5, now.Add(time.Microsecond), "trial")}
|
|
got2, _ := resolveEffectivePlan(now, "active", subs2, freePlan())
|
|
if got2.PlanCode != "pro" {
|
|
t.Fatalf("expires_at just after now should be active; want pro, got %s", got2.PlanCode)
|
|
}
|
|
}
|
|
|
|
func TestResolveEffectivePlan_NoSubsGivesFree(t *testing.T) {
|
|
got, apiErr := resolveEffectivePlan(time.Now().UTC(), "active", nil, freePlan())
|
|
if apiErr != nil {
|
|
t.Fatalf("unexpected error: %v", apiErr)
|
|
}
|
|
if got.PlanCode != "free" || got.Source != "free" {
|
|
t.Fatalf("want free/free, got %s/%s", got.PlanCode, got.Source)
|
|
}
|
|
}
|
|
|
|
func TestResolveEffectivePlan_DailyMinutesPropagated(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
s := mkSub("pro", 5, now.Add(time.Hour), "code")
|
|
s.DailyMinutes = sql.NullInt64{} // pro: unlimited
|
|
got, _ := resolveEffectivePlan(now, "active", []effSub{s}, freePlan())
|
|
if got.DailyMinutes != nil {
|
|
t.Errorf("pro daily_minutes should be nil (unlimited), got %d", *got.DailyMinutes)
|
|
}
|
|
|
|
free := resolveFree(t, now)
|
|
if free.DailyMinutes == nil || *free.DailyMinutes != 10 {
|
|
t.Errorf("free daily_minutes should be 10, got %v", free.DailyMinutes)
|
|
}
|
|
}
|
|
|
|
func resolveFree(t *testing.T, now time.Time) Plan {
|
|
t.Helper()
|
|
got, apiErr := resolveEffectivePlan(now, "active", nil, freePlan())
|
|
if apiErr != nil {
|
|
t.Fatalf("unexpected error: %v", apiErr)
|
|
}
|
|
return got
|
|
}
|
|
|
|
func TestCheckDeviceQuota(t *testing.T) {
|
|
free := Plan{PlanCode: "free", MaxDevices: 1}
|
|
pro := Plan{PlanCode: "pro", MaxDevices: 5}
|
|
|
|
if err := CheckDeviceQuota(free, 0); err != nil {
|
|
t.Errorf("0 < 1 should pass, got %v", err)
|
|
}
|
|
if err := CheckDeviceQuota(free, 1); err == nil {
|
|
t.Error("1 >= 1 should be rejected")
|
|
} else if err.Code != "DEVICE_LIMIT_EXCEEDED" {
|
|
t.Errorf("want DEVICE_LIMIT_EXCEEDED, got %s", err.Code)
|
|
}
|
|
if err := CheckDeviceQuota(pro, 4); err != nil {
|
|
t.Errorf("4 < 5 should pass, got %v", err)
|
|
}
|
|
if err := CheckDeviceQuota(pro, 5); err == nil {
|
|
t.Error("5 >= 5 should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestErrDeviceLimitContainsNumber(t *testing.T) {
|
|
e := errDeviceLimit(5)
|
|
if !strings.Contains(e.MessageZH, "5") {
|
|
t.Errorf("zh message should contain the limit 5: %q", e.MessageZH)
|
|
}
|
|
if !strings.Contains(e.MessageEn, "5") {
|
|
t.Errorf("en message should contain the limit 5: %q", e.MessageEn)
|
|
}
|
|
}
|
|
|
|
func TestRequirePaidTier(t *testing.T) {
|
|
if err := RequirePaidTier(Plan{PlanCode: "free"}); err == nil {
|
|
t.Error("free should require paid tier")
|
|
} else if err.Code != "PAID_TIER_REQUIRED" {
|
|
t.Errorf("want PAID_TIER_REQUIRED, got %s", err.Code)
|
|
}
|
|
if err := RequirePaidTier(Plan{PlanCode: "pro"}); err != nil {
|
|
t.Errorf("pro should pass, got %v", err)
|
|
}
|
|
if err := RequirePaidTier(Plan{PlanCode: "team"}); err != nil {
|
|
t.Errorf("team should pass, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNormalizePlatform(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"ios": true, "iOS": true, "ANDROID": true, "windows": true, "macos": true,
|
|
"linux": false, "": false, "blackberry": false,
|
|
}
|
|
for in, wantOK := range cases {
|
|
_, ok := normalizePlatform(in)
|
|
if ok != wantOK {
|
|
t.Errorf("normalizePlatform(%q) ok=%v, want %v", in, ok, wantOK)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeName(t *testing.T) {
|
|
if got := normalizeName("", "ios"); got != "ios" {
|
|
t.Errorf("empty name should fall back to platform, got %q", got)
|
|
}
|
|
if got := normalizeName(" iPhone 15 ", "ios"); got != "iPhone 15" {
|
|
t.Errorf("name should be trimmed, got %q", got)
|
|
}
|
|
long := strings.Repeat("名", 100)
|
|
if got := normalizeName(long, "ios"); len([]rune(got)) != 64 {
|
|
t.Errorf("name should be truncated to 64 runes, got %d", len([]rune(got)))
|
|
}
|
|
}
|
|
|
|
func TestUserIDContextRoundTrip(t *testing.T) {
|
|
ctx := WithUserID(context.Background(), 42)
|
|
id, ok := UserIDFromContext(ctx)
|
|
if !ok || id != 42 {
|
|
t.Fatalf("want 42/true, got %d/%v", id, ok)
|
|
}
|
|
if _, ok := UserIDFromContext(context.Background()); ok {
|
|
t.Error("empty context should yield ok=false")
|
|
}
|
|
// userID 0 is treated as absent.
|
|
if _, ok := UserIDFromContext(WithUserID(context.Background(), 0)); ok {
|
|
t.Error("userID 0 should be treated as absent")
|
|
}
|
|
}
|
|
|
|
func TestNoopRevokerRecordsCalls(t *testing.T) {
|
|
n := &NoopRevoker{}
|
|
_ = n.RevokeForUser(context.Background(), 7, "device_deleted")
|
|
if len(n.Calls) != 1 || n.Calls[0].UserID != 7 || n.Calls[0].Reason != "device_deleted" {
|
|
t.Fatalf("unexpected calls: %+v", n.Calls)
|
|
}
|
|
}
|