Files
sub2api/backend/internal/service/setting_service_public_test.go
T
feeeei 9d475f9edb feat(settings): 新增「站点类型」三态开关,关闭订阅后用户端收起所有「订阅」入口与文案
后台功能开关页新增「站点类型」单选:充值 & 订阅(默认,即现状)/ 仅充值 / 仅订阅。
它由两个后端开关派生并在保存时同时写回:
- subscription_enabled(新增;默认 true,缺省/空值视为开启,仅显式 false 关闭),按既有
  开关模式贯通常量、公共设置、SSR 注入载荷、默认值、更新写入、DTO、审计 diff 与前端注册表;
- payment_balance_disabled(支付配置里既有的 BALANCE_PAYMENT_DISABLED,此前只能走接口/数据库
  改),补进公共设置与注入载荷,让用户端在任何鉴权请求之前就能推导站点类型。
两者都关闭是接口层可表达但 UI 不提供的组合,前端归为「仅充值」,后台保存时会把余额充值重新打开。

「仅充值」时的表现:
- 用户侧边栏隐藏「我的订阅」,「充值/订阅」入口收窄为「充值」;管理端侧边栏隐藏「订阅管理」
  入口(页面仍可通过地址访问);
- 购买页不再渲染「订阅」tab,只剩单个 tab 时顶部切换器随之隐藏;tab 列表变化时当前 tab
  自动收敛到可用项(关闭订阅回到充值、仅订阅站点重新打开订阅进入订阅),列表为空时展示
  「充值与订阅均暂未开放」而不是一个必定被后端拒绝的充值表单;
- /purchase 的页头标题/描述与 document.title 改走 resolveRouteMetaKeys 按站点类型切换;
- /subscriptions 路由新增 requiresSubscription 守卫,直达时重定向到仪表盘;
- 顶栏订阅进度徽章不再挂载,App.vue、PaymentView 与徽章组件都不再预加载/轮询订阅接口;
- 用量页隐藏「计费类型(余额/订阅)」筛选,Key 用量公开页「订阅类型」行改为「计费方式」。
「仅订阅」时侧边栏入口与页头显示为「订阅」,购买页只剩订阅 tab(余额下单由后端既有逻辑拒绝)。

购买订阅接口、已有订阅的计费、兑换码发放订阅与管理端订阅管理页面本身均不受影响。
featureFlags 抽出纯函数 resolveFeatureFlag,新增 utils/siteBillingMode 承载三态派生与反解,
PaymentView 测试改用响应式 store mock 覆盖开关双向切换。
2026-09-11 00:50:17 +08:00

312 lines
12 KiB
Go

//go:build unit
package service
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
type settingPublicRepoStub struct {
values map[string]string
err error
}
func (s *settingPublicRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
panic("unexpected Get call")
}
func (s *settingPublicRepoStub) GetValue(ctx context.Context, key string) (string, error) {
panic("unexpected GetValue call")
}
func (s *settingPublicRepoStub) Set(ctx context.Context, key, value string) error {
panic("unexpected Set call")
}
func (s *settingPublicRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
if s.err != nil {
return nil, s.err
}
out := make(map[string]string, len(keys))
for _, key := range keys {
if value, ok := s.values[key]; ok {
out[key] = value
}
}
return out, nil
}
func (s *settingPublicRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
panic("unexpected SetMultiple call")
}
func (s *settingPublicRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
panic("unexpected GetAll call")
}
func (s *settingPublicRepoStub) Delete(ctx context.Context, key string) error {
panic("unexpected Delete call")
}
func TestSettingService_GetPublicSettings_ExposesRegistrationEmailSuffixWhitelist(t *testing.T) {
repo := &settingPublicRepoStub{
values: map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyEmailVerifyEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@EXAMPLE.com"," @foo.bar ","*.EDU.CN","@invalid_domain",""]`,
},
}
svc := NewSettingService(repo, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"@example.com", "@foo.bar", "*.edu.cn"}, settings.RegistrationEmailSuffixWhitelist)
}
func TestSettingService_GetPublicSettings_ExposesTablePreferences(t *testing.T) {
repo := &settingPublicRepoStub{
values: map[string]string{
SettingKeyTableDefaultPageSize: "50",
SettingKeyTablePageSizeOptions: "[20,50,100]",
},
}
svc := NewSettingService(repo, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.Equal(t, 50, settings.TableDefaultPageSize)
require.Equal(t, []int{20, 50, 100}, settings.TablePageSizeOptions)
}
func TestSettingService_GetPublicSettings_ExposesCompactHomeEnabled(t *testing.T) {
repo := &settingPublicRepoStub{
values: map[string]string{
SettingKeyCompactHomeEnabled: "true",
},
}
svc := NewSettingService(repo, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.True(t, settings.CompactHomeEnabled)
missingSettings, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).
GetPublicSettings(context.Background())
require.NoError(t, err)
require.False(t, missingSettings.CompactHomeEnabled)
}
func TestSettingService_ChannelMonitorHideThroughputDefaultsToPrivate(t *testing.T) {
missing := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
require.True(t, missing.HideThroughput)
public, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetPublicSettings(context.Background())
require.NoError(t, err)
require.True(t, public.ChannelMonitorHideThroughput)
for _, value := range []string{"false", "0", "off", "disabled"} {
runtime := NewSettingService(&settingPublicRepoStub{values: map[string]string{
SettingKeyChannelMonitorHideThroughput: value,
}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
require.False(t, runtime.HideThroughput, "value=%q", value)
}
}
func TestSettingService_ChannelMonitorShowQuotaFailsClosed(t *testing.T) {
// 缺省(迁移插入 'false' / 老库无行)一律不展示。
missingRuntime := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
require.False(t, missingRuntime.ShowQuota)
missingPublic, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).
GetPublicSettings(context.Background())
require.NoError(t, err)
require.False(t, missingPublic.ChannelMonitorShowQuota)
// 仅字面 "true" 视为开启;其余值(含异常值)fail-closed。
runtime := NewSettingService(&settingPublicRepoStub{values: map[string]string{
SettingKeyChannelMonitorShowQuota: "true",
}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
require.True(t, runtime.ShowQuota)
for _, value := range []string{"false", "TRUE", "1", "yes", "on", "garbage"} {
rt := NewSettingService(&settingPublicRepoStub{values: map[string]string{
SettingKeyChannelMonitorShowQuota: value,
}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
require.False(t, rt.ShowQuota, "value=%q", value)
}
}
func TestSettingService_ChannelMonitorHideUserRankingDefaultsToVisible(t *testing.T) {
missing := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
require.False(t, missing.HideUserRanking)
public, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetPublicSettings(context.Background())
require.NoError(t, err)
require.False(t, public.ChannelMonitorHideUserRanking)
for _, value := range []string{"true", "1", "on", "enabled"} {
runtime := NewSettingService(&settingPublicRepoStub{values: map[string]string{
SettingKeyChannelMonitorHideUserRanking: value,
}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
require.True(t, runtime.HideUserRanking, "value=%q", value)
}
}
func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) {
repo := &settingPublicRepoStub{
values: map[string]string{
SettingKeyForceEmailOnThirdPartySignup: "true",
},
}
svc := NewSettingService(repo, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.True(t, settings.ForceEmailOnThirdPartySignup)
}
func TestSettingService_GetPublicSettings_ExposesAllowUserViewErrorRequests(t *testing.T) {
repo := &settingPublicRepoStub{
values: map[string]string{
SettingKeyAllowUserViewErrorRequests: "true",
},
}
svc := NewSettingService(repo, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.True(t, settings.AllowUserViewErrorRequests)
}
func TestSettingService_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) {
svc := NewSettingService(&settingPublicRepoStub{
values: map[string]string{
SettingKeyWeChatConnectEnabled: "true",
SettingKeyWeChatConnectAppID: "wx-mp-app",
SettingKeyWeChatConnectAppSecret: "wx-mp-secret",
SettingKeyWeChatConnectMode: "mp",
SettingKeyWeChatConnectScopes: "snsapi_base",
SettingKeyWeChatConnectOpenEnabled: "true",
SettingKeyWeChatConnectMPEnabled: "true",
SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback",
SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
},
}, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.True(t, settings.WeChatOAuthEnabled)
require.True(t, settings.WeChatOAuthOpenEnabled)
require.True(t, settings.WeChatOAuthMPEnabled)
}
func TestSettingService_GetPublicSettings_DoesNotExposeMobileOnlyWeChatAsWebOAuthAvailable(t *testing.T) {
svc := NewSettingService(&settingPublicRepoStub{
values: map[string]string{
SettingKeyWeChatConnectEnabled: "true",
SettingKeyWeChatConnectMobileEnabled: "true",
SettingKeyWeChatConnectMode: "mobile",
SettingKeyWeChatConnectMobileAppID: "wx-mobile-app",
SettingKeyWeChatConnectMobileAppSecret: "wx-mobile-secret",
SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
},
}, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.False(t, settings.WeChatOAuthEnabled)
require.False(t, settings.WeChatOAuthOpenEnabled)
require.False(t, settings.WeChatOAuthMPEnabled)
require.True(t, settings.WeChatOAuthMobileEnabled)
}
func TestSettingService_GetPublicSettings_FallsBackToConfigForWeChatOAuthCapabilities(t *testing.T) {
svc := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{
WeChat: config.WeChatConnectConfig{
Enabled: true,
OpenEnabled: true,
OpenAppID: "wx-open-config",
OpenAppSecret: "wx-open-secret",
FrontendRedirectURL: "/auth/wechat/config-callback",
},
})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.True(t, settings.WeChatOAuthEnabled)
require.True(t, settings.WeChatOAuthOpenEnabled)
require.False(t, settings.WeChatOAuthMPEnabled)
require.False(t, settings.WeChatOAuthMobileEnabled)
}
// subscription_enabled gates the user-facing "My Subscriptions" sidebar entry and
// is opt-out: only an explicit "false" hides it.
func TestSettingService_GetPublicSettings_SubscriptionEnabledOnlyExplicitFalseDisables(t *testing.T) {
cases := []struct {
name string
values map[string]string
want bool
}{
{name: "missing key defaults to enabled", values: map[string]string{}, want: true},
{name: "empty value stays enabled", values: map[string]string{SettingKeySubscriptionEnabled: ""}, want: true},
{name: "explicit true", values: map[string]string{SettingKeySubscriptionEnabled: "true"}, want: true},
{name: "explicit false disables", values: map[string]string{SettingKeySubscriptionEnabled: "false"}, want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
svc := NewSettingService(&settingPublicRepoStub{values: tc.values}, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.Equal(t, tc.want, settings.SubscriptionEnabled)
})
}
}
// The SSR injection payload must mirror the switch so the opt-out flag does not
// flicker off on first render before the async public-settings fetch resolves.
func TestSettingService_GetPublicSettingsForInjection_MirrorsSubscriptionEnabled(t *testing.T) {
for _, value := range []string{"false", "true"} {
repo := &settingPublicRepoStub{values: map[string]string{SettingKeySubscriptionEnabled: value}}
svc := NewSettingService(repo, &config.Config{})
raw, err := svc.GetPublicSettingsForInjection(context.Background())
require.NoError(t, err)
payload, ok := raw.(*PublicSettingsInjectionPayload)
require.True(t, ok)
require.Equal(t, value == "true", payload.SubscriptionEnabled, "value=%q", value)
}
}
// payment_balance_disabled is exposed publicly so the user shell can derive the site
// billing mode (recharge & subscription / recharge only / subscription only) before any
// authenticated checkout call. Strict true, mirroring the payment-config parser.
func TestSettingService_GetPublicSettings_PaymentBalanceDisabledStrictTrue(t *testing.T) {
cases := []struct {
name string
value map[string]string
want bool
}{
{name: "missing key stays enabled", value: map[string]string{}, want: false},
{name: "explicit false", value: map[string]string{SettingBalancePayDisabled: "false"}, want: false},
{name: "explicit true disables balance recharge", value: map[string]string{SettingBalancePayDisabled: "true"}, want: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
svc := NewSettingService(&settingPublicRepoStub{values: tc.value}, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.Equal(t, tc.want, settings.PaymentBalanceDisabled)
raw, err := svc.GetPublicSettingsForInjection(context.Background())
require.NoError(t, err)
payload, ok := raw.(*PublicSettingsInjectionPayload)
require.True(t, ok)
require.Equal(t, tc.want, payload.PaymentBalanceDisabled)
})
}
}