410 lines
15 KiB
Go
410 lines
15 KiB
Go
package templates
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
gotemplate "html/template"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/flamego/template"
|
||
|
||
"github.com/tamakyi/TamaBox/internal/conf"
|
||
"github.com/tamakyi/TamaBox/internal/db"
|
||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
||
templatepkg "github.com/tamakyi/TamaBox/internal/template"
|
||
)
|
||
|
||
// 审核严格度下拉框的解析:不依赖属性的书写顺序或是否存在额外属性
|
||
// (模板可能被页面编辑器注入 data-* 属性),因此用正则而不是精确子串匹配。
|
||
var (
|
||
strictnessOptionRe = regexp.MustCompile(`<option value="(lax|standard|strict)"([^>]*)>`)
|
||
selectedAttrRe = regexp.MustCompile(`\bselected\b`)
|
||
)
|
||
|
||
// selectedStrictnessOptions 返回渲染结果中被标记 selected 的档位值(去重后升序)。
|
||
// 三个面板各渲染一份下拉框,正常情况下应得到恰好一个值;返回多个说明面板之间不一致。
|
||
func selectedStrictnessOptions(out string) []string {
|
||
seen := map[string]struct{}{}
|
||
var values []string
|
||
for _, m := range strictnessOptionRe.FindAllStringSubmatch(out, -1) {
|
||
if !selectedAttrRe.MatchString(m[2]) {
|
||
continue
|
||
}
|
||
if _, ok := seen[m[1]]; ok {
|
||
continue
|
||
}
|
||
seen[m[1]] = struct{}{}
|
||
values = append(values, m[1])
|
||
}
|
||
sort.Strings(values)
|
||
return values
|
||
}
|
||
|
||
// renderWithData 用给定数据渲染模板(复刻生产环境 Templater + FuncMap)。
|
||
func renderWithData(t *testing.T, name string, data map[string]interface{}) error {
|
||
t.Helper()
|
||
_, err := renderToString(t, name, data)
|
||
return err
|
||
}
|
||
|
||
// renderToString 渲染模板并返回输出,供需要断言渲染结果的用例使用。
|
||
func renderToString(t *testing.T, name string, data map[string]interface{}) (string, error) {
|
||
t.Helper()
|
||
fs, err := template.EmbedFS(FS, ".", []string{".html"})
|
||
if err != nil {
|
||
t.Fatalf("EmbedFS: %v", err)
|
||
}
|
||
|
||
tpl := gotemplate.New("Flamego.Template")
|
||
for _, f := range fs.Files() {
|
||
tt := tpl.New(f.Name())
|
||
for _, funcMap := range templatepkg.FuncMap() {
|
||
tt.Funcs(funcMap)
|
||
}
|
||
dataBytes, err := f.Data()
|
||
if err != nil {
|
||
t.Fatalf("get data of %q: %v", f.Name(), err)
|
||
}
|
||
if _, err := tt.Parse(string(dataBytes)); err != nil {
|
||
t.Fatalf("parse %q: %v", f.Name(), err)
|
||
}
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
if err := tpl.ExecuteTemplate(&buf, name, data); err != nil {
|
||
return "", err
|
||
}
|
||
return buf.String(), nil
|
||
}
|
||
|
||
// mockLoggedUser 覆盖 templates/user/profile.html 引用到的 LoggedUser 字段。
|
||
type mockLoggedUser struct {
|
||
Domain string
|
||
Name string
|
||
Email string
|
||
Avatar string
|
||
Background string
|
||
BackgroundImage string
|
||
Backgroundimagealpha string
|
||
Intro string
|
||
Introcolor string
|
||
Usernamecolor string
|
||
Qrcodebackcolor string
|
||
Qrcodecolor string
|
||
Qrcodepdpcolor string
|
||
Dotscale string
|
||
UseCustomQRColor bool
|
||
Notify string
|
||
Language string
|
||
HarassmentSetting string
|
||
BlockWords string
|
||
// 审核严格度档位(lax / standard / strict),设置页三个功能面板共用一份。
|
||
ModerationStrictness string
|
||
Tags string
|
||
ShowInHome bool
|
||
Acfunlink string
|
||
Bililink string
|
||
Weibolink string
|
||
Xiaohongshulink string
|
||
Bloglink string
|
||
ChatMaxMessages int
|
||
DailyMessageLimit int
|
||
}
|
||
|
||
// profileBaseData 构造 /user/profile 渲染所需的基础数据。
|
||
func profileBaseData() map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"Title": "设置",
|
||
"LangCode": "zh-CN",
|
||
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}},
|
||
"IsLogged": true,
|
||
"LoggedUser": &mockLoggedUser{
|
||
Domain: "tester", Name: "测试箱主", Email: "[email protected]",
|
||
Usernamecolor: "#000", Introcolor: "#666", Qrcodecolor: "#000",
|
||
Qrcodepdpcolor: "#000", Qrcodebackcolor: "#fff", Dotscale: "0.35",
|
||
Backgroundimagealpha: "0.8", Notify: "email", Language: "zh-CN",
|
||
HarassmentSetting: "none", BlockWords: "广告,spam", Tags: "杂谈",
|
||
ModerationStrictness: "standard",
|
||
ChatMaxMessages: 20, DailyMessageLimit: 0,
|
||
},
|
||
"CSRFTokenHTML": gotemplate.HTML(`<input type="hidden" name="_csrf" value="x">`),
|
||
"IsUserPage": false,
|
||
"IsPixel": false,
|
||
"VConsole": false,
|
||
"HasPagePassword": false,
|
||
"HasAskPassword": true,
|
||
"ActiveProfileTab": "account",
|
||
"ICP": "",
|
||
// 标签 / 屏蔽词上限,供标签块输入组件做实时提示(生产由 route/user.Profile 注入,
|
||
// 值来自 conf.Limits)
|
||
"TagLimits": map[string]int{
|
||
"MaxTags": conf.Limits.MaxTags,
|
||
"MaxTagLen": conf.Limits.MaxTagLen,
|
||
"MaxBlockWords": conf.Limits.MaxBlockWords,
|
||
"MaxBlockWordLen": conf.Limits.MaxBlockWordLen,
|
||
},
|
||
// 私聊面板数值上下界(同样来自 conf.Limits)
|
||
"ChatLimits": map[string]int{
|
||
"MaxChatMessages": conf.Limits.ChatMaxMessages,
|
||
"DailyMessageLimitMax": conf.Limits.DailyMessageLimitMax,
|
||
},
|
||
}
|
||
}
|
||
|
||
// TestRenderUserProfileTabs 渲染整合后的账号设置页:用户设置 + 提问/私聊/投票 三个功能面板,
|
||
// 覆盖"开启 / 手动关闭 / 已达定量上限"三种功能状态。
|
||
func TestRenderUserProfileTabs(t *testing.T) {
|
||
type mockControl struct {
|
||
Enabled bool
|
||
OpenAt *time.Time
|
||
CloseAt *time.Time
|
||
QuotaLimit int64
|
||
QuotaCount int64
|
||
QuotaResetAt *time.Time
|
||
}
|
||
type mockState struct {
|
||
Enabled bool
|
||
Open bool
|
||
Reason string
|
||
QuotaLimit int64
|
||
QuotaCount int64
|
||
}
|
||
type mockFeature struct {
|
||
Key string
|
||
LangCode string
|
||
NameText string
|
||
StatusText string
|
||
CSRFTokenHTML interface{}
|
||
Control *mockControl
|
||
State *mockState
|
||
OpenAtStr string
|
||
CloseAtStr string
|
||
QuotaResetAtStr string
|
||
ScheduleDays string
|
||
ScheduleStart string
|
||
ScheduleEnd string
|
||
Mode string
|
||
}
|
||
|
||
resetAt := time.Now().Add(-24 * time.Hour)
|
||
csrf := gotemplate.HTML(`<input type="hidden" name="_csrf" value="x">`)
|
||
|
||
base := profileBaseData()
|
||
base["QuotaLimitMax"] = db.QuotaLimitMax
|
||
base["CSRFTokenHTML"] = csrf
|
||
base["FeatureQuestion"] = &mockFeature{
|
||
Key: "question", LangCode: "zh-CN", NameText: "提问", StatusText: "进行中", CSRFTokenHTML: csrf,
|
||
Control: &mockControl{Enabled: true},
|
||
State: &mockState{Enabled: true, Open: true},
|
||
Mode: "always",
|
||
}
|
||
// 手动关闭
|
||
base["FeatureChat"] = &mockFeature{
|
||
Key: "chat", LangCode: "zh-CN", NameText: "私聊", StatusText: "已关闭", CSRFTokenHTML: csrf,
|
||
Control: &mockControl{Enabled: false},
|
||
State: &mockState{Enabled: false, Reason: "disabled"},
|
||
Mode: "always",
|
||
}
|
||
// 定时 + 定量:已达上限(历史数据存在循环定时,模式推断为 recurring)
|
||
base["FeatureVote"] = &mockFeature{
|
||
Key: "vote", LangCode: "zh-CN", NameText: "投票", StatusText: "已达上限", CSRFTokenHTML: csrf,
|
||
Control: &mockControl{Enabled: true, QuotaLimit: 100, QuotaCount: 100, QuotaResetAt: &resetAt},
|
||
State: &mockState{Enabled: true, Reason: "quota_reached", QuotaLimit: 100, QuotaCount: 100},
|
||
OpenAtStr: "2026-09-01T09:00", CloseAtStr: "2026-09-30T18:00",
|
||
QuotaResetAtStr: resetAt.Format("2006-01-02 15:04"),
|
||
ScheduleDays: "1,3,5", ScheduleStart: "09:00", ScheduleEnd: "18:00",
|
||
Mode: "recurring",
|
||
}
|
||
|
||
// 逐个面板渲染:模板能正常执行,且只有目标面板可见、其余面板带 hidden
|
||
all := []string{"account", "question", "chat", "vote"}
|
||
for _, tab := range all {
|
||
base["ActiveProfileTab"] = tab
|
||
out, err := renderToString(t, "user/profile", base)
|
||
if err != nil {
|
||
t.Fatalf("render user/profile (tab=%s) failed: %v", tab, err)
|
||
}
|
||
if tab == "account" {
|
||
// 标签块组件的上限常量已注入(缺注入时 with 会静默跳过,页面失去计数/锁定能力)
|
||
wantMax := fmt.Sprintf(`data-tb-max="%d" data-tb-max-len="%d"`, conf.Limits.MaxTags, conf.Limits.MaxTagLen)
|
||
if !strings.Contains(out, wantMax) {
|
||
t.Fatalf("account: 标签输入缺 data-tb-max 注入,want %q", wantMax)
|
||
}
|
||
wantBlock := fmt.Sprintf(`data-tb-max="%d" data-tb-max-len="%d"`, conf.Limits.MaxBlockWords, conf.Limits.MaxBlockWordLen)
|
||
if !strings.Contains(out, wantBlock) {
|
||
t.Fatalf("account: 屏蔽词输入缺 data-tb-max 注入,want %q", wantBlock)
|
||
}
|
||
}
|
||
// 审核严格度:提问 / 私聊 / 投票三个面板都必须渲染同一份设置,且把每个档位的
|
||
// 拦截范围平铺展示出来 —— 要求是用户按下保存前就知道哪些内容会被拦、哪些会放行。
|
||
if tab == "question" || tab == "chat" || tab == "vote" {
|
||
if got := selectedStrictnessOptions(out); len(got) != 1 || got[0] != "standard" {
|
||
t.Fatalf("%s: 审核严格度未按当前档位渲染 selected 状态,got %v", tab, got)
|
||
}
|
||
for _, key := range []string{
|
||
"admin.moderationStrictnessScope.lax",
|
||
"admin.moderationStrictnessScope.standard",
|
||
"admin.moderationStrictnessScope.strict",
|
||
} {
|
||
if want := i18n.T("zh-CN", key); !strings.Contains(out, want) {
|
||
t.Fatalf("%s: 档位拦截范围缺失 %s,want %q", tab, key, want)
|
||
}
|
||
}
|
||
// 底线拦截是强制项、任何档位不可关闭,界面上必须明确告知。
|
||
if want := i18n.T("zh-CN", "admin.moderationStrictnessBaseline"); !strings.Contains(out, want) {
|
||
t.Fatalf("%s: 缺少底线拦截说明,want %q", tab, want)
|
||
}
|
||
}
|
||
if tab == "chat" {
|
||
// 私聊面板的 number 输入框 min/max 与 conf.Limits 同源
|
||
wantChat := fmt.Sprintf(`name="chat_max_messages" class="uk-input uk-width-small" type="number" min="1" max="%d"`, conf.Limits.ChatMaxMessages)
|
||
if !strings.Contains(out, wantChat) {
|
||
t.Fatalf("chat: 会话条数输入缺 conf 上限,want %q", wantChat)
|
||
}
|
||
wantDaily := fmt.Sprintf(`name="chat_daily_limit" class="uk-input uk-width-small" type="number" min="0" max="%d"`, conf.Limits.DailyMessageLimitMax)
|
||
if !strings.Contains(out, wantDaily) {
|
||
t.Fatalf("chat: 每日条数输入缺 conf 上限,want %q", wantDaily)
|
||
}
|
||
}
|
||
for _, other := range all {
|
||
want := fmt.Sprintf(`data-profile-panel="%s"`, other)
|
||
visible := strings.Contains(out, want+">")
|
||
hidden := strings.Contains(out, want+" hidden>")
|
||
if other == tab && !visible {
|
||
t.Fatalf("tab=%s: 面板 %s 未渲染为可见", tab, other)
|
||
}
|
||
if other != tab && !hidden {
|
||
t.Fatalf("tab=%s: 面板 %s 未隐藏", tab, other)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestRenderProfileStrictnessReflectsEffectiveTier 覆盖「档位回显」这个易错点。
|
||
//
|
||
// 存量账号的 moderation_strictness 为空值(语义是"跟随站点默认")。若模板直接拿该字段值
|
||
// 去和三个 <option> 逐个比较,会全部不匹配 —— 浏览器随后默认选中第一项「宽松」,
|
||
// 界面显示的档位就与真实生效的档位(站点默认,通常为严格)相反,把箱主误导到最松的一档。
|
||
// 因此回显必须走「最终生效档位」。
|
||
func TestRenderProfileStrictnessReflectsEffectiveTier(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
stored string
|
||
want []string
|
||
}{
|
||
{name: "未设置:回显生效档位,而非浏览器默认选中的宽松", stored: "", want: []string{string(moderation.StrictnessFor(""))}},
|
||
{name: "宽松", stored: "lax", want: []string{"lax"}},
|
||
{name: "标准", stored: "standard", want: []string{"standard"}},
|
||
{name: "严格", stored: "strict", want: []string{"strict"}},
|
||
{name: "非法值:回退严格", stored: "bogus", want: []string{string(moderation.StrictnessFor("bogus"))}},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
base := profileBaseData()
|
||
base["ActiveProfileTab"] = "question"
|
||
base["LoggedUser"].(*mockLoggedUser).ModerationStrictness = tc.stored
|
||
|
||
out, err := renderToString(t, "user/profile", base)
|
||
if err != nil {
|
||
t.Fatalf("render user/profile failed: %v", err)
|
||
}
|
||
|
||
got := selectedStrictnessOptions(out)
|
||
if fmt.Sprint(got) != fmt.Sprint(tc.want) {
|
||
t.Fatalf("档位回显不符:stored=%q got=%v want=%v", tc.stored, got, tc.want)
|
||
}
|
||
// 核心回归点:未设置时绝不能显示为「宽松」,那是浏览器默认选中首项的陷阱。
|
||
if tc.stored == "" && len(got) == 1 && got[0] == "lax" {
|
||
t.Fatal("未设置档位时回显为「宽松」—— 与真实生效档位相反")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestRenderUserQuestionList 模拟 /user/questions 的数据渲染,复现 500 错误。
|
||
func TestRenderUserQuestionList(t *testing.T) {
|
||
type mockItem struct {
|
||
Type string
|
||
CreatedAt time.Time
|
||
Content string
|
||
Link string
|
||
IsAnswered bool
|
||
IsRead bool
|
||
IsPrivate bool
|
||
IsBlocked bool
|
||
QuestionID uint
|
||
IsPinned bool
|
||
PinLink string
|
||
// ReportCount 对应线上的 receivedItem(箱主列表的「被举报 N 次」标记)。
|
||
// 这里漏掉会让 question-list 模板在 range 时取字段失败。
|
||
ReportCount int64
|
||
}
|
||
type mockConv struct {
|
||
Token string
|
||
CreatedAt time.Time
|
||
}
|
||
type mockConvDetail struct {
|
||
Conversation *mockConv
|
||
OwnerDomain string
|
||
OwnerName string
|
||
FirstMessage string
|
||
IsBlocked bool
|
||
}
|
||
|
||
base := map[string]interface{}{
|
||
"Title": "我的提问",
|
||
"LangCode": "zh-CN",
|
||
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}},
|
||
"IsLogged": true,
|
||
"LoggedUser": map[string]interface{}{"Domain": "test", "Name": "tester"},
|
||
"LoggedUserName": "tester",
|
||
"CurrentURI": "/user/questions",
|
||
"IsUserPage": true,
|
||
"IsPixel": false,
|
||
"VConsole": false,
|
||
"CSRFTokenHTML": gotemplate.HTML(`<input type="hidden" name="_csrf" value="x">`),
|
||
"RecaptchaSiteKey": "",
|
||
"CaptchaType": "recaptcha",
|
||
"PageUser": map[string]interface{}{"Domain": "test", "Name": "tester", "Avatar": ""},
|
||
"ICP": "",
|
||
"Stats": map[string]int64{
|
||
"total": 3, "answered": 2, "unanswered": 1, "read": 2, "unread": 1, "private": 1, "pinned": 1,
|
||
},
|
||
"ChatStats": map[string]int64{
|
||
"total": 2, "received": 1, "initiated": 1, "closed": 0,
|
||
},
|
||
"Keyword": "",
|
||
"SelectedSort": "latest",
|
||
"SelectedStatus": "all",
|
||
"SelectedRead": "all",
|
||
"SelectedType": "all",
|
||
"ReceivedItems": []interface{}{
|
||
mockItem{Type: "question_public", CreatedAt: time.Now(), Content: "你好吗?", Link: "/_/test/1",
|
||
IsAnswered: true, IsRead: true, QuestionID: 1, IsPinned: true, PinLink: "/_/test/1/pin"},
|
||
mockItem{Type: "question_private", CreatedAt: time.Now(), Content: "悄悄话", Link: "/_/test/2",
|
||
IsAnswered: false, IsPrivate: true, QuestionID: 2, PinLink: "/_/test/2/pin"},
|
||
},
|
||
"ReceivedChats": []interface{}{
|
||
mockItem{Type: "chat", CreatedAt: time.Now(), Content: "你好", Link: "/_/test/chat/abc", IsBlocked: false},
|
||
},
|
||
"ConversationDetails": []interface{}{
|
||
mockConvDetail{Conversation: &mockConv{Token: "abc", CreatedAt: time.Now()},
|
||
OwnerDomain: "other", OwnerName: "别人", FirstMessage: "hi", IsBlocked: false},
|
||
},
|
||
}
|
||
|
||
if err := renderWithData(t, "user/question-list", base); err != nil {
|
||
t.Fatalf("render user/question-list failed: %v", err)
|
||
}
|
||
|
||
// 带搜索关键词的场景
|
||
base["Keyword"] = "hello"
|
||
if err := renderWithData(t, "user/question-list", base); err != nil {
|
||
t.Fatalf("render user/question-list (keyword) failed: %v", err)
|
||
}
|
||
}
|