Files
HomeDash/admin-server/pages_test.go
HomeDash Bot 92fd32e7a9 feat(api): 全量配置项暴露为接口 + 后台增加门户卡片编辑框
用户要求:所有可配置项都应当是 API,方便 agent 直接把站点配成用户喜欢的样子。

- 新增 GET /api/schema:一次返回
  · items:全部单值配置项(站点信息、登录页/门户页文案、备案号、页脚…),
    含当前值、类型、写入口与请求体示例;**当前为空的键也会列出**,否则 agent 发现不了
  · resources:结构化资源与其真正的写入口(导航、路由表+apply、门户卡片、壁纸、
    门户口令、历史、图标上传、重载),并在 hint 里写明"路由表改完必须 apply"这类约束
- 后台「站点信息」新增「门户页服务卡片」编辑框:载入 / 保存 / 清空即回内置,
  与 /api/portal-cards 同一份数据(界面能改的 agent 都能改)
- README 新增「配置接口(agent 可直接改)」:schema 用法、写入口对照表、可直接复制的示例
- 测试:schema 必须列出关键项与写入口;卡片接口写入生效、清空返回 reset 并回落内置
2026-09-12 10:19:22 +08:00

139 lines
5.1 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 main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// 未配置任何文案时应回落到内置默认值(不配置也照旧)。
func TestPageStrFallbacks(t *testing.T) {
empty := map[string]any{}
if got := pageStr(empty, "authTitle", defaultAuthTitle); got != defaultAuthTitle {
t.Fatalf("空配置应回落默认,得到 %q", got)
}
info := map[string]any{"authTitle": " 内网入口 ", "heroTitle": ""}
if got := pageStr(info, "authTitle", defaultAuthTitle); got != "内网入口" {
t.Fatalf("应取配置值并去空白,得到 %q", got)
}
// 空串视为未配置
if got := pageStr(info, "heroTitle", defaultPortalHero); got != defaultPortalHero {
t.Fatalf("空串应回落默认,得到 %q", got)
}
}
// 认证页字段从 pageInfo 读取,缺失时回落。
func TestAuthPageInfoFallback(t *testing.T) {
dir := t.TempDir()
old := confFile
confFile = filepath.Join(dir, "conf.yml")
defer func() { confFile = old }()
if err := os.WriteFile(confFile, []byte("pageInfo:\n authTitle: 我的门户\n authLogo: AB\n"), 0o644); err != nil {
t.Fatal(err)
}
title, subtitle, logo, _, _ := authPageInfo()
if title != "我的门户" || logo != "AB" {
t.Fatalf("未读到配置值: %q %q", title, logo)
}
if subtitle != defaultAuthSubtitle {
t.Fatalf("未配置的字段应回落默认,得到 %q", subtitle)
}
}
// 数据卷里的整页覆盖优先于内置模板。
func TestPortalOverrideWins(t *testing.T) {
dir := t.TempDir()
old := confFile
confFile = filepath.Join(dir, "conf.yml")
defer func() { confFile = old }()
if err := os.MkdirAll(filepath.Join(dir, "portal"), 0o755); err != nil {
t.Fatal(err)
}
custom := "<!doctype html><title>我的门户</title>"
if err := os.WriteFile(filepath.Join(dir, "portal", "index.html"), []byte(custom), 0o644); err != nil {
t.Fatal(err)
}
if got := string(renderPortalPage()); !strings.Contains(got, "我的门户") {
t.Fatalf("数据卷覆盖未生效: %q", got)
}
// 空白覆盖文件应被忽略(回落到内置渲染)
if err := os.WriteFile(filepath.Join(dir, "portal", "index.html"), []byte(" \n"), 0o644); err != nil {
t.Fatal(err)
}
if got := string(renderPortalPage()); strings.Contains(got, "我的门户") {
t.Fatalf("空白覆盖文件应被忽略")
}
}
// 数据卷里的卡片片段应替换标记之间的内容(改门户链接无需重建镜像)。
func TestCardsOverrideReplacesMarkedBlock(t *testing.T) {
dir := t.TempDir()
old := confFile
defer func() { confFile = old }()
confFile = filepath.Join(dir, "conf.yml")
if err := os.MkdirAll(filepath.Join(dir, "portal"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "portal", "cards.html"), []byte("<a href=\"https://mine.example.org\">我的服务</a>"), 0o644); err != nil {
t.Fatal(err)
}
page := "<div>" + cardsStartMarker + "<a href=\"https://old.example.com\">旧</a>" + cardsEndMarker + "</div>"
got := applyCardsOverride(page)
if !strings.Contains(got, "mine.example.org") || strings.Contains(got, "old.example.com") {
t.Fatalf("覆盖未生效: %s", got)
}
// 清空后回落内置卡片
if err := os.WriteFile(filepath.Join(dir, "portal", "cards.html"), []byte(" \n"), 0o644); err != nil {
t.Fatal(err)
}
if got := applyCardsOverride(page); !strings.Contains(got, "old.example.com") {
t.Fatalf("空白片段应回落内置卡片: %s", got)
}
}
// /api/schema 必须列出全部可配置项与写入口(agent 靠它按图配置)。
func TestSchemaListsConfigurableItems(t *testing.T) {
rec := httptest.NewRecorder()
handleSchema(rec, httptest.NewRequest(http.MethodGet, "/api/schema", nil))
if rec.Code != 200 {
t.Fatalf("期望 200,实际 %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"pageInfo.heroTitle", "pageInfo.icp", "pageInfo.authLogo",
"portalCards", "gatewayRoutes", "portalAuth", "wallpaper", "/api/gateway/apply"} {
if !strings.Contains(body, want) {
t.Fatalf("schema 缺少 %q", want)
}
}
}
// 卡片接口:写入后读得到,写空串等于重置。
func TestPortalCardsAPIRoundTrip(t *testing.T) {
dir := t.TempDir()
old := confFile
defer func() { confFile = old }()
confFile = filepath.Join(dir, "conf.yml")
post := func(html string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/portal-cards", strings.NewReader(`{"html":"`+html+`"}`))
rec := httptest.NewRecorder()
handlePortalCards(rec, req)
return rec
}
if rec := post(`<a href='https://mine.example.org'>我的服务</a>`); rec.Code != 200 {
t.Fatalf("写入失败 %d: %s", rec.Code, rec.Body.String())
}
if got := string(applyCardsOverride(cardsStartMarker + "old" + cardsEndMarker)); !strings.Contains(got, "mine.example.org") {
t.Fatalf("写入后未生效: %s", got)
}
if rec := post(""); rec.Code != 200 || !strings.Contains(rec.Body.String(), "reset") {
t.Fatalf("清空应返回 reset: %d %s", rec.Code, rec.Body.String())
}
if got := string(applyCardsOverride(cardsStartMarker + "old" + cardsEndMarker)); !strings.Contains(got, "old") {
t.Fatalf("清空后应回落内置: %s", got)
}
}