// httpapi 集成测试环境:httptest + 临时目录真实 SQLite(§13)。
package httpapi
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"testing"
"text/template"
"time"
"pure-note/internal/auth"
"pure-note/internal/config"
"pure-note/internal/middleware"
"pure-note/internal/store"
)
const testPassword = "admin-password-123"
// testEnv 测试环境。
type testEnv struct {
t *testing.T
st *store.Store
srv *Server
ts *httptest.Server
csrf string // 当前管理员 CSRF token
}
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(newDiscard(), nil))
}
func newEnv(t *testing.T) *testEnv {
t.Helper()
e := newEnvCustom(t, nil, nil, nil)
return e
}
// newEnvCustom 可注入限流器(nil = 高容量测试桶,避免全局限流干扰)。
func newEnvCustom(t *testing.T, global, loginIP, loginAcct *middleware.Limiter) *testEnv {
t.Helper()
if global == nil {
global = middleware.NewLimiter(1e9, 1<<20, 1<<20, time.Minute)
}
if loginIP == nil {
loginIP = middleware.NewLimiter(1e9, 1<<20, 1<<20, time.Minute)
}
if loginAcct == nil {
loginAcct = middleware.NewLimiter(1e9, 1<<20, 1<<20, time.Minute)
}
dir := t.TempDir()
st, err := store.Open(dir+"/pure-note.db", false)
if err != nil {
t.Fatalf("打开测试库失败: %v", err)
}
t.Cleanup(func() { st.Close() })
hash, err := auth.HashPassword(testPassword)
if err != nil {
t.Fatal(err)
}
if err := st.SetSetting(store.KeyAdminPasswordHash, hash); err != nil {
t.Fatal(err)
}
if err := st.SetSetting(store.KeySiteTitle, "测试站"); err != nil {
t.Fatal(err)
}
cfg := &config.Config{Dev: true, DataDir: dir}
srv := NewWithLimiters(st, cfg, quietLogger(), global, loginIP, loginAcct)
// index 模板桩:渲染 ResolveMeta 结果,用于 meta 注入断言
tmpl := template.Must(template.New("i").Parse(
`
{{.Title}}SPA`))
ui := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m := srv.ResolveMeta(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = tmpl.Execute(w, m)
})
ts := httptest.NewServer(srv.Handler(ui))
t.Cleanup(ts.Close)
return &testEnv{t: t, st: st, srv: srv, ts: ts}
}
func newDiscard() *bytes.Buffer { return &bytes.Buffer{} }
// client 新建带 Cookie Jar 的客户端。
func (e *testEnv) client() *http.Client {
jar, _ := cookiejar.New(nil)
return &http.Client{Jar: jar}
}
// do 发请求。默认带 Origin(与服务器同源)。
func (e *testEnv) do(c *http.Client, method, path string, body []byte, hdr map[string]string) (*http.Response, []byte) {
e.t.Helper()
var rd *bytes.Reader
if body == nil {
rd = bytes.NewReader(nil)
} else {
rd = bytes.NewReader(body)
}
req, err := http.NewRequest(method, e.ts.URL+path, rd)
if err != nil {
e.t.Fatal(err)
}
if method != http.MethodGet && method != http.MethodHead {
if _, ok := hdr["Origin"]; !ok {
req.Header.Set("Origin", e.ts.URL)
}
}
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := c.Do(req)
if err != nil {
e.t.Fatal(err)
}
defer resp.Body.Close()
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(resp.Body)
return resp, buf.Bytes()
}
func (e *testEnv) get(c *http.Client, path string) (*http.Response, []byte) {
return e.do(c, http.MethodGet, path, nil, nil)
}
// loginAdmin 登录并保留会话与 CSRF。
func (e *testEnv) loginAdmin() *http.Client {
e.t.Helper()
c := e.client()
resp, body := e.do(c, http.MethodPost, "/api/auth/login",
[]byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
if resp.StatusCode != http.StatusOK {
e.t.Fatalf("管理员登录失败: %d %s", resp.StatusCode, body)
}
var out struct {
Data struct {
CSRFToken string `json:"csrf_token"`
} `json:"data"`
}
if err := json.Unmarshal(body, &out); err != nil {
e.t.Fatalf("解析登录响应失败: %v", err)
}
e.csrf = out.Data.CSRFToken
return c
}
func (e *testEnv) adminHeaders() map[string]string {
e.t.Helper()
if e.csrf == "" {
e.t.Fatal("尚未登录管理员")
}
return map[string]string{"X-CSRF-Token": e.csrf, "Content-Type": "application/json"}
}
// createNote 管理员建笔记,返回 Note JSON。
func (e *testEnv) createNote(c *http.Client, slug, title, content, status string, tags []string) map[string]any {
e.t.Helper()
payload := map[string]any{
"title": title, "slug": slug, "content": content, "status": status, "tags": tags,
}
b, _ := json.Marshal(payload)
resp, body := e.do(c, http.MethodPost, "/api/admin/notes", b, e.adminHeaders())
if resp.StatusCode != http.StatusCreated {
e.t.Fatalf("创建笔记失败(%s): %d %s", title, resp.StatusCode, body)
}
var out struct {
Data map[string]any `json:"data"`
}
_ = json.Unmarshal(body, &out)
return out.Data
}
// uploadPNG 管理员上传一张最小 PNG,返回图片 id。
func (e *testEnv) uploadPNG(c *http.Client, png []byte) int64 {
e.t.Helper()
var body bytes.Buffer
boundary := "testboundary123"
body.WriteString("--" + boundary + "\r\n")
body.WriteString(`Content-Disposition: form-data; name="file"; filename="t.png"` + "\r\n")
body.WriteString("Content-Type: image/png\r\n\r\n")
body.Write(png)
body.WriteString("\r\n--" + boundary + "--\r\n")
req, err := http.NewRequest(http.MethodPost, e.ts.URL+"/api/admin/images", &body)
if err != nil {
e.t.Fatal(err)
}
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
req.Header.Set("Origin", e.ts.URL)
req.Header.Set("X-CSRF-Token", e.csrf)
resp, err := c.Do(req)
if err != nil {
e.t.Fatal(err)
}
defer resp.Body.Close()
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(resp.Body)
if resp.StatusCode != http.StatusCreated {
e.t.Fatalf("上传图片失败: %d %s", resp.StatusCode, buf.String())
}
var out struct {
Data struct {
ID int64 `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(buf.Bytes(), &out)
return out.Data.ID
}
// fixtures 构造可见性矩阵的标准夹具(§13)。
type fixtures struct {
admin *http.Client
anon *http.Client
pubID int64 // 公开笔记(引用 imgPublic)
privID int64 // 私有笔记(引用 imgPrivate)
trashID int64 // 回收站笔记(原公开,引用 imgTrash)
pubSlug string
privSlug string
trashSlug string
imgPub int64
imgPriv int64
imgTrash int64
imgOrphan int64
}
var png1x1 = []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00,
0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xDD, 0x8D, 0xB0, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
}
// uniquePNG 追加 IEND 之后的差异化尾部,绕开 sha256 去重(设计 §14:
// 去重会合并同字节图片;夹具需要四张不同图)。
func uniquePNG(tag byte) []byte {
b := make([]byte, len(png1x1), len(png1x1)+8)
copy(b, png1x1)
return append(b, 0, 0, 0, 0, 't', 'a', 'g', tag)
}
func (e *testEnv) fixtures() *fixtures {
e.t.Helper()
f := &fixtures{admin: e.loginAdmin(), anon: e.client()}
f.imgPub = e.uploadPNG(f.admin, uniquePNG('a'))
f.imgPriv = e.uploadPNG(f.admin, uniquePNG('b'))
f.imgTrash = e.uploadPNG(f.admin, uniquePNG('c'))
f.imgOrphan = e.uploadPNG(f.admin, uniquePNG('d'))
pub := e.createNote(f.admin, "pub-note", "公开笔记Alpha", "公开内容 +")", "public", []string{"公开"})
priv := e.createNote(f.admin, "priv-note", "私有笔记Beta", "私有内容 +")", "private", []string{"秘密"})
trash := e.createNote(f.admin, "trash-note", "回收站笔记Gamma", "回收站内容 +")", "public", []string{"公开"})
f.pubID = int64(pub["id"].(float64))
f.privID = int64(priv["id"].(float64))
f.trashID = int64(trash["id"].(float64))
f.pubSlug = pub["slug"].(string)
f.privSlug = priv["slug"].(string)
f.trashSlug = trash["slug"].(string)
// 软删除 trash-note
resp, body := e.do(f.admin, http.MethodDelete, fmt.Sprintf("/api/admin/notes/%d", f.trashID), nil, e.adminHeaders())
if resp.StatusCode != http.StatusOK {
e.t.Fatalf("软删除失败: %d %s", resp.StatusCode, body)
}
return f
}