// 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+"/pn.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", "公开内容 ![x](/api/images/"+fmt.Sprint(f.imgPub)+")", "public", []string{"公开"}) priv := e.createNote(f.admin, "priv-note", "私有笔记Beta", "私有内容 ![y](/api/images/"+fmt.Sprint(f.imgPriv)+")", "private", []string{"秘密"}) trash := e.createNote(f.admin, "trash-note", "回收站笔记Gamma", "回收站内容 ![z](/api/images/"+fmt.Sprint(f.imgTrash)+")", "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 }