package web import ( "fmt" "net/http/httptest" "net/url" "os" "strings" "testing" ) // sliceFunc 取出 src 里某个函数的函数体(从签名到下一个顶层 func 之前)。 // 找不到直接 Fatal——否则静态守卫会因为「切片切空了」而假绿。 func sliceFunc(t *testing.T, src, sig string) string { t.Helper() i := strings.Index(src, sig) if i < 0 { t.Fatalf("源码里找不到 %s,静态守卫需要同步更新", sig) } body := src[i:] if j := strings.Index(body[len(sig):], "\nfunc "); j >= 0 { body = body[:len(sig)+j] } if len(body) < 200 { t.Fatalf("%s 的函数体只切到 %d 字节,切片判据失效", sig, len(body)) } return body } // D5 回归(静态):收藏切换必须是「一条语句翻转并回读」。 // // 以前是 UPDATE 之后再单独 SELECT 回读,那次回读一旦失败就落进 else 报「已取消收藏」—— // 状态根本没读出来,这句话有一半是错的。换成 UPDATE ... RETURNING 后没有第二次读, // 也就没有「读失败只能猜」这个分支。 // // 为什么只能静态守:要让那条独立回读失败得先有故障注入(本项目还没有这套设施), // 而正常路径下新旧写法的输出完全一样,行为测试分辨不出来。 func TestVisitBookmarkTogglesInOneStatement(t *testing.T) { src, err := os.ReadFile(pkgFile(t, "handlers_edit.go")) if err != nil { t.Fatalf("读 handlers_edit.go 失败: %v", err) } body := sliceFunc(t, string(src), "func (s *Server) adminVisitBookmark(") if !strings.Contains(body, "RETURNING bookmarked") { t.Error("收藏切换应当用 UPDATE ... RETURNING 一条语句翻转并回读") } if strings.Contains(body, "SELECT bookmarked") { t.Error("不该再有独立的回读语句:它失败时无法知道状态,只能猜") } if strings.Contains(body, "err == nil && on") { t.Error("不许把「读失败」和「读到 false」合成一个分支——那正是误报「已取消收藏」的写法") } if !strings.Contains(body, "s.serverError(w, r, err)") { t.Error("语句失败必须按错误上报,不能伪装成成功") } } // D5 回归(行为):连点两次星标要真的来回翻,且提示必须与库里的状态一致。 // 「翻过来但提示说反了」这种错,只有把提示和库内实况对起来才看得出来。 func TestVisitBookmarkFlashMatchesRow(t *testing.T) { f := newMovementFixture(t) var src int if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM visits WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil { t.Fatalf("找不到到访记录: %v", err) } t.Cleanup(func() { f.pg.ExecContext(f.ctx, `UPDATE visits SET bookmarked=FALSE WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, src) }) click := func() (*httptest.ResponseRecorder, bool) { t.Helper() form := url.Values{"src_pk": {fmt.Sprint(src)}} r := httptest.NewRequest("POST", "/admin/edit/visit/bookmark", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r = r.WithContext(f.ctx) rec := httptest.NewRecorder() f.srv.adminVisitBookmark(rec, r) var on bool if err := f.pg.QueryRowContext(f.ctx, `SELECT bookmarked FROM visits WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, src).Scan(&on); err != nil { t.Fatalf("读库内收藏状态失败: %v", err) } return rec, on } rec, on := click() if !on { t.Fatalf("点一次应当把收藏打开,库里仍是 %v", on) } if msg := flashMsg(t, rec); msg != "已标记为收藏" { t.Errorf("提示 %q 与库内状态(已收藏)不一致", msg) } rec, on = click() if on { t.Fatalf("再点一次应当取消收藏,库里仍是 %v", on) } if msg := flashMsg(t, rec); msg != "已取消收藏" { t.Errorf("提示 %q 与库内状态(未收藏)不一致", msg) } // 不存在的记录要报错,不能假装成功 form := url.Values{"src_pk": {"2147483000"}} r := httptest.NewRequest("POST", "/admin/edit/visit/bookmark", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r = r.WithContext(f.ctx) rec = httptest.NewRecorder() f.srv.adminVisitBookmark(rec, r) if msg := flashMsg(t, rec); strings.Contains(msg, "已") { t.Errorf("对不存在的记录不该回一句成功提示: %q", msg) } } // flashMsg 从重定向的 Location 里取出 flash 文案(redirectEdit 把提示挂在查询串上)。 func flashMsg(t *testing.T, rec *httptest.ResponseRecorder) string { t.Helper() loc := rec.Header().Get("Location") if loc == "" { t.Fatalf("没有重定向,实际状态 %d:%s", rec.Code, firstLine(rec.Body.String())) } u, err := url.Parse(loc) if err != nil { t.Fatalf("解析 Location 失败: %v", err) } msg := u.Query().Get("ok") if msg == "" { msg = u.Query().Get("err") } if msg == "" { t.Fatalf("Location 里没有提示文案: %s", loc) } return msg }