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