From 3957349dc82fc1f1ca5e55973675653d7f2b3add Mon Sep 17 00:00:00 2001
From: ShukeBta <272197458+ShukeBta@users.noreply.github.com>
Date: Wed, 24 Jun 2026 14:18:30 +0800
Subject: [PATCH] refactor: split bot feature tests
---
internal/service/bot_commands_test.go | 175 +++++++
internal/service/bot_device_policy_test.go | 169 ++++++
internal/service/bot_features_test.go | 525 +------------------
internal/service/bot_policy_commands_test.go | 124 +++++
internal/service/bot_registration_test.go | 94 ++++
5 files changed, 563 insertions(+), 524 deletions(-)
create mode 100644 internal/service/bot_commands_test.go
create mode 100644 internal/service/bot_device_policy_test.go
create mode 100644 internal/service/bot_policy_commands_test.go
create mode 100644 internal/service/bot_registration_test.go
diff --git a/internal/service/bot_commands_test.go b/internal/service/bot_commands_test.go
new file mode 100644
index 0000000..53a328f
--- /dev/null
+++ b/internal/service/bot_commands_test.go
@@ -0,0 +1,175 @@
+package service
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+)
+
+func TestBotRegistrationCommandUsesOpenRegQuota(t *testing.T) {
+ ctx := context.Background()
+ repos, bot := newBotTestService(t)
+ admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}
+ if err := repos.User.Create(ctx, admin); err != nil {
+ t.Fatal(err)
+ }
+ if err := repos.Setting.Set(ctx, SettingOpenRegEnabled, "false"); err != nil {
+ t.Fatal(err)
+ }
+ if err := repos.DB.Create(&model.TelegramBinding{
+ TelegramUserID: 9051,
+ TelegramName: "@root",
+ ChatID: 9051,
+ UserID: admin.ID,
+ }).Error; err != nil {
+ t.Fatal(err)
+ }
+ channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9051"}`}
+ msg := &TelegramMessage{From: TelegramUser{ID: 9051, Username: "root"}, Chat: TelegramChat{ID: 9051, Type: "private"}}
+
+ reply, err := bot.executeCommand(ctx, channel, msg, "/registration on 2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(reply.Text, "2 个名额") {
+ t.Fatalf("expected quota feedback, got %q", reply.Text)
+ }
+ capacity := bot.loadCapacity(ctx)
+ if !capacity.OpenRegOn || capacity.OpenRegLimit != 2 || capacity.OpenRegUsed != 0 {
+ t.Fatalf("registration command should open quota-aware registration, got %+v", capacity)
+ }
+}
+
+func TestBotUserCommandsAndAdminGate(t *testing.T) {
+ ctx := context.Background()
+ repos, bot := newBotTestService(t)
+ user := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true}
+ if err := repos.User.Create(ctx, user); err != nil {
+ t.Fatal(err)
+ }
+ if err := repos.DB.Create(&model.TelegramBinding{
+ TelegramUserID: 9101,
+ TelegramName: "@viewer",
+ ChatID: 9101,
+ UserID: user.ID,
+ }).Error; err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ if err := repos.UserDevice.Create(ctx, &model.UserDevice{
+ UserID: user.ID, DeviceID: "dev-1", DeviceName: "iPhone", Client: "Infuse", FirstSeenAt: now, LastSeenAt: now,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`}
+ msg := &TelegramMessage{From: TelegramUser{ID: 9101, Username: "viewer"}, Chat: TelegramChat{ID: 9101, Type: "private"}}
+
+ reply, err := bot.executeCommand(ctx, channel, msg, "/antishare on")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(reply.Text, "仅管理员") {
+ t.Fatalf("regular user should not manage policy, got %q", reply.Text)
+ }
+
+ reply, err = bot.executeCommand(ctx, channel, msg, "/devices")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(reply.Text, "我的登录设备") {
+ t.Fatalf("expected device list, got %q", reply.Text)
+ }
+
+ reply, err = bot.executeCommand(ctx, channel, msg, "/kick 1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(reply.Text, "已踢下线") {
+ t.Fatalf("expected kick feedback, got %q", reply.Text)
+ }
+ if kicked := bot.device; kicked != nil {
+ t.Fatal("test should not require wired device service")
+ }
+ if ok := NewDeviceService(zap.NewNop(), repos).IsDeviceKicked(ctx, user.ID, "dev-1"); !ok {
+ t.Fatal("device should be marked kicked")
+ }
+}
+
+func TestBotAdminCodeAndUserCommands(t *testing.T) {
+ ctx := context.Background()
+ repos, bot := newBotTestService(t)
+ admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}
+ user := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true}
+ if err := repos.User.Create(ctx, admin); err != nil {
+ t.Fatal(err)
+ }
+ if err := repos.User.Create(ctx, user); err != nil {
+ t.Fatal(err)
+ }
+ if err := repos.DB.Create(&model.TelegramBinding{
+ TelegramUserID: 9301,
+ TelegramName: "@root",
+ ChatID: 9301,
+ UserID: admin.ID,
+ }).Error; err != nil {
+ t.Fatal(err)
+ }
+ channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9301"}`}
+ msg := &TelegramMessage{From: TelegramUser{ID: 9301, Username: "root"}, Chat: TelegramChat{ID: 9301, Type: "private"}}
+
+ reply, err := bot.executeCommand(ctx, channel, msg, "/gencode renew 90 7")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(reply.Text, "已生成续期码") {
+ t.Fatalf("expected generated renew code, got %q", reply.Text)
+ }
+
+ reply, err = bot.executeCommand(ctx, channel, msg, "/renew_user viewer 30")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(reply.Text, "viewer") {
+ t.Fatalf("renew command should return user actions, got %q", reply.Text)
+ }
+ updated, _ := repos.User.FindByID(ctx, user.ID)
+ if updated.ExpiredAt == nil || updated.ExpiredAt.Before(time.Now()) {
+ t.Fatalf("renew_user should set future expiry, got %v", updated.ExpiredAt)
+ }
+
+ reply, err = bot.executeCommand(ctx, channel, msg, "/delete_user viewer")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(reply.Text, "需要确认") {
+ t.Fatalf("delete without confirm should be rejected, got %q", reply.Text)
+ }
+}
+
+func TestBotGroupMenuShowsAdminActionsOnlyForAdmins(t *testing.T) {
+ ctx := context.Background()
+ _, bot := newBotTestService(t)
+ channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9301","group_chat_id":"-1001"}`}
+ adminMsg := &TelegramMessage{From: TelegramUser{ID: 9301, Username: "admin"}, Chat: TelegramChat{ID: -1001, Type: "group"}}
+ reply, err := bot.executeCommand(ctx, channel, adminMsg, "/menu")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(reply.Text, "管理员入口") || len(reply.Buttons) == 0 {
+ t.Fatalf("admin group menu should expose management actions, got %#v", reply)
+ }
+
+ userMsg := &TelegramMessage{From: TelegramUser{ID: 9302, Username: "user"}, Chat: TelegramChat{ID: -1001, Type: "group"}}
+ reply, err = bot.executeCommand(ctx, channel, userMsg, "/menu")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(reply.Text, "管理员入口") {
+ t.Fatalf("non-admin group menu must not expose management actions, got %#v", reply)
+ }
+}
diff --git a/internal/service/bot_device_policy_test.go b/internal/service/bot_device_policy_test.go
new file mode 100644
index 0000000..5521476
--- /dev/null
+++ b/internal/service/bot_device_policy_test.go
@@ -0,0 +1,169 @@
+package service
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+)
+
+func TestDeviceKickAndConcurrency(t *testing.T) {
+ ctx := context.Background()
+ repos, _ := newBotTestService(t)
+ dev := NewDeviceService(zap.NewNop(), repos)
+ u := &model.User{Username: "carol", PasswordHash: "x", Role: "user", IsActive: true}
+ if err := repos.User.Create(ctx, u); err != nil {
+ t.Fatal(err)
+ }
+
+ dev.RecordLogin(ctx, u.ID, "dev-1", "iPhone", "Infuse", "1.2.3.4")
+ dev.RecordPlayback(ctx, u.ID, "dev-1", "iPhone", "Infuse")
+ devices, _ := dev.ListDevices(ctx, u.ID)
+ if len(devices) != 1 {
+ t.Fatalf("expected 1 device, got %d", len(devices))
+ }
+
+ // 踢下线后命中 kicked
+ if err := dev.KickDevice(ctx, u.ID, "dev-1"); err != nil {
+ t.Fatal(err)
+ }
+ if !dev.IsDeviceKicked(ctx, u.ID, "dev-1") {
+ t.Fatal("device should be kicked")
+ }
+ // 重新登录清除 kicked
+ dev.RecordLogin(ctx, u.ID, "dev-1", "iPhone", "Infuse", "1.2.3.4")
+ if dev.IsDeviceKicked(ctx, u.ID, "dev-1") {
+ t.Fatal("re-login should clear kicked flag")
+ }
+
+ // 并发播放计数
+ now := time.Now()
+ for i, id := range []string{"d1", "d2", "d3", "d4"} {
+ _ = repos.UserDevice.Create(ctx, &model.UserDevice{
+ UserID: u.ID, DeviceID: id, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now,
+ })
+ _ = i
+ }
+ n, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, now.Add(-time.Minute))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n < 4 {
+ t.Fatalf("expected >=4 concurrent playing, got %d", n)
+ }
+}
+
+func TestTerminalDeviceLimitDeduplicatesAppsOnSameDevice(t *testing.T) {
+ ctx := context.Background()
+ repos, _ := newBotTestService(t)
+ dev := NewDeviceService(zap.NewNop(), repos)
+ u := &model.User{Username: "device-user", PasswordHash: "x", Role: "user", IsActive: true}
+ if err := repos.User.Create(ctx, u); err != nil {
+ t.Fatal(err)
+ }
+ if err := repos.Setting.Set(ctx, SettingAntiShareEnabled, "true"); err != nil {
+ t.Fatal(err)
+ }
+ if err := repos.Setting.Set(ctx, SettingMaxLoggedClients, "3"); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, login := range []struct {
+ id string
+ name string
+ client string
+ }{
+ {id: "phone-infuse", name: "iPhone", client: "Infuse"},
+ {id: "phone-emby", name: " iPhone ", client: "Emby"},
+ {id: "phone-jellyfin", name: "IPHONE", client: "Jellyfin"},
+ } {
+ dev.RecordLogin(ctx, u.ID, login.id, login.name, login.client, "1.2.3.4")
+ }
+ count, err := repos.UserDevice.CountActiveClients(ctx, u.ID, time.Now().Add(-24*time.Hour))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 1 {
+ t.Fatalf("same terminal through multiple apps should count as 1, got %d", count)
+ }
+ got, _ := repos.User.FindByID(ctx, u.ID)
+ if !got.IsActive {
+ t.Fatal("same terminal through multiple apps must not disable the account")
+ }
+
+ dev.RecordLogin(ctx, u.ID, "tablet", "iPad", "Infuse", "1.2.3.4")
+ dev.RecordLogin(ctx, u.ID, "pc", "Windows PC", "Browser", "1.2.3.4")
+ count, err = repos.UserDevice.CountActiveClients(ctx, u.ID, time.Now().Add(-24*time.Hour))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 3 {
+ t.Fatalf("three distinct terminal devices should count as 3, got %d", count)
+ }
+ got, _ = repos.User.FindByID(ctx, u.ID)
+ if !got.IsActive {
+ t.Fatal("device limit is inclusive; 3 of 3 terminals should stay active")
+ }
+
+ dev.RecordLogin(ctx, u.ID, "tv", "Apple TV", "Emby", "1.2.3.4")
+ got, _ = repos.User.FindByID(ctx, u.ID)
+ if got.IsActive {
+ t.Fatal("fourth distinct terminal should disable the account")
+ }
+}
+
+func TestConcurrentPlaybackDeduplicatesAppsOnSameDevice(t *testing.T) {
+ ctx := context.Background()
+ repos, _ := newBotTestService(t)
+ u := &model.User{Username: "play-user", PasswordHash: "x", Role: "user", IsActive: true}
+ if err := repos.User.Create(ctx, u); err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ fp := fingerprint("Infuse", "Living Room TV")
+ for _, row := range []model.UserDevice{
+ {UserID: u.ID, DeviceID: "tv-emby", DeviceName: "Living Room TV", Client: "Emby", Fingerprint: fp, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now},
+ {UserID: u.ID, DeviceID: "tv-jellyfin", DeviceName: "living room tv", Client: "Jellyfin", Fingerprint: fp, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now},
+ {UserID: u.ID, DeviceID: "phone", DeviceName: "iPhone", Client: "Infuse", Fingerprint: fingerprint("Infuse", "iPhone"), FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now},
+ } {
+ if err := repos.UserDevice.Create(ctx, &row); err != nil {
+ t.Fatal(err)
+ }
+ }
+ count, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, now.Add(-time.Minute))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 2 {
+ t.Fatalf("same terminal playback through multiple apps should count as 1 terminal, got %d", count)
+ }
+}
+
+func TestProtectedAdminNeverViolated(t *testing.T) {
+ ctx := context.Background()
+ repos, _ := newBotTestService(t)
+ dev := NewDeviceService(zap.NewNop(), repos)
+ admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}
+ if err := repos.User.Create(ctx, admin); err != nil {
+ t.Fatal(err)
+ }
+ _ = repos.Setting.Set(ctx, SettingAntiShareEnabled, "true")
+ cfg := loadBotConfig(ctx, repos)
+ // 多次违规也不应删除/警告/禁用管理员
+ for i := 0; i < 5; i++ {
+ dev.registerFingerprintWarning(ctx, admin.ID, "test", cfg)
+ }
+ got, _ := repos.User.FindByID(ctx, admin.ID)
+ if got == nil {
+ t.Fatal("admin must never be auto-deleted")
+ }
+ if !got.IsActive {
+ t.Fatal("admin must never be auto-disabled")
+ }
+ if got.ShareWarnings != 0 {
+ t.Fatalf("admin should accrue no warnings, got %d", got.ShareWarnings)
+ }
+}
diff --git a/internal/service/bot_features_test.go b/internal/service/bot_features_test.go
index 8708f89..d5b5758 100644
--- a/internal/service/bot_features_test.go
+++ b/internal/service/bot_features_test.go
@@ -2,15 +2,13 @@ package service
import (
"context"
- "strings"
"testing"
"time"
- "go.uber.org/zap"
-
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
+ "go.uber.org/zap"
)
func newBotTestService(t *testing.T) (*repository.Container, *TelegramBotService) {
@@ -135,524 +133,3 @@ func TestSignInStreak(t *testing.T) {
t.Fatalf("broken streak should reset to 1: %+v", res)
}
}
-
-func TestRegistrationCodeRedeemOnce(t *testing.T) {
- ctx := context.Background()
- repos, bot := newBotTestService(t)
-
- code, err := bot.generateCode(ctx, model.RegistrationCodeRenew, 30, 0, "")
- if err != nil {
- t.Fatal(err)
- }
- // 首次校验通过
- rc, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew)
- if rc == nil {
- t.Fatalf("expected valid code, got msg=%q", msg)
- }
- // 标记使用后不可再用
- if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-1"); err != nil {
- t.Fatal(err)
- }
- if _, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew); msg == "" {
- t.Fatal("used code must not validate again")
- }
- // 第二次 MarkUsed 应失败(防止双花)
- if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-2"); err == nil {
- t.Fatal("double-spend should be rejected")
- }
- // 类型不匹配应被拒
- reg, _ := bot.generateCode(ctx, model.RegistrationCodeRegister, 0, 0, "")
- if _, msg := bot.lookupRedeemableCode(ctx, reg.Code, model.RegistrationCodeRenew); msg == "" {
- t.Fatal("register code should not validate as renew")
- }
-}
-
-func TestRegistrationCodeCanBeGeneratedForMultipleUses(t *testing.T) {
- ctx := context.Background()
- repos, bot := newBotTestService(t)
-
- code, err := bot.generateCodeWithUses(ctx, model.RegistrationCodeRenew, 30, 0, 2, "")
- if err != nil {
- t.Fatal(err)
- }
- rc, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew)
- if rc == nil {
- t.Fatalf("expected valid code, got msg=%q", msg)
- }
- if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-1"); err != nil {
- t.Fatal(err)
- }
- rc, msg = bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew)
- if rc == nil {
- t.Fatalf("code should remain redeemable after first use, got msg=%q", msg)
- }
- if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-2"); err != nil {
- t.Fatal(err)
- }
- if _, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew); msg == "" {
- t.Fatal("code should be exhausted after max uses")
- }
- var used model.RegistrationCode
- if err := repos.DB.Where("id = ?", code.ID).First(&used).Error; err != nil {
- t.Fatal(err)
- }
- if used.UsedCount != 2 || used.UsedAt == nil {
- t.Fatalf("expected exhausted code with used_count=2, got %+v", used)
- }
-}
-
-func TestRenewalClearsExpiry(t *testing.T) {
- ctx := context.Background()
- repos, bot := newBotTestService(t)
- past := time.Now().Add(-time.Hour)
- u := &model.User{Username: "bob", PasswordHash: "x", Role: "user", IsActive: false, ExpiredAt: &past}
- if err := repos.User.Create(ctx, u); err != nil {
- t.Fatal(err)
- }
- if err := bot.applyRenewal(ctx, u.ID, 30); err != nil {
- t.Fatal(err)
- }
- got, _ := repos.User.FindByID(ctx, u.ID)
- if !got.IsActive {
- t.Fatal("renewal should re-activate account")
- }
- if got.ExpiredAt == nil || got.ExpiredAt.Before(time.Now()) {
- t.Fatalf("renewal should set future expiry, got %v", got.ExpiredAt)
- }
-}
-
-func TestDeviceKickAndConcurrency(t *testing.T) {
- ctx := context.Background()
- repos, _ := newBotTestService(t)
- dev := NewDeviceService(zap.NewNop(), repos)
- u := &model.User{Username: "carol", PasswordHash: "x", Role: "user", IsActive: true}
- if err := repos.User.Create(ctx, u); err != nil {
- t.Fatal(err)
- }
-
- dev.RecordLogin(ctx, u.ID, "dev-1", "iPhone", "Infuse", "1.2.3.4")
- dev.RecordPlayback(ctx, u.ID, "dev-1", "iPhone", "Infuse")
- devices, _ := dev.ListDevices(ctx, u.ID)
- if len(devices) != 1 {
- t.Fatalf("expected 1 device, got %d", len(devices))
- }
-
- // 踢下线后命中 kicked
- if err := dev.KickDevice(ctx, u.ID, "dev-1"); err != nil {
- t.Fatal(err)
- }
- if !dev.IsDeviceKicked(ctx, u.ID, "dev-1") {
- t.Fatal("device should be kicked")
- }
- // 重新登录清除 kicked
- dev.RecordLogin(ctx, u.ID, "dev-1", "iPhone", "Infuse", "1.2.3.4")
- if dev.IsDeviceKicked(ctx, u.ID, "dev-1") {
- t.Fatal("re-login should clear kicked flag")
- }
-
- // 并发播放计数
- now := time.Now()
- for i, id := range []string{"d1", "d2", "d3", "d4"} {
- _ = repos.UserDevice.Create(ctx, &model.UserDevice{
- UserID: u.ID, DeviceID: id, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now,
- })
- _ = i
- }
- n, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, now.Add(-time.Minute))
- if err != nil {
- t.Fatal(err)
- }
- if n < 4 {
- t.Fatalf("expected >=4 concurrent playing, got %d", n)
- }
-}
-
-func TestTerminalDeviceLimitDeduplicatesAppsOnSameDevice(t *testing.T) {
- ctx := context.Background()
- repos, _ := newBotTestService(t)
- dev := NewDeviceService(zap.NewNop(), repos)
- u := &model.User{Username: "device-user", PasswordHash: "x", Role: "user", IsActive: true}
- if err := repos.User.Create(ctx, u); err != nil {
- t.Fatal(err)
- }
- if err := repos.Setting.Set(ctx, SettingAntiShareEnabled, "true"); err != nil {
- t.Fatal(err)
- }
- if err := repos.Setting.Set(ctx, SettingMaxLoggedClients, "3"); err != nil {
- t.Fatal(err)
- }
-
- for _, login := range []struct {
- id string
- name string
- client string
- }{
- {id: "phone-infuse", name: "iPhone", client: "Infuse"},
- {id: "phone-emby", name: " iPhone ", client: "Emby"},
- {id: "phone-jellyfin", name: "IPHONE", client: "Jellyfin"},
- } {
- dev.RecordLogin(ctx, u.ID, login.id, login.name, login.client, "1.2.3.4")
- }
- count, err := repos.UserDevice.CountActiveClients(ctx, u.ID, time.Now().Add(-24*time.Hour))
- if err != nil {
- t.Fatal(err)
- }
- if count != 1 {
- t.Fatalf("same terminal through multiple apps should count as 1, got %d", count)
- }
- got, _ := repos.User.FindByID(ctx, u.ID)
- if !got.IsActive {
- t.Fatal("same terminal through multiple apps must not disable the account")
- }
-
- dev.RecordLogin(ctx, u.ID, "tablet", "iPad", "Infuse", "1.2.3.4")
- dev.RecordLogin(ctx, u.ID, "pc", "Windows PC", "Browser", "1.2.3.4")
- count, err = repos.UserDevice.CountActiveClients(ctx, u.ID, time.Now().Add(-24*time.Hour))
- if err != nil {
- t.Fatal(err)
- }
- if count != 3 {
- t.Fatalf("three distinct terminal devices should count as 3, got %d", count)
- }
- got, _ = repos.User.FindByID(ctx, u.ID)
- if !got.IsActive {
- t.Fatal("device limit is inclusive; 3 of 3 terminals should stay active")
- }
-
- dev.RecordLogin(ctx, u.ID, "tv", "Apple TV", "Emby", "1.2.3.4")
- got, _ = repos.User.FindByID(ctx, u.ID)
- if got.IsActive {
- t.Fatal("fourth distinct terminal should disable the account")
- }
-}
-
-func TestConcurrentPlaybackDeduplicatesAppsOnSameDevice(t *testing.T) {
- ctx := context.Background()
- repos, _ := newBotTestService(t)
- u := &model.User{Username: "play-user", PasswordHash: "x", Role: "user", IsActive: true}
- if err := repos.User.Create(ctx, u); err != nil {
- t.Fatal(err)
- }
- now := time.Now()
- fp := fingerprint("Infuse", "Living Room TV")
- for _, row := range []model.UserDevice{
- {UserID: u.ID, DeviceID: "tv-emby", DeviceName: "Living Room TV", Client: "Emby", Fingerprint: fp, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now},
- {UserID: u.ID, DeviceID: "tv-jellyfin", DeviceName: "living room tv", Client: "Jellyfin", Fingerprint: fp, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now},
- {UserID: u.ID, DeviceID: "phone", DeviceName: "iPhone", Client: "Infuse", Fingerprint: fingerprint("Infuse", "iPhone"), FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now},
- } {
- if err := repos.UserDevice.Create(ctx, &row); err != nil {
- t.Fatal(err)
- }
- }
- count, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, now.Add(-time.Minute))
- if err != nil {
- t.Fatal(err)
- }
- if count != 2 {
- t.Fatalf("same terminal playback through multiple apps should count as 1 terminal, got %d", count)
- }
-}
-
-func TestProtectedAdminNeverViolated(t *testing.T) {
- ctx := context.Background()
- repos, _ := newBotTestService(t)
- dev := NewDeviceService(zap.NewNop(), repos)
- admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}
- if err := repos.User.Create(ctx, admin); err != nil {
- t.Fatal(err)
- }
- _ = repos.Setting.Set(ctx, SettingAntiShareEnabled, "true")
- cfg := loadBotConfig(ctx, repos)
- // 多次违规也不应删除/警告/禁用管理员
- for i := 0; i < 5; i++ {
- dev.registerFingerprintWarning(ctx, admin.ID, "test", cfg)
- }
- got, _ := repos.User.FindByID(ctx, admin.ID)
- if got == nil {
- t.Fatal("admin must never be auto-deleted")
- }
- if !got.IsActive {
- t.Fatal("admin must never be auto-disabled")
- }
- if got.ShareWarnings != 0 {
- t.Fatalf("admin should accrue no warnings, got %d", got.ShareWarnings)
- }
-}
-
-func TestBotAdminCommandsManageDevicePolicy(t *testing.T) {
- ctx := context.Background()
- repos, bot := newBotTestService(t)
- admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}
- if err := repos.User.Create(ctx, admin); err != nil {
- t.Fatal(err)
- }
- if err := repos.DB.Create(&model.TelegramBinding{
- TelegramUserID: 9001,
- TelegramName: "@root",
- ChatID: 9001,
- UserID: admin.ID,
- }).Error; err != nil {
- t.Fatal(err)
- }
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`}
- msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}}
-
- reply, err := bot.executeCommand(ctx, channel, msg, "/antishare on play=4 login=5 warn=3")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "防共享:已开启") {
- t.Fatalf("expected antishare enabled reply, got %q", reply.Text)
- }
- cfg := loadBotConfig(ctx, repos)
- if !cfg.AntiShareEnabled || cfg.MaxConcurrentPlay != 4 || cfg.MaxLoggedClients != 5 || cfg.WarnThreshold != 3 {
- t.Fatalf("unexpected device policy: %+v", cfg)
- }
-
- reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_mode count 2")
- if err != nil {
- t.Fatal(err)
- }
- cfg = loadBotConfig(ctx, repos)
- if cfg.AccountCleanupKeepMode != "any" || cfg.AccountCleanupRequiredCount != 1 {
- t.Fatalf("unexpected cleanup mode: %+v; reply=%q", cfg, reply.Text)
- }
- if !strings.Contains(reply.Text, "满足任意一条") {
- t.Fatalf("cleanup mode should explain fixed any-rule policy, got %q", reply.Text)
- }
-
- reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule add recent_login login_7d 七天内登录 7")
- if err != nil {
- t.Fatal(err)
- }
- cfg = loadBotConfig(ctx, repos)
- found := false
- for _, rule := range cfg.AccountCleanupRules {
- if rule.ID == "login_7d" && rule.Type == "recent_login" && rule.WindowDaysMax == 7 {
- found = true
- }
- }
- if !found {
- t.Fatalf("cleanup rule not added; reply=%q rules=%+v", reply.Text, cfg.AccountCleanupRules)
- }
-
- reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule edit recent_login login_7d 十四天内登录 14")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "已更新规则") {
- t.Fatalf("expected cleanup rule update reply, got %q", reply.Text)
- }
- cfg = loadBotConfig(ctx, repos)
- matches := 0
- for _, rule := range cfg.AccountCleanupRules {
- if rule.ID == "login_7d" {
- matches++
- if rule.Type != "recent_login" || rule.WindowDaysMax != 14 || rule.Name != "十四天内登录" {
- t.Fatalf("cleanup rule should be updated in place, got %+v", rule)
- }
- }
- }
- if matches != 1 {
- t.Fatalf("cleanup rule update should not create duplicates, got %d rules=%+v", matches, cfg.AccountCleanupRules)
- }
-
- reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule 修改 recent_login login_7d 二十一天内登录 21")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "已更新规则") {
- t.Fatalf("expected Chinese cleanup rule update reply, got %q", reply.Text)
- }
- cfg = loadBotConfig(ctx, repos)
- matches = 0
- for _, rule := range cfg.AccountCleanupRules {
- if rule.ID == "login_7d" {
- matches++
- if rule.WindowDaysMax != 21 || rule.Name != "二十一天内登录" {
- t.Fatalf("Chinese cleanup rule update should update values, got %+v", rule)
- }
- }
- }
- if matches != 1 {
- t.Fatalf("Chinese cleanup rule update should not create duplicates, got %d rules=%+v", matches, cfg.AccountCleanupRules)
- }
-
- reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule add account_age_grace new_7d 7")
- if err != nil {
- t.Fatal(err)
- }
- cfg = loadBotConfig(ctx, repos)
- found = false
- for _, rule := range cfg.AccountCleanupRules {
- if rule.ID == "new_7d" && rule.Type == "account_age_grace" && rule.MinCount == 7 {
- found = true
- }
- }
- if !found {
- t.Fatalf("cleanup shorthand rule not added; reply=%q rules=%+v", reply.Text, cfg.AccountCleanupRules)
- }
-}
-
-func TestBotRegistrationCommandUsesOpenRegQuota(t *testing.T) {
- ctx := context.Background()
- repos, bot := newBotTestService(t)
- admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}
- if err := repos.User.Create(ctx, admin); err != nil {
- t.Fatal(err)
- }
- if err := repos.Setting.Set(ctx, SettingOpenRegEnabled, "false"); err != nil {
- t.Fatal(err)
- }
- if err := repos.DB.Create(&model.TelegramBinding{
- TelegramUserID: 9051,
- TelegramName: "@root",
- ChatID: 9051,
- UserID: admin.ID,
- }).Error; err != nil {
- t.Fatal(err)
- }
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9051"}`}
- msg := &TelegramMessage{From: TelegramUser{ID: 9051, Username: "root"}, Chat: TelegramChat{ID: 9051, Type: "private"}}
-
- reply, err := bot.executeCommand(ctx, channel, msg, "/registration on 2")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "2 个名额") {
- t.Fatalf("expected quota feedback, got %q", reply.Text)
- }
- capacity := bot.loadCapacity(ctx)
- if !capacity.OpenRegOn || capacity.OpenRegLimit != 2 || capacity.OpenRegUsed != 0 {
- t.Fatalf("registration command should open quota-aware registration, got %+v", capacity)
- }
-}
-
-func TestBotUserCommandsAndAdminGate(t *testing.T) {
- ctx := context.Background()
- repos, bot := newBotTestService(t)
- user := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true}
- if err := repos.User.Create(ctx, user); err != nil {
- t.Fatal(err)
- }
- if err := repos.DB.Create(&model.TelegramBinding{
- TelegramUserID: 9101,
- TelegramName: "@viewer",
- ChatID: 9101,
- UserID: user.ID,
- }).Error; err != nil {
- t.Fatal(err)
- }
- now := time.Now()
- if err := repos.UserDevice.Create(ctx, &model.UserDevice{
- UserID: user.ID, DeviceID: "dev-1", DeviceName: "iPhone", Client: "Infuse", FirstSeenAt: now, LastSeenAt: now,
- }); err != nil {
- t.Fatal(err)
- }
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`}
- msg := &TelegramMessage{From: TelegramUser{ID: 9101, Username: "viewer"}, Chat: TelegramChat{ID: 9101, Type: "private"}}
-
- reply, err := bot.executeCommand(ctx, channel, msg, "/antishare on")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "仅管理员") {
- t.Fatalf("regular user should not manage policy, got %q", reply.Text)
- }
-
- reply, err = bot.executeCommand(ctx, channel, msg, "/devices")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "我的登录设备") {
- t.Fatalf("expected device list, got %q", reply.Text)
- }
-
- reply, err = bot.executeCommand(ctx, channel, msg, "/kick 1")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "已踢下线") {
- t.Fatalf("expected kick feedback, got %q", reply.Text)
- }
- if kicked := bot.device; kicked != nil {
- t.Fatal("test should not require wired device service")
- }
- if ok := NewDeviceService(zap.NewNop(), repos).IsDeviceKicked(ctx, user.ID, "dev-1"); !ok {
- t.Fatal("device should be marked kicked")
- }
-}
-
-func TestBotAdminCodeAndUserCommands(t *testing.T) {
- ctx := context.Background()
- repos, bot := newBotTestService(t)
- admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}
- user := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true}
- if err := repos.User.Create(ctx, admin); err != nil {
- t.Fatal(err)
- }
- if err := repos.User.Create(ctx, user); err != nil {
- t.Fatal(err)
- }
- if err := repos.DB.Create(&model.TelegramBinding{
- TelegramUserID: 9301,
- TelegramName: "@root",
- ChatID: 9301,
- UserID: admin.ID,
- }).Error; err != nil {
- t.Fatal(err)
- }
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9301"}`}
- msg := &TelegramMessage{From: TelegramUser{ID: 9301, Username: "root"}, Chat: TelegramChat{ID: 9301, Type: "private"}}
-
- reply, err := bot.executeCommand(ctx, channel, msg, "/gencode renew 90 7")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "已生成续期码") {
- t.Fatalf("expected generated renew code, got %q", reply.Text)
- }
-
- reply, err = bot.executeCommand(ctx, channel, msg, "/renew_user viewer 30")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "viewer") {
- t.Fatalf("renew command should return user actions, got %q", reply.Text)
- }
- updated, _ := repos.User.FindByID(ctx, user.ID)
- if updated.ExpiredAt == nil || updated.ExpiredAt.Before(time.Now()) {
- t.Fatalf("renew_user should set future expiry, got %v", updated.ExpiredAt)
- }
-
- reply, err = bot.executeCommand(ctx, channel, msg, "/delete_user viewer")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "需要确认") {
- t.Fatalf("delete without confirm should be rejected, got %q", reply.Text)
- }
-}
-
-func TestBotGroupMenuShowsAdminActionsOnlyForAdmins(t *testing.T) {
- ctx := context.Background()
- _, bot := newBotTestService(t)
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9301","group_chat_id":"-1001"}`}
- adminMsg := &TelegramMessage{From: TelegramUser{ID: 9301, Username: "admin"}, Chat: TelegramChat{ID: -1001, Type: "group"}}
- reply, err := bot.executeCommand(ctx, channel, adminMsg, "/menu")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "管理员入口") || len(reply.Buttons) == 0 {
- t.Fatalf("admin group menu should expose management actions, got %#v", reply)
- }
-
- userMsg := &TelegramMessage{From: TelegramUser{ID: 9302, Username: "user"}, Chat: TelegramChat{ID: -1001, Type: "group"}}
- reply, err = bot.executeCommand(ctx, channel, userMsg, "/menu")
- if err != nil {
- t.Fatal(err)
- }
- if strings.Contains(reply.Text, "管理员入口") {
- t.Fatalf("non-admin group menu must not expose management actions, got %#v", reply)
- }
-}
diff --git a/internal/service/bot_policy_commands_test.go b/internal/service/bot_policy_commands_test.go
new file mode 100644
index 0000000..4ae8faf
--- /dev/null
+++ b/internal/service/bot_policy_commands_test.go
@@ -0,0 +1,124 @@
+package service
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+)
+
+func TestBotAdminCommandsManageDevicePolicy(t *testing.T) {
+ ctx := context.Background()
+ repos, bot := newBotTestService(t)
+ admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}
+ if err := repos.User.Create(ctx, admin); err != nil {
+ t.Fatal(err)
+ }
+ if err := repos.DB.Create(&model.TelegramBinding{
+ TelegramUserID: 9001,
+ TelegramName: "@root",
+ ChatID: 9001,
+ UserID: admin.ID,
+ }).Error; err != nil {
+ t.Fatal(err)
+ }
+ channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`}
+ msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}}
+
+ reply, err := bot.executeCommand(ctx, channel, msg, "/antishare on play=4 login=5 warn=3")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(reply.Text, "防共享:已开启") {
+ t.Fatalf("expected antishare enabled reply, got %q", reply.Text)
+ }
+ cfg := loadBotConfig(ctx, repos)
+ if !cfg.AntiShareEnabled || cfg.MaxConcurrentPlay != 4 || cfg.MaxLoggedClients != 5 || cfg.WarnThreshold != 3 {
+ t.Fatalf("unexpected device policy: %+v", cfg)
+ }
+
+ reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_mode count 2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg = loadBotConfig(ctx, repos)
+ if cfg.AccountCleanupKeepMode != "any" || cfg.AccountCleanupRequiredCount != 1 {
+ t.Fatalf("unexpected cleanup mode: %+v; reply=%q", cfg, reply.Text)
+ }
+ if !strings.Contains(reply.Text, "满足任意一条") {
+ t.Fatalf("cleanup mode should explain fixed any-rule policy, got %q", reply.Text)
+ }
+
+ reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule add recent_login login_7d 七天内登录 7")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg = loadBotConfig(ctx, repos)
+ found := false
+ for _, rule := range cfg.AccountCleanupRules {
+ if rule.ID == "login_7d" && rule.Type == "recent_login" && rule.WindowDaysMax == 7 {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("cleanup rule not added; reply=%q rules=%+v", reply.Text, cfg.AccountCleanupRules)
+ }
+
+ reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule edit recent_login login_7d 十四天内登录 14")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(reply.Text, "已更新规则") {
+ t.Fatalf("expected cleanup rule update reply, got %q", reply.Text)
+ }
+ cfg = loadBotConfig(ctx, repos)
+ matches := 0
+ for _, rule := range cfg.AccountCleanupRules {
+ if rule.ID == "login_7d" {
+ matches++
+ if rule.Type != "recent_login" || rule.WindowDaysMax != 14 || rule.Name != "十四天内登录" {
+ t.Fatalf("cleanup rule should be updated in place, got %+v", rule)
+ }
+ }
+ }
+ if matches != 1 {
+ t.Fatalf("cleanup rule update should not create duplicates, got %d rules=%+v", matches, cfg.AccountCleanupRules)
+ }
+
+ reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule 修改 recent_login login_7d 二十一天内登录 21")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(reply.Text, "已更新规则") {
+ t.Fatalf("expected Chinese cleanup rule update reply, got %q", reply.Text)
+ }
+ cfg = loadBotConfig(ctx, repos)
+ matches = 0
+ for _, rule := range cfg.AccountCleanupRules {
+ if rule.ID == "login_7d" {
+ matches++
+ if rule.WindowDaysMax != 21 || rule.Name != "二十一天内登录" {
+ t.Fatalf("Chinese cleanup rule update should update values, got %+v", rule)
+ }
+ }
+ }
+ if matches != 1 {
+ t.Fatalf("Chinese cleanup rule update should not create duplicates, got %d rules=%+v", matches, cfg.AccountCleanupRules)
+ }
+
+ reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule add account_age_grace new_7d 7")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg = loadBotConfig(ctx, repos)
+ found = false
+ for _, rule := range cfg.AccountCleanupRules {
+ if rule.ID == "new_7d" && rule.Type == "account_age_grace" && rule.MinCount == 7 {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("cleanup shorthand rule not added; reply=%q rules=%+v", reply.Text, cfg.AccountCleanupRules)
+ }
+}
diff --git a/internal/service/bot_registration_test.go b/internal/service/bot_registration_test.go
new file mode 100644
index 0000000..5ea91b1
--- /dev/null
+++ b/internal/service/bot_registration_test.go
@@ -0,0 +1,94 @@
+package service
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+)
+
+func TestRegistrationCodeRedeemOnce(t *testing.T) {
+ ctx := context.Background()
+ repos, bot := newBotTestService(t)
+
+ code, err := bot.generateCode(ctx, model.RegistrationCodeRenew, 30, 0, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // 首次校验通过
+ rc, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew)
+ if rc == nil {
+ t.Fatalf("expected valid code, got msg=%q", msg)
+ }
+ // 标记使用后不可再用
+ if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-1"); err != nil {
+ t.Fatal(err)
+ }
+ if _, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew); msg == "" {
+ t.Fatal("used code must not validate again")
+ }
+ // 第二次 MarkUsed 应失败(防止双花)
+ if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-2"); err == nil {
+ t.Fatal("double-spend should be rejected")
+ }
+ // 类型不匹配应被拒
+ reg, _ := bot.generateCode(ctx, model.RegistrationCodeRegister, 0, 0, "")
+ if _, msg := bot.lookupRedeemableCode(ctx, reg.Code, model.RegistrationCodeRenew); msg == "" {
+ t.Fatal("register code should not validate as renew")
+ }
+}
+
+func TestRegistrationCodeCanBeGeneratedForMultipleUses(t *testing.T) {
+ ctx := context.Background()
+ repos, bot := newBotTestService(t)
+
+ code, err := bot.generateCodeWithUses(ctx, model.RegistrationCodeRenew, 30, 0, 2, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ rc, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew)
+ if rc == nil {
+ t.Fatalf("expected valid code, got msg=%q", msg)
+ }
+ if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-1"); err != nil {
+ t.Fatal(err)
+ }
+ rc, msg = bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew)
+ if rc == nil {
+ t.Fatalf("code should remain redeemable after first use, got msg=%q", msg)
+ }
+ if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-2"); err != nil {
+ t.Fatal(err)
+ }
+ if _, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew); msg == "" {
+ t.Fatal("code should be exhausted after max uses")
+ }
+ var used model.RegistrationCode
+ if err := repos.DB.Where("id = ?", code.ID).First(&used).Error; err != nil {
+ t.Fatal(err)
+ }
+ if used.UsedCount != 2 || used.UsedAt == nil {
+ t.Fatalf("expected exhausted code with used_count=2, got %+v", used)
+ }
+}
+
+func TestRenewalClearsExpiry(t *testing.T) {
+ ctx := context.Background()
+ repos, bot := newBotTestService(t)
+ past := time.Now().Add(-time.Hour)
+ u := &model.User{Username: "bob", PasswordHash: "x", Role: "user", IsActive: false, ExpiredAt: &past}
+ if err := repos.User.Create(ctx, u); err != nil {
+ t.Fatal(err)
+ }
+ if err := bot.applyRenewal(ctx, u.ID, 30); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := repos.User.FindByID(ctx, u.ID)
+ if !got.IsActive {
+ t.Fatal("renewal should re-activate account")
+ }
+ if got.ExpiredAt == nil || got.ExpiredAt.Before(time.Now()) {
+ t.Fatalf("renewal should set future expiry, got %v", got.ExpiredAt)
+ }
+}