c9a4404052
- 公开前端 SPA(根路径 /):首页大搜索框、检索结果、只读商品详情、贡献档案表单 - 公开写入端点 POST /api/public/submissions(无需登录,基础频率限流),投稿进入 submission 待审核队列,不直接写 product - 迁移 0006:submission 投稿表 + community 来源(trust=0.50) - 后台审核队列:列表(待审核/已通过/已驳回) → 查看投稿 → 通过(创建/补全商品 + 记 source=community + 字段级溯源 + 审计 + 重算质量分) / 驳回(记原因) - 公开只读 api 服务内嵌公开 SPA;Dockerfile.prod 增加 node 构建阶段 + 内嵌 dist Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
135 lines
4.0 KiB
Go
135 lines
4.0 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/baicai2026-baicai/goods/api/internal/store"
|
|
)
|
|
|
|
// newTestHandler connects to the test database, skipping if unavailable or
|
|
// unmigrated. It inserts a known product (cleaned up via t.Cleanup) so the
|
|
// endpoint assertions are deterministic.
|
|
func newTestHandler(t *testing.T) (*Handler, string) {
|
|
t.Helper()
|
|
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
|
|
if dsn == "" {
|
|
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Skipf("no database: %v", err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
pool.Close()
|
|
t.Skipf("database not reachable: %v", err)
|
|
}
|
|
|
|
var hasProduct bool
|
|
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.product') IS NOT NULL").Scan(&hasProduct); err != nil || !hasProduct {
|
|
pool.Close()
|
|
t.Skip("migrations not applied")
|
|
}
|
|
|
|
gtin := "4006381333931"
|
|
_, err = pool.Exec(context.Background(), `
|
|
INSERT INTO product (gtin, name, category_id, net_content_value, net_content_unit)
|
|
VALUES ($1, 'Test Cola', (SELECT id FROM category WHERE path='food.beverages.carbonated'), 330, 'ml')
|
|
ON CONFLICT (gtin) WHERE gtin IS NOT NULL DO UPDATE SET name = EXCLUDED.name`, gtin)
|
|
if err != nil {
|
|
pool.Close()
|
|
t.Fatalf("seed insert failed: %v", err)
|
|
}
|
|
var pid string
|
|
_ = pool.QueryRow(context.Background(), "SELECT id FROM product WHERE gtin=$1", gtin).Scan(&pid)
|
|
_, _ = pool.Exec(context.Background(), `
|
|
INSERT INTO food_detail (product_id, nutrition_basis, nutriments)
|
|
VALUES ($1, 'per_100ml', '{"energy_kcal": 42}'::jsonb)
|
|
ON CONFLICT (product_id) DO UPDATE SET nutriments = EXCLUDED.nutriments`, pid)
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(context.Background(), "DELETE FROM product WHERE gtin=$1", gtin)
|
|
pool.Close()
|
|
})
|
|
return New(store.New(pool), nil), gtin
|
|
}
|
|
|
|
func doGET(t *testing.T, h *Handler, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
rec := httptest.NewRecorder()
|
|
h.Router().ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestProductByBarcode(t *testing.T) {
|
|
h, gtin := newTestHandler(t)
|
|
rec := doGET(t, h, "/api/"+APIVersion+"/products/barcode/"+gtin)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var p store.Product
|
|
if err := json.NewDecoder(rec.Body).Decode(&p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p.Name != "Test Cola" || p.GTIN == nil || *p.GTIN != gtin {
|
|
t.Fatalf("unexpected product: %+v", p)
|
|
}
|
|
if p.CategoryPath == nil || *p.CategoryPath != "food.beverages.carbonated" {
|
|
t.Fatalf("category not joined: %+v", p.CategoryPath)
|
|
}
|
|
}
|
|
|
|
func TestProductByBarcodeNotFound(t *testing.T) {
|
|
h, _ := newTestHandler(t)
|
|
rec := doGET(t, h, "/api/"+APIVersion+"/products/barcode/0000000000000")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSearchProducts(t *testing.T) {
|
|
h, _ := newTestHandler(t)
|
|
rec := doGET(t, h, "/api/"+APIVersion+"/products/search?q=Cola&category=food.beverages")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
var body struct {
|
|
Items []store.ProductSummary `json:"items"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Total < 1 {
|
|
t.Fatalf("expected at least 1 result, got %d", body.Total)
|
|
}
|
|
}
|
|
|
|
func TestListCategories(t *testing.T) {
|
|
h, _ := newTestHandler(t)
|
|
rec := doGET(t, h, "/api/"+APIVersion+"/categories")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
var body struct {
|
|
Items []store.Category `json:"items"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(body.Items) < 20 {
|
|
t.Fatalf("expected seeded categories, got %d", len(body.Items))
|
|
}
|
|
}
|