diff --git a/api/src/api/opportunities_v1/opportunity_schemas.py b/api/src/api/opportunities_v1/opportunity_schemas.py index 9de3bf40d..6366321ff 100644 --- a/api/src/api/opportunities_v1/opportunity_schemas.py +++ b/api/src/api/opportunities_v1/opportunity_schemas.py @@ -337,38 +337,34 @@ class OpportunitySearchFilterV1Schema(Schema): ) expected_number_of_awards = fields.Nested( IntegerSearchSchemaBuilder("ExpectedNumberAwardsFilterV1Schema") - .with_minimum_value(example=0) - .with_maximum_value(example=25) + .with_integer_range(min_example=0, max_example=25) .build() ) award_floor = fields.Nested( IntegerSearchSchemaBuilder("AwardFloorFilterV1Schema") - .with_minimum_value(example=0) - .with_maximum_value(example=10_000) + .with_integer_range(min_example=0, max_example=10_000) .build() ) award_ceiling = fields.Nested( IntegerSearchSchemaBuilder("AwardCeilingFilterV1Schema") - .with_minimum_value(example=0) - .with_maximum_value(example=10_000_000) + .with_integer_range(min_example=0, max_example=10_000_000) .build() ) estimated_total_program_funding = fields.Nested( IntegerSearchSchemaBuilder("EstimatedTotalProgramFundingFilterV1Schema") - .with_minimum_value(example=0) - .with_maximum_value(example=10_000_000) + .with_integer_range(min_example=0, max_example=10_000_000) .build() ) post_date = fields.Nested( - DateSearchSchemaBuilder("PostDateFilterV1Schema").with_start_date().with_end_date().build() + DateSearchSchemaBuilder("PostDateFilterV1Schema").with_date_range().build() ) close_date = fields.Nested( - DateSearchSchemaBuilder("CloseDateFilterV1Schema").with_start_date().with_end_date().build() + DateSearchSchemaBuilder("CloseDateFilterV1Schema").with_date_range().build() ) diff --git a/api/src/api/schemas/search_schema.py b/api/src/api/schemas/search_schema.py index 35be5a1b6..0bd5acf99 100644 --- a/api/src/api/schemas/search_schema.py +++ b/api/src/api/schemas/search_schema.py @@ -1,5 +1,5 @@ from enum import StrEnum -from typing import Any, Pattern, Type +from typing import Any, Callable, Pattern, Type from marshmallow import ValidationError, validates_schema @@ -37,8 +37,9 @@ def validates_non_empty(self, data: dict, **kwargs: Any) -> None: class BaseSearchSchemaBuilder: def __init__(self, schema_class_name: str): + # schema fields are the fields and functions of the class + self.schema_fields: dict[str, fields.MixinField | Callable[..., Any]] = {} # The schema class name is used on the endpoint - self.schema_fields: dict[str, fields.MixinField] = {} self.schema_class_name = schema_class_name def build(self) -> Schema: @@ -147,13 +148,23 @@ class IntegerSearchSchemaBuilder(BaseSearchSchemaBuilder): class OpportunitySearchFilterSchema(Schema): example_int_field = fields.Nested( IntegerSearchSchemaBuilder("ExampleIntFieldSchema") - .with_minimum_value(example=1) - .with_maximum_value(example=25) + .with_integer_range(min_example=1, max_example=25) .build() ) """ - def with_minimum_value( + def with_integer_range( + self, + min_example: int | None = None, + max_example: int | None = None, + positive_only: bool = True, + ) -> "IntegerSearchSchemaBuilder": + self._with_minimum_value(min_example, positive_only) + self._with_maximum_value(max_example, positive_only) + self._with_int_range_validator() + return self + + def _with_minimum_value( self, example: int | None = None, positive_only: bool = True ) -> "IntegerSearchSchemaBuilder": metadata = {} @@ -169,7 +180,7 @@ def with_minimum_value( ) return self - def with_maximum_value( + def _with_maximum_value( self, example: int | None = None, positive_only: bool = True ) -> "IntegerSearchSchemaBuilder": metadata = {} @@ -185,6 +196,28 @@ def with_maximum_value( ) return self + def _with_int_range_validator(self) -> "IntegerSearchSchemaBuilder": + # Define a schema validator function that we'll use to define any + # rules that go across fields in the validation + @validates_schema + def validate_int_range(_: Any, data: dict, **kwargs: Any) -> None: + min_value = data.get("min", None) + max_value = data.get("max", None) + + # Error if min and max value are None (either explicitly set, or because they are missing) + if min_value is None and max_value is None: + raise ValidationError( + [ + MarshmallowErrorContainer( + ValidationErrorType.REQUIRED, + "At least one of min or max must be provided.", + ) + ] + ) + + self.schema_fields["validate_int_range"] = validate_int_range + return self + class BoolSearchSchemaBuilder(BaseSearchSchemaBuilder): """ @@ -250,30 +283,38 @@ class DateSearchSchemaBuilder(BaseSearchSchemaBuilder): Usage:: # In a search request schema, you would use it like so: - example_start_date_field = fields.Nested( - DateSearchSchemaBuilder("ExampleStartDateFieldSchema") - .with_start_date() - .build() - ) - - example_end_date_field = fields.Nested( - DateSearchSchemaBuilder("ExampleEndDateFieldSchema") - .with_end_date() - .build() - ) - example_startend_date_field = fields.Nested( DateSearchSchemaBuilder("ExampleStartEndDateFieldSchema") - .with_start_date() - .with_end_date() + .with_date_range() .build() ) """ - def with_start_date(self) -> "DateSearchSchemaBuilder": + def with_date_range(self) -> "DateSearchSchemaBuilder": self.schema_fields["start_date"] = fields.Date(allow_none=True) + self.schema_fields["end_date"] = fields.Date(allow_none=True) + self._with_date_range_validator() + return self - def with_end_date(self) -> "DateSearchSchemaBuilder": - self.schema_fields["end_date"] = fields.Date(allow_none=True) + def _with_date_range_validator(self) -> "DateSearchSchemaBuilder": + # Define a schema validator function that we'll use to define any + # rules that go across fields in the validation + @validates_schema + def validate_date_range(_: Any, data: dict, **kwargs: Any) -> None: + start_date = data.get("start_date", None) + end_date = data.get("end_date", None) + + # Error if start and end date are None (either explicitly set, or because they are missing) + if start_date is None and end_date is None: + raise ValidationError( + [ + MarshmallowErrorContainer( + ValidationErrorType.REQUIRED, + "At least one of start_date or end_date must be provided.", + ) + ] + ) + + self.schema_fields["validate_date_range"] = validate_date_range return self diff --git a/api/src/search/search_models.py b/api/src/search/search_models.py new file mode 100644 index 000000000..e3982323a --- /dev/null +++ b/api/src/search/search_models.py @@ -0,0 +1,21 @@ +from datetime import date + +from pydantic import BaseModel + + +class StrSearchFilter(BaseModel): + one_of: list[str] | None = None + + +class BoolSearchFilter(BaseModel): + one_of: list[bool] | None = None + + +class IntSearchFilter(BaseModel): + min: int | None = None + max: int | None = None + + +class DateSearchFilter(BaseModel): + start_date: date | None = None + end_date: date | None = None diff --git a/api/src/services/opportunities_v1/search_opportunities.py b/api/src/services/opportunities_v1/search_opportunities.py index e3252e90e..e6f1efb69 100644 --- a/api/src/services/opportunities_v1/search_opportunities.py +++ b/api/src/services/opportunities_v1/search_opportunities.py @@ -8,6 +8,12 @@ from src.api.opportunities_v1.opportunity_schemas import OpportunityV1Schema from src.pagination.pagination_models import PaginationInfo, PaginationParams, SortDirection from src.search.search_config import get_search_config +from src.search.search_models import ( + BoolSearchFilter, + DateSearchFilter, + IntSearchFilter, + StrSearchFilter, +) logger = logging.getLogger(__name__) @@ -28,6 +34,11 @@ "funding_instrument": "summary.funding_instruments.keyword", "funding_category": "summary.funding_categories.keyword", "applicant_type": "summary.applicant_types.keyword", + "is_cost_sharing": "summary.is_cost_sharing", + "expected_number_of_awards": "summary.expected_number_of_awards", + "award_floor": "summary.award_floor", + "award_ceiling": "summary.award_ceiling", + "estimated_total_program_funding": "summary.estimated_total_program_funding", } SEARCH_FIELDS = [ @@ -45,11 +56,31 @@ SCHEMA = OpportunityV1Schema() +class OpportunityFilters(BaseModel): + applicant_type: StrSearchFilter | None = None + funding_instrument: StrSearchFilter | None = None + funding_category: StrSearchFilter | None = None + funding_applicant_type: StrSearchFilter | None = None + opportunity_status: StrSearchFilter | None = None + agency: StrSearchFilter | None = None + assistance_listing_number: StrSearchFilter | None = None + + is_cost_sharing: BoolSearchFilter | None = None + + expected_number_of_awards: IntSearchFilter | None = None + award_floor: IntSearchFilter | None = None + award_ceiling: IntSearchFilter | None = None + estimated_total_program_funding: IntSearchFilter | None = None + + post_date: DateSearchFilter | None = None + close_date: DateSearchFilter | None = None + + class SearchOpportunityParams(BaseModel): pagination: PaginationParams query: str | None = Field(default=None) - filters: dict | None = Field(default=None) + filters: OpportunityFilters | None = Field(default=None) def _adjust_field_name(field: str) -> str: @@ -68,16 +99,30 @@ def _get_sort_by(pagination: PaginationParams) -> list[tuple[str, SortDirection] return sort_by -def _add_search_filters(builder: search.SearchQueryBuilder, filters: dict | None) -> None: +def _add_search_filters( + builder: search.SearchQueryBuilder, filters: OpportunityFilters | None +) -> None: if filters is None: return - for field, field_filters in filters.items(): - # one_of filters translate to an opensearch term filter - # see: https://opensearch.org/docs/latest/query-dsl/term/terms/ - one_of_filters = field_filters.get("one_of", None) - if one_of_filters: - builder.filter_terms(_adjust_field_name(field), one_of_filters) + for field in filters.model_fields_set: + field_filters = getattr(filters, field) + field_name = _adjust_field_name(field) + + # We use the type of the search filter to determine what methods + # we call on the builder. This way we can make sure we have the proper + # type mappings. + if isinstance(field_filters, StrSearchFilter) and field_filters.one_of: + builder.filter_terms(field_name, field_filters.one_of) + + elif isinstance(field_filters, BoolSearchFilter) and field_filters.one_of: + builder.filter_terms(field_name, field_filters.one_of) + + elif isinstance(field_filters, IntSearchFilter): + builder.filter_int_range(field_name, field_filters.min, field_filters.max) + + elif isinstance(field_filters, DateSearchFilter): + builder.filter_date_range(field_name, field_filters.start_date, field_filters.end_date) def _add_aggregations(builder: search.SearchQueryBuilder) -> None: diff --git a/api/tests/src/api/opportunities_v1/test_opportunity_route_search.py b/api/tests/src/api/opportunities_v1/test_opportunity_route_search.py index c1661e431..ae5ee250b 100644 --- a/api/tests/src/api/opportunities_v1/test_opportunity_route_search.py +++ b/api/tests/src/api/opportunities_v1/test_opportunity_route_search.py @@ -72,6 +72,11 @@ def build_opp( funding_categories: list, post_date: date, close_date: date | None, + is_cost_sharing: bool, + expected_number_of_awards: int | None, + award_floor: int | None, + award_ceiling: int | None, + estimated_total_program_funding: int | None, ) -> Opportunity: opportunity = OpportunityFactory.build( opportunity_title=opportunity_title, @@ -98,6 +103,11 @@ def build_opp( funding_categories=funding_categories, post_date=post_date, close_date=close_date, + is_cost_sharing=is_cost_sharing, + expected_number_of_awards=expected_number_of_awards, + award_floor=award_floor, + award_ceiling=award_ceiling, + estimated_total_program_funding=estimated_total_program_funding, ) opportunity.current_opportunity_summary = CurrentOpportunitySummaryFactory.build( @@ -135,6 +145,11 @@ def build_opp( funding_categories=[FundingCategory.EDUCATION], post_date=date(2020, 3, 1), close_date=date(2027, 6, 1), + is_cost_sharing=True, + expected_number_of_awards=3, + award_floor=50_000, + award_ceiling=5_000_000, + estimated_total_program_funding=15_000_000, ) NASA_INNOVATIONS = build_opp( @@ -149,6 +164,11 @@ def build_opp( funding_categories=[FundingCategory.SCIENCE_TECHNOLOGY_AND_OTHER_RESEARCH_AND_DEVELOPMENT], post_date=date(2019, 3, 1), close_date=None, + is_cost_sharing=False, + expected_number_of_awards=1, + award_floor=5000, + award_ceiling=5000, + estimated_total_program_funding=5000, ) NASA_SUPERSONIC = build_opp( @@ -163,6 +183,11 @@ def build_opp( funding_categories=[FundingCategory.SCIENCE_TECHNOLOGY_AND_OTHER_RESEARCH_AND_DEVELOPMENT], post_date=date(2021, 3, 1), close_date=date(2030, 6, 1), + is_cost_sharing=True, + expected_number_of_awards=9, + award_floor=10_000, + award_ceiling=50_000, + estimated_total_program_funding=None, ) NASA_K12_DIVERSITY = build_opp( @@ -177,6 +202,11 @@ def build_opp( funding_categories=[FundingCategory.EDUCATION], post_date=date(2025, 3, 1), close_date=date(2018, 6, 1), + is_cost_sharing=False, + expected_number_of_awards=None, + award_floor=None, + award_ceiling=None, + estimated_total_program_funding=None, ) LOC_TEACHING = build_opp( @@ -197,6 +227,11 @@ def build_opp( funding_categories=[FundingCategory.EDUCATION], post_date=date(2031, 3, 1), close_date=date(2010, 6, 1), + is_cost_sharing=True, + expected_number_of_awards=100, + award_floor=500, + award_ceiling=1_000, + estimated_total_program_funding=10_000, ) LOC_HIGHER_EDUCATION = build_opp( @@ -214,6 +249,11 @@ def build_opp( funding_categories=[FundingCategory.OTHER], post_date=date(2026, 3, 1), close_date=None, + is_cost_sharing=False, + expected_number_of_awards=1, + award_floor=None, + award_ceiling=None, + estimated_total_program_funding=15_000_000, ) DOS_DIGITAL_LITERACY = build_opp( @@ -233,6 +273,11 @@ def build_opp( funding_categories=[FundingCategory.OTHER], post_date=date(2028, 3, 1), close_date=date(2023, 6, 1), + is_cost_sharing=True, + expected_number_of_awards=2, + award_floor=5, + award_ceiling=10, + estimated_total_program_funding=15, ) DOC_SPACE_COAST = build_opp( @@ -251,6 +296,11 @@ def build_opp( funding_categories=[FundingCategory.OTHER, FundingCategory.REGIONAL_DEVELOPMENT], post_date=date(2017, 3, 1), close_date=date(2019, 6, 1), + is_cost_sharing=False, + expected_number_of_awards=1000, + award_floor=1, + award_ceiling=2, + estimated_total_program_funding=2000, ) DOC_MANUFACTURING = build_opp( @@ -269,6 +319,11 @@ def build_opp( ], post_date=date(2013, 3, 1), close_date=date(2035, 6, 1), + is_cost_sharing=True, + expected_number_of_awards=25, + award_floor=50_000_000, + award_ceiling=5_000_000_000, + estimated_total_program_funding=15_000_000_000, ) OPPORTUNITIES = [ @@ -709,33 +764,257 @@ def test_search_filters_200(self, client, api_auth_token, search_request, expect call_search_and_validate(client, api_auth_token, search_request, expected_results) @pytest.mark.parametrize( - "search_request", + "search_request, expected_results", [ - # Post Date - (get_search_request(post_date={"start_date": None})), - (get_search_request(post_date={"end_date": None})), - (get_search_request(post_date={"start_date": "2020-01-01"})), - (get_search_request(post_date={"end_date": "2020-02-01"})), - (get_search_request(post_date={"start_date": None, "end_date": None})), - (get_search_request(post_date={"start_date": "2020-01-01", "end_date": None})), - (get_search_request(post_date={"start_date": None, "end_date": "2020-02-01"})), - (get_search_request(post_date={"start_date": "2020-01-01", "end_date": "2020-02-01"})), - # Close Date - (get_search_request(close_date={"start_date": None})), - (get_search_request(close_date={"end_date": None})), - (get_search_request(close_date={"start_date": "2020-01-01"})), - (get_search_request(close_date={"end_date": "2020-02-01"})), - (get_search_request(close_date={"start_date": None, "end_date": None})), - (get_search_request(close_date={"start_date": "2020-01-01", "end_date": None})), - (get_search_request(close_date={"start_date": None, "end_date": "2020-02-01"})), - (get_search_request(close_date={"start_date": "2020-01-01", "end_date": "2020-02-01"})), + # Post date + ( + get_search_request( + post_date={"start_date": "1970-01-01", "end_date": "2050-01-01"} + ), + OPPORTUNITIES, + ), + ( + get_search_request( + post_date={"start_date": "1999-01-01", "end_date": "2000-01-01"} + ), + [], + ), + ( + get_search_request( + post_date={"start_date": "2015-01-01", "end_date": "2018-01-01"} + ), + [DOC_SPACE_COAST], + ), + ( + get_search_request( + post_date={"start_date": "2019-06-01", "end_date": "2024-01-01"} + ), + [NASA_SPACE_FELLOWSHIP, NASA_SUPERSONIC], + ), + (get_search_request(post_date={"end_date": "2016-01-01"}), [DOC_MANUFACTURING]), + # Close date + ( + get_search_request( + close_date={"start_date": "1970-01-01", "end_date": "2050-01-01"} + ), + [ + NASA_SPACE_FELLOWSHIP, + NASA_SUPERSONIC, + NASA_K12_DIVERSITY, + LOC_TEACHING, + DOS_DIGITAL_LITERACY, + DOC_SPACE_COAST, + DOC_MANUFACTURING, + ], + ), + ( + get_search_request(close_date={"start_date": "2019-01-01"}), + [ + NASA_SPACE_FELLOWSHIP, + NASA_SUPERSONIC, + DOS_DIGITAL_LITERACY, + DOC_SPACE_COAST, + DOC_MANUFACTURING, + ], + ), + ( + get_search_request(close_date={"end_date": "2019-01-01"}), + [NASA_K12_DIVERSITY, LOC_TEACHING], + ), + ( + get_search_request( + close_date={"start_date": "2015-01-01", "end_date": "2019-12-01"} + ), + [NASA_K12_DIVERSITY, DOC_SPACE_COAST], + ), ], ) - def test_search_validate_date_filters_200(self, client, api_auth_token, search_request): - resp = client.post( - "/v1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token} - ) - assert resp.status_code == 200 + def test_search_filters_date_200( + self, client, api_auth_token, search_request, expected_results + ): + call_search_and_validate(client, api_auth_token, search_request, expected_results) + + @pytest.mark.parametrize( + "search_request, expected_results", + [ + # Is cost sharing + (get_search_request(is_cost_sharing_one_of=[True, False]), OPPORTUNITIES), + (get_search_request(is_cost_sharing_one_of=["1", "0"]), OPPORTUNITIES), + ( + get_search_request(is_cost_sharing_one_of=["t"]), + [ + NASA_SPACE_FELLOWSHIP, + NASA_SUPERSONIC, + LOC_TEACHING, + DOS_DIGITAL_LITERACY, + DOC_MANUFACTURING, + ], + ), + ( + get_search_request(is_cost_sharing_one_of=["on"]), + [ + NASA_SPACE_FELLOWSHIP, + NASA_SUPERSONIC, + LOC_TEACHING, + DOS_DIGITAL_LITERACY, + DOC_MANUFACTURING, + ], + ), + ( + get_search_request(is_cost_sharing_one_of=["false"]), + [NASA_INNOVATIONS, NASA_K12_DIVERSITY, LOC_HIGHER_EDUCATION, DOC_SPACE_COAST], + ), + ( + get_search_request(is_cost_sharing_one_of=["no"]), + [NASA_INNOVATIONS, NASA_K12_DIVERSITY, LOC_HIGHER_EDUCATION, DOC_SPACE_COAST], + ), + ], + ) + def test_search_bool_filters_200( + self, client, api_auth_token, search_request, expected_results + ): + call_search_and_validate(client, api_auth_token, search_request, expected_results) + + @pytest.mark.parametrize( + "search_request, expected_results", + [ + # Expected Number of Awards + ( + get_search_request(expected_number_of_awards={"min": 0, "max": 1000}), + [ + NASA_SPACE_FELLOWSHIP, + NASA_INNOVATIONS, + NASA_SUPERSONIC, + LOC_TEACHING, + LOC_HIGHER_EDUCATION, + DOS_DIGITAL_LITERACY, + DOC_SPACE_COAST, + DOC_MANUFACTURING, + ], + ), + ( + get_search_request(expected_number_of_awards={"min": 5, "max": 10}), + [NASA_SUPERSONIC], + ), + ( + get_search_request(expected_number_of_awards={"min": 12}), + [LOC_TEACHING, DOC_SPACE_COAST, DOC_MANUFACTURING], + ), + ( + get_search_request(expected_number_of_awards={"min": 7}), + [NASA_SUPERSONIC, LOC_TEACHING, DOC_SPACE_COAST, DOC_MANUFACTURING], + ), + # Award Floor + ( + get_search_request(award_floor={"min": 0, "max": 10_000_000_000}), + [ + NASA_SPACE_FELLOWSHIP, + NASA_INNOVATIONS, + NASA_SUPERSONIC, + LOC_TEACHING, + DOS_DIGITAL_LITERACY, + DOC_SPACE_COAST, + DOC_MANUFACTURING, + ], + ), + ( + get_search_request(award_floor={"min": 1, "max": 5_000}), + [ + NASA_INNOVATIONS, + LOC_TEACHING, + DOS_DIGITAL_LITERACY, + DOC_SPACE_COAST, + ], + ), + ( + get_search_request(award_floor={"min": 5_000, "max": 10_000}), + [ + NASA_INNOVATIONS, + NASA_SUPERSONIC, + ], + ), + # Award Ceiling + ( + get_search_request(award_ceiling={"min": 0, "max": 10_000_000_000}), + [ + NASA_SPACE_FELLOWSHIP, + NASA_INNOVATIONS, + NASA_SUPERSONIC, + LOC_TEACHING, + DOS_DIGITAL_LITERACY, + DOC_SPACE_COAST, + DOC_MANUFACTURING, + ], + ), + ( + get_search_request(award_ceiling={"min": 5_000, "max": 50_000}), + [ + NASA_INNOVATIONS, + NASA_SUPERSONIC, + ], + ), + ( + get_search_request(award_ceiling={"min": 50_000}), + [ + NASA_SPACE_FELLOWSHIP, + NASA_SUPERSONIC, + DOC_MANUFACTURING, + ], + ), + # Estimated Total Program Funding + ( + get_search_request( + estimated_total_program_funding={"min": 0, "max": 100_000_000_000} + ), + [ + NASA_SPACE_FELLOWSHIP, + NASA_INNOVATIONS, + LOC_TEACHING, + LOC_HIGHER_EDUCATION, + DOS_DIGITAL_LITERACY, + DOC_SPACE_COAST, + DOC_MANUFACTURING, + ], + ), + ( + get_search_request(estimated_total_program_funding={"min": 0, "max": 5_000}), + [ + NASA_INNOVATIONS, + DOS_DIGITAL_LITERACY, + DOC_SPACE_COAST, + ], + ), + # Mix + ( + get_search_request( + expected_number_of_awards={"min": 0}, + award_floor={"max": 10_000}, + award_ceiling={"max": 10_000_000}, + estimated_total_program_funding={"min": 10_000}, + ), + [LOC_TEACHING], + ), + ( + get_search_request( + expected_number_of_awards={"max": 10}, + award_floor={"min": 1_000, "max": 10_000}, + award_ceiling={"max": 10_000_000}, + ), + [NASA_INNOVATIONS, NASA_SUPERSONIC], + ), + ( + get_search_request( + expected_number_of_awards={"min": 1, "max": 2}, + award_floor={"min": 0, "max": 1000}, + award_ceiling={"min": 10000, "max": 10000000}, + estimated_total_program_funding={"min": 123456, "max": 345678}, + ), + [], + ), + ], + ) + def test_search_int_filters_200(self, client, api_auth_token, search_request, expected_results): + call_search_and_validate(client, api_auth_token, search_request, expected_results) @pytest.mark.parametrize( "search_request", @@ -760,7 +1039,7 @@ def test_search_validate_date_filters_200(self, client, api_auth_token, search_r (get_search_request(close_date={"end_date": 5})), ], ) - def test_search_validate_date_filters_422(self, client, api_auth_token, search_request): + def test_search_validate_date_filters_format_422(self, client, api_auth_token, search_request): resp = client.post( "/v1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token} ) @@ -771,6 +1050,34 @@ def test_search_validate_date_filters_422(self, client, api_auth_token, search_r assert json["message"] == "Validation error" assert error["message"] == "Not a valid date." + @pytest.mark.parametrize( + "search_request", + [ + # Post Date + (get_search_request(post_date={"start_date": None, "end_date": None})), + (get_search_request(post_date={"start_date": None})), + (get_search_request(post_date={"end_date": None})), + (get_search_request(post_date={})), + # Close Date + (get_search_request(close_date={"start_date": None, "end_date": None})), + (get_search_request(close_date={"start_date": None})), + (get_search_request(close_date={"end_date": None})), + (get_search_request(close_date={})), + ], + ) + def test_search_validate_date_filters_nullability_422( + self, client, api_auth_token, search_request + ): + resp = client.post( + "/v1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token} + ) + assert resp.status_code == 422 + + json = resp.get_json() + error = json["errors"][0] + assert json["message"] == "Validation error" + assert error["message"] == "At least one of start_date or end_date must be provided." + @pytest.mark.parametrize( "search_request", [ @@ -812,23 +1119,6 @@ def test_search_validate_assistance_listing_filters_422( assert json["message"] == "Validation error" assert error["message"] == "String does not match expected pattern." - @pytest.mark.parametrize( - "search_request", - [ - get_search_request(is_cost_sharing_one_of=[True, False]), - get_search_request(is_cost_sharing_one_of=["1", "0"]), - get_search_request(is_cost_sharing_one_of=["t", "f"]), - get_search_request(is_cost_sharing_one_of=["true", "false"]), - get_search_request(is_cost_sharing_one_of=["on", "off"]), - get_search_request(is_cost_sharing_one_of=["yes", "no"]), - ], - ) - def test_search_validate_is_cost_sharing_200(self, client, api_auth_token, search_request): - resp = client.post( - "/v1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token} - ) - assert resp.status_code == 200 - @pytest.mark.parametrize( "search_request", [ @@ -852,33 +1142,6 @@ def test_search_validate_is_cost_sharing_filters_422( assert json["message"] == "Validation error" assert error["message"] == "Not a valid boolean." - @pytest.mark.parametrize( - "search_request", - [ - get_search_request( - expected_number_of_awards={"min": 0}, - award_floor={"max": 35}, - award_ceiling={"max": "10000000"}, - estimated_total_program_funding={"min": "123456"}, - ), - get_search_request( - expected_number_of_awards={"min": 1, "max": 2}, - award_floor={"min": 0, "max": 1000}, - award_ceiling={"min": 10000, "max": 10000000}, - estimated_total_program_funding={"min": 123456, "max": 345678}, - ), - get_search_request(expected_number_of_awards={"min": 1, "max": 2}), - get_search_request(award_floor={"min": 0, "max": 1000}), - get_search_request(award_ceiling={"min": "10000", "max": 10000000}), - get_search_request(estimated_total_program_funding={"min": 123456, "max": "345678"}), - ], - ) - def test_search_validate_award_values_200(self, client, api_auth_token, search_request): - resp = client.post( - "/v1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token} - ) - assert resp.status_code == 200 - @pytest.mark.parametrize( "search_request", [ @@ -925,6 +1188,44 @@ def test_search_validate_award_values_negative_422( for error in json["errors"]: assert error["message"] == "Must be greater than or equal to 0." + @pytest.mark.parametrize( + "search_request", + [ + # Both set to None + get_search_request( + expected_number_of_awards={"min": None, "max": None}, + award_floor={"min": None, "max": None}, + award_ceiling={"min": None, "max": None}, + estimated_total_program_funding={"min": None, "max": None}, + ), + # Min only set + get_search_request( + expected_number_of_awards={"min": None}, + award_floor={"min": None}, + award_ceiling={"min": None}, + estimated_total_program_funding={"min": None}, + ), + # Max only set + get_search_request( + expected_number_of_awards={"max": None}, + award_floor={"max": None}, + award_ceiling={"max": None}, + estimated_total_program_funding={"max": None}, + ), + ], + ) + def test_search_validate_award_values_nullability_422( + self, client, api_auth_token, search_request + ): + resp = client.post( + "/v1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token} + ) + + json = resp.get_json() + assert json["message"] == "Validation error" + for error in json["errors"]: + assert error["message"] == "At least one of min or max must be provided." + @pytest.mark.parametrize( "search_request, expected_results", [