From 1260eed1add2f66d5fd5c52c6370130c854f74e3 Mon Sep 17 00:00:00 2001 From: Kial Jinnah Date: Tue, 6 Aug 2024 16:22:38 -0700 Subject: [PATCH] API - v2 search endpoints Signed-off-by: Kial Jinnah --- search-api/src/search_api/__init__.py | 3 +- search-api/src/search_api/config.py | 1 + .../src/search_api/resources/__init__.py | 3 +- .../resources/v1/businesses/search.py | 8 +- .../src/search_api/resources/v2/__init__.py | 15 + .../resources/v2/search/__init__.py | 23 ++ .../resources/v2/search/businesses.py | 113 +++++ .../search_api/resources/v2/search/parties.py | 122 ++++++ .../search_api/utils/validators/__init__.py | 36 ++ search-api/tests/unit/api/v2/__init__.py | 14 + .../tests/unit/api/v2/search/__init__.py | 14 + .../unit/api/v2/search/test_businesses.py | 387 ++++++++++++++++++ .../tests/unit/api/v2/search/test_parties.py | 252 ++++++++++++ 13 files changed, 987 insertions(+), 4 deletions(-) create mode 100644 search-api/src/search_api/resources/v2/__init__.py create mode 100644 search-api/src/search_api/resources/v2/search/__init__.py create mode 100644 search-api/src/search_api/resources/v2/search/businesses.py create mode 100644 search-api/src/search_api/resources/v2/search/parties.py create mode 100644 search-api/tests/unit/api/v2/__init__.py create mode 100644 search-api/tests/unit/api/v2/search/__init__.py create mode 100644 search-api/tests/unit/api/v2/search/test_businesses.py create mode 100644 search-api/tests/unit/api/v2/search/test_parties.py diff --git a/search-api/src/search_api/__init__.py b/search-api/src/search_api/__init__.py index f4a50ba9..3267f354 100644 --- a/search-api/src/search_api/__init__.py +++ b/search-api/src/search_api/__init__.py @@ -29,7 +29,7 @@ from search_api import errorhandlers, models from search_api.config import config from search_api.models import db -from search_api.resources import v1_endpoint +from search_api.resources import v1_endpoint, v2_endpoint from search_api.services import Flags, business_solr, queue from search_api.translations import babel from search_api.utils.auth import jwt @@ -68,6 +68,7 @@ def create_app(config_name: str = os.getenv('APP_ENV') or 'production', **kwargs migrate.init_app(app, db) v1_endpoint.init_app(app) + v2_endpoint.init_app(app) setup_jwt_manager(app, jwt) @app.before_request diff --git a/search-api/src/search_api/config.py b/search-api/src/search_api/config.py index 6a72ecac..aa25462c 100644 --- a/search-api/src/search_api/config.py +++ b/search-api/src/search_api/config.py @@ -44,6 +44,7 @@ class Config(): # pylint: disable=too-few-public-methods SOLR_SVC_BUS_FOLLOWER_CORE = os.getenv('SOLR_SVC_BUS_FOLLOWER_CORE', 'business_follower') SOLR_SVC_BUS_LEADER_URL = os.getenv('SOLR_SVC_BUS_LEADER_URL', 'http://localhost:8873/solr') SOLR_SVC_BUS_FOLLOWER_URL = os.getenv('SOLR_SVC_BUS_FOLLOWER_URL', 'http://localhost:8873/solr') + SOLR_SVC_BUS_MAX_ROWS = int(os.getenv('SOLR_SVC_BUS_MAX_ROWS', '10000')) PAYMENT_SVC_URL = os.getenv('PAYMENT_SVC_URL', 'http://') AUTH_SVC_URL = os.getenv('AUTH_SVC_URL', 'http://') diff --git a/search-api/src/search_api/resources/__init__.py b/search-api/src/search_api/resources/__init__.py index ef2e0a34..ff3fca57 100644 --- a/search-api/src/search_api/resources/__init__.py +++ b/search-api/src/search_api/resources/__init__.py @@ -14,6 +14,7 @@ """Exposes the versioned endpoints.""" from .constants import EndpointVersionPath from .v1 import bus_bp, internal_bp, meta_bp, ops_bp, purchases_bp +from .v2 import search_bp from .version_endpoint import VersionEndpoint @@ -25,4 +26,4 @@ v2_endpoint = VersionEndpoint( # pylint: disable=invalid-name name='API_V2', path=EndpointVersionPath.API_V2, - bps=[]) + bps=[search_bp]) diff --git a/search-api/src/search_api/resources/v1/businesses/search.py b/search-api/src/search_api/resources/v1/businesses/search.py index f407df83..1c7d26c8 100644 --- a/search-api/src/search_api/resources/v1/businesses/search.py +++ b/search-api/src/search_api/resources/v1/businesses/search.py @@ -163,7 +163,8 @@ def facets(): # pylint: disable=too-many-branches, too-many-locals 'start': start or 0 }, 'totalResults': results.get('response', {}).get('numFound'), - 'results': results.get('response', {}).get('docs')}} + 'results': results.get('response', {}).get('docs')}, + 'warnings': ['This endpoint is depreciated. Please use POST /api/v2/search/businesses instead.']} return jsonify(response), HTTPStatus.OK @@ -287,7 +288,10 @@ def parties(): # pylint: disable=too-many-branches, too-many-return-statements, 'rows': rows or business_solr.default_rows, 'start': start or 0}, 'totalResults': results.get('response', {}).get('numFound'), - 'results': results.get('response', {}).get('docs')}} + 'results': results.get('response', {}).get('docs') + }, + 'warnings': ['This endpoint is depreciated. Please use POST /api/v2/search/parties instead.'] + } return jsonify(response), HTTPStatus.OK diff --git a/search-api/src/search_api/resources/v2/__init__.py b/search-api/src/search_api/resources/v2/__init__.py new file mode 100644 index 00000000..fd5c4788 --- /dev/null +++ b/search-api/src/search_api/resources/v2/__init__.py @@ -0,0 +1,15 @@ +# Copyright © 2022 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Exposes all of the resource v2 endpoints in Flask-Blueprint style.""" +from .search import bp as search_bp diff --git a/search-api/src/search_api/resources/v2/search/__init__.py b/search-api/src/search_api/resources/v2/search/__init__.py new file mode 100644 index 00000000..b74871df --- /dev/null +++ b/search-api/src/search_api/resources/v2/search/__init__.py @@ -0,0 +1,23 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""API endpoints for Search.""" +from flask import Blueprint + +from .businesses import bp as businesses_bp +from .parties import bp as parties_bp + + +bp = Blueprint('SEARCH', __name__, url_prefix='/search') # pylint: disable=invalid-name +bp.register_blueprint(businesses_bp) +bp.register_blueprint(parties_bp) diff --git a/search-api/src/search_api/resources/v2/search/businesses.py b/search-api/src/search_api/resources/v2/search/businesses.py new file mode 100644 index 00000000..1f840415 --- /dev/null +++ b/search-api/src/search_api/resources/v2/search/businesses.py @@ -0,0 +1,113 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""API endpoints for Search.""" +from http import HTTPStatus + +from flask import jsonify, Blueprint +from flask_cors import cross_origin + +import search_api.resources.utils as resource_utils +from search_api.exceptions import SolrException +from search_api.services import business_solr +from search_api.services.base_solr.utils import QueryParams, parse_facets, prep_query_str +from search_api.services.business_solr.doc_fields import BusinessField, PartyField +from search_api.services.business_solr.utils import business_search +from search_api.utils.validators import validate_search_request + + +bp = Blueprint('BUSINESSES', __name__, url_prefix='/businesses') # pylint: disable=invalid-name + + +@bp.post('') +@cross_origin(origin='*') +def businesses(): + """Return a list of business results.""" + try: + request_json, errors = validate_search_request() + if errors: + return resource_utils.bad_request_response('Errors processing request.', errors) + # set base query params + query_json: dict = request_json.get('query', {}) + value = query_json.get('value', None) + query = { + 'value': prep_query_str(value, True), + BusinessField.NAME_SINGLE.value: prep_query_str(query_json.get(BusinessField.NAME.value, '')), + BusinessField.IDENTIFIER_Q.value: prep_query_str(query_json.get(BusinessField.IDENTIFIER.value, '')), + BusinessField.BN_Q.value: prep_query_str(query_json.get(BusinessField.BN.value, '')) + } + # set child query params + child_query = { + PartyField.PARTY_NAME_SINGLE.value: query_json.get('parties', {}).get(PartyField.PARTY_NAME.value, '') + } + # set faceted category params + categories_json: dict = request_json.get('categories', {}) + categories = { + BusinessField.TYPE: categories_json.get(BusinessField.TYPE.value, None), + BusinessField.STATE: categories_json.get(BusinessField.STATE.value, None) + } + + # set doc fields to return + fields = business_solr.business_with_parties_fields + # create solr search params obj from parsed params + params = QueryParams(query=query, + start=request_json.get('start', business_solr.default_start), + rows=request_json.get('rows', business_solr.default_rows), + categories=categories, + fields=fields, + query_fields={ + BusinessField.NAME_Q: 'parent', + BusinessField.NAME_STEM_AGRO: 'parent', + BusinessField.NAME_SINGLE: 'parent', + BusinessField.NAME_XTRA_Q: 'parent', + BusinessField.BN_Q: 'parent', + BusinessField.IDENTIFIER_Q: 'parent'}, + query_boost_fields={ + BusinessField.NAME_Q: 2, + BusinessField.NAME_STEM_AGRO: 2, + BusinessField.NAME_SINGLE: 2}, + query_fuzzy_fields={ + BusinessField.NAME_Q: {'short': 1, 'long': 2}, + BusinessField.NAME_STEM_AGRO: {'short': 1, 'long': 2}, + BusinessField.NAME_SINGLE: {'short': 1, 'long': 2}}, + child_query=child_query, + child_categories={}, + child_date_ranges={}) + # execute search + results = business_search(params, business_solr) + response = { + 'facets': parse_facets(results), + 'searchResults': { + 'queryInfo': { + 'query': { + 'value': query['value'], + BusinessField.NAME.value: query[BusinessField.NAME_SINGLE.value] or '', + BusinessField.IDENTIFIER.value: query[BusinessField.IDENTIFIER_Q.value] or '', + BusinessField.BN.value: query[BusinessField.BN_Q.value] or '' + }, + 'categories': { + BusinessField.TYPE.value: categories.get(BusinessField.TYPE, ''), + BusinessField.STATE.value: categories.get(BusinessField.STATE, '')}, + 'rows': params.rows, + 'start': params.start + }, + 'totalResults': results.get('response', {}).get('numFound'), + 'results': results.get('response', {}).get('docs')}, + } + + return jsonify(response), HTTPStatus.OK + + except SolrException as solr_exception: + return resource_utils.exception_response(solr_exception) + except Exception as default_exception: # noqa: B902 + return resource_utils.default_exception_response(default_exception) diff --git a/search-api/src/search_api/resources/v2/search/parties.py b/search-api/src/search_api/resources/v2/search/parties.py new file mode 100644 index 00000000..9cbcc86e --- /dev/null +++ b/search-api/src/search_api/resources/v2/search/parties.py @@ -0,0 +1,122 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""API endpoints for Search.""" +from http import HTTPStatus + +from flask import jsonify, Blueprint +from flask_cors import cross_origin + +from search_api.exceptions import SolrException +from search_api.services import business_solr +from search_api.services.base_solr.utils import QueryParams, parse_facets, prep_query_str +from search_api.services.business_solr.doc_fields import PartyField +from search_api.services.business_solr.utils import parties_search +import search_api.resources.utils as resource_utils +from search_api.utils.validators import validate_search_request + + +bp = Blueprint('PARTIES', __name__, url_prefix='/parties') # pylint: disable=invalid-name + + +@bp.post('') +@cross_origin(origin='*') +def parties(): + """Return a list of parties results.""" + try: + request_json, errors = validate_search_request() + if errors: + return resource_utils.bad_request_response('Errors processing request.', errors) + # set base query params + query_json: dict = request_json.get('query', {}) + value = query_json.get('value', None) + query = { + 'value': prep_query_str(value, True), + PartyField.PARTY_NAME_SINGLE.value: prep_query_str(query_json.get(PartyField.PARTY_NAME.value, '')), + PartyField.PARENT_NAME_SINGLE.value: prep_query_str(query_json.get(PartyField.PARENT_NAME.value, '')), + PartyField.PARENT_IDENTIFIER_Q.value: prep_query_str(query_json.get( + PartyField.PARENT_IDENTIFIER.value, '')), + PartyField.PARENT_BN_Q.value: prep_query_str(query_json.get(PartyField.PARENT_BN.value, '')) + } + + # set faceted category params + categories_json: dict = request_json.get('categories', {}) + categories = { + PartyField.PARENT_TYPE: categories_json.get(PartyField.PARENT_TYPE.value, None), + PartyField.PARENT_STATE: categories_json.get(PartyField.PARENT_STATE.value, None), + PartyField.PARTY_ROLE: categories_json.get(PartyField.PARTY_ROLE.value, None) + } + + # validate party roles + if not (party_roles := categories.get(PartyField.PARTY_ROLE)): + errors = [{'Invalid payload': f"Expected 'categories/{PartyField.PARTY_ROLE.value}:[...]'."}] + return resource_utils.bad_request_response('Errors processing request.', errors) + + if [x for x in party_roles if x.lower() not in ['partner', 'proprietor']]: + errors = [{ + 'Invalid payload': + f"Expected 'categories/{PartyField.PARTY_ROLE.value}:' with values 'partner' and/or " + + "'proprietor'. Other party roles are not available." + }] + return resource_utils.bad_request_response('Errors processing request.', errors) + + params = QueryParams(query=query, + start=int(request_json.get('start', business_solr.default_start)), + rows=int(request_json.get('rows', business_solr.default_rows)), + categories=categories, + fields=business_solr.party_fields, + query_fields={ + PartyField.PARTY_NAME_Q: 'parent', + PartyField.PARTY_NAME_STEM_AGRO: 'parent', + PartyField.PARTY_NAME_SINGLE: 'parent', + PartyField.PARTY_NAME_XTRA_Q: 'parent'}, + query_boost_fields={ + PartyField.PARTY_NAME_Q: 2, + PartyField.PARTY_NAME_STEM_AGRO: 2, + PartyField.PARTY_NAME_SINGLE: 2}, + query_fuzzy_fields={ + PartyField.PARTY_NAME_Q: {'short': 1, 'long': 2}, + PartyField.PARTY_NAME_STEM_AGRO: {'short': 1, 'long': 2}, + PartyField.PARTY_NAME_SINGLE: {'short': 1, 'long': 2}}, + child_query={}, + child_categories={}, + child_date_ranges={}) + results = parties_search(params, business_solr) + response = { + 'facets': parse_facets(results), + 'searchResults': { + 'queryInfo': { + 'query': { + 'value': query['value'], + PartyField.PARTY_NAME.value: query[PartyField.PARTY_NAME_SINGLE.value] or '', + PartyField.PARENT_NAME.value: query[PartyField.PARENT_NAME_SINGLE.value] or '', + PartyField.PARENT_IDENTIFIER.value: query[PartyField.PARENT_IDENTIFIER_Q.value] or '', + PartyField.PARENT_BN.value: query[PartyField.PARENT_BN_Q.value] or '' + }, + 'categories': { + PartyField.PARENT_TYPE.value: categories[PartyField.PARENT_TYPE], + PartyField.PARENT_STATE.value: categories[PartyField.PARENT_STATE], + PartyField.PARTY_ROLE.value: categories[PartyField.PARTY_ROLE]}, + 'rows': params.rows, + 'start': params.start}, + 'totalResults': results.get('response', {}).get('numFound'), + 'results': results.get('response', {}).get('docs') + } + } + + return jsonify(response), HTTPStatus.OK + + except SolrException as solr_exception: + return resource_utils.exception_response(solr_exception) + except Exception as default_exception: # noqa: B902 + return resource_utils.default_exception_response(default_exception) diff --git a/search-api/src/search_api/utils/validators/__init__.py b/search-api/src/search_api/utils/validators/__init__.py index e9e3b2d9..ea0367b0 100644 --- a/search-api/src/search_api/utils/validators/__init__.py +++ b/search-api/src/search_api/utils/validators/__init__.py @@ -12,8 +12,44 @@ # See the License for the specific language governing permissions and # limitations under the License. """This module holds request non-schema data validation functions and helpers.""" +from flask import current_app, request def valid_charset(word: str) -> bool: """Verify word characters adhere to a supported set.""" return word == word.encode('ascii', 'ignore').decode('utf-8') + + +def validate_search_request() -> tuple[dict, list[dict]]: + """Validate the search request headers / payload.""" + errors = [] + request_json = request.get_json() + query_json = request_json.get('query', None) + if not isinstance(query_json, dict): + errors.append({'Invalid payload': "Expected an object for 'query'."}) + else: + value = query_json.get('value', None) + if not value or not isinstance(value, str): + errors.append({'Invalid payload': "Expected a string for 'query/value'."}) + + if not isinstance(query_json.get('parties', {}), dict): + errors.append({'Invalid payload': "Expected an object for 'query/parties'."}) + + categories = request_json.get('categories', {}) + if not isinstance(categories, dict): + errors.append({'Invalid payload': "Expected an object for 'categories'."}) + else: + for key, value in categories.items(): + if not isinstance(value, list): + errors.append({'Invalid payload': f"Expected a list for 'categories/{key}'."}) + try: + start = int(request_json.get('start', 0)) + rows = int(request_json.get('rows', 0)) + if start < 0: + errors.append({'Invalid payload': "Expected 'start' to be >= 0."}) + if rows > (max_rows := current_app.config['SOLR_SVC_BUS_MAX_ROWS']) or rows < 0: + errors.append({'Invalid payload': f"Expected 'rows' to be between 0 and {max_rows}."}) + except ValueError: # catch invalid start/row entry + errors.append({'Invalid payload': "Expected integer for params: 'start', 'rows'"}) + + return request_json, errors diff --git a/search-api/tests/unit/api/v2/__init__.py b/search-api/tests/unit/api/v2/__init__.py new file mode 100644 index 00000000..dfd0546b --- /dev/null +++ b/search-api/tests/unit/api/v2/__init__.py @@ -0,0 +1,14 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Test-Suite for the v2 API.""" \ No newline at end of file diff --git a/search-api/tests/unit/api/v2/search/__init__.py b/search-api/tests/unit/api/v2/search/__init__.py new file mode 100644 index 00000000..703f7079 --- /dev/null +++ b/search-api/tests/unit/api/v2/search/__init__.py @@ -0,0 +1,14 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Test-Suite for the v2 search endpoints.""" diff --git a/search-api/tests/unit/api/v2/search/test_businesses.py b/search-api/tests/unit/api/v2/search/test_businesses.py new file mode 100644 index 00000000..e3b7c1f8 --- /dev/null +++ b/search-api/tests/unit/api/v2/search/test_businesses.py @@ -0,0 +1,387 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Test-Suite to ensure that the business search endpoints/functions work as expected.""" +import time +from http import HTTPStatus + +import pytest + +from search_api.services import business_solr +from search_api.services.business_solr.doc_fields import BusinessField + +from tests import integration_solr +from tests.unit.utils import SOLR_TEST_DOCS + + +@pytest.mark.parametrize('test_name,query,categories', [ + ('test_basic', {'value': '123'}, {}), + ('test_filters', + {'value': 'test filters', BusinessField.NAME.value: 'name', BusinessField.IDENTIFIER.value: 'BC23', BusinessField.BN.value: '023'}, + {} + ), + ('test_categories', + {'value': 'test categories'}, + {BusinessField.STATE.value:['ACTIVE'], BusinessField.TYPE.value: ['BC', 'CP', 'SP']} + ), + ('test_all_combined', + { + 'value': 'test all combined', + BusinessField.NAME.value: 'name', + BusinessField.IDENTIFIER.value: 'BC23', + BusinessField.BN.value: '023' + }, + { + BusinessField.STATE.value:['ACTIVE'], + BusinessField.TYPE.value: ['BC', 'CP', 'SP'] + }) +]) +def test_businesses_solr_mock(app, session, client, requests_mock, test_name, query, categories): + """Assert that the entities search call works returns successfully.""" + # setup mocks + requests_mock.post(f"{app.config.get('SOLR_SVC_BUS_LEADER_URL')}/business/query", json={'response': {'docs': [], 'numFound': 0, 'start': 0}}) + # call search + resp = client.post('/api/v2/search/businesses', + headers={'content-type': 'application/json'}, + json={'query': query, 'categories': categories}) + # test + assert resp.status_code == HTTPStatus.OK + resp_json = resp.json + assert resp_json['facets'] == {'fields': {}} + assert resp_json['searchResults']['queryInfo']['rows'] == 10 + assert resp_json['searchResults']['queryInfo']['start'] == 0 + assert resp_json['searchResults']['results'] == [] + assert resp_json['searchResults']['totalResults'] == 0 + + +@integration_solr +@pytest.mark.parametrize('test_name,query,categories,expected', [ + ('test_basic_name', # NOTE: test setup checks for 'test_basic_name' on the first run + {'value': 'business one'}, + {}, + [{'bn': 'BN00012334', 'goodStanding': True, 'identifier': 'CP1234567', 'legalType': 'CP', 'name': 'business one 1', 'status': 'ACTIVE'}] + ), + ('test_basic_name_case', + {'value': 'BusIness ONE'}, + {}, + [{'bn': 'BN00012334', 'goodStanding': True, 'identifier': 'CP1234567', 'legalType': 'CP', 'name': 'business one 1', 'status': 'ACTIVE'}] + ), + ('test_basic_name_partial_1', + {'value': 'bus one'}, + {}, + [{'bn': 'BN00012334', 'goodStanding': True, 'identifier': 'CP1234567', 'legalType': 'CP', 'name': 'business one 1', 'status': 'ACTIVE'}] + ), + ('test_basic_name_partial_2', + {'value': 'siness on'}, + {}, + [{'bn': 'BN00012334', 'goodStanding': True, 'identifier': 'CP1234567', 'legalType': 'CP', 'name': 'business one 1', 'status': 'ACTIVE'}] + ), + ('test_basic_name_partial_3', + {'value': 'IVINE STERI'}, + {}, + [{'bn': 'BN00012388', 'identifier': 'BC0030016', 'legalType': 'BEN', 'name': 'DIVINE ÉBÉNISTERIE INC.', 'status': 'ACTIVE'}] + ), + ('test_basic_name_spellcheck', + {'value': 'basiness thrae'}, + {}, + [{'goodStanding': True, 'identifier': 'CP0034567', 'legalType': 'CP', 'name': 'business three 3', 'status': 'ACTIVE'}] + ), + ('test_basic_name_stem_1', + {'value': 'business eights'}, + {}, + [{'bn': '1255323221', 'identifier': 'BC0020047', 'legalType': 'BEN', 'name': 'business eight 8 special&match', 'status': 'ACTIVE'}] + ), + ('test_basic_name_stem_2', + {'value': 'businessing one'}, + {}, + [{'bn': 'BN00012334', 'goodStanding': True, 'identifier': 'CP1234567', 'legalType': 'CP', 'name': 'business one 1', 'status': 'ACTIVE'}] + ), + ('test_basic_name_stem_3', + {'value': 'businessed one'}, + {}, + [{'bn': 'BN00012334', 'goodStanding': True, 'identifier': 'CP1234567', 'legalType': 'CP', 'name': 'business one 1', 'status': 'ACTIVE'}] + ), + ('test_basic_name_mix', + {'value': 'one business'}, + {}, + [{'bn': 'BN00012334', 'goodStanding': True, 'identifier': 'CP1234567', 'legalType': 'CP', 'name': 'business one 1', 'status': 'ACTIVE'}] + ), + ('test_basic_name_mix_partial', + {'value': 'STERI IVINE'}, + {}, + [{'bn': 'BN00012388', 'identifier': 'BC0030016', 'legalType': 'BEN', 'name': 'DIVINE ÉBÉNISTERIE INC.', 'status': 'ACTIVE'}] + ), + ('test_basic_name_mix_stem', + {'value': 'one businesses'}, + {}, + [{'bn': 'BN00012334', 'goodStanding': True, 'identifier': 'CP1234567', 'legalType': 'CP', 'name': 'business one 1', 'status': 'ACTIVE'}] + ), + ('test_basic_name_adv_chars', + {'value': 'b*s o?e "1"'}, + {}, + [{'bn': 'BN00012334', 'goodStanding': True, 'identifier': 'CP1234567', 'legalType': 'CP', 'name': 'business one 1', 'status': 'ACTIVE'}] + ), + ('test_basic_name_spec_char', + {'value': 'b!u(si)ness fou}l{rt-een ~`@#$%^-_=[]|\\;:\'",<>./'}, + {}, + [{'bn': '123456776BC0001', 'identifier': 'BC0030014', 'legalType': 'BEN', 'name': 'b!u(si)ness fou}l{rt-een ~`@#$%^-_=[]|\\;:\'",<>./', 'status': 'ACTIVE'}] + ), + ('test_basic_name_and_and', + {'value': 'special and match'}, + {}, + [{'bn': '242217', 'identifier': 'BC0000067', 'legalType': 'BEN', 'name': 'business six 6 special and match', 'status': 'ACTIVE'}, + {'bn': '124221', 'identifier': 'BC0000007', 'legalType': 'BEN', 'name': 'business seven 7 special & match', 'status': 'ACTIVE'}, + {'bn': '1255323221', 'identifier': 'BC0020047', 'legalType': 'BEN', 'name': 'business eight 8 special&match', 'status': 'ACTIVE'}, + {'bn': '123', 'identifier': 'FM1000028', 'legalType': 'SP', 'name': 'firm nine 9 special + match', 'parties': [{'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person', 'score': 0.0}], 'status': 'ACTIVE'}, + {'identifier': 'FM1001118', 'legalType': 'GP', 'name': 'firm ten 10 special+match', 'parties': [{'partyName': 'organization one', 'partyRoles': ['partner'], 'partyType': 'organization', 'score': 0.0}], 'status': 'ACTIVE'}] + ), + ('test_basic_name_and_&_1', + {'value': 'special & match'}, + {}, + [{'bn': '242217', 'identifier': 'BC0000067', 'legalType': 'BEN', 'name': 'business six 6 special and match', 'status': 'ACTIVE'}, + {'bn': '124221', 'identifier': 'BC0000007', 'legalType': 'BEN', 'name': 'business seven 7 special & match', 'status': 'ACTIVE'}, + {'bn': '1255323221', 'identifier': 'BC0020047', 'legalType': 'BEN', 'name': 'business eight 8 special&match', 'status': 'ACTIVE'}, + {'bn': '123', 'identifier': 'FM1000028', 'legalType': 'SP', 'name': 'firm nine 9 special + match', 'parties': [{'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person', 'score': 0.0}], 'status': 'ACTIVE'}, + {'identifier': 'FM1001118', 'legalType': 'GP', 'name': 'firm ten 10 special+match', 'parties': [{'partyName': 'organization one', 'partyRoles': ['partner'], 'partyType': 'organization', 'score': 0.0}], 'status': 'ACTIVE'}] + ), + ('test_basic_name_and_&_2', + {'value': 'special&match'}, + {}, + [{'bn': '242217', 'identifier': 'BC0000067', 'legalType': 'BEN', 'name': 'business six 6 special and match', 'status': 'ACTIVE'}, + {'bn': '124221', 'identifier': 'BC0000007', 'legalType': 'BEN', 'name': 'business seven 7 special & match', 'status': 'ACTIVE'}, + {'bn': '1255323221', 'identifier': 'BC0020047', 'legalType': 'BEN', 'name': 'business eight 8 special&match', 'status': 'ACTIVE'}, + {'bn': '123', 'identifier': 'FM1000028', 'legalType': 'SP', 'name': 'firm nine 9 special + match', 'parties': [{'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person', 'score': 0.0}], 'status': 'ACTIVE'}, + {'identifier': 'FM1001118', 'legalType': 'GP', 'name': 'firm ten 10 special+match', 'parties': [{'partyName': 'organization one', 'partyRoles': ['partner'], 'partyType': 'organization', 'score': 0.0}], 'status': 'ACTIVE'}] + ), + ('test_basic_name_and_+_1', + {'value': 'special + match'}, + {}, + [{'bn': '242217', 'identifier': 'BC0000067', 'legalType': 'BEN', 'name': 'business six 6 special and match', 'status': 'ACTIVE'}, + {'bn': '124221', 'identifier': 'BC0000007', 'legalType': 'BEN', 'name': 'business seven 7 special & match', 'status': 'ACTIVE'}, + {'bn': '1255323221', 'identifier': 'BC0020047', 'legalType': 'BEN', 'name': 'business eight 8 special&match', 'status': 'ACTIVE'}, + {'bn': '123', 'identifier': 'FM1000028', 'legalType': 'SP', 'name': 'firm nine 9 special + match', 'parties': [{'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person', 'score': 0.0}], 'status': 'ACTIVE'}, + {'identifier': 'FM1001118', 'legalType': 'GP', 'name': 'firm ten 10 special+match', 'parties': [{'partyName': 'organization one', 'partyRoles': ['partner'], 'partyType': 'organization', 'score': 0.0}], 'status': 'ACTIVE'}] + ), + ('test_basic_name_and_+_2', + {'value': 'special+match'}, + {}, + [{'bn': '242217', 'identifier': 'BC0000067', 'legalType': 'BEN', 'name': 'business six 6 special and match', 'status': 'ACTIVE'}, + {'bn': '124221', 'identifier': 'BC0000007', 'legalType': 'BEN', 'name': 'business seven 7 special & match', 'status': 'ACTIVE'}, + {'bn': '1255323221', 'identifier': 'BC0020047', 'legalType': 'BEN', 'name': 'business eight 8 special&match', 'status': 'ACTIVE'}, + {'bn': '123', 'identifier': 'FM1000028', 'legalType': 'SP', 'name': 'firm nine 9 special + match', 'parties': [{'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person', 'score': 0.0}], 'status': 'ACTIVE'}, + {'identifier': 'FM1001118', 'legalType': 'GP', 'name': 'firm ten 10 special+match', 'parties': [{'partyName': 'organization one', 'partyRoles': ['partner'], 'partyType': 'organization', 'score': 0.0}], 'status': 'ACTIVE'}] + ), + ('test_basic_name_._1', + {'value': 'firm eleven y.z.'}, + {}, + [{'identifier': 'FM0004018', 'legalType': 'GP', 'name': 'firm eleven 11 periods y.z. xk', 'parties': [{'partyName': 'organization two y.z. xk', 'partyRoles': ['partner'], 'partyType': 'organization', 'score': 0.0}, {'partyName': 'person two', 'partyRoles': ['partner'], 'partyType': 'person', 'score': 0.0}], 'status': 'ACTIVE'}] + ), + ('test_basic_name_._2', + {'value': 'firm eleven yz'}, + {}, + [{'identifier': 'FM0004018', 'legalType': 'GP', 'name': 'firm eleven 11 periods y.z. xk', 'parties': [{'partyName': 'organization two y.z. xk', 'partyRoles': ['partner'], 'partyType': 'organization', 'score': 0.0}, {'partyName': 'person two', 'partyRoles': ['partner'], 'partyType': 'person', 'score': 0.0}], 'status': 'ACTIVE'}] + ), + ('test_basic_name_._3', + {'value': 'firm eleven x.k.'}, + {}, + [{'identifier': 'FM0004018', 'legalType': 'GP', 'name': 'firm eleven 11 periods y.z. xk', 'parties': [{'partyName': 'organization two y.z. xk', 'partyRoles': ['partner'], 'partyType': 'organization', 'score': 0.0}, {'partyName': 'person two', 'partyRoles': ['partner'], 'partyType': 'person', 'score': 0.0}], 'status': 'ACTIVE'}] + ), + ('test_basic_name_._4', + {'value': 'firm eleven xk'}, + {}, + [{'identifier': 'FM0004018', 'legalType': 'GP', 'name': 'firm eleven 11 periods y.z. xk', 'parties': [{'partyName': 'organization two y.z. xk', 'partyRoles': ['partner'], 'partyType': 'organization', 'score': 0.0}, {'partyName': 'person two', 'partyRoles': ['partner'], 'partyType': 'person', 'score': 0.0}], 'status': 'ACTIVE'}] + ), + ('test_basic_name_-_1', + {'value': 'special - match'}, + {}, + [{'bn': '123456786BC0001', 'identifier': 'BC0030024', 'legalType': 'BEN', 'name': 'business thirteen 13 special - match', 'status': 'ACTIVE'}, + {'bn': '123456785BC0001', 'identifier': 'BC0030023', 'legalType': 'BEN', 'name': 'business twelve 12 special-match', 'status': 'ACTIVE'}] + ), + ('test_basic_name_-_2', + {'value': 'special-match'}, + {}, + [{'bn': '123456786BC0001', 'identifier': 'BC0030024', 'legalType': 'BEN', 'name': 'business thirteen 13 special - match', 'status': 'ACTIVE'}, + {'bn': '123456785BC0001', 'identifier': 'BC0030023', 'legalType': 'BEN', 'name': 'business twelve 12 special-match', 'status': 'ACTIVE'}] + ), + ('test_basic_identifier', + {'value': 'BC0004567'}, + {}, + [{'bn': '00987766800988', 'goodStanding': False, 'identifier': 'BC0004567', 'legalType': 'BEN', 'name': 'business four 4', 'status': 'ACTIVE'}] + ), + ('test_basic_identifier_partial', + {'value': 'BC00045'}, + {}, + [{'bn': '00987766800988', 'goodStanding': False, 'identifier': 'BC0004567', 'legalType': 'BEN', 'name': 'business four 4', 'status': 'ACTIVE'}] + ), + ('test_basic_identifier_order_1', + {'value': 'C000456'}, + {}, + [{'bn': '111111111BC0001', 'identifier': 'C0004569', 'legalType': 'C', 'name': 'c_identifier', 'status': 'ACTIVE'}, + {'bn': '00987766800988', 'goodStanding': False, 'identifier': 'BC0004567', 'legalType': 'BEN', 'name': 'business four 4', 'status': 'ACTIVE'}] + ), + ('test_basic_identifier_order_2', + {'value': 'C0004'}, + {}, + [{'bn': '111111111BC0001', 'identifier': 'C0004569', 'legalType': 'C', 'name': 'c_identifier', 'status': 'ACTIVE'}, + {'bn': '00987766800988', 'goodStanding': False, 'identifier': 'BC0004567', 'legalType': 'BEN', 'name': 'business four 4', 'status': 'ACTIVE'}, + {'bn': '123456786BC0001', 'identifier': 'BC0030004', 'legalType': 'BEN', 'name': '04 solr special + char', 'status': 'ACTIVE'}] + ), + ('test_basic_identifier_no_spellcheck', + {'value': 'BC1004567'}, + {}, + [] + ), + ('test_basic_bn', + {'value': '00987766800988'}, + {}, + [{'bn': '00987766800988', 'goodStanding': False, 'identifier': 'BC0004567', 'legalType': 'BEN', 'name': 'business four 4', 'status': 'ACTIVE'}] + ), + ('test_basic_bn_partial', + {'value': '00987766'}, + {}, + [{'bn': '00987766800988', 'goodStanding': False, 'identifier': 'BC0004567', 'legalType': 'BEN', 'name': 'business four 4', 'status': 'ACTIVE'}] + ), + ('test_basic_bn_no_spellcheck', + {'value': '00987766800989'}, + {}, + [] + ), + ('test_basic_combined', + {'value': 'business BC0004567 00987766800988'}, + {}, + [{'bn': '00987766800988', 'goodStanding': False, 'identifier': 'BC0004567', 'legalType': 'BEN', 'name': 'business four 4', 'status': 'ACTIVE'}] + ), + ('test_basic_no_match', {'value': 'zzz no match here qljrb'}, {},[]), + ('test_filters_name', + {'value': 'business', BusinessField.NAME.value: 'three'}, + {}, + [{'goodStanding': True, 'identifier': 'CP0034567', 'legalType': 'CP', 'name': 'business three 3', 'status': 'ACTIVE'}] + ), + ('test_filters_parties_1', + {'value': '0', 'parties': {'partyName': 'test'}}, + {}, + [{'bn': 'BN9000776557', 'identifier': 'BC0000567', 'legalType': 'BC', 'name': 'business five 5', 'parties': [{'partyName': 'test si', 'partyRoles': ['significant individual'], 'partyType': 'person', 'score': 0.0}], 'status': 'HISTORICAL'}] + ), + ('test_filters_parties_2', + {'value': '0', 'parties': {'partyName': 'one'}}, + {}, + [{'identifier': 'FM1001118', 'legalType': 'GP', 'name': 'firm ten 10 special+match', 'parties': [{'partyName': 'organization one', 'partyRoles': ['partner'], 'partyType': 'organization', 'score': 0.0}], 'status': 'ACTIVE'}, + {'bn': '123', 'identifier': 'FM1000028', 'legalType': 'SP', 'name': 'firm nine 9 special + match', 'parties': [{'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person', 'score': 0.0}], 'status': 'ACTIVE'}] + ), + # ('test_filters_no_match', + # {'value': 'business', BusinessField.NAME.value: 'threa'}, + # {}, + # [] + # ), + ('test_categories_state', + {'value': 'business two'}, + {BusinessField.TYPE.value: ['CP']}, + [{'bn': '09876K', 'goodStanding': True, 'identifier': 'CP0234567', 'legalType': 'CP', 'name': 'business two 2', 'status': 'HISTORICAL'}] + ), + ('test_categories_no_match', + {'value': 'business two'}, + {BusinessField.TYPE.value: ['BEN']}, + [] + ), + ('test_all_combined', + { + 'value': 'business', + BusinessField.NAME.value: 'two', + BusinessField.IDENTIFIER.value: 'CP0234567', + BusinessField.BN.value: '09876K', + }, + { + BusinessField.STATE.value: ['HISTORICAL'], + BusinessField.TYPE.value: ['CP'] + }, + [{'bn': '09876K', 'goodStanding': True, 'identifier': 'CP0234567', 'legalType': 'CP', 'name': 'business two 2', 'status': 'HISTORICAL'}] + ) +]) +def test_businesses(app, session, client, test_name, query, categories, expected): + """Assert that the business search call works returns successfully.""" + # test setup + if test_name == 'test_basic_name': + # setup solr data for test (only needed the first time) + business_solr.delete_all_docs() + time.sleep(1) + business_solr.create_or_replace_docs(SOLR_TEST_DOCS) + time.sleep(2) + + # call search + resp = client.post('/api/v2/search/businesses', + headers={'content-type': 'application/json'}, + json={'query': query, 'categories': categories}) + # test + assert resp.status_code == HTTPStatus.OK + resp_json = resp.json + assert resp_json['facets'] + assert resp_json['searchResults'] + results = resp_json['searchResults']['results'] + for result in results: + del result['score'] + assert resp_json['searchResults']['totalResults'] == len(expected) + assert results == expected + + +def test_search_error(app, session, client, requests_mock): + """Assert that the business search call error handling works as expected.""" + # setup solr error mock + mocked_error_msg = 'mocked error' + mocked_status_code = HTTPStatus.BAD_GATEWAY + requests_mock.post(f"{app.config.get('SOLR_SVC_BUS_LEADER_URL')}/business/query", json={'error': {'msg': mocked_error_msg}}, status_code=mocked_status_code) + # call search + resp = client.post('/api/v2/search/businesses', + headers={'content-type': 'application/json'}, + json={'query': {'value': 'test'}}) + # test + assert resp.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + resp_json = resp.json + assert resp_json.get('detail') == f'{mocked_error_msg}, {mocked_status_code}' + assert resp_json.get('message') == 'Solr service error while processing request.' + + +@pytest.mark.parametrize('test_name,query,errors', [ + ('test_no_query', {}, [{'Invalid payload': "Expected an object for 'query'."}]), + ('test_invalid_query', {'query': 'wrong'}, [{'Invalid payload': "Expected an object for 'query'."}]), + ('test_no_value', {'query': {'notValue': 'bla'}}, [{'Invalid payload': "Expected a string for 'query/value'."}]), + ('test_invalid_value', {'query': {'value': 1}}, [{'Invalid payload': "Expected a string for 'query/value'."}]), + ('test_invalid_parties', {'query': {'value': 'test', 'parties': 'wrong'}}, [{'Invalid payload': "Expected an object for 'query/parties'."}]), + ('test_invalid_category', {'query': {'value': 'test'}, 'categories': {'status': 'CP'}}, [{'Invalid payload': "Expected a list for 'categories/status'."}]), + ('test_invalid_start_1', + {'query': {'value': 'test'}, 'start': -10}, + [{'Invalid payload': "Expected 'start' to be >= 0."}]), + ('test_invalid_start_2', + {'query': {'value': 'test'}, 'start': 'lala'}, + [{'Invalid payload': "Expected integer for params: 'start', 'rows'"}]), + ('test_invalid_rows_1', + {'query': {'value': 'test'}, 'rows': -22}, + [{'Invalid payload': "Expected 'rows' to be between 0 and 10000."}]), + ('test_invalid_rows_2', + {'query': {'value': 'test'}, 'rows': 200000}, + [{'Invalid payload': "Expected 'rows' to be between 0 and 10000."}]), + ('test_invalid_rows_2', + {'query': {'value': 'test'}, 'rows': '&899'}, + [{'Invalid payload': "Expected integer for params: 'start', 'rows'"}]), +]) +def test_search_bad_request(app, session, client, test_name, query, errors): + """Assert that the business search call validates the payload.""" + # call search + resp = client.post('/api/v2/search/businesses', + headers={'content-type': 'application/json'}, + json=query) + # test + assert resp.status_code == HTTPStatus.BAD_REQUEST + resp_json = resp.json + assert resp_json.get('message') == 'Errors processing request.' + assert resp_json.get('details') == errors + diff --git a/search-api/tests/unit/api/v2/search/test_parties.py b/search-api/tests/unit/api/v2/search/test_parties.py new file mode 100644 index 00000000..c47e4888 --- /dev/null +++ b/search-api/tests/unit/api/v2/search/test_parties.py @@ -0,0 +1,252 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Test-Suite to ensure that the parties search endpoints/functions work as expected.""" +import time +from http import HTTPStatus + +import pytest + +from search_api.services import business_solr +from search_api.services.business_solr.doc_fields import PartyField + +from tests import integration_solr +from tests.unit.utils import SOLR_TEST_DOCS + + +@pytest.mark.parametrize('test_name,query,categories', [ + ('test_basic', {'value': '123'}, {PartyField.PARTY_ROLE.value: ['partner','proprietor']}), + ('test_filters', + {'value': 'test filters', PartyField.PARENT_NAME.value: 'name', PartyField.PARENT_IDENTIFIER.value: 'BC23', PartyField.PARENT_BN.value: '023'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']} + ), + ('test_categories', + {'value': 'test categories'}, + {PartyField.PARENT_STATE.value:['ACTIVE'], PartyField.PARENT_TYPE.value: ['BC', 'CP', 'SP'], PartyField.PARTY_ROLE.value: ['partner','proprietor']} + ), + ('test_all_combined', + { + 'value': 'test all combined', + PartyField.PARENT_NAME.value: 'name', + PartyField.PARENT_IDENTIFIER.value: 'BC23', + PartyField.PARENT_BN.value: '023' + }, + { + PartyField.PARENT_STATE.value: ['ACTIVE'], + PartyField.PARENT_TYPE.value: ['BC', 'CP', 'SP'], + PartyField.PARTY_ROLE.value: ['partner','proprietor'] + }) +]) +def test_parties_solr_mock(app, session, client, requests_mock, test_name, query, categories): + """Assert that the parties search call works returns successfully.""" + # setup mocks + requests_mock.post(f"{app.config.get('SOLR_SVC_BUS_LEADER_URL')}/business/query", json={'response': {'docs': [], 'numFound': 0, 'start': 0}}) + # call search + resp = client.post('/api/v2/search/parties', + headers={'content-type': 'application/json'}, + json={'query': query, 'categories': categories}) + # test + assert resp.status_code == HTTPStatus.OK + resp_json = resp.json + assert resp_json['facets'] == {'fields': {}} + assert resp_json['searchResults']['queryInfo']['rows'] == 10 + assert resp_json['searchResults']['queryInfo']['start'] == 0 + assert resp_json['searchResults']['results'] == [] + assert resp_json['searchResults']['totalResults'] == 0 + + +@integration_solr +@pytest.mark.parametrize('test_name,query,categories,expected', [ + ('test_basic_name', # NOTE: test setup checks for 'test_basic' on the first run + {'value': 'person one'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentBN': '123', 'parentIdentifier': 'FM1000028', 'parentLegalType': 'SP', 'parentName': 'firm nine 9 special + match', 'parentStatus': 'ACTIVE', 'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person'}] + ), + ('test_basic_name_case', # NOTE: test setup checks for 'test_basic' on the first run + {'value': 'pErson ONE'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentBN': '123', 'parentIdentifier': 'FM1000028', 'parentLegalType': 'SP', 'parentName': 'firm nine 9 special + match', 'parentStatus': 'ACTIVE', 'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person'}] + ), + ('test_basic_name_partial_1', + {'value': 'pers one'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentBN': '123', 'parentIdentifier': 'FM1000028', 'parentLegalType': 'SP', 'parentName': 'firm nine 9 special + match', 'parentStatus': 'ACTIVE', 'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person'}] + ), + ('test_basic_name_partial_2', + {'value': 'erson one'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentBN': '123', 'parentIdentifier': 'FM1000028', 'parentLegalType': 'SP', 'parentName': 'firm nine 9 special + match', 'parentStatus': 'ACTIVE', 'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person'}] + ), + ('test_basic_name_partial_3', + {'value': 'erso ne'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentBN': '123', 'parentIdentifier': 'FM1000028', 'parentLegalType': 'SP', 'parentName': 'firm nine 9 special + match', 'parentStatus': 'ACTIVE', 'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person'}] + ), + ('test_basic_name_spellcheck', + {'value': 'parson one'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentBN': '123', 'parentIdentifier': 'FM1000028', 'parentLegalType': 'SP', 'parentName': 'firm nine 9 special + match', 'parentStatus': 'ACTIVE', 'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person'}] + ), + ('test_basic_name_mix', + {'value': 'one person'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentBN': '123', 'parentIdentifier': 'FM1000028', 'parentLegalType': 'SP', 'parentName': 'firm nine 9 special + match', 'parentStatus': 'ACTIVE', 'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person'}] + ), + ('test_basic_name_mix_partial', + {'value': 'ne erson'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentBN': '123', 'parentIdentifier': 'FM1000028', 'parentLegalType': 'SP', 'parentName': 'firm nine 9 special + match', 'parentStatus': 'ACTIVE', 'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person'}] + ), + ('test_basic_name_adv_chars', + {'value': 'p*n o?e "one"'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentBN': '123', 'parentIdentifier': 'FM1000028', 'parentLegalType': 'SP', 'parentName': 'firm nine 9 special + match', 'parentStatus': 'ACTIVE', 'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person'}] + ), + ('test_basic_name_._1', + {'value': 'organization two y.z.'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentIdentifier': 'FM0004018', 'parentLegalType': 'GP', 'parentName': 'firm eleven 11 periods y.z. xk', 'parentStatus': 'ACTIVE', 'partyName': 'organization two y.z. xk', 'partyRoles': ['partner'], 'partyType': 'organization'}] + ), + ('test_basic_name_._2', + {'value': 'organization two yz'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentIdentifier': 'FM0004018', 'parentLegalType': 'GP', 'parentName': 'firm eleven 11 periods y.z. xk', 'parentStatus': 'ACTIVE', 'partyName': 'organization two y.z. xk', 'partyRoles': ['partner'], 'partyType': 'organization'}] + ), + ('test_basic_name_._3', + {'value': 'organization two x.k.'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentIdentifier': 'FM0004018', 'parentLegalType': 'GP', 'parentName': 'firm eleven 11 periods y.z. xk', 'parentStatus': 'ACTIVE', 'partyName': 'organization two y.z. xk', 'partyRoles': ['partner'], 'partyType': 'organization'}] + ), + ('test_basic_name_._4', + {'value': 'organization two xk'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentIdentifier': 'FM0004018', 'parentLegalType': 'GP', 'parentName': 'firm eleven 11 periods y.z. xk', 'parentStatus': 'ACTIVE', 'partyName': 'organization two y.z. xk', 'partyRoles': ['partner'], 'partyType': 'organization'}] + ), + ('test_basic_no_match', {'value': 'zzz no match here qljrb'}, {PartyField.PARTY_ROLE.value: ['partner','proprietor']},[]), + ('test_filters_name', + {'value': 'person', PartyField.PARENT_NAME.value: 'nine'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentBN': '123', 'parentIdentifier': 'FM1000028', 'parentLegalType': 'SP', 'parentName': 'firm nine 9 special + match', 'parentStatus': 'ACTIVE', 'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person'}] + ), + ('test_filters_no_match', + {'value': 'person', PartyField.PARENT_NAME.value: 'three'}, + {PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [] + ), + ('test_categories_state', + {'value': 'person'}, + {PartyField.PARENT_TYPE.value: ['SP'], PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [{'parentBN': '123', 'parentIdentifier': 'FM1000028', 'parentLegalType': 'SP', 'parentName': 'firm nine 9 special + match', 'parentStatus': 'ACTIVE', 'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person'}] + ), + ('test_categories_no_match', + {'value': 'person'}, + {PartyField.PARENT_TYPE.value: ['BEN'], PartyField.PARTY_ROLE.value: ['partner','proprietor']}, + [] + ), + ('test_all_combined', + { + 'value': 'person', + PartyField.PARENT_NAME.value: 'nine', + PartyField.PARENT_IDENTIFIER.value: 'FM1000028', + PartyField.PARENT_BN.value: '123', + }, + { + PartyField.PARENT_STATE.value: ['ACTIVE'], + PartyField.PARENT_TYPE.value: ['SP'], + PartyField.PARTY_ROLE.value: ['partner','proprietor'] + }, + [{'parentBN': '123', 'parentIdentifier': 'FM1000028', 'parentLegalType': 'SP', 'parentName': 'firm nine 9 special + match', 'parentStatus': 'ACTIVE', 'partyName': 'person one', 'partyRoles': ['proprietor'], 'partyType': 'person'}] + ) +]) +def test_parties(app, session, client, test_name, query, categories, expected): + """Assert that the parties search call works returns successfully.""" + # test setup + if test_name == 'test_basic_name': + # setup solr data for test (only needed the first time) + business_solr.delete_all_docs() + time.sleep(1) + business_solr.create_or_replace_docs(SOLR_TEST_DOCS) + time.sleep(2) + + # call search + resp = client.post('/api/v2/search/parties', + headers={'content-type': 'application/json'}, + json={'query': query, 'categories': categories}) + # test + assert resp.status_code == HTTPStatus.OK + resp_json = resp.json + assert resp_json['facets'] + assert resp_json['searchResults'] + results = resp_json['searchResults']['results'] + assert resp_json['searchResults']['totalResults'] == len(expected) + assert results == expected + + +def test_search_error(app, session, client, requests_mock): + """Assert that the parties search call error handling works as expected.""" + # setup solr error mock + mocked_error_msg = 'mocked error' + mocked_status_code = HTTPStatus.BAD_GATEWAY + requests_mock.post(f"{app.config.get('SOLR_SVC_BUS_LEADER_URL')}/business/query", json={'error': {'msg': mocked_error_msg}}, status_code=mocked_status_code) + # call search + resp = client.post('/api/v2/search/parties', + headers={'content-type': 'application/json'}, + json={'query': {'value':'test'}, 'categories': {'partyRoles': ['partner','proprietor']}}) + # test + assert resp.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + resp_json = resp.json + assert resp_json.get('detail') == f'{mocked_error_msg}, {mocked_status_code}' + assert resp_json.get('message') == 'Solr service error while processing request.' + + +@pytest.mark.parametrize('test_name,payload,errors', [ + ('test_no_query', {}, [{'Invalid payload': "Expected an object for 'query'."}]), + ('test_invalid_query', {'query': 'wrong'}, [{'Invalid payload': "Expected an object for 'query'."}]), + ('test_no_value', {'query': {'notValue': 'bla'}}, [{'Invalid payload': "Expected a string for 'query/value'."}]), + ('test_invalid_value', {'query': {'value': 1}}, [{'Invalid payload': "Expected a string for 'query/value'."}]), + ('test_no_partyRoles', + {'query': {'value': 'test'}, 'categories': {'noPartyRoles': ['partner']}}, + [{'Invalid payload': "Expected 'categories/partyRoles:[...]'."}]), + ('test_invalid_partyRoles', + {'query': {'value': 'test'}, 'categories': {'partyRoles': ['director']}}, + [{'Invalid payload': "Expected 'categories/partyRoles:' with values 'partner' and/or 'proprietor'. Other party roles are not available."}]), + ('test_invalid_category', + {'query': {'value': 'test'}, 'categories': {'partyRoles': 'partner'}}, + [{'Invalid payload': "Expected a list for 'categories/partyRoles'."}]), + ('test_invalid_start_1', + {'query': {'value': 'test'}, 'categories': {'partyRoles': ['partner']}, 'start': -10}, + [{'Invalid payload': "Expected 'start' to be >= 0."}]), + ('test_invalid_start_2', + {'query': {'value': 'test'}, 'categories': {'partyRoles': ['partner']}, 'start': 'lala'}, + [{'Invalid payload': "Expected integer for params: 'start', 'rows'"}]), + ('test_invalid_rows_1', + {'query': {'value': 'test'}, 'categories': {'partyRoles': ['partner']}, 'rows': -22}, + [{'Invalid payload': "Expected 'rows' to be between 0 and 10000."}]), + ('test_invalid_rows_2', + {'query': {'value': 'test'}, 'categories': {'partyRoles': ['partner']}, 'rows': 200000}, + [{'Invalid payload': "Expected 'rows' to be between 0 and 10000."}]), + ('test_invalid_rows_2', + {'query': {'value': 'test'}, 'categories': {'partyRoles': ['partner']}, 'rows': '&899'}, + [{'Invalid payload': "Expected integer for params: 'start', 'rows'"}]), +]) +def test_search_bad_request(app, session, client, test_name, payload, errors): + """Assert that the business search call validates the payload.""" + # call search + resp = client.post('/api/v2/search/parties', + headers={'content-type': 'application/json'}, + json=payload) + # test + assert resp.status_code == HTTPStatus.BAD_REQUEST + resp_json = resp.json + assert resp_json.get('message') == 'Errors processing request.' + assert resp_json.get('details') == errors