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) } }