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