0904
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user