Files
new-api/service/user_notify_test.go
Calcium-Ion 6461f084ff feat(notify): turn each kind of notification on or off (#7868)
SMTP settings now only decide whether mail can be sent. Verification
codes, password reset, email binding and account security mails depend
on SMTP alone, and no notification switch affects them.

Admins get a site-wide low balance reminder switch, on by default, next
to the SMTP settings. The default threshold is entered as an amount.

Users choose which notifications they receive. Low balance reminders are
on for everyone by default. Admins can also turn channel notifications
and upstream model update notices on or off. The root user receives
channel notifications until it turns them off. Other admins receive them
after they turn them on.

A low balance reminder is sent once, when a charge takes the balance
below the threshold. A top-up above the threshold arms it again.

Channel notifications reach every admin who has them on, in that admin's
language. Email binding codes and account security mails follow the
account's language.

Saving notification settings no longer clears the language, the sidebar
layout or the billing preference.

Existing settings keep their behavior, and old clients that do not send
the new fields keep the stored switches. Webhook, Bark and Gotify
payloads are unchanged.
2026-10-17 22:17:59 +08:00

192 lines
7.1 KiB
Go

package service
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// A low-balance reminder goes out once, on the charge that takes a wallet or
// subscription balance below the threshold. A balance already below it earns
// nothing more until a top-up lifts it back above the line. The site switch
// and the user's switch both turn reminders off; a user who never chose gets
// them.
func TestQuotaReminderFiresOncePerDrop(t *testing.T) {
previousEnabled, previousThreshold := common.QuotaRemindEnabled, common.QuotaRemindThreshold
t.Cleanup(func() {
common.QuotaRemindEnabled, common.QuotaRemindThreshold = previousEnabled, previousThreshold
})
common.QuotaRemindThreshold = 1000
on, off := true, false
// changes move the balance from start: negative ones are charges, which
// may remind; positive ones are top-ups. want holds one entry per charge.
for _, tc := range []struct {
name string
siteEnabled bool
setting dto.UserSetting
start int64
changes []int64
want []bool
}{
{
name: "never chosen: one reminder per drop, again after a top-up",
siteEnabled: true,
start: 1500,
changes: []int64{-400, -200, -300, +1500, -1200},
want: []bool{false, true, false, true},
},
{
name: "switched on with its own threshold",
siteEnabled: true,
setting: dto.UserSetting{QuotaWarningEnabled: &on, QuotaWarningThreshold: 5000},
start: 6000,
changes: []int64{-500, -600, -2000},
want: []bool{false, true, false},
},
{
name: "a charge that lands exactly on the threshold does not remind",
siteEnabled: true,
start: 1200,
changes: []int64{-200, -1},
want: []bool{false, true},
},
{
name: "user switched off",
siteEnabled: true,
setting: dto.UserSetting{QuotaWarningEnabled: &off},
start: 1500,
changes: []int64{-600},
want: []bool{false},
},
{
name: "site switched off",
setting: dto.UserSetting{QuotaWarningEnabled: &on},
start: 1500,
changes: []int64{-600},
want: []bool{false},
},
} {
t.Run(tc.name, func(t *testing.T) {
common.QuotaRemindEnabled = tc.siteEnabled
got := make([]bool, 0, len(tc.want))
balance := tc.start
for _, change := range tc.changes {
if change < 0 {
got = append(got, quotaReminderDue(tc.setting, balance, balance+change))
}
balance += change
}
assert.Equal(t, tc.want, got)
})
}
}
// Channel notices go to every enabled administrator whose channel switch is
// on, in that administrator's saved language. Until an administrator chooses,
// root receives them and other administrators do not; other users never do.
func TestChannelNoticesFollowEachAdministratorsSwitch(t *testing.T) {
previousDB := model.DB
previousCache, previousRedis := common.MemoryCacheEnabled, common.RedisEnabled
previousNotifyLimit := constant.NotifyLimitCount
previousClient, previousWorker := httpClient, system_setting.WorkerUrl
fetch := system_setting.GetFetchSetting()
previousFetch := *fetch
t.Cleanup(func() {
model.DB = previousDB
common.MemoryCacheEnabled, common.RedisEnabled = previousCache, previousRedis
constant.NotifyLimitCount = previousNotifyLimit
httpClient, system_setting.WorkerUrl = previousClient, previousWorker
*fetch = previousFetch
})
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := database.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.User{}))
model.DB = database
common.MemoryCacheEnabled, common.RedisEnabled = false, false
constant.NotifyLimitCount = 100
var mutex sync.Mutex
received := map[string][]string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload WebhookPayload
if err := common.DecodeJson(r.Body, &payload); err == nil {
mutex.Lock()
recipient := strings.TrimPrefix(r.URL.Path, "/")
received[recipient] = append(received[recipient], payload.Type+" "+payload.Title)
mutex.Unlock()
}
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
httpClient, system_setting.WorkerUrl = server.Client(), ""
fetch.EnableSSRFProtection = false
on, off := true, false
for _, user := range []struct {
name string
role int
status int
receives *bool
language string
}{
{"root-never-chose", common.RoleRootUser, common.UserStatusEnabled, nil, ""},
{"root-turned-off", common.RoleRootUser, common.UserStatusEnabled, &off, ""},
{"admin-never-chose", common.RoleAdminUser, common.UserStatusEnabled, nil, ""},
{"admin-turned-on", common.RoleAdminUser, common.UserStatusEnabled, &on, "zhCN"},
{"disabled-admin-turned-on", common.RoleAdminUser, common.UserStatusDisabled, &on, ""},
{"user-with-switch-stored-on", common.RoleCommonUser, common.UserStatusEnabled, &on, ""},
} {
setting, err := common.Marshal(dto.UserSetting{
NotifyType: dto.NotifyTypeWebhook,
WebhookUrl: server.URL + "/" + user.name,
ChannelNotifyEnabled: user.receives,
Language: user.language,
})
require.NoError(t, err)
require.NoError(t, database.Create(&model.User{Username: user.name, Role: user.role, Status: user.status, Setting: string(setting), AffCode: user.name}).Error)
}
channel := &model.Channel{Name: "notice-review", Key: "fixture-key", Type: 1, Status: common.ChannelStatusEnabled, Group: "default", Models: "test-model"}
require.NoError(t, channel.Insert())
DisableChannel(types.ChannelError{ChannelId: channel.Id, ChannelName: channel.Name, AutoBan: true}, "upstream failed")
EnableChannel(channel.Id, "", channel.Name)
NotifyChannelWatchers(dto.NotifyTypeChannelTest, func(lang string) (string, string) {
return i18n.Translate(lang, i18n.MsgChannelTestCompletedSubject), i18n.Translate(lang, i18n.MsgChannelTestCompletedContent)
})
disabledType := formatNotifyType(channel.Id, common.ChannelStatusAutoDisabled)
enabledType := formatNotifyType(channel.Id, common.ChannelStatusEnabled)
assert.Equal(t, map[string][]string{
"root-never-chose": {
fmt.Sprintf(`%s Channel "notice-review" (#%d) has been disabled`, disabledType, channel.Id),
fmt.Sprintf(`%s Channel "notice-review" (#%d) has been enabled`, enabledType, channel.Id),
"channel_test Channel test completed",
},
"admin-turned-on": {
fmt.Sprintf("%s 通道「notice-review」(#%d)已被禁用", disabledType, channel.Id),
fmt.Sprintf("%s 通道「notice-review」(#%d)已被启用", enabledType, channel.Id),
"channel_test 通道测试完成",
},
}, received)
}