314 lines
11 KiB
Go
314 lines
11 KiB
Go
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)
|
||
}
|
||
}
|