Files
tamabox/templates/render_test.go
T
tamakyi e3e59e7c69 chore: baseline snapshot before moderation-strictness feature
Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
2026-09-10 10:56:27 +08:00

314 lines
11 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"
"strings"
"testing"
"time"
"github.com/flamego/template"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
)
// 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
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: "杂谈",
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 == "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)
}
}
}
}
// 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
}
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)
}
}