e750501b44
- store: pgx 只读数据访问层(productByGTIN/ByID/search/nutriments/msrp/brands/categories/source) - handler: 真实查询替换 501 占位, 统一分页 + 错误信封, MSRP 带免责声明无购买入口 - main: pgxpool 连接池接线 - search: 名称模糊 + 分类子树过滤(ltree <@) - 测试: healthz/pageParams 单测 + DB-backed handler 集成测试(无库自动跳过) - CI: Go job 增加 postgres service + migrate up, 实跑 DB 测试 - 依赖: pgx v5.7.2 (固定到兼容 go1.23 的版本) - 本地实跑: 8 个端点对真实 OFF 数据返回正确(barcode/search/nutriments/msrp/brands/categories/source/404) 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)), 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))
|
|
}
|
|
}
|