Files
tamabox/templates/render_test.go
T
2026-10-19 02:25:19 +08:00

426 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
// 处置期条幅:必须显示「人话」级别名而不是裸代码(裸 L2 对箱主没有意义)。
base["Keyword"] = ""
base["RestrictionLevel"] = "L2"
base["RestrictionLevelName"] = i18n.T("zh-CN", "restriction.level.L2")
base["RestrictionExpiresAt"] = "2026-11-01"
out, err := renderToString(t, "user/question-list", base)
if err != nil {
t.Fatalf("render user/question-list (restriction banner) failed: %v", err)
}
if !strings.Contains(out, "收窄公开") {
t.Fatal("restriction banner should show level name (收窄公开), got none")
}
if !strings.Contains(out, "2026-11-01") {
t.Fatal("restriction banner should show expiry date")
}
}