69a0149bbe
Search: - migration 0009: trigram GIN index on brand.name + btree on country_of_origin - SearchProducts: typo-tolerant word_similarity matching (>=0.42) on top of ILIKE substring + barcode; new brand/country filters; rank by similarity * (0.5 + quality_score). Response gains country_of_origin, quality_score and per-result relevance score. - public search UI: brand/country filter inputs; show country in results Docs: - serve embedded OpenAPI 3 spec at GET /api/v1/openapi.json (not rate limited) - ApiDocs page: auth + rate-limit section, updated search params/response - docs/api.md developer guide Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
230 lines
7.0 KiB
Go
230 lines
7.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 TestSearchFuzzyAndFilters(t *testing.T) {
|
|
h, _ := newTestHandler(t)
|
|
ctx := context.Background()
|
|
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
|
|
if dsn == "" {
|
|
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
|
|
}
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Skipf("no database: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
_, err = pool.Exec(ctx,
|
|
"INSERT INTO brand (name, normalized_name) VALUES ('ZZ Test Brand','zz test brand') ON CONFLICT DO NOTHING")
|
|
if err != nil {
|
|
t.Fatalf("seed brand: %v", err)
|
|
}
|
|
_, err = pool.Exec(ctx, `
|
|
INSERT INTO product (name, brand_id, country_of_origin, quality_score, status)
|
|
VALUES ('ZZ Hazelnut Chocolate', (SELECT id FROM brand WHERE name='ZZ Test Brand'), 'Testland', 0.5, 'active')`)
|
|
if err != nil {
|
|
t.Fatalf("seed product: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(ctx, "DELETE FROM product WHERE name='ZZ Hazelnut Chocolate'")
|
|
_, _ = pool.Exec(ctx, "DELETE FROM brand WHERE name='ZZ Test Brand'")
|
|
})
|
|
|
|
decode := func(path string) []store.ProductSummary {
|
|
rec := doGET(t, h, path)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s -> status %d", path, rec.Code)
|
|
}
|
|
var body struct {
|
|
Items []store.ProductSummary `json:"items"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return body.Items
|
|
}
|
|
has := func(items []store.ProductSummary, name string) *store.ProductSummary {
|
|
for i := range items {
|
|
if items[i].Name == name {
|
|
return &items[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Typo "choclate" should fuzzy-match via word_similarity and carry a score.
|
|
got := has(decode("/api/"+APIVersion+"/products/search?q=choclate"), "ZZ Hazelnut Chocolate")
|
|
if got == nil {
|
|
t.Fatal("fuzzy query 'choclate' did not match 'ZZ Hazelnut Chocolate'")
|
|
}
|
|
if got.Score == nil || *got.Score <= 0 {
|
|
t.Fatalf("expected positive fuzzy score, got %v", got.Score)
|
|
}
|
|
|
|
// Brand filter.
|
|
if has(decode("/api/"+APIVersion+"/products/search?brand=ZZ+Test+Brand"), "ZZ Hazelnut Chocolate") == nil {
|
|
t.Fatal("brand filter did not return the product")
|
|
}
|
|
// Country filter (case-insensitive prefix).
|
|
if has(decode("/api/"+APIVersion+"/products/search?country=test"), "ZZ Hazelnut Chocolate") == nil {
|
|
t.Fatal("country filter did not return the product")
|
|
}
|
|
// Non-matching country excludes it.
|
|
if has(decode("/api/"+APIVersion+"/products/search?country=france"), "ZZ Hazelnut Chocolate") != nil {
|
|
t.Fatal("country filter 'france' should not return the product")
|
|
}
|
|
}
|
|
|
|
func TestOpenAPISpec(t *testing.T) {
|
|
h, _ := newTestHandler(t)
|
|
rec := doGET(t, h, "/api/"+APIVersion+"/openapi.json")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
var spec struct {
|
|
OpenAPI string `json:"openapi"`
|
|
Paths map[string]any `json:"paths"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&spec); err != nil {
|
|
t.Fatalf("openapi.json is not valid JSON: %v", err)
|
|
}
|
|
if spec.OpenAPI == "" || len(spec.Paths) == 0 {
|
|
t.Fatalf("unexpected spec: %+v", spec)
|
|
}
|
|
if _, ok := spec.Paths["/products/search"]; !ok {
|
|
t.Fatal("spec missing /products/search path")
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|