Files
MailUI4Agents/server/internal/handler/topbar_test.go
JianFeeeee 31939f2b10 服务端: 顶栏内容端点(一言句库缓存 + 个人签名)+ 修老库升级时序 bug
用户裁定:
  · 「可以在服务器集成一言与签名,同时 app 本地缓存一部分」
  · 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」
  · 「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」

新增端点
  · GET /api/v1/me/topbar → { quotes: [{text, source}], signature }
    一次给一批(默认 10 条),客户端拿去本地轮播 —— 轮播是秒级的,
    每条问一次服务器既浪费又会在断网时停下(而轮播的观感依赖"一直有下一条")。
  · PUT /api/v1/me/signature —— 改个人签名(「我的」页用)
  · quotes 表(句库缓存)+ users.signature 列

设计要点
  · 一言**落库缓存**:库里有就**不打外网**(常态路径);不足 20 条才去
    hitokoto 补一批。补失败**不影响返回** —— 装饰性内容不该成为失败点
    (顶栏少轮播内容是小事,整个接口 500 会让 App 启动时顶栏坏掉)。
  · 签名存 users 而不是 quotes 表:它是**用户资料**(跟账号走、
    在「我的」页可编辑),放 quotes 里会让"改签名"变成"改一条 quote"。
  · 限长 80 字,超了**拒绝且不落库** —— 顶栏是一行,静默截断比报错更坏
    (用户以为存进去了,实际存的是被砍过的)。
  · 迁移改两处(本仓既定纪律):init_sqlite.sql 给新库 +
    sqliteAddColumns 给老库。

★ 顺手修掉一个既有 bug(不是本次引入的)
  「从很旧的库升级会直接启动失败」:
      migrate sqlite (语句 #10 … idx_sessions_path_alias_uniq):
        SQL logic error: no such column: workspace

  根因是**时序**:这条索引引用 sessions.workspace,而那是**后补的列**
  (sqliteAddColumns),索引却住在 init_sqlite.sql(在补列**之前**执行)。
  新库没事(建表时就有该列);老库直接炸,且报错指向索引名 ——
  看着像索引写错,实际是顺序问题。
  生产库一直没暴露,因为它早就补过列了(暴露面只有"从很旧的库升级")。

  证据:`git stash` 掉当天全部改动后**同样复现**。
  修法:把索引搬到 migrate.go 的 sqliteAddIndexes(那个列表在补列之后跑)。

测试(internal/handler/topbar_test.go,5/5)
  ① 签名账号隔离 —— bob 没设过就该是空串,不能串到 alice 的
     (本仓 user_appearance 那轮踩过"多账号共用一份",同一形状不许重演)
  ② 有货不打外网(灌 25 条,断言返回不超过 quoteBatchSize)
  ③ ★ 外网挂了仍返回 —— 耗时 4.01s = quoteHTTPTimeout,
     证明它真去拉了并按超时降级,不是假绿
  ④ 限长:81 字拒绝**且不落库**;80 字(边界)接受
  ⑤ 未登录读写都 401

★ 两个踩过的坑(记进注释了)
  1. `init_sqlite.sql` **只能写 `--` 行注释**:切语句器只跳过 `--` 开头的行,
     块注释的文字会被当 SQL 执行。我第一版用 `/* */`,新库初始化直接失败,
     且报错指向一个完全无关的地方(no such column: workspace)。
  2. 该 SQL 文件的 splitStatements 也会被注释里的反引号/连续减号破坏。
2026-09-25 16:29:19 +08:00

198 lines
7.3 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 handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/repo"
)
/*
顶栏内容(一言 + 签名)—— 2026-09-24。
用户原话:「可以在服务器集成一言与签名,同时 app 本地缓存一部分」+
「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」+
「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」。
服务端这半边钉四件事:
1. 签名存得下、读得回,且**账号级**(与外观同一条纪律);
2. 一言从库里取,**不打外网**(外网只用于补库);
3. ★ 外网挂了不影响返回 —— 这是"装饰性内容不该成为失败点"的落实;
4. 签名限长(顶栏是一行,超了必然被截,静默截断比报错更坏)。
*/
func setupTopbarDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
if err := db.Connect(context.Background(), "sqlite://"+filepath.Join(dir, "t.db")); err != nil {
t.Fatalf("connect: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
for _, u := range []string{"alice", "bob"} {
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO users (username, password_hash, role) VALUES ($1, 'x', 'user')`, u); err != nil {
t.Fatalf("建用户 %s: %v", u, err)
}
}
t.Cleanup(func() { db.Close() })
}
func getTopbar(t *testing.T, username string) map[string]any {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/me/topbar", nil)
resp := httptest.NewRecorder()
GetTopbar(resp, asUser(req, username))
if resp.Code != http.StatusOK {
t.Fatalf("GET topbar = %d(%s)", resp.Code, resp.Body.String())
}
var out map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
func putSignature(t *testing.T, username, sig string) *httptest.ResponseRecorder {
t.Helper()
raw, _ := json.Marshal(map[string]string{"signature": sig})
req := httptest.NewRequest(http.MethodPut, "/api/v1/me/signature", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
PutSignature(resp, asUser(req, username))
return resp
}
// ① 签名存得下、读得回,且两个账号互不干扰。
//
// 改坏会红:SetSignature 改成不带 username 条件(两个人会互相覆盖)。
func TestSignatureRoundTripPerAccount(t *testing.T) {
setupTopbarDB(t)
if resp := putSignature(t, "alice", "今天也要好好写代码"); resp.Code != http.StatusOK {
t.Fatalf("PUT signature = %d(%s)", resp.Code, resp.Body.String())
}
if got := getTopbar(t, "alice")["signature"]; got != "今天也要好好写代码" {
t.Fatalf("alice 的签名 = %v,want 今天也要好好写代码", got)
}
/*
* ★ 这条是重点:bob 没设过 ⇒ 必须是空串,而不是 alice 的那句。
* 本仓 `user_appearance` 那轮踩过**多账号共用一份**(键是全局常量),
* 同一个形状不能在签名上重演。
*/
if got := getTopbar(t, "bob")["signature"]; got != "" {
t.Fatalf("bob 的签名 = %v,want 空(他没设过;串到 alice 的值就是多账号未隔离)", got)
}
}
// ② 一言从库里取;有货就**不打外网**(这是常态路径,也是"缓存"的意义)。
//
// 改坏会红:ensureQuotes 改成每次无条件 fetchHitokoto。
func TestQuotesServedFromLocalCache(t *testing.T) {
setupTopbarDB(t)
ctx := context.Background()
/*
* 灌满阈值以上(quoteSeedThreshold = 20),保证走"库里够用"那条路。
* 造 25 条而不是刚好 20:留余量,免得将来阈值调大这条因数据不足而改走外网分支
* (那时它会真的联网,判据就变成了不可靠的集成测试)。
*/
quotes := make([]repo.Quote, 0, 25)
for i := 0; i < 25; i++ {
quotes = append(quotes, repo.Quote{Text: "句子" + string(rune('A'+i)), Source: "出处"})
}
if _, err := repo.InsertQuotes(ctx, quotes, "hitokoto"); err != nil {
t.Fatalf("InsertQuotes: %v", err)
}
out := getTopbar(t, "alice")
got, _ := out["quotes"].([]any)
if len(got) == 0 {
t.Fatal("库里已有 25 条,quotes 不该为空")
}
if len(got) > quoteBatchSize {
t.Fatalf("一次给了 %d 条,超过 quoteBatchSize=%d(客户端只需一批够轮播)", len(got), quoteBatchSize)
}
}
// ③ ★ 外网挂了不影响返回:句库空 + 外网不通时,接口仍 200 且结构完整。
//
// 这是"装饰性内容不该成为失败点"的落实。顶栏少了轮播内容是小事,
// 整个接口 500 会让 App 启动时顶栏直接坏掉 —— 那才是大事。
//
// 改坏会红:ensureQuotes/GetTopbar 把拉取失败当致命错误往上抛。
func TestTopbarSurvivesQuoteFetchFailure(t *testing.T) {
setupTopbarDB(t)
/*
* 这里**不 mock 网络**:测试环境本来就没有外网(CI 常驻离线),
* ensureQuotes 会走 fetchHitokoto 并失败 —— 正是要验的场景。
* 若哪天测试机真能连上,它也只是"补库成功",下面的断言照样成立
* (断言的是"能返回"与"结构对",不是"库必须空")。
*/
out := getTopbar(t, "alice")
if _, ok := out["quotes"]; !ok {
t.Fatal("quotes 键必须存在(哪怕是空数组)—— 客户端要有东西可解")
}
if _, ok := out["signature"]; !ok {
t.Fatal("signature 键必须存在(哪怕空串)")
}
}
// ④ 签名限长:超 80 字拒绝,且拒绝时**不落库**。
//
// 顶栏是一行文字,超了必然被截。静默截断比报错更坏 ——
// 用户以为存进去了,实际存的是被砍过的。
//
// 改坏会红:去掉长度检查(会返回 200 并把超长串存下来)。
func TestSignatureLengthLimit(t *testing.T) {
setupTopbarDB(t)
long := strings.Repeat("字", 81)
resp := putSignature(t, "alice", long)
if resp.Code != http.StatusBadRequest {
t.Fatalf("81 字应被拒(400),实际 %d", resp.Code)
}
/* 拒绝就不该落库 —— 否则"被拒了但值变了"更让人困惑 */
if got := getTopbar(t, "alice")["signature"]; got != "" {
t.Fatalf("超长签名被拒后不该写库,实际存成了 %v", got)
}
/* 边界:正好 80 字要接受(否则是"限长 80"实现成了 79) */
ok := strings.Repeat("字", 80)
if resp := putSignature(t, "alice", ok); resp.Code != http.StatusOK {
t.Fatalf("80 字应被接受,实际 %d(%s)", resp.Code, resp.Body.String())
}
}
// ⑤ 鉴权:未登录不得读取(顶栏含个人签名,是账号级数据)。
//
// 改坏会红:GetTopbar/PutSignature 去掉 middleware.GetUser 判空。
func TestTopbarRequiresAuth(t *testing.T) {
setupTopbarDB(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/me/topbar", nil)
resp := httptest.NewRecorder()
GetTopbar(resp, req) // 不带用户
if resp.Code != http.StatusUnauthorized {
t.Fatalf("未登录读 topbar 应 401,实际 %d", resp.Code)
}
raw, _ := json.Marshal(map[string]string{"signature": "x"})
req2 := httptest.NewRequest(http.MethodPut, "/api/v1/me/signature", bytes.NewReader(raw))
resp2 := httptest.NewRecorder()
PutSignature(resp2, req2)
if resp2.Code != http.StatusUnauthorized {
t.Fatalf("未登录写签名应 401,实际 %d", resp2.Code)
}
}