package handler import ( "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "gorm.io/gorm" "github.com/wangjia/jiu/backend/testutil" ) // SSR 公开页(2026-07-07 去 Flutter 化):商品页 /product/:id 与店铺页 /shop/:code // 由后端直出轻量 HTML;断言内容、OG 标签、敏感字段零暴露、搜索与分页。 func setupPageRouter(db *gorm.DB) *gin.Engine { h := NewPublicHandler(db) r := gin.New() r.Use(gin.Recovery()) r.GET("/product/:public_id", h.ProductPage) r.GET("/shop/:shop_code", h.ShopPage) return r } func getPage(r *gin.Engine, path string) *httptest.ResponseRecorder { req := httptest.NewRequest("GET", path, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) return w } func TestProductPage_SSR(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PPG01") wh := testutil.CreateTestWarehouse(db, shop.ID, "仓") r := setupPageRouter(db) p := testutil.CreateTestProduct(db, shop.ID, "茅台飞天53度") setPublicID(db, p.ID, "pub-ssr-001") addInventory(db, shop.ID, wh.ID, p.ID, 6) w := getPage(r, "/product/pub-ssr-001") assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Header().Get("Content-Type"), "text/html") assert.Equal(t, "no-store", w.Header().Get("Cache-Control")) body := w.Body.String() // 轻量页:不再是 Flutter 壳 assert.NotContains(t, body, "flutter_bootstrap") assert.NotContains(t, body, ``) // 内容 assert.Contains(t, body, "茅台飞天53度") assert.Contains(t, body, shop.Name) // OG 标签(og:url 指向 SSR 短链) assert.Contains(t, body, `property="og:title"`) assert.Contains(t, body, "/product/pub-ssr-001") // 门店互跳;「在 App 中查看」已移除(2026-07-07 用户拍板) assert.Contains(t, body, "/shop/"+shop.Code) assert.NotContains(t, body, "/app/product/") // 敏感字段零暴露(成本/进价永不出现在公开页) for _, s := range []string{"cost", "purchase_price", "profit"} { assert.NotContains(t, body, s, "公开页不得出现敏感字段名 %s", s) } } func TestProductPage_NotFound(t *testing.T) { db := testutil.SetupTestDB() r := setupPageRouter(db) w := getPage(r, "/product/no-such-id") // 历史行为保持:HTTP 200 + 友好文案(对爬虫不报错) assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), "商品不存在") } func TestShopPage_SSR_SearchAndPaging(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SPG01") wh := testutil.CreateTestWarehouse(db, shop.ID, "仓") r := setupPageRouter(db) // 25 个有库存商品 → 2 页;外加一个名称可搜索的 for i := 0; i < 25; i++ { p := testutil.CreateTestProduct(db, shop.ID, "批量酒"+strings.Repeat("x", i%3)) setPublicID(db, p.ID, "sp-"+strings.Repeat("a", 1)+string(rune('A'+i%26))+strings.Repeat("b", i/26+1)) addInventory(db, shop.ID, wh.ID, p.ID, 3) } target := testutil.CreateTestProduct(db, shop.ID, "五粮液普五") setPublicID(db, target.ID, "sp-target") addInventory(db, shop.ID, wh.ID, target.ID, 2) // 第 1 页:20 条 + 下一页链接 w := getPage(r, "/shop/"+shop.Code) assert.Equal(t, http.StatusOK, w.Code) body := w.Body.String() assert.Contains(t, body, shop.Name) assert.Contains(t, body, "26 件") assert.Contains(t, body, "page=2") assert.Equal(t, 20, strings.Count(body, `class="item"`), "第一页应有 20 张商品卡") assert.NotContains(t, body, "flutter_bootstrap") // 第 2 页:6 条 + 上一页链接 w = getPage(r, "/shop/"+shop.Code+"?page=2") body = w.Body.String() assert.Equal(t, 6, strings.Count(body, `class="item"`)) assert.Contains(t, body, "上一页") // 搜索:只命中目标商品,商品卡链接到 SSR 商品页 w = getPage(r, "/shop/"+shop.Code+"?q="+urlQueryEscape("五粮液")) body = w.Body.String() assert.Equal(t, 1, strings.Count(body, `class="item"`)) assert.Contains(t, body, "/product/sp-target") // 搜索无结果:空态文案 w = getPage(r, "/shop/"+shop.Code+"?q=NOPE") assert.Contains(t, w.Body.String(), "没有找到") } func TestShopPage_NotFound(t *testing.T) { db := testutil.SetupTestDB() r := setupPageRouter(db) w := getPage(r, "/shop/NOSHOP") assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), "店铺不存在") } // API 与 SSR 共用装配后的回归:ListShopProducts 行为不变(不受 keyword 影响) func TestListShopProducts_UnaffectedByRefactor(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SPG02") wh := testutil.CreateTestWarehouse(db, shop.ID, "仓") p := testutil.CreateTestProduct(db, shop.ID, "剑南春") setPublicID(db, p.ID, "sp2-x") addInventory(db, shop.ID, wh.ID, p.ID, 1) r := setupPublicRouter(db) w := getPage(r, "/api/v1/public/shops/"+shop.Code+"/products") assert.Equal(t, http.StatusOK, w.Code) resp := parseResponse(w) assert.Equal(t, float64(1), resp["total"].(float64)) } func urlQueryEscape(s string) string { // 测试用最小转义(仅中文场景) var b strings.Builder for _, c := range []byte(s) { b.WriteString("%") const hex = "0123456789ABCDEF" b.WriteByte(hex[c>>4]) b.WriteByte(hex[c&0xF]) } return b.String() }