package product_catalog_test import ( "bytes" "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "github.com/food-ai/backend/internal/domain/product" "github.com/food-ai/backend/internal/infra/middleware" "github.com/go-chi/chi/v5" ) // mockProductSearcher is an inline mock for product.ProductSearcher (catalog search only). type mockProductSearcher struct { searchFn func(ctx context.Context, query string, limit int) ([]*product.Product, error) } func (m *mockProductSearcher) Search(ctx context.Context, query string, limit int) ([]*product.Product, error) { if m.searchFn != nil { return m.searchFn(ctx, query, limit) } return []*product.Product{}, nil } func (m *mockProductSearcher) GetByBarcode(_ context.Context, _ string) (*product.Product, error) { return nil, errors.New("not implemented") } func (m *mockProductSearcher) UpsertByBarcode(_ context.Context, catalogProduct *product.Product) (*product.Product, error) { return catalogProduct, nil } // mockOpenFoodFacts is an inline mock for product.OpenFoodFactsClient (unused in search tests). type mockOpenFoodFacts struct{} func (m *mockOpenFoodFacts) Fetch(_ context.Context, _ string) (*product.Product, error) { return nil, errors.New("not implemented") } type alwaysAuthValidator struct{ userID string } func (v *alwaysAuthValidator) ValidateAccessToken(_ string) (*middleware.TokenClaims, error) { return &middleware.TokenClaims{UserID: v.userID}, nil } func buildRouter(handler *product.Handler) *chi.Mux { router := chi.NewRouter() router.Use(middleware.Auth(&alwaysAuthValidator{userID: "user-1"})) router.Get("/products/search", handler.Search) return router } func authorizedRequest(target string) *http.Request { request := httptest.NewRequest(http.MethodGet, target, bytes.NewReader(nil)) request.Header.Set("Authorization", "Bearer test-token") return request } func TestSearch_EmptyQuery_ReturnsEmptyArray(t *testing.T) { // When q is empty, the handler returns [] without calling the repository. handler := product.NewHandler(&mockProductSearcher{}, &mockOpenFoodFacts{}) router := buildRouter(handler) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, authorizedRequest("/products/search")) if recorder.Code != http.StatusOK { t.Errorf("expected 200, got %d", recorder.Code) } if body := recorder.Body.String(); body != "[]" { t.Errorf("expected body [], got %q", body) } } func TestSearch_LimitTooLarge_UsesDefault(t *testing.T) { // When limit > 50, the handler ignores it and uses default 10. calledLimit := 0 mockRepo := &mockProductSearcher{ searchFn: func(ctx context.Context, query string, limit int) ([]*product.Product, error) { calledLimit = limit return []*product.Product{}, nil }, } handler := product.NewHandler(mockRepo, &mockOpenFoodFacts{}) router := buildRouter(handler) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, authorizedRequest("/products/search?q=apple&limit=100")) if recorder.Code != http.StatusOK { t.Errorf("expected 200, got %d", recorder.Code) } if calledLimit != 10 { t.Errorf("expected repo called with limit=10 (default), got %d", calledLimit) } } func TestSearch_DefaultLimit(t *testing.T) { // When no limit is supplied, the handler uses default 10. calledLimit := 0 mockRepo := &mockProductSearcher{ searchFn: func(ctx context.Context, query string, limit int) ([]*product.Product, error) { calledLimit = limit return []*product.Product{}, nil }, } handler := product.NewHandler(mockRepo, &mockOpenFoodFacts{}) router := buildRouter(handler) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, authorizedRequest("/products/search?q=apple")) if recorder.Code != http.StatusOK { t.Errorf("expected 200, got %d", recorder.Code) } if calledLimit != 10 { t.Errorf("expected default limit 10, got %d", calledLimit) } } func TestSearch_ValidLimit(t *testing.T) { // limit=25 is within range and should be forwarded as-is. calledLimit := 0 mockRepo := &mockProductSearcher{ searchFn: func(ctx context.Context, query string, limit int) ([]*product.Product, error) { calledLimit = limit return []*product.Product{}, nil }, } handler := product.NewHandler(mockRepo, &mockOpenFoodFacts{}) router := buildRouter(handler) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, authorizedRequest("/products/search?q=apple&limit=25")) if recorder.Code != http.StatusOK { t.Errorf("expected 200, got %d", recorder.Code) } if calledLimit != 25 { t.Errorf("expected limit 25, got %d", calledLimit) } } func TestSearch_Success(t *testing.T) { mockRepo := &mockProductSearcher{ searchFn: func(ctx context.Context, query string, limit int) ([]*product.Product, error) { return []*product.Product{ {ID: "prod-1", CanonicalName: "apple"}, }, nil }, } handler := product.NewHandler(mockRepo, &mockOpenFoodFacts{}) router := buildRouter(handler) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, authorizedRequest("/products/search?q=apple")) if recorder.Code != http.StatusOK { t.Errorf("expected 200, got %d", recorder.Code) } var products []product.Product if decodeError := json.NewDecoder(recorder.Body).Decode(&products); decodeError != nil { t.Fatalf("decode response: %v", decodeError) } if len(products) != 1 { t.Errorf("expected 1 result, got %d", len(products)) } if products[0].CanonicalName != "apple" { t.Errorf("expected canonical_name=apple, got %q", products[0].CanonicalName) } }