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)) } }