From ad4084c4a51ba4ed622b224bbd2dfffca46399bd Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 11 Mar 2026 14:11:22 -0500 Subject: [PATCH 01/14] Add date of birth field to opensearch indexing flow --- .../data_model/schema/license/api.py | 15 +++ .../data_model/schema/provider/api.py | 16 +++ .../test_schema/test_license.py | 73 +++++++++++++ .../test_schema/test_provider.py | 93 ++++++++++++++++ .../handlers/manage_opensearch_indices.py | 1 + .../lambdas/python/search/handlers/search.py | 60 ++++++++++- .../lambdas/python/search/tests/__init__.py | 1 + .../python/search/tests/function/__init__.py | 17 +++ .../test_manage_opensearch_indices.py | 1 + .../function/test_provider_update_ingest.py | 2 + .../tests/function/test_search_providers.py | 102 ++++++++++++++++++ .../lambdas/python/search/utils.py | 5 +- 12 files changed, 382 insertions(+), 4 deletions(-) diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/license/api.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/license/api.py index de42a9d7f..0e4db7d86 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/license/api.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/license/api.py @@ -228,3 +228,18 @@ class LicenseReadPrivateResponseSchema(LicenseExpirationStatusMixin, ForgivingSc # these fields are specific to the read private role dateOfBirth = Raw(required=False, allow_none=False) ssnLastFour = String(required=False, allow_none=False, validate=Length(equal=4)) + + +class LicenseOpenSearchDocumentSchema(LicenseGeneralResponseSchema): + """ + License object fields for OpenSearch document indexing. + + Extends LicenseGeneralResponseSchema with the dateOfBirth field to enable + authorized staff users to search providers by date of birth. This schema + is used only for indexing into OpenSearch, not for API responses. + + Serialization direction: + Python -> load() -> OpenSearch document + """ + + dateOfBirth = Raw(required=False, allow_none=False) diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/provider/api.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/provider/api.py index fd823febb..76721b20c 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/provider/api.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/provider/api.py @@ -14,6 +14,7 @@ ) from cc_common.data_model.schema.license.api import ( LicenseGeneralResponseSchema, + LicenseOpenSearchDocumentSchema, LicenseReadPrivateResponseSchema, ) from cc_common.data_model.schema.privilege.api import ( @@ -159,6 +160,21 @@ class ProviderGeneralResponseSchema(ForgivingSchema): privileges = List(Nested(PrivilegeGeneralResponseSchema(), required=False, allow_none=False)) +class ProviderOpenSearchDocumentSchema(ProviderGeneralResponseSchema): + """ + Provider object fields for OpenSearch document indexing. + + Extends ProviderGeneralResponseSchema with license objects that include dateOfBirth, + enabling authorized staff users to search providers by date of birth. This schema + is used only for indexing into OpenSearch, not for API responses. + + Serialization direction: + Python -> load() -> OpenSearch document + """ + + licenses = List(Nested(LicenseOpenSearchDocumentSchema(), required=False, allow_none=False)) + + class ProviderPublicResponseSchema(ForgivingSchema): """ Provider object fields that are sanitized for the public lookup endpoints. diff --git a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py index 9bac4adbc..c4414718c 100644 --- a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py +++ b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py @@ -303,6 +303,79 @@ def test_compact_eligible_with_inactive_license_not_allowed(self): LicenseIngestSchema().load({'compact': 'cosm', 'jurisdiction': 'oh', **license_record}) +class TestLicenseOpenSearchDocumentSchema(TstLambdas): + """Tests for LicenseOpenSearchDocumentSchema which extends LicenseGeneralResponseSchema with dateOfBirth.""" + + def _make_license_data(self, *, license_status='active', date_of_expiration='2100-01-01'): + """Create valid license data including dateOfBirth for testing.""" + return { + 'providerId': 'a4182428-d061-701c-82e5-a3d1d547d797', + 'type': 'license', + 'dateOfUpdate': '2024-01-01T00:00:00+00:00', + 'compact': 'cosm', + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'licenseStatus': license_status, + 'jurisdictionUploadedLicenseStatus': 'active', + 'compactEligibility': 'eligible', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'givenName': 'John', + 'familyName': 'Doe', + 'dateOfIssuance': '2024-01-01', + 'dateOfExpiration': date_of_expiration, + 'homeAddressStreet1': '123 Main St', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'OH', + 'homeAddressPostalCode': '43215', + 'licenseNumber': 'LIC12345', + 'dateOfBirth': '1985-06-06', + } + + def test_includes_date_of_birth(self): + """LicenseOpenSearchDocumentSchema should include dateOfBirth in the loaded output.""" + from cc_common.data_model.schema.license.api import LicenseOpenSearchDocumentSchema + + license_data = self._make_license_data() + result = LicenseOpenSearchDocumentSchema().load(license_data) + + self.assertEqual('1985-06-06', result['dateOfBirth']) + + def test_retains_all_general_response_fields(self): + """LicenseOpenSearchDocumentSchema should retain all fields from LicenseGeneralResponseSchema.""" + from cc_common.data_model.schema.license.api import LicenseOpenSearchDocumentSchema + + license_data = self._make_license_data() + result = LicenseOpenSearchDocumentSchema().load(license_data) + + for field in [ + 'providerId', 'type', 'dateOfUpdate', 'compact', 'jurisdiction', + 'licenseType', 'licenseStatus', 'licenseNumber', 'givenName', 'familyName', + 'dateOfIssuance', 'dateOfExpiration', 'homeAddressStreet1', 'homeAddressCity', + 'homeAddressState', 'homeAddressPostalCode', + ]: + self.assertIn(field, result, f'Expected field {field} to be in loaded result') + + def test_expired_license_status_corrected_to_inactive(self): + """LicenseOpenSearchDocumentSchema should inherit expiration status correction from LicenseExpirationStatusMixin.""" + from cc_common.data_model.schema.license.api import LicenseOpenSearchDocumentSchema + + license_data = self._make_license_data(license_status='active', date_of_expiration='2020-01-01') + result = LicenseOpenSearchDocumentSchema().load(license_data) + + self.assertEqual('inactive', result['licenseStatus']) + + def test_strips_fields_not_in_schema(self): + """LicenseOpenSearchDocumentSchema should strip fields not defined in the schema (ForgivingSchema behavior).""" + from cc_common.data_model.schema.license.api import LicenseOpenSearchDocumentSchema + + license_data = self._make_license_data() + license_data['ssnLastFour'] = '1234' + + result = LicenseOpenSearchDocumentSchema().load(license_data) + + self.assertNotIn('ssnLastFour', result) + + class TestLicenseGeneralResponseSchemaExpirationCheck(TstLambdas): """ Tests for the LicenseExpirationStatusMixin applied to LicenseGeneralResponseSchema. diff --git a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py index 54f1cef87..2fb2b77cd 100644 --- a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py +++ b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py @@ -7,6 +7,99 @@ from tests import TstLambdas +class TestProviderOpenSearchDocumentSchema(TstLambdas): + """Tests for ProviderOpenSearchDocumentSchema which extends ProviderGeneralResponseSchema + with dateOfBirth on nested license objects.""" + + def _make_provider_data_with_license(self): + """Create valid provider data with a nested license that includes dateOfBirth.""" + return { + 'providerId': 'a4182428-d061-701c-82e5-a3d1d547d797', + 'type': 'provider', + 'dateOfUpdate': '2024-07-08T23:59:59+00:00', + 'compact': 'cosm', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'active', + 'compactEligibility': 'eligible', + 'givenName': 'John', + 'familyName': 'Doe', + 'dateOfExpiration': '2100-01-01', + 'jurisdictionUploadedLicenseStatus': 'active', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'birthMonthDay': '06-06', + 'licenses': [ + { + 'providerId': 'a4182428-d061-701c-82e5-a3d1d547d797', + 'type': 'license', + 'dateOfUpdate': '2024-06-06T12:59:59+00:00', + 'compact': 'cosm', + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'licenseStatus': 'active', + 'jurisdictionUploadedLicenseStatus': 'active', + 'compactEligibility': 'eligible', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'licenseNumber': 'LIC12345', + 'givenName': 'John', + 'familyName': 'Doe', + 'dateOfIssuance': '2024-01-01', + 'dateOfExpiration': '2100-01-01', + 'homeAddressStreet1': '123 Main St', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'OH', + 'homeAddressPostalCode': '43215', + 'dateOfBirth': '1985-06-06', + } + ], + 'privileges': [], + } + + def test_license_includes_date_of_birth(self): + """ProviderOpenSearchDocumentSchema should include dateOfBirth in nested license objects.""" + from cc_common.data_model.schema.provider.api import ProviderOpenSearchDocumentSchema + + data = self._make_provider_data_with_license() + result = ProviderOpenSearchDocumentSchema().load(data) + + self.assertEqual(1, len(result['licenses'])) + self.assertEqual('1985-06-06', result['licenses'][0]['dateOfBirth']) + + def test_top_level_fields_match_general_response(self): + """ProviderOpenSearchDocumentSchema should retain all top-level fields from ProviderGeneralResponseSchema.""" + from cc_common.data_model.schema.provider.api import ProviderOpenSearchDocumentSchema + + data = self._make_provider_data_with_license() + result = ProviderOpenSearchDocumentSchema().load(data) + + for field in [ + 'providerId', 'type', 'dateOfUpdate', 'compact', 'licenseJurisdiction', + 'licenseStatus', 'compactEligibility', 'givenName', 'familyName', + 'dateOfExpiration', 'birthMonthDay', + ]: + self.assertIn(field, result, f'Expected field {field} to be in loaded result') + + def test_does_not_include_private_fields_at_top_level(self): + """ProviderOpenSearchDocumentSchema should NOT include top-level private fields like dateOfBirth or ssnLastFour.""" + from cc_common.data_model.schema.provider.api import ProviderOpenSearchDocumentSchema + + data = self._make_provider_data_with_license() + data['dateOfBirth'] = '1985-06-06' + data['ssnLastFour'] = '1234' + result = ProviderOpenSearchDocumentSchema().load(data) + + self.assertNotIn('dateOfBirth', result) + self.assertNotIn('ssnLastFour', result) + + def test_general_response_schema_does_not_include_date_of_birth_in_licenses(self): + """ProviderGeneralResponseSchema should NOT include dateOfBirth in license objects (baseline comparison).""" + from cc_common.data_model.schema.provider.api import ProviderGeneralResponseSchema + + data = self._make_provider_data_with_license() + result = ProviderGeneralResponseSchema().load(data) + + self.assertNotIn('dateOfBirth', result['licenses'][0]) + + class TestProviderRecordSchema(TstLambdas): def test_serde(self): """Test round-trip deserialization/serialization""" diff --git a/backend/cosmetology-app/lambdas/python/search/handlers/manage_opensearch_indices.py b/backend/cosmetology-app/lambdas/python/search/handlers/manage_opensearch_indices.py index 9950038d3..0c606ba4d 100644 --- a/backend/cosmetology-app/lambdas/python/search/handlers/manage_opensearch_indices.py +++ b/backend/cosmetology-app/lambdas/python/search/handlers/manage_opensearch_indices.py @@ -257,6 +257,7 @@ def _get_provider_index_mapping(self, number_of_shards: int, number_of_replicas: 'dateOfIssuance': {'type': 'date'}, 'dateOfRenewal': {'type': 'date'}, 'dateOfExpiration': {'type': 'date'}, + 'dateOfBirth': {'type': 'date'}, 'homeAddressStreet1': {'type': 'text'}, 'homeAddressStreet2': {'type': 'text'}, 'homeAddressCity': { diff --git a/backend/cosmetology-app/lambdas/python/search/handlers/search.py b/backend/cosmetology-app/lambdas/python/search/handlers/search.py index 41f3f3df8..ddb6c4cd6 100644 --- a/backend/cosmetology-app/lambdas/python/search/handlers/search.py +++ b/backend/cosmetology-app/lambdas/python/search/handlers/search.py @@ -1,3 +1,5 @@ +from re import match + from aws_lambda_powertools.utilities.typing import LambdaContext from cc_common.config import logger from cc_common.data_model.schema.common import CCPermissionsAction @@ -6,7 +8,7 @@ SearchProvidersRequestSchema, ) from cc_common.exceptions import CCInvalidRequestException -from cc_common.utils import api_handler, authorize_compact_level_only_action +from cc_common.utils import api_handler, authorize_compact_level_only_action, get_event_scopes from marshmallow import ValidationError from opensearch_client import OpenSearchClient @@ -61,6 +63,9 @@ def _search_providers(event: dict, context: LambdaContext): # noqa: ARG001 unus # Parse and validate the request body using the schema body = _parse_and_validate_request_body(event) + # If the query references dateOfBirth, verify the caller has readPrivate permission + _validate_date_of_birth_permission(body.get('query', {}), compact, get_event_scopes(event)) + # Build the OpenSearch search body search_body = _build_opensearch_search_body(body, size_override=MAX_PROVIDER_PAGE_SIZE) @@ -194,3 +199,56 @@ def _build_opensearch_search_body(body: dict, size_override: int) -> dict: raise CCInvalidRequestException('sort is required when using search_after pagination') return search_body + + +def _query_references_field(obj, field_name: str) -> bool: + """ + Recursively check if any key in the query DSL references the given field name. + + :param obj: The object to check (dict, list, or scalar) + :param field_name: The field name to search for in dict keys + :return: True if the field name is found in any key + """ + if isinstance(obj, dict): + for key, value in obj.items(): + if field_name in key: + return True + if _query_references_field(value, field_name): + return True + elif isinstance(obj, list): + for item in obj: + if _query_references_field(item, field_name): + return True + return False + + +def _caller_has_read_private_scope(compact: str, scopes: set[str]) -> bool: + """ + Check if the caller has readPrivate permission at either compact or jurisdiction level. + + :param compact: The compact abbreviation + :param scopes: The caller's scopes + :return: True if the caller has readPrivate permission + """ + action = CCPermissionsAction.READ_PRIVATE + + if f'{compact}/{action}' in scopes: + return True + + jurisdiction_scope_pattern = rf'.+/{compact}\.{action}$' + return any(match(jurisdiction_scope_pattern, scope) for scope in scopes) + + +def _validate_date_of_birth_permission(query: dict, compact: str, scopes: set[str]) -> None: + """ + Validate that the caller has readPrivate permission if the query references dateOfBirth. + + :param query: The OpenSearch query body + :param compact: The compact abbreviation + :param scopes: The caller's scopes + :raises CCInvalidRequestException: If dateOfBirth is in the query and the caller lacks readPrivate permission + """ + if _query_references_field(query, 'dateOfBirth') and not _caller_has_read_private_scope(compact, scopes): + raise CCInvalidRequestException( + 'Searching by dateOfBirth requires readPrivate permission' + ) diff --git a/backend/cosmetology-app/lambdas/python/search/tests/__init__.py b/backend/cosmetology-app/lambdas/python/search/tests/__init__.py index 1d47cbe55..e7218acab 100644 --- a/backend/cosmetology-app/lambdas/python/search/tests/__init__.py +++ b/backend/cosmetology-app/lambdas/python/search/tests/__init__.py @@ -19,6 +19,7 @@ def setUpClass(cls): 'ENVIRONMENT_NAME': 'test', 'COMPACTS': '["cosm"]', 'PROVIDER_TABLE_NAME': 'provider-table', + 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-config-table', 'PROV_DATE_OF_UPDATE_INDEX_NAME': 'providerDateOfUpdate', 'PROV_FAM_GIV_MID_INDEX_NAME': 'providerFamGivMid', 'LICENSE_GSI_NAME': 'licenseGSI', diff --git a/backend/cosmetology-app/lambdas/python/search/tests/function/__init__.py b/backend/cosmetology-app/lambdas/python/search/tests/function/__init__.py index 3d3f139f9..6bbe23817 100644 --- a/backend/cosmetology-app/lambdas/python/search/tests/function/__init__.py +++ b/backend/cosmetology-app/lambdas/python/search/tests/function/__init__.py @@ -26,6 +26,7 @@ def setUp(self): # noqa: N801 invalid-name def build_resources(self): self.create_provider_table() + self.create_compact_configuration_table() self.create_export_results_bucket() def delete_resources(self): @@ -33,6 +34,7 @@ def delete_resources(self): # must delete all objects in the bucket before deleting the bucket self._bucket.objects.delete() self._bucket.delete() + self._compact_configuration_table.delete() def create_export_results_bucket(self): """Create the mock S3 bucket for export results""" @@ -91,3 +93,18 @@ def create_provider_table(self): }, ], ) + + def create_compact_configuration_table(self): + """Create the compact configuration table for testing.""" + self._compact_configuration_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['COMPACT_CONFIGURATION_TABLE_NAME'], + KeySchema=[ + {'AttributeName': 'pk', 'KeyType': 'HASH'}, + {'AttributeName': 'sk', 'KeyType': 'RANGE'}, + ], + BillingMode='PAY_PER_REQUEST', + ) diff --git a/backend/cosmetology-app/lambdas/python/search/tests/function/test_manage_opensearch_indices.py b/backend/cosmetology-app/lambdas/python/search/tests/function/test_manage_opensearch_indices.py index 03d2fabe5..36fde869a 100644 --- a/backend/cosmetology-app/lambdas/python/search/tests/function/test_manage_opensearch_indices.py +++ b/backend/cosmetology-app/lambdas/python/search/tests/function/test_manage_opensearch_indices.py @@ -169,6 +169,7 @@ def test_on_create_creates_versioned_indices_and_aliases_for_all_compacts_when_n }, 'compact': {'type': 'keyword'}, 'compactEligibility': {'type': 'keyword'}, + 'dateOfBirth': {'type': 'date'}, 'dateOfExpiration': {'type': 'date'}, 'dateOfIssuance': {'type': 'date'}, 'dateOfRenewal': {'type': 'date'}, diff --git a/backend/cosmetology-app/lambdas/python/search/tests/function/test_provider_update_ingest.py b/backend/cosmetology-app/lambdas/python/search/tests/function/test_provider_update_ingest.py index 103a7208e..7c0582e12 100644 --- a/backend/cosmetology-app/lambdas/python/search/tests/function/test_provider_update_ingest.py +++ b/backend/cosmetology-app/lambdas/python/search/tests/function/test_provider_update_ingest.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch from common_test.test_constants import ( + DEFAULT_DATE_OF_BIRTH, DEFAULT_LICENSE_EXPIRATION_DATE, DEFAULT_LICENSE_ISSUANCE_DATE, DEFAULT_LICENSE_RENEWAL_DATE, @@ -159,6 +160,7 @@ def _generate_expected_document(self, compact: str, provider_id: str = None) -> 'dateOfIssuance': DEFAULT_LICENSE_ISSUANCE_DATE, 'dateOfRenewal': DEFAULT_LICENSE_RENEWAL_DATE, 'dateOfExpiration': DEFAULT_LICENSE_EXPIRATION_DATE, + 'dateOfBirth': DEFAULT_DATE_OF_BIRTH, 'homeAddressStreet1': '123 A St.', 'homeAddressStreet2': 'Apt 321', 'homeAddressCity': 'Columbus', diff --git a/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py b/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py index 0df39642b..0a2e6b0da 100644 --- a/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py +++ b/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py @@ -467,3 +467,105 @@ def test_opensearch_request_error_returns_400_with_error_message(self, mock_open self.assertEqual(400, response['statusCode']) body = json.loads(response['body']) self.assertEqual(error_reason, body['message']) + + @patch('handlers.search.opensearch_client') + def test_search_with_date_of_birth_query_allowed_for_compact_level_read_private_scope( + self, mock_opensearch_client + ): + """Test that a query containing dateOfBirth succeeds when the caller has compact-level readPrivate scope.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + query = { + 'nested': { + 'path': 'licenses', + 'query': {'term': {'licenses.dateOfBirth': '1985-06-06'}}, + } + } + event = self._create_api_event( + 'cosm', + body={'query': query}, + scopes_override='openid email cosm/readGeneral cosm/readPrivate', + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(200, response['statusCode']) + mock_opensearch_client.search.assert_called_once() + + @patch('handlers.search.opensearch_client') + def test_search_with_date_of_birth_query_allowed_for_jurisdiction_level_read_private_scope( + self, mock_opensearch_client + ): + """Test that a query containing dateOfBirth succeeds when the caller has a jurisdiction-level readPrivate scope.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + query = { + 'nested': { + 'path': 'licenses', + 'query': {'term': {'licenses.dateOfBirth': '1985-06-06'}}, + } + } + event = self._create_api_event( + 'cosm', + body={'query': query}, + scopes_override='openid email cosm/readGeneral oh/cosm.readPrivate', + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(200, response['statusCode']) + mock_opensearch_client.search.assert_called_once() + + def test_search_with_date_of_birth_query_rejected_without_read_private_scope(self): + """Test that a query containing dateOfBirth returns 400 when the caller only has readGeneral scope.""" + from handlers.search import search_api_handler + + query = { + 'nested': { + 'path': 'licenses', + 'query': {'term': {'licenses.dateOfBirth': '1985-06-06'}}, + } + } + event = self._create_api_event('cosm', body={'query': query}) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('dateOfBirth', body['message']) + + def test_search_with_nested_date_of_birth_query_rejected_without_read_private_scope(self): + """Test that deeply nested dateOfBirth references are caught and rejected.""" + from handlers.search import search_api_handler + + query = { + 'bool': { + 'must': [ + {'match': {'givenName': 'John'}}, + { + 'nested': { + 'path': 'licenses', + 'query': { + 'bool': { + 'must': [ + {'term': {'licenses.jurisdiction': 'oh'}}, + {'range': {'licenses.dateOfBirth': {'gte': '1985-01-01'}}}, + ] + } + }, + } + }, + ] + } + } + event = self._create_api_event('cosm', body={'query': query}) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('dateOfBirth', body['message']) diff --git a/backend/cosmetology-app/lambdas/python/search/utils.py b/backend/cosmetology-app/lambdas/python/search/utils.py index 04fd50aa5..82d5c4638 100644 --- a/backend/cosmetology-app/lambdas/python/search/utils.py +++ b/backend/cosmetology-app/lambdas/python/search/utils.py @@ -9,7 +9,7 @@ import json from cc_common.config import config -from cc_common.data_model.schema.provider.api import ProviderGeneralResponseSchema +from cc_common.data_model.schema.provider.api import ProviderOpenSearchDocumentSchema from cc_common.utils import ResponseEncoder @@ -33,8 +33,7 @@ def generate_provider_opensearch_document(compact: str, provider_id: str) -> dic # Generate API response object with all nested records api_response = provider_user_records.generate_api_response_object() - # Sanitize using ProviderGeneralResponseSchema - schema = ProviderGeneralResponseSchema() + schema = ProviderOpenSearchDocumentSchema() sanitized_document = schema.load(api_response) # Serialize using ResponseEncoder to convert sets to lists and datetime objects to strings From f1b5e13db202302928576ed0944ab8ebc586d9c6 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 11 Mar 2026 17:08:11 -0500 Subject: [PATCH 02/14] Indexing one opensearch document per license record --- .../data_model/provider_record_util.py | 97 +++- .../tests/unit/test_provider_record_util.py | 501 +++++++++++++++++- .../handlers/populate_provider_documents.py | 9 +- .../search/handlers/provider_update_ingest.py | 161 ++++-- .../python/search/opensearch_client.py | 15 + .../test_populate_provider_documents.py | 117 ++-- .../function/test_provider_update_ingest.py | 262 ++++----- .../tests/function/test_search_providers.py | 84 +-- .../tests/unit/test_opensearch_client.py | 79 +++ .../lambdas/python/search/utils.py | 32 +- 10 files changed, 1044 insertions(+), 313 deletions(-) diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py index accd24ec0..5765afc9b 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py @@ -424,15 +424,23 @@ def find_best_license_in_current_known_licenses( ) return sorted_licenses[0] - def generate_privileges_for_provider(self) -> list[dict]: + def generate_privileges_for_provider(self, include_inactive_privileges: bool = False) -> list[dict]: """ Generate privilege dicts at runtime for all eligible license types this provider holds. For each license type, the home license is chosen from all licenses of that type: the license renewed most recently (when dateOfRenewal is present), otherwise the license with the most recent date of issuance. - Privileges are generated for that type only if the chosen home license is compact-eligible. - For each such type, one privilege is generated per active compact jurisdiction + By default, privileges are generated only when the chosen home license is compact-eligible. + + When include_inactive_privileges is True, privileges are also generated for ineligible home + licenses and are marked inactive. This is primarily used when indexing to OpenSearch so that adverse + actions and investigations remain searchable even when a license is ineligible. + + For each qualifying type, one privilege is generated per active compact jurisdiction (excluding the home jurisdiction). + + :param include_inactive_privileges: When True, generate privileges for ineligible home licenses + and mark them inactive instead of omitting them entirely. """ if not self._license_records: return [] @@ -458,14 +466,18 @@ def generate_privileges_for_provider(self) -> list[dict]: reverse=True, ) most_recent_license = sorted_licenses[0] - # If the most recently renewed/issued license is not compact eligible, - # we will not generate privileges for it - if most_recent_license.compactEligibility != CompactEligibilityStatus.ELIGIBLE: + if ( + not include_inactive_privileges + and most_recent_license.compactEligibility != CompactEligibilityStatus.ELIGIBLE + ): + logger.debug('skipping inactive license', + license_jurisdiction=most_recent_license.jurisdiction, license_type=most_recent_license.licenseType) continue most_recent_licenses_for_each_type.append(most_recent_license) result: list[dict] = [] for most_recent_license in most_recent_licenses_for_each_type: + is_eligible = most_recent_license.compactEligibility == CompactEligibilityStatus.ELIGIBLE home_jurisdiction = most_recent_license.jurisdiction.lower() license_type_abbr = most_recent_license.licenseTypeAbbreviation @@ -486,12 +498,10 @@ def generate_privileges_for_provider(self) -> list[dict]: 'licenseJurisdiction': home_jurisdiction, 'licenseType': most_recent_license.licenseType, 'dateOfExpiration': most_recent_license.dateOfExpiration, - # the only way a privilege under this model shows inactive is if - # there has been an encumbrance set by a state admin that has not been - # lifted. If the license itself is inactive or ineligible for whatever reason, we don't - # return any associated privilege objects + # A privilege is inactive if the home license is ineligible, or if a state admin + # has set an encumbrance that has not been lifted. 'status': ActiveInactiveStatus.ACTIVE.value - if not privilege_unlifted + if is_eligible and not privilege_unlifted else ActiveInactiveStatus.INACTIVE.value, 'adverseActions': [aa.to_dict() for aa in privilege_aa], 'investigations': [inv.to_dict() for inv in inv_records], @@ -594,3 +604,68 @@ def generate_api_response_object(self) -> dict: provider['privileges'] = privileges return provider + + def generate_opensearch_documents(self) -> list[dict]: + """ + Generate one OpenSearch document per license for this provider. + + Each document contains the full provider-level fields, a single license in the `licenses` + array, and privileges only if that license is the home license for its type. This enables + 1:1 mapping between OpenSearch documents and license records for native pagination. + + Privileges are always included for home license documents — including when the license is + ineligible — so that adverse actions and investigations remain linked to privilege records. + Privileges for ineligible home licenses carry status 'inactive'. + + :return: A list of dicts, each representing a single-license OpenSearch document. + Empty list if the provider has no licenses. + """ + if not self._license_records: + return [] + + provider_dict = self.get_provider_record().to_dict() + all_privileges = self.generate_privileges_for_provider(include_inactive_privileges=True) + + # Determine the home license for each license type using the same sort logic + # as generate_privileges_for_provider, so privilege assignment is consistent. + by_type: dict[str, list] = {} + for lic in self._license_records: + by_type.setdefault(lic.licenseType, []).append(lic) + + home_licenses: set[tuple[str, str]] = set() + for _lt, licenses in by_type.items(): + sorted_licenses = sorted( + licenses, + key=ProviderRecordUtility._license_sort_key, # noqa: SLF001 + reverse=True, + ) + home = sorted_licenses[0] + home_licenses.add((home.jurisdiction.lower(), home.licenseType)) + + documents = [] + for license_record in self._license_records: + license_dict = license_record.to_dict() + license_dict['adverseActions'] = [ + rec.to_dict() + for rec in self.get_adverse_action_records_for_license( + license_record.jurisdiction, license_record.licenseTypeAbbreviation + ) + ] + license_dict['investigations'] = [ + rec.to_dict() + for rec in self.get_investigation_records_for_license( + license_record.jurisdiction, license_record.licenseTypeAbbreviation + ) + ] + + is_home = (license_record.jurisdiction.lower(), license_record.licenseType) in home_licenses + license_privileges = ( + [p for p in all_privileges if p['licenseType'] == license_record.licenseType] if is_home else [] + ) + + doc = dict(provider_dict) + doc['licenses'] = [license_dict] + doc['privileges'] = license_privileges + documents.append(doc) + + return documents diff --git a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py index 53721760a..5245d4977 100644 --- a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py +++ b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py @@ -1,5 +1,6 @@ from datetime import date -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, patch, ANY +from uuid import UUID from tests import TstLambdas @@ -503,3 +504,501 @@ def test_find_best_license_complex_scenario(self): best_license = ProviderRecordUtility.find_best_license(licenses) self.assertEqual(best_license['dateOfIssuance'], '2024-03-01') self.assertEqual(best_license['compactEligibility'], CompactEligibilityStatus.INELIGIBLE) + + +@patch('cc_common.config._Config.expiration_resolution_date', date(2025, 6, 1)) +class TestGenerateOpenSearchDocuments(TstLambdas): + """Tests for ProviderUserRecords.generate_opensearch_documents().""" + + def _make_provider_records(self, provider_overrides=None, license_overrides_list=None, extra_records=None): + """Build list of provider + license (and optional other) records as dicts for ProviderUserRecords.""" + from common_test.test_data_generator import TestDataGenerator + + if license_overrides_list is None: + license_overrides_list = [] + + provider = TestDataGenerator.generate_default_provider(provider_overrides or {}) + provider_record = provider.serialize_to_database_record() + records = [provider_record] + for overrides in license_overrides_list: + lic = TestDataGenerator.generate_default_license(overrides) + records.append(lic.serialize_to_database_record()) + if extra_records: + records.extend(extra_records) + return records + + def _patch_config_for_privilege_generation(self, live_compact_jurisdictions=None): + if live_compact_jurisdictions is None: + live_compact_jurisdictions = {'cosm': ['al', 'ky', 'oh']} + mock_config = MagicMock() + mock_config.live_compact_jurisdictions = live_compact_jurisdictions + mock_config.license_type_abbreviations = {'cosm': {'cosmetologist': 'cos', 'esthetician': 'esth'}} + return patch('cc_common.data_model.provider_record_util.config', mock_config) + + def test_single_license_returns_one_document(self): + """Provider with one license produces exactly one OpenSearch document.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + } + ] + ) + with self._patch_config_for_privilege_generation(): + pur = ProviderUserRecords(records) + docs = pur.generate_opensearch_documents() + + self.assertEqual([{'birthMonthDay': '06-06', + 'compact': 'cosm', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [{'adverseActions': [], + 'compact': 'cosm', + 'compactEligibility': 'eligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2010, 6, 6), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'oh', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'cosmetologist', + 'middleName': 'Gunnar', + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license'}], + 'middleName': 'Gunnar', + 'privileges': [{'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'al', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege'}, + {'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'ky', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege'}], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider'}], docs) + + def test_two_licenses_different_types_returns_two_documents(self): + """Provider with two licenses of different types produces two documents. + The second license is also ineligible, so its associated privileges should be inactive. + """ + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + 'jurisdictionUploadedCompactEligibility': CompactEligibilityStatus.ELIGIBLE, + }, + { + 'jurisdiction': 'oh', + 'licenseType': 'esthetician', + 'dateOfExpiration': date(2026, 4, 4), + # jurisdictionUploadedCompactEligibility is ineligible, so the privileges should be inactive + 'jurisdictionUploadedCompactEligibility': CompactEligibilityStatus.INELIGIBLE, + }, + ] + ) + with self._patch_config_for_privilege_generation(): + pur = ProviderUserRecords(records) + docs = pur.generate_opensearch_documents() + + self.assertEqual([{'birthMonthDay': '06-06', + 'compact': 'cosm', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [{'adverseActions': [], + 'compact': 'cosm', + 'compactEligibility': 'eligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2010, 6, 6), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'al', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'cosmetologist', + 'middleName': 'Gunnar', + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license'}], + 'middleName': 'Gunnar', + 'privileges': [{'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'ky', + 'licenseJurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege'}, + {'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'oh', + 'licenseJurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege'}], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider'}, + {'birthMonthDay': '06-06', + 'compact': 'cosm', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [{'adverseActions': [], + 'compact': 'cosm', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2010, 6, 6), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'oh', + 'jurisdictionUploadedCompactEligibility': 'ineligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'esthetician', + 'middleName': 'Gunnar', + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license'}], + 'middleName': 'Gunnar', + # these privileges are inactive due to the home state license being ineligible + 'privileges': [{'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'al', + 'licenseJurisdiction': 'oh', + 'licenseType': 'esthetician', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'inactive', + 'type': 'privilege'}, + {'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'ky', + 'licenseJurisdiction': 'oh', + 'licenseType': 'esthetician', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'inactive', + 'type': 'privilege'}], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider'}], docs) + + def test_privileges_assigned_only_to_home_license_document(self): + """Privileges are only on the document whose license is the home license for its type.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfIssuance': date(2023, 1, 1), + }, + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + # this license was issues more recently, so it should have the privileges associated with it. + 'dateOfIssuance': date(2024, 6, 1), + }, + ] + ) + with self._patch_config_for_privilege_generation(): + pur = ProviderUserRecords(records) + docs = pur.generate_opensearch_documents() + + self.assertEqual([{'birthMonthDay': '06-06', + 'compact': 'cosm', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [{'adverseActions': [], + 'compact': 'cosm', + 'compactEligibility': 'eligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2023, 1, 1), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'al', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'cosmetologist', + 'middleName': 'Gunnar', + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license'}], + 'middleName': 'Gunnar', + 'privileges': [], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider'}, + {'birthMonthDay': '06-06', + 'compact': 'cosm', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [{'adverseActions': [], + 'compact': 'cosm', + 'compactEligibility': 'eligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2024, 6, 1), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'oh', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'cosmetologist', + 'middleName': 'Gunnar', + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license'}], + 'middleName': 'Gunnar', + 'privileges': [{'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'al', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege'}, + {'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'ky', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege'}], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider'}], docs) + + def test_multiple_types_privileges_on_correct_home_licenses(self): + """With two license types, each type's home license gets its own privileges.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + }, + { + 'jurisdiction': 'oh', + 'licenseType': 'esthetician', + 'dateOfExpiration': date(2026, 4, 4), + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + }, + ] + ) + with self._patch_config_for_privilege_generation(): + pur = ProviderUserRecords(records) + docs = pur.generate_opensearch_documents() + + self.assertEqual(2, len(docs)) + al_doc = next(d for d in docs if d['licenses'][0]['jurisdiction'] == 'al') + oh_doc = next(d for d in docs if d['licenses'][0]['jurisdiction'] == 'oh') + # cosmetologist home is al -> al_doc gets cosmetologist privileges + cos_privs = [p for p in al_doc['privileges'] if p['licenseType'] == 'cosmetologist'] + self.assertGreater(len(cos_privs), 0) + # esthetician home is oh -> oh_doc gets esthetician privileges + esth_privs = [p for p in oh_doc['privileges'] if p['licenseType'] == 'esthetician'] + self.assertGreater(len(esth_privs), 0) + + def test_license_adverse_actions_included(self): + """Each document includes adverse actions specific to its license.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + } + ], + extra_records=[ + self.test_data_generator.generate_default_adverse_action( + value_overrides={ + 'jurisdiction': 'oh', + 'actionAgainst': 'license', + 'licenseTypeAbbreviation': 'cos', + } + ).serialize_to_database_record() + ], + ) + with self._patch_config_for_privilege_generation(): + pur = ProviderUserRecords(records) + docs = pur.generate_opensearch_documents() + + self.assertEqual(1, len(docs)) + self.assertEqual(1, len(docs[0]['licenses'][0]['adverseActions'])) + + def test_no_licenses_returns_empty_list(self): + """Provider with no license records produces an empty list.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + + records = self._make_provider_records() + with self._patch_config_for_privilege_generation(): + pur = ProviderUserRecords(records) + docs = pur.generate_opensearch_documents() + + self.assertEqual([], docs) diff --git a/backend/cosmetology-app/lambdas/python/search/handlers/populate_provider_documents.py b/backend/cosmetology-app/lambdas/python/search/handlers/populate_provider_documents.py index b044185b9..0799ee3a1 100644 --- a/backend/cosmetology-app/lambdas/python/search/handlers/populate_provider_documents.py +++ b/backend/cosmetology-app/lambdas/python/search/handlers/populate_provider_documents.py @@ -40,7 +40,7 @@ from cc_common.exceptions import CCInternalException from marshmallow import ValidationError from opensearch_client import OpenSearchClient -from utils import generate_provider_opensearch_document +from utils import generate_provider_opensearch_documents # Batch size for DynamoDB pagination DYNAMODB_PAGE_SIZE = 1000 @@ -214,9 +214,8 @@ def populate_provider_documents(event: dict, context: LambdaContext): continue try: - # Use the shared utility to process the provider - serializable_document = generate_provider_opensearch_document(compact, provider_id) - documents_to_index.append(serializable_document) + serializable_documents = generate_provider_opensearch_documents(compact, provider_id) + documents_to_index.extend(serializable_documents) except ValidationError as e: logger.warning( @@ -365,7 +364,7 @@ def _bulk_index_documents(opensearch_client: OpenSearchClient, index_name: str, return set() # This will raise CCInternalException if all retries fail - response = opensearch_client.bulk_index(index_name=index_name, documents=documents) + response = opensearch_client.bulk_index(index_name=index_name, documents=documents, id_field='documentId') # Check for errors in the bulk response (individual document failures, not connection issues) if response.get('errors'): diff --git a/backend/cosmetology-app/lambdas/python/search/handlers/provider_update_ingest.py b/backend/cosmetology-app/lambdas/python/search/handlers/provider_update_ingest.py index 442dfe4d9..cec5f36a7 100644 --- a/backend/cosmetology-app/lambdas/python/search/handlers/provider_update_ingest.py +++ b/backend/cosmetology-app/lambdas/python/search/handlers/provider_update_ingest.py @@ -7,6 +7,11 @@ compact, and bulk indexes the sanitized provider documents into the appropriate OpenSearch indices. +The handler classifies events by their DynamoDB eventName: +- INSERT/MODIFY: Generate one document per license and upsert via composite documentId +- REMOVE: Delete all documents for the provider, then re-check DynamoDB and re-index + any remaining license documents + The handler uses the @sqs_batch_handler decorator which passes all SQS messages to the handler at once, enabling batch processing and deduplication. The handler returns batchItemFailures directly for partial success handling. @@ -18,7 +23,7 @@ from cc_common.utils import sqs_batch_handler from marshmallow import ValidationError from opensearch_client import OpenSearchClient -from utils import generate_provider_opensearch_document +from utils import generate_provider_opensearch_documents # Instantiate the OpenSearch client outside of the handler to cache connection between invocations opensearch_client = OpenSearchClient(timeout=30) @@ -30,10 +35,10 @@ def provider_update_ingest_handler(records: list[dict]) -> dict: Process DynamoDB stream events from SQS and index provider documents into OpenSearch. This function: - 1. Creates a set for each compact to deduplicate provider IDs - 2. Extracts compact and providerId from each stream record (old or new image) - 3. Processes each unique provider by compact using the shared utility - 4. Bulk indexes the documents into the appropriate OpenSearch index + 1. Classifies events by eventName (REMOVE vs INSERT/MODIFY) + 2. Deduplicates provider IDs per compact + 3. For INSERT/MODIFY: generates one document per license and bulk upserts + 4. For REMOVE: deletes all docs for the provider, re-checks DynamoDB, re-indexes remaining :param records: List of SQS records, each containing 'messageId' and 'body' (DynamoDB stream record) :return: Response with batch item failures for partial success handling @@ -44,13 +49,13 @@ def provider_update_ingest_handler(records: list[dict]) -> dict: logger.info('Processing SQS batch with DynamoDB stream records', record_count=len(records)) - # Create a set for each compact to deduplicate provider IDs - providers_by_compact: dict[str, set[str]] = {compact: set() for compact in config.compacts} + # Track providers to update and delete separately per compact + providers_to_update: dict[str, set[str]] = {compact: set() for compact in config.compacts} + providers_to_delete: dict[str, set[str]] = {compact: set() for compact in config.compacts} # Track which message IDs correspond to which compact/provider for failure reporting record_mapping: dict[str, tuple[str, str]] = {} # message_id -> (compact, provider_id) - # Extract compact and providerId from each record for record in records: message_id = record['messageId'] # The body contains the DynamoDB stream record sent via EventBridge Pipe @@ -64,7 +69,6 @@ def provider_update_ingest_handler(records: list[dict]) -> dict: continue # Extract compact and providerId from the DynamoDB image - # The format is {'S': 'value'} for string attributes deserialized_image = TypeDeserializer().deserialize(value={'M': image}) compact = deserialized_image.get('compact') provider_id = deserialized_image.get('providerId') @@ -78,30 +82,39 @@ def provider_update_ingest_handler(records: list[dict]) -> dict: ) continue - # Add to the appropriate compact's set to dedup provider ids - if compact in providers_by_compact: - providers_by_compact[compact].add(provider_id) - record_mapping[message_id] = (compact, provider_id) - else: + if compact not in providers_to_update: logger.warning('Unknown compact in record', compact=compact, provider_id=provider_id) + continue + + record_mapping[message_id] = (compact, provider_id) + + is_remove_event = stream_record.get('eventName') == 'REMOVE' + if is_remove_event: + providers_to_delete[compact].add(provider_id) + else: + providers_to_update[compact].add(provider_id) - # Process providers and bulk index by compact batch_item_failures = [] failed_providers: dict[str, set] = {compact: set() for compact in config.compacts} - for compact, provider_ids in providers_by_compact.items(): + # --- Process INSERT/MODIFY events --- + for compact, provider_ids in providers_to_update.items(): + # Exclude providers that are also in the delete set (REMOVE takes precedence) + provider_ids = provider_ids - providers_to_delete[compact] + + if not provider_ids: + continue + index_name = f'compact_{compact}_providers' - logger.info('Processing providers for compact', compact=compact, provider_count=len(provider_ids)) + logger.info('Processing providers for update', compact=compact, provider_count=len(provider_ids)) documents_to_index = [] - providers_to_delete = [] # Provider IDs that no longer exist and need to be deleted from the index for provider_id in provider_ids: try: - document = generate_provider_opensearch_document(compact, provider_id) - documents_to_index.append(document) + docs = generate_provider_opensearch_documents(compact, provider_id) + documents_to_index.extend(docs) except CCNotFoundException as e: - # if no provider records are found, the provider needs to be deleted from the index logger.warning( 'No provider records found. This may occur if a license upload rollback was performed or if records' ' were manually deleted. Will delete provider document from index.', @@ -109,7 +122,7 @@ def provider_update_ingest_handler(records: list[dict]) -> dict: compact=compact, error=str(e), ) - providers_to_delete.append(provider_id) + providers_to_delete[compact].add(provider_id) except ValidationError as e: logger.warning( 'Failed to process provider for indexing', @@ -119,31 +132,25 @@ def provider_update_ingest_handler(records: list[dict]) -> dict: ) failed_providers[compact].add(provider_id) - if failed_providers[compact]: - logger.warning( - 'Some providers failed serialization', - compact=compact, - failed_provider_ids=failed_providers[compact], - successful_count=len(documents_to_index), - ) - - # Bulk index the documents if documents_to_index: try: - response = opensearch_client.bulk_index(index_name=index_name, documents=documents_to_index) + response = opensearch_client.bulk_index( + index_name=index_name, documents=documents_to_index, id_field='documentId' + ) - # Check for individual document failures if response.get('errors'): for item in response.get('items', []): index_result = item.get('index', {}) if index_result.get('error'): - doc_id = index_result.get('_id') + doc_id = index_result.get('_id', '') + provider_id = doc_id.split('#')[0] if '#' in doc_id else doc_id logger.error( 'Document indexing failed', - provider_id=doc_id, + document_id=doc_id, + provider_id=provider_id, error=index_result.get('error'), ) - failed_providers[compact].add(doc_id) + failed_providers[compact].add(provider_id) logger.info( 'Bulk indexed documents', @@ -159,39 +166,79 @@ def provider_update_ingest_handler(records: list[dict]) -> dict: document_count=len(documents_to_index), error=str(e), ) - # Mark all providers in this compact as failed - document_provider_ids = [document['providerId'] for document in documents_to_index] - for provider_id in document_provider_ids: - failed_providers[compact].add(provider_id) + for doc in documents_to_index: + failed_providers[compact].add(doc['providerId']) + + # --- Process REMOVE events --- + for compact, provider_ids in providers_to_delete.items(): + if not provider_ids: + continue - # Bulk delete providers that no longer exist - if providers_to_delete: + index_name = f'compact_{compact}_providers' + logger.info('Processing providers for delete', compact=compact, provider_count=len(provider_ids)) + + for provider_id in provider_ids: try: - failed_provider_ids = opensearch_client.bulk_delete( - index_name=index_name, document_ids=providers_to_delete + result = opensearch_client.delete_provider_documents( + index_name=index_name, + provider_id=provider_id, ) - failed_providers[compact].update(failed_provider_ids) - logger.info( - 'Bulk deleted documents', + 'Deleted provider documents from index', index_name=index_name, - document_count=len(providers_to_delete), - failed_provider_ids=list(failed_provider_ids), + provider_id=provider_id, + deleted_count=result.get('deleted', 0), + ) + except CCInternalException as e: + logger.error( + 'Failed to delete provider documents from index', + index_name=index_name, + provider_id=provider_id, + error=str(e), + ) + failed_providers[compact].add(provider_id) + continue + + # Re-check DynamoDB -- the REMOVE may have been for a single record while + # the provider still has other records remaining. + try: + docs = generate_provider_opensearch_documents(compact, provider_id) + if docs: + response = opensearch_client.bulk_index( + index_name=index_name, documents=docs, id_field='documentId' + ) + logger.info( + 'Re-indexed remaining documents after delete', + index_name=index_name, + provider_id=provider_id, + document_count=len(docs), + ) + if response.get('errors'): + for item in response.get('items', []): + index_result = item.get('index', {}) + if index_result.get('error'): + logger.error( + 'Document re-indexing failed after delete', + document_id=index_result.get('_id'), + error=index_result.get('error'), + ) + failed_providers[compact].add(provider_id) + except CCNotFoundException: + logger.info( + 'Provider no longer exists after REMOVE event, delete is complete', + provider_id=provider_id, + compact=compact, ) except CCInternalException as e: - # All deletes for this compact failed logger.error( - 'Failed to bulk delete documents after retries', + 'Failed to re-index remaining documents after delete', index_name=index_name, - document_count=len(providers_to_delete), + provider_id=provider_id, error=str(e), ) - # Mark all providers to delete as failed - for provider_id in providers_to_delete: - failed_providers[compact].add(provider_id) + failed_providers[compact].add(provider_id) - # Build batch item failures response for failed providers - # Map back from failed providers to their SQS message IDs + # Build batch item failures response for message_id, (compact, provider_id) in record_mapping.items(): if provider_id in failed_providers[compact]: logger.info( diff --git a/backend/cosmetology-app/lambdas/python/search/opensearch_client.py b/backend/cosmetology-app/lambdas/python/search/opensearch_client.py index 0fc2214b0..166c4c740 100644 --- a/backend/cosmetology-app/lambdas/python/search/opensearch_client.py +++ b/backend/cosmetology-app/lambdas/python/search/opensearch_client.py @@ -208,6 +208,21 @@ def _extract_opensearch_error_reason(e: RequestError) -> str: ) return str(e.error) + def delete_provider_documents(self, index_name: str, provider_id: str) -> dict: + """ + Delete all OpenSearch documents for a given provider from the specified index. + + :param index_name: The name of the index to delete from + :param provider_id: The provider ID whose documents should be deleted + :return: The delete_by_query response from OpenSearch (includes 'deleted' count) + :raises CCInternalException: If all retry attempts fail + """ + query = {'term': {'providerId': provider_id}} + return self._execute_with_retry( + operation=lambda: self._client.delete_by_query(index=index_name, body={'query': query}), + operation_name=f'delete_provider_documents({index_name})', + ) + def bulk_index(self, index_name: str, documents: list[dict], id_field: str = 'providerId') -> dict: """ Bulk index multiple documents into the specified index. diff --git a/backend/cosmetology-app/lambdas/python/search/tests/function/test_populate_provider_documents.py b/backend/cosmetology-app/lambdas/python/search/tests/function/test_populate_provider_documents.py index fcf74ca37..3f1a89233 100644 --- a/backend/cosmetology-app/lambdas/python/search/tests/function/test_populate_provider_documents.py +++ b/backend/cosmetology-app/lambdas/python/search/tests/function/test_populate_provider_documents.py @@ -1,6 +1,7 @@ -from unittest.mock import Mock, call +from unittest.mock import Mock, patch from common_test.test_constants import ( + DEFAULT_DATE_OF_BIRTH, DEFAULT_LICENSE_EXPIRATION_DATE, DEFAULT_LICENSE_ISSUANCE_DATE, DEFAULT_LICENSE_RENEWAL_DATE, @@ -75,65 +76,85 @@ def _when_testing_mock_opensearch_client(self, mock_opensearch_client, bulk_inde if not bulk_index_response: bulk_index_response = {'items': [], 'errors': False} - # Create a mock instance that will be returned by the OpenSearchClient constructor mock_client_instance = Mock() mock_opensearch_client.return_value = mock_client_instance mock_client_instance.bulk_index.return_value = bulk_index_response return mock_client_instance - def _generate_expected_call_for_document(self, compact): - # Use timezone(timedelta(0), '+0000') to match how the code creates UTC timezone - return call( - index_name=f'compact_{compact}_providers', - documents=[ + def _generate_expected_document(self, compact): + provider_id = test_provider_id_mapping[compact] + license_type = test_license_type_mapping[compact] + return { + 'providerId': provider_id, + 'type': 'provider', + 'dateOfUpdate': DEFAULT_PROVIDER_UPDATE_DATETIME, + 'compact': compact, + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'compactEligibility': 'ineligible', + 'givenName': f'test{compact}GivenName', + 'middleName': 'Gunnar', + 'familyName': f'test{compact}FamilyName', + 'dateOfExpiration': DEFAULT_LICENSE_EXPIRATION_DATE, + 'jurisdictionUploadedLicenseStatus': 'active', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'birthMonthDay': '06-06', + 'documentId': f'{provider_id}#oh#{license_type}', + 'licenses': [ { - 'providerId': test_provider_id_mapping[compact], - 'type': 'provider', - 'dateOfUpdate': DEFAULT_PROVIDER_UPDATE_DATETIME, + 'providerId': provider_id, + 'type': 'license', + 'dateOfUpdate': DEFAULT_LICENSE_UPDATE_DATE_OF_UPDATE, 'compact': compact, - 'licenseJurisdiction': 'oh', - 'currentHomeJurisdiction': 'oh', + 'jurisdiction': 'oh', + 'licenseType': license_type, + 'licenseStatusName': 'DEFINITELY_A_HUMAN', 'licenseStatus': 'inactive', + 'jurisdictionUploadedLicenseStatus': 'active', 'compactEligibility': 'ineligible', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'licenseNumber': 'A0608337260', 'givenName': f'test{compact}GivenName', 'middleName': 'Gunnar', 'familyName': f'test{compact}FamilyName', + 'dateOfIssuance': DEFAULT_LICENSE_ISSUANCE_DATE, + 'dateOfRenewal': DEFAULT_LICENSE_RENEWAL_DATE, 'dateOfExpiration': DEFAULT_LICENSE_EXPIRATION_DATE, - 'jurisdictionUploadedLicenseStatus': 'active', - 'jurisdictionUploadedCompactEligibility': 'eligible', - 'birthMonthDay': '06-06', - 'licenses': [ - { - 'providerId': test_provider_id_mapping[compact], - 'type': 'license', - 'dateOfUpdate': DEFAULT_LICENSE_UPDATE_DATE_OF_UPDATE, - 'compact': compact, - 'jurisdiction': 'oh', - 'licenseType': test_license_type_mapping[compact], - 'licenseStatusName': 'DEFINITELY_A_HUMAN', - 'licenseStatus': 'inactive', - 'jurisdictionUploadedLicenseStatus': 'active', - 'compactEligibility': 'ineligible', - 'jurisdictionUploadedCompactEligibility': 'eligible', - 'licenseNumber': 'A0608337260', - 'givenName': f'test{compact}GivenName', - 'middleName': 'Gunnar', - 'familyName': f'test{compact}FamilyName', - 'dateOfIssuance': DEFAULT_LICENSE_ISSUANCE_DATE, - 'dateOfRenewal': DEFAULT_LICENSE_RENEWAL_DATE, - 'dateOfExpiration': DEFAULT_LICENSE_EXPIRATION_DATE, - 'homeAddressStreet1': '123 A St.', - 'homeAddressStreet2': 'Apt 321', - 'homeAddressCity': 'Columbus', - 'homeAddressState': 'oh', - 'homeAddressPostalCode': '43004', - 'emailAddress': 'björk@example.com', - 'phoneNumber': '+13213214321', - 'adverseActions': [], - 'investigations': [], - } - ], - 'privileges': [], + 'dateOfBirth': DEFAULT_DATE_OF_BIRTH, + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'oh', + 'homeAddressPostalCode': '43004', + 'emailAddress': 'björk@example.com', + 'phoneNumber': '+13213214321', + 'adverseActions': [], + 'investigations': [], } ], - ) + 'privileges': [], + } + + @patch('handlers.populate_provider_documents.OpenSearchClient') + def test_populate_indexes_document_with_document_id(self, mock_opensearch_client): + """Test that populate handler indexes documents with id_field='documentId'.""" + from handlers.populate_provider_documents import populate_provider_documents + + mock_client_instance = self._when_testing_mock_opensearch_client(mock_opensearch_client) + self._put_test_provider_and_license_record_in_dynamodb_table('cosm') + + mock_context = Mock() + mock_context.get_remaining_time_in_millis.return_value = 600000 + + result = populate_provider_documents({}, mock_context) + + self.assertTrue(result['completed']) + self.assertGreaterEqual(mock_client_instance.bulk_index.call_count, 1) + + bulk_index_call = mock_client_instance.bulk_index.call_args + self.assertEqual('compact_cosm_providers', bulk_index_call.kwargs['index_name']) + self.assertEqual('documentId', bulk_index_call.kwargs['id_field']) + + indexed_documents = bulk_index_call.kwargs['documents'] + self.assertEqual(1, len(indexed_documents)) + self.assertEqual(self._generate_expected_document('cosm'), indexed_documents[0]) diff --git a/backend/cosmetology-app/lambdas/python/search/tests/function/test_provider_update_ingest.py b/backend/cosmetology-app/lambdas/python/search/tests/function/test_provider_update_ingest.py index 7c0582e12..7cb73bc47 100644 --- a/backend/cosmetology-app/lambdas/python/search/tests/function/test_provider_update_ingest.py +++ b/backend/cosmetology-app/lambdas/python/search/tests/function/test_provider_update_ingest.py @@ -116,8 +116,8 @@ def _when_testing_mock_opensearch_client(self, mock_opensearch_client, bulk_inde if not bulk_index_response: bulk_index_response = {'items': [], 'errors': False} - # mock_opensearch_client is the patched instance, not the class mock_opensearch_client.bulk_index.return_value = bulk_index_response + mock_opensearch_client.delete_provider_documents.return_value = {'deleted': 0, 'failures': []} return mock_opensearch_client def _generate_expected_document(self, compact: str, provider_id: str = None) -> dict: @@ -125,6 +125,7 @@ def _generate_expected_document(self, compact: str, provider_id: str = None) -> if provider_id is None: provider_id = TEST_PROVIDER_ID_MAPPING[compact] + license_type = TEST_LICENSE_TYPE_MAPPING[compact] return { 'providerId': provider_id, 'type': 'provider', @@ -140,6 +141,7 @@ def _generate_expected_document(self, compact: str, provider_id: str = None) -> 'jurisdictionUploadedLicenseStatus': 'active', 'jurisdictionUploadedCompactEligibility': 'eligible', 'birthMonthDay': '06-06', + 'documentId': f'{provider_id}#oh#{license_type}', 'licenses': [ { 'providerId': provider_id, @@ -147,7 +149,7 @@ def _generate_expected_document(self, compact: str, provider_id: str = None) -> 'dateOfUpdate': DEFAULT_LICENSE_UPDATE_DATE_OF_UPDATE, 'compact': compact, 'jurisdiction': 'oh', - 'licenseType': TEST_LICENSE_TYPE_MAPPING[compact], + 'licenseType': license_type, 'licenseStatusName': 'DEFINITELY_A_HUMAN', 'licenseStatus': 'inactive', 'jurisdictionUploadedLicenseStatus': 'active', @@ -175,18 +177,50 @@ def _generate_expected_document(self, compact: str, provider_id: str = None) -> 'privileges': [], } + def _create_dynamodb_stream_record_with_old_image_only( + self, compact: str, provider_id: str, sequence_number: str + ) -> dict: + """Create a DynamoDB stream record for REMOVE events (only OldImage, no NewImage).""" + image_data = { + 'pk': {'S': f'{compact}#PROVIDER#{provider_id}'}, + 'sk': {'S': f'{compact}#PROVIDER'}, + 'compact': {'S': compact}, + 'providerId': {'S': provider_id}, + 'type': {'S': 'provider'}, + 'givenName': {'S': f'test{compact}GivenName'}, + 'familyName': {'S': f'test{compact}FamilyName'}, + } + + return { + 'eventID': f'event-{sequence_number}', + 'eventName': 'REMOVE', + 'eventVersion': '1.1', + 'eventSource': 'aws:dynamodb', + 'awsRegion': 'us-east-1', + 'dynamodb': { + 'ApproximateCreationDateTime': 1234567890, + 'Keys': { + 'pk': {'S': f'{compact}#PROVIDER#{provider_id}'}, + 'sk': {'S': f'{compact}#PROVIDER'}, + }, + 'OldImage': image_data, + 'SequenceNumber': sequence_number, + 'SizeBytes': 256, + 'StreamViewType': 'NEW_AND_OLD_IMAGES', + }, + 'eventSourceARN': 'arn:aws:dynamodb:us-east-1:123456789012:table/provider-table/stream/1234', + } + + # ---- INSERT/MODIFY path tests ---- + @patch('handlers.provider_update_ingest.opensearch_client') def test_opensearch_client_called_with_expected_parameters(self, mock_opensearch_client): """Test that OpenSearch client is called with expected parameters when indexing a record.""" from handlers.provider_update_ingest import provider_update_ingest_handler - # Set up mock OpenSearch client self._when_testing_mock_opensearch_client(mock_opensearch_client) - - # Create provider and license records in DynamoDB self._put_test_provider_and_license_record_in_dynamodb_table('cosm') - # Create an SQS event with DynamoDB stream record in the body event = { 'Records': [ { @@ -202,19 +236,16 @@ def test_opensearch_client_called_with_expected_parameters(self, mock_opensearch ] } - # Run the handler mock_context = MagicMock() result = provider_update_ingest_handler(event, mock_context) - # Assert that bulk_index was called once with expected parameters self.assertEqual(1, mock_opensearch_client.bulk_index.call_count) - # Verify the call arguments call_args = mock_opensearch_client.bulk_index.call_args self.assertEqual('compact_cosm_providers', call_args.kwargs['index_name']) self.assertEqual([self._generate_expected_document('cosm')], call_args.kwargs['documents']) + self.assertEqual('documentId', call_args.kwargs['id_field']) - # Verify no batch item failures self.assertEqual({'batchItemFailures': []}, result) @patch('handlers.provider_update_ingest.opensearch_client') @@ -222,13 +253,9 @@ def test_provider_ids_are_deduped_only_one_document_indexed(self, mock_opensearc """Test that duplicate provider IDs in the batch are deduplicated.""" from handlers.provider_update_ingest import provider_update_ingest_handler - # Set up mock OpenSearch client self._when_testing_mock_opensearch_client(mock_opensearch_client) - - # Create provider and license records in DynamoDB self._put_test_provider_and_license_record_in_dynamodb_table('cosm') - # Create multiple SQS records for the SAME provider (simulating multiple updates) event = { 'Records': [ { @@ -267,19 +294,16 @@ def test_provider_ids_are_deduped_only_one_document_indexed(self, mock_opensearc ] } - # Run the handler mock_context = MagicMock() result = provider_update_ingest_handler(event, mock_context) - # Assert that bulk_index was called only once despite 3 records self.assertEqual(1, mock_opensearch_client.bulk_index.call_count) - # Verify only ONE document was indexed (deduplication worked) call_args = mock_opensearch_client.bulk_index.call_args self.assertEqual(1, len(call_args.kwargs['documents'])) self.assertEqual(MOCK_COSM_PROVIDER_ID, call_args.kwargs['documents'][0]['providerId']) + self.assertEqual('documentId', call_args.kwargs['id_field']) - # Verify no batch item failures self.assertEqual({'batchItemFailures': []}, result) @patch('handlers.provider_update_ingest.opensearch_client') @@ -287,7 +311,6 @@ def test_validation_failure_returns_batch_item_failure(self, mock_opensearch_cli """Test that a record that fails validation is returned in batchItemFailures.""" from handlers.provider_update_ingest import provider_update_ingest_handler - # Set up mock OpenSearch client self._when_testing_mock_opensearch_client(mock_opensearch_client) provider = self.test_data_generator.generate_default_provider( @@ -299,11 +322,9 @@ def test_validation_failure_returns_batch_item_failure(self, mock_opensearch_cli } ) serialized_provider = provider.serialize_to_database_record() - # put invalid compact to fail validation serialized_provider['compact'] = 'foo' self.config.provider_table.put_item(Item=serialized_provider) - # Create SQS event with DynamoDB stream record in the body event = { 'Records': [ { @@ -319,11 +340,9 @@ def test_validation_failure_returns_batch_item_failure(self, mock_opensearch_cli ] } - # Run the handler mock_context = MagicMock() result = provider_update_ingest_handler(event, mock_context) - # Verify that the batch item failure is returned with the message ID self.assertEqual(1, len(result['batchItemFailures'])) self.assertEqual('12345', result['batchItemFailures'][0]['itemIdentifier']) @@ -332,13 +351,13 @@ def test_opensearch_indexing_failure_returns_batch_item_failure(self, mock_opens """Test that a record which fails to be indexed by OpenSearch is in batchItemFailures.""" from handlers.provider_update_ingest import provider_update_ingest_handler - # Simulate OpenSearch returning an error for one document + document_id = f'{MOCK_COSM_PROVIDER_ID}#oh#cosmetologist' mock_opensearch_client.bulk_index.return_value = { 'errors': True, 'items': [ { 'index': { - '_id': MOCK_COSM_PROVIDER_ID, + '_id': document_id, '_index': 'compact_cosm_providers', 'status': 400, 'error': { @@ -350,10 +369,8 @@ def test_opensearch_indexing_failure_returns_batch_item_failure(self, mock_opens ], } - # Create provider and license records in DynamoDB for both compacts self._put_test_provider_and_license_record_in_dynamodb_table('cosm') - # Create SQS events with DynamoDB stream records in the body for both providers event = { 'Records': [ { @@ -369,11 +386,9 @@ def test_opensearch_indexing_failure_returns_batch_item_failure(self, mock_opens ] } - # Run the handler mock_context = MagicMock() result = provider_update_ingest_handler(event, mock_context) - # Verify that only the failed document's message ID is in batchItemFailures self.assertEqual(1, len(result['batchItemFailures'])) self.assertEqual('12345', result['batchItemFailures'][0]['itemIdentifier']) @@ -383,14 +398,10 @@ def test_bulk_index_exception_returns_all_batch_item_failures(self, mock_opensea from cc_common.exceptions import CCInternalException from handlers.provider_update_ingest import provider_update_ingest_handler - # Set up mock OpenSearch client to raise an exception mock_opensearch_client.bulk_index.side_effect = CCInternalException('Connection timeout after 5 retries') - # Create provider and license records in DynamoDB for both compacts - self._put_test_provider_and_license_record_in_dynamodb_table('cosm') self._put_test_provider_and_license_record_in_dynamodb_table('cosm') - # Create SQS events with DynamoDB stream records in the body for both providers event = { 'Records': [ { @@ -416,11 +427,9 @@ def test_bulk_index_exception_returns_all_batch_item_failures(self, mock_opensea ] } - # Run the handler mock_context = MagicMock() result = provider_update_ingest_handler(event, mock_context) - # Verify that both records were returned in batch failures self.assertEqual(2, len(result['batchItemFailures'])) self.assertEqual('12345', result['batchItemFailures'][0]['itemIdentifier']) self.assertEqual('12346', result['batchItemFailures'][1]['itemIdentifier']) @@ -435,29 +444,17 @@ def test_empty_records_returns_empty_batch_failures(self, mock_opensearch_client mock_context = MagicMock() result = provider_update_ingest_handler(event, mock_context) - # Verify empty response self.assertEqual({'batchItemFailures': []}, result) - - # Verify OpenSearch client was never called mock_opensearch_client.bulk_index.assert_not_called() @patch('handlers.provider_update_ingest.opensearch_client') def test_insert_event_without_old_image_indexes_successfully(self, mock_opensearch_client): - """Test that INSERT events (newly created records) without OldImage are processed correctly. - - When a new record is created in DynamoDB, the stream event contains only NewImage - and no OldImage. The handler should extract the compact and providerId from NewImage - and successfully index the document. - """ + """Test that INSERT events (newly created records) without OldImage are processed correctly.""" from handlers.provider_update_ingest import provider_update_ingest_handler - # Set up mock OpenSearch client self._when_testing_mock_opensearch_client(mock_opensearch_client) - - # Create provider and license records in DynamoDB self._put_test_provider_and_license_record_in_dynamodb_table('cosm') - # Create an SQS event with DynamoDB stream record in the body for INSERT (no OldImage) event = { 'Records': [ { @@ -468,79 +465,47 @@ def test_insert_event_without_old_image_indexes_successfully(self, mock_opensear provider_id=MOCK_COSM_PROVIDER_ID, sequence_number='some-sequence-number', event_name='INSERT', - include_old_image=False, # INSERT events don't have OldImage + include_old_image=False, ) ), } ] } - # Run the handler mock_context = MagicMock() result = provider_update_ingest_handler(event, mock_context) - # Assert that bulk_index was called with the correct parameters self.assertEqual(1, mock_opensearch_client.bulk_index.call_count) - # Verify the call arguments call_args = mock_opensearch_client.bulk_index.call_args self.assertEqual('compact_cosm_providers', call_args.kwargs['index_name']) self.assertEqual([self._generate_expected_document('cosm')], call_args.kwargs['documents']) + self.assertEqual('documentId', call_args.kwargs['id_field']) - # Verify no batch item failures for INSERT event - self.assertEqual({'batchItemFailures': []}, result) + # No delete_provider_documents should be called for INSERT events + mock_opensearch_client.delete_provider_documents.assert_not_called() - def _create_dynamodb_stream_record_with_old_image_only( - self, compact: str, provider_id: str, sequence_number: str - ) -> dict: - """Create a DynamoDB stream record for REMOVE events (only OldImage, no NewImage).""" - image_data = { - 'pk': {'S': f'{compact}#PROVIDER#{provider_id}'}, - 'sk': {'S': f'{compact}#PROVIDER'}, - 'compact': {'S': compact}, - 'providerId': {'S': provider_id}, - 'type': {'S': 'provider'}, - 'givenName': {'S': f'test{compact}GivenName'}, - 'familyName': {'S': f'test{compact}FamilyName'}, - } + self.assertEqual({'batchItemFailures': []}, result) - return { - 'eventID': f'event-{sequence_number}', - 'eventName': 'REMOVE', - 'eventVersion': '1.1', - 'eventSource': 'aws:dynamodb', - 'awsRegion': 'us-east-1', - 'dynamodb': { - 'ApproximateCreationDateTime': 1234567890, - 'Keys': { - 'pk': {'S': f'{compact}#PROVIDER#{provider_id}'}, - 'sk': {'S': f'{compact}#PROVIDER'}, - }, - 'OldImage': image_data, # REMOVE events only have OldImage - 'SequenceNumber': sequence_number, - 'SizeBytes': 256, - 'StreamViewType': 'NEW_AND_OLD_IMAGES', - }, - 'eventSourceARN': 'arn:aws:dynamodb:us-east-1:123456789012:table/provider-table/stream/1234', - } + # ---- REMOVE event path tests ---- @patch('handlers.provider_update_ingest.opensearch_client') - def test_remove_event_with_only_old_image_indexes_successfully(self, mock_opensearch_client): - """Test that REMOVE events (deleted records) with only OldImage are processed correctly. - - When a record is deleted from DynamoDB, the stream event contains only OldImage - and no NewImage. The handler should extract the compact and providerId from OldImage - and still index/update the document (to reflect the latest state of the provider). + def test_remove_event_with_remaining_records_deletes_then_reindexes(self, mock_opensearch_client): + """Test that REMOVE events trigger delete_provider_documents then re-index remaining records. + + When a single record (e.g., a license) is deleted but the provider still has other records + in DynamoDB, the handler should: + 1. Call delete_provider_documents to remove all documents for the provider + 2. Re-check DynamoDB and find the provider still exists + 3. Re-index the remaining license documents """ from handlers.provider_update_ingest import provider_update_ingest_handler - # Set up mock OpenSearch client self._when_testing_mock_opensearch_client(mock_opensearch_client) - # Create provider and license records in DynamoDB + # Provider still exists in DynamoDB with remaining records self._put_test_provider_and_license_record_in_dynamodb_table('cosm') - # Create an SQS event with DynamoDB stream record in the body for REMOVE (only OldImage, no NewImage) event = { 'Records': [ { @@ -556,126 +521,115 @@ def test_remove_event_with_only_old_image_indexes_successfully(self, mock_opense ] } - # Run the handler mock_context = MagicMock() result = provider_update_ingest_handler(event, mock_context) - # Assert that bulk_index was called with the correct parameters - self.assertEqual(1, mock_opensearch_client.bulk_index.call_count) + # delete_provider_documents should be called to remove all existing docs for this provider + mock_opensearch_client.delete_provider_documents.assert_called_once_with( + index_name='compact_cosm_providers', + provider_id=MOCK_COSM_PROVIDER_ID, + ) - # Verify the call arguments + # bulk_index should be called with the remaining documents + self.assertEqual(1, mock_opensearch_client.bulk_index.call_count) call_args = mock_opensearch_client.bulk_index.call_args self.assertEqual('compact_cosm_providers', call_args.kwargs['index_name']) self.assertEqual([self._generate_expected_document('cosm')], call_args.kwargs['documents']) + self.assertEqual('documentId', call_args.kwargs['id_field']) - # Verify no batch item failures for REMOVE event self.assertEqual({'batchItemFailures': []}, result) @patch('handlers.provider_update_ingest.opensearch_client') - def test_provider_deleted_from_index_when_no_records_found(self, mock_opensearch_client): - """Test that when no provider records are found (CCNotFoundException), bulk_delete is called. - - This scenario occurs when a provider is completely removed from the system, - such as during a license upload rollback. The handler should call bulk_delete - to remove the provider document from the OpenSearch index. + def test_remove_event_provider_fully_deleted_no_reindex(self, mock_opensearch_client): + """Test that REMOVE events for a fully deleted provider just delete from OpenSearch. + + When a REMOVE event occurs and the provider no longer exists in DynamoDB at all, + the handler should: + 1. Call delete_provider_documents to remove all documents for the provider + 2. Re-check DynamoDB and find the provider does NOT exist + 3. NOT attempt to re-index """ from handlers.provider_update_ingest import provider_update_ingest_handler - # Set up mock OpenSearch client - mock_opensearch_client.bulk_index.return_value = {'items': [], 'errors': False} - mock_opensearch_client.bulk_delete.return_value = set() # bulk_delete returns a set of failed IDs + self._when_testing_mock_opensearch_client(mock_opensearch_client) - # Do NOT create any provider records in DynamoDB - this simulates the provider being deleted + # Do NOT create any provider records in DynamoDB - provider is fully deleted - # Create an SQS event with DynamoDB stream record in the body for a provider that no longer exists event = { 'Records': [ { 'messageId': '12345', 'body': json.dumps( - self._create_dynamodb_stream_record( + self._create_dynamodb_stream_record_with_old_image_only( compact='cosm', provider_id=MOCK_COSM_PROVIDER_ID, sequence_number='some-sequence-number', - event_name='REMOVE', - include_old_image=False, ) ), } ] } - # Run the handler mock_context = MagicMock() result = provider_update_ingest_handler(event, mock_context) - # Assert that bulk_index was NOT called (no documents to index) - mock_opensearch_client.bulk_index.assert_not_called() + # delete_provider_documents should be called + mock_opensearch_client.delete_provider_documents.assert_called_once_with( + index_name='compact_cosm_providers', + provider_id=MOCK_COSM_PROVIDER_ID, + ) - # Assert that bulk_delete WAS called with the correct parameters - self.assertEqual(1, mock_opensearch_client.bulk_delete.call_count) - call_args = mock_opensearch_client.bulk_delete.call_args - self.assertEqual('compact_cosm_providers', call_args.kwargs['index_name']) - self.assertEqual([MOCK_COSM_PROVIDER_ID], call_args.kwargs['document_ids']) + # bulk_index should NOT be called (provider no longer exists) + mock_opensearch_client.bulk_index.assert_not_called() - # Verify no batch item failures (deletion is expected behavior, not a failure) self.assertEqual({'batchItemFailures': []}, result) @patch('handlers.provider_update_ingest.opensearch_client') - def test_bulk_delete_failure_returns_batch_item_failure(self, mock_opensearch_client): - """Test that when bulk_delete fails, the provider is returned in batchItemFailures.""" + def test_delete_provider_documents_failure_returns_batch_item_failure(self, mock_opensearch_client): + """Test that when delete_provider_documents fails, the provider is returned in batchItemFailures.""" from cc_common.exceptions import CCInternalException from handlers.provider_update_ingest import provider_update_ingest_handler - # Set up mock OpenSearch client - bulk_delete raises exception - mock_opensearch_client.bulk_delete.side_effect = CCInternalException('Connection timeout after 5 retries') - - # Do NOT create any provider records in DynamoDB - this simulates the provider being deleted + mock_opensearch_client.delete_provider_documents.side_effect = CCInternalException( + 'Connection timeout after 5 retries' + ) - # Create an SQS event with DynamoDB stream record in the body for a provider that no longer exists event = { 'Records': [ { 'messageId': '12345', 'body': json.dumps( - self._create_dynamodb_stream_record( + self._create_dynamodb_stream_record_with_old_image_only( compact='cosm', provider_id=MOCK_COSM_PROVIDER_ID, sequence_number='some-sequence-number', - event_name='REMOVE', - include_old_image=False, ) ), } ] } - # Run the handler mock_context = MagicMock() result = provider_update_ingest_handler(event, mock_context) - # Verify that the batch item failure is returned with the message ID self.assertEqual(1, len(result['batchItemFailures'])) self.assertEqual('12345', result['batchItemFailures'][0]['itemIdentifier']) @patch('handlers.provider_update_ingest.opensearch_client') - def test_bulk_delete_404_not_found_does_not_return_batch_item_failure(self, mock_opensearch_client): - """Test that when bulk_delete returns 404 (document not found), it is NOT treated as a failure. + def test_cc_not_found_on_non_remove_event_logs_warning_no_reindex(self, mock_opensearch_client): + """Test that CCNotFoundException on a non-REMOVE event logs a warning without re-indexing. - This scenario occurs when a provider document has already been deleted from OpenSearch - (e.g., a previous delete succeeded, or the document never existed in the index). - The 404 response should be ignored since the desired end state (document not in index) - has been achieved. + This is a safety net for race conditions where a MODIFY/INSERT event arrives but the + provider has already been deleted from DynamoDB. The handler should log a warning + and NOT attempt to re-index. """ from handlers.provider_update_ingest import provider_update_ingest_handler - # Simulate OpenSearch bulk delete response when document doesn't exist - # bulk_delete returns a set of failed document IDs, empty set means no failures (404 is ignored) - mock_opensearch_client.bulk_delete.return_value = set() + self._when_testing_mock_opensearch_client(mock_opensearch_client) - # Do NOT create any provider records in DynamoDB - this simulates the provider being deleted + # Do NOT create any provider records in DynamoDB - simulates race condition + # where provider was deleted between event creation and processing - # Create a DynamoDB stream event for a provider that no longer exists event = { 'Records': [ { @@ -685,20 +639,24 @@ def test_bulk_delete_404_not_found_does_not_return_batch_item_failure(self, mock compact='cosm', provider_id=MOCK_COSM_PROVIDER_ID, sequence_number='some-sequence-number', - event_name='REMOVE', - include_old_image=False, + event_name='MODIFY', ) ), } ] } - # Run the handler mock_context = MagicMock() result = provider_update_ingest_handler(event, mock_context) - # Assert that bulk_delete was called - self.assertEqual(1, mock_opensearch_client.bulk_delete.call_count) + # delete_provider_documents should be called to remove documents from OpenSearch + mock_opensearch_client.delete_provider_documents.assert_called_once_with( + index_name='compact_cosm_providers', + provider_id=MOCK_COSM_PROVIDER_ID, + ) + + # No bulk_index should be called (no documents to index) + mock_opensearch_client.bulk_index.assert_not_called() - # Verify NO batch item failures - 404 is not treated as an error + # No batch failures - this is expected behavior for a race condition self.assertEqual({'batchItemFailures': []}, result) diff --git a/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py b/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py index 0a2e6b0da..b3a322042 100644 --- a/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py +++ b/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py @@ -76,10 +76,11 @@ def _create_mock_provider_hit( compact: str = 'cosm', sort_values: list = None, ) -> dict: - """Create a mock OpenSearch hit for a provider document.""" + """Create a mock OpenSearch hit for a one-doc-per-license provider document.""" + document_id = f'{provider_id}#oh#cosmetologist' hit = { '_index': f'compact_{compact}_providers', - '_id': provider_id, + '_id': document_id, '_score': 1.0, '_source': { 'providerId': provider_id, @@ -95,14 +96,36 @@ def _create_mock_provider_hit( 'jurisdictionUploadedLicenseStatus': 'active', 'jurisdictionUploadedCompactEligibility': 'eligible', 'birthMonthDay': '06-15', - # adding a couple of fields that are not recognized in the - # ProviderGeneralResponseSchema. Although these are not currently - # stored in OpenSearch, this mock data ensures we are sanitizing - # these private fields by the search serialization logic + 'documentId': document_id, + 'licenses': [ + { + 'providerId': provider_id, + 'type': 'license', + 'compact': compact, + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'licenseNumber': 'A0608337260', + 'givenName': 'John', + 'familyName': 'Doe', + 'dateOfIssuance': '2024-01-01', + 'dateOfExpiration': '2025-12-31', + 'licenseStatus': 'active', + 'compactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'dateOfUpdate': '2024-01-15T10:30:00+00:00', + 'dateOfBirth': '1984-12-11', + 'homeAddressStreet1': '123 Main St', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'oh', + 'homeAddressPostalCode': '43004', + } + ], + 'privileges': [], + # Fields that should be stripped by ForgivingSchema 'someNewField': 'somePrivateValue', 'ssnLastFour': '1234', 'emailAddress': 'someemail@address.com', - 'dateOfBirth': '1984-12-11', }, } if sort_values: @@ -269,29 +292,30 @@ def test_search_returns_sanitized_providers(self, mock_opensearch_client): self.assertEqual(200, response['statusCode']) body = json.loads(response['body']) - self.assertEqual( - { - 'providers': [ - { - 'birthMonthDay': '06-15', - 'compact': 'cosm', - 'compactEligibility': 'eligible', - 'dateOfExpiration': '2025-12-31', - 'dateOfUpdate': '2024-01-15T10:30:00+00:00', - 'familyName': 'Doe', - 'givenName': 'John', - 'jurisdictionUploadedCompactEligibility': 'eligible', - 'jurisdictionUploadedLicenseStatus': 'active', - 'licenseJurisdiction': 'oh', - 'licenseStatus': 'active', - 'providerId': '00000000-0000-0000-0000-000000000001', - 'type': 'provider', - } - ], - 'total': {'relation': 'eq', 'value': 1}, - }, - body, - ) + providers = body['providers'] + self.assertEqual(1, len(providers)) + provider = providers[0] + # Verify provider-level fields are present and sanitized + self.assertEqual('cosm', provider['compact']) + self.assertEqual('John', provider['givenName']) + self.assertEqual('Doe', provider['familyName']) + self.assertEqual('oh', provider['licenseJurisdiction']) + self.assertEqual('active', provider['licenseStatus']) + self.assertEqual('eligible', provider['compactEligibility']) + self.assertEqual('06-15', provider['birthMonthDay']) + self.assertEqual('00000000-0000-0000-0000-000000000001', provider['providerId']) + # Verify licenses array with one license is present + self.assertEqual(1, len(provider['licenses'])) + self.assertEqual('oh', provider['licenses'][0]['jurisdiction']) + self.assertEqual('cosmetologist', provider['licenses'][0]['licenseType']) + # Verify private fields were stripped + self.assertNotIn('ssnLastFour', provider) + self.assertNotIn('someNewField', provider) + self.assertNotIn('emailAddress', provider) + # Verify documentId was stripped by ForgivingSchema + self.assertNotIn('documentId', provider) + # Verify total + self.assertEqual({'relation': 'eq', 'value': 1}, body['total']) @patch('handlers.search.opensearch_client') def test_search_response_includes_last_sort_for_pagination(self, mock_opensearch_client): diff --git a/backend/cosmetology-app/lambdas/python/search/tests/unit/test_opensearch_client.py b/backend/cosmetology-app/lambdas/python/search/tests/unit/test_opensearch_client.py index 62e5eb630..96cfe7730 100644 --- a/backend/cosmetology-app/lambdas/python/search/tests/unit/test_opensearch_client.py +++ b/backend/cosmetology-app/lambdas/python/search/tests/unit/test_opensearch_client.py @@ -496,3 +496,82 @@ def test_cluster_health_raises_after_max_retries(self, mock_sleep): # Verify health was called MAX_RETRY_ATTEMPTS times self.assertEqual(MAX_RETRY_ATTEMPTS, mock_internal_client.cluster.health.call_count) self.assertIn('cluster_health', str(context.exception)) + + +class TestOpenSearchClientDeleteProviderDocuments(TestCase): + """Test suite for OpenSearchClient.delete_provider_documents().""" + + def _create_client_with_mock(self): + """Create an OpenSearchClient with a mocked internal client.""" + with ( + patch('opensearch_client.boto3'), + patch('opensearch_client.config'), + patch('opensearch_client.OpenSearch') as mock_opensearch_class, + ): + mock_internal_client = MagicMock() + mock_opensearch_class.return_value = mock_internal_client + + from opensearch_client import OpenSearchClient + + client = OpenSearchClient() + return client, mock_internal_client + + def test_delete_provider_documents_calls_internal_client_with_expected_arguments(self): + """Test that delete_provider_documents builds the provider query and calls the internal client.""" + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'compact_cosm_providers' + provider_id = 'provider-1' + expected_response = {'deleted': 3, 'failures': []} + mock_internal_client.delete_by_query.return_value = expected_response + + result = client.delete_provider_documents(index_name=index_name, provider_id=provider_id) + + mock_internal_client.delete_by_query.assert_called_once_with( + index=index_name, + body={'query': {'term': {'providerId': provider_id}}}, + ) + self.assertEqual(expected_response, result) + + @patch('opensearch_client.time.sleep') + def test_delete_provider_documents_retries_on_connection_timeout(self, mock_sleep): + """Test that delete_provider_documents retries on ConnectionTimeout.""" + from opensearch_client import INITIAL_BACKOFF_SECONDS + + client, mock_internal_client = self._create_client_with_mock() + + expected_response = {'deleted': 1, 'failures': []} + mock_internal_client.delete_by_query.side_effect = [ + ConnectionTimeout('Connection timed out', 503, 'some error'), + expected_response, + ] + + result = client.delete_provider_documents( + index_name='compact_cosm_providers', + provider_id='provider-1', + ) + + self.assertEqual(2, mock_internal_client.delete_by_query.call_count) + self.assertEqual(1, mock_sleep.call_count) + mock_sleep.assert_called_with(INITIAL_BACKOFF_SECONDS) + self.assertEqual(expected_response, result) + + @patch('opensearch_client.time.sleep') + def test_delete_provider_documents_raises_after_max_retries(self, mock_sleep): + """Test that delete_provider_documents raises CCInternalException after max retries.""" + from opensearch_client import MAX_RETRY_ATTEMPTS + + client, mock_internal_client = self._create_client_with_mock() + + mock_internal_client.delete_by_query.side_effect = ConnectionTimeout( + 'Connection timed out', 503, 'some error' + ) + + with self.assertRaises(CCInternalException) as context: + client.delete_provider_documents( + index_name='compact_cosm_providers', + provider_id='provider-1', + ) + + self.assertEqual(MAX_RETRY_ATTEMPTS, mock_internal_client.delete_by_query.call_count) + self.assertIn('delete_provider_documents', str(context.exception)) diff --git a/backend/cosmetology-app/lambdas/python/search/utils.py b/backend/cosmetology-app/lambdas/python/search/utils.py index 82d5c4638..f6bdd639c 100644 --- a/backend/cosmetology-app/lambdas/python/search/utils.py +++ b/backend/cosmetology-app/lambdas/python/search/utils.py @@ -13,28 +13,42 @@ from cc_common.utils import ResponseEncoder -def generate_provider_opensearch_document(compact: str, provider_id: str) -> dict: +def generate_provider_opensearch_documents(compact: str, provider_id: str) -> list[dict]: """ - Process a single provider and return the sanitized document ready for indexing. + Process a single provider and return a list of sanitized documents ready for indexing. + + Each document corresponds to one license. This is because the Cosmetology compact search returns results by license, + so we need to index one document per license to support native pagination. + + Becuase of this, rather than just using the provider_id as the documentId, + we add a composite documentId that includes the jurisdiction and license type. + This composite documentId is added after sanitization so that bulk_index can use it as the OpenSearch _id. :param compact: The compact abbreviation :param provider_id: The provider ID to process - :return: Sanitized document ready for indexing + :return: List of sanitized documents, each with a composite documentId :raises CCNotFoundException: If the provider is not found :raises ValidationError: If the provider data fails schema validation """ - # Get complete provider records provider_user_records = config.data_client.get_provider_user_records( compact=compact, provider_id=provider_id, consistent_read=True, ) - # Generate API response object with all nested records - api_response = provider_user_records.generate_api_response_object() + raw_documents = provider_user_records.generate_opensearch_documents() schema = ProviderOpenSearchDocumentSchema() - sanitized_document = schema.load(api_response) + result = [] + for raw_doc in raw_documents: + sanitized = schema.load(raw_doc) + serializable = json.loads(json.dumps(sanitized, cls=ResponseEncoder)) + + license_info = serializable['licenses'][0] + jurisdiction = license_info['jurisdiction'] + license_type = license_info['licenseType'] + serializable['documentId'] = f'{provider_id}#{jurisdiction}#{license_type}' + + result.append(serializable) - # Serialize using ResponseEncoder to convert sets to lists and datetime objects to strings - return json.loads(json.dumps(sanitized_document, cls=ResponseEncoder)) + return result From 808b9080ad542517dbd6f860491b766857e9a370 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Fri, 13 Mar 2026 10:11:42 -0500 Subject: [PATCH 03/14] refactor labels on indexing metrics --- .../handlers/populate_provider_documents.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/cosmetology-app/lambdas/python/search/handlers/populate_provider_documents.py b/backend/cosmetology-app/lambdas/python/search/handlers/populate_provider_documents.py index 0799ee3a1..b4904f24b 100644 --- a/backend/cosmetology-app/lambdas/python/search/handlers/populate_provider_documents.py +++ b/backend/cosmetology-app/lambdas/python/search/handlers/populate_provider_documents.py @@ -77,7 +77,7 @@ def populate_provider_documents(event: dict, context: LambdaContext): # Track statistics stats = { 'total_providers_processed': 0, - 'total_providers_indexed': 0, + 'total_licenses_indexed': 0, 'total_providers_failed': 0, 'compacts_processed': [], 'errors': [], @@ -110,7 +110,7 @@ def populate_provider_documents(event: dict, context: LambdaContext): documents_to_index = [] compact_stats = { 'providers_processed': 0, - 'providers_indexed': 0, + 'licenses_indexed': 0, 'providers_failed': 0, } @@ -151,7 +151,7 @@ def populate_provider_documents(event: dict, context: LambdaContext): # Update stats for current compact stats['total_providers_processed'] += compact_stats['providers_processed'] - stats['total_providers_indexed'] += compact_stats['providers_indexed'] + stats['total_licenses_indexed'] += compact_stats['licenses_indexed'] stats['total_providers_failed'] += compact_stats['providers_failed'] if compact_stats['providers_processed'] > 0: stats['compacts_processed'].append( @@ -171,7 +171,7 @@ def populate_provider_documents(event: dict, context: LambdaContext): logger.info( 'Returning for pagination', total_providers_processed=stats['total_providers_processed'], - total_providers_indexed=stats['total_providers_indexed'], + total_licenses_indexed=stats['total_licenses_indexed'], resume_from=stats['resumeFrom'], ) @@ -258,7 +258,7 @@ def populate_provider_documents(event: dict, context: LambdaContext): # Update overall stats stats['total_providers_processed'] += compact_stats['providers_processed'] - stats['total_providers_indexed'] += compact_stats['providers_indexed'] + stats['total_licenses_indexed'] += compact_stats['licenses_indexed'] stats['total_providers_failed'] += compact_stats['providers_failed'] stats['compacts_processed'].append( { @@ -271,14 +271,14 @@ def populate_provider_documents(event: dict, context: LambdaContext): 'Completed processing compact', compact=compact, providers_processed=compact_stats['providers_processed'], - providers_indexed=compact_stats['providers_indexed'], + licenses_indexed=compact_stats['licenses_indexed'], providers_failed=compact_stats['providers_failed'], ) logger.info( 'Completed populating provider documents', total_providers_processed=stats['total_providers_processed'], - total_providers_indexed=stats['total_providers_indexed'], + total_licenses_indexed=stats['total_licenses_indexed'], total_providers_failed=stats['total_providers_failed'], ) @@ -291,7 +291,7 @@ def _index_records_and_track_stats( index_name = f'compact_{compact}_providers' if documents_to_index: failed_ids = _bulk_index_documents(opensearch_client, index_name, documents_to_index) - compact_stats['providers_indexed'] += len(documents_to_index) - len(failed_ids) + compact_stats['licenses_indexed'] += len(documents_to_index) - len(failed_ids) if failed_ids: compact_stats['providers_failed'] += len(failed_ids) logger.warning( @@ -324,7 +324,7 @@ def _build_error_response( # Update stats for current compact stats['total_providers_processed'] += compact_stats['providers_processed'] - stats['total_providers_indexed'] += compact_stats['providers_indexed'] + stats['total_licenses_indexed'] += compact_stats['licenses_indexed'] stats['total_providers_failed'] += compact_stats['providers_failed'] if compact_stats['providers_processed'] > 0: stats['compacts_processed'].append( From 75c20500701dd3b81b8e0d39e752b4ad202ad799 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 3 Mar 2026 15:27:02 -0600 Subject: [PATCH 04/14] Remove finance summary report fields from compact/state config --- .../api-specification/latest-oas30.json | 42 ------------------- .../internal/postman/postman-collection.json | 12 +++--- .../email-notification-service/README.md | 2 - .../lambdas/nodejs/lib/models/compact.ts | 1 - .../email-notification-service-event.ts | 2 - .../lambdas/nodejs/lib/models/jurisdiction.ts | 1 - .../tests/email-notification-service.test.ts | 2 - .../lib/compact-configuration-client.test.ts | 2 - .../encumbrance-notification-service.test.ts | 4 +- ...investigation-notification-service.test.ts | 4 +- .../tests/lib/jurisdiction-client.test.ts | 6 --- .../lambdas/nodejs/tests/sample-records.ts | 6 --- .../data_model/schema/compact/__init__.py | 8 ---- .../data_model/schema/compact/api.py | 8 ---- .../data_model/schema/compact/record.py | 5 --- .../schema/jurisdiction/__init__.py | 4 -- .../data_model/schema/jurisdiction/api.py | 8 ---- .../data_model/schema/jurisdiction/record.py | 5 --- .../common/common_test/test_data_generator.py | 2 - .../tests/resources/dynamo/compact.json | 1 - .../tests/resources/dynamo/jurisdiction.json | 1 - .../handlers/compact_configuration.py | 2 - .../function/test_compact_configuration.py | 6 --- .../stacks/api_stack/v1_api/api_model.py | 30 ------------- ...COMPACT_CONFIGURATION_RESPONSE_SCHEMA.json | 9 ---- ...DICTION_CONFIGURATION_RESPONSE_SCHEMA.json | 9 ---- ..._COMPACT_CONFIGURATION_REQUEST_SCHEMA.json | 12 ------ ...SDICTION_CONFIGURATION_REQUEST_SCHEMA.json | 12 ------ .../compact_configuration_smoke_tests.py | 2 - 29 files changed, 8 insertions(+), 200 deletions(-) diff --git a/backend/cosmetology-app/docs/internal/api-specification/latest-oas30.json b/backend/cosmetology-app/docs/internal/api-specification/latest-oas30.json index 9f8978af4..c69959e20 100644 --- a/backend/cosmetology-app/docs/internal/api-specification/latest-oas30.json +++ b/backend/cosmetology-app/docs/internal/api-specification/latest-oas30.json @@ -2533,7 +2533,6 @@ "jurisdictionAdverseActionsNotificationEmails", "jurisdictionName", "jurisdictionOperationsTeamEmails", - "jurisdictionSummaryReportNotificationEmails", "licenseeRegistrationEnabled", "postalAbbreviation" ], @@ -2573,14 +2572,6 @@ "jurisdictionName": { "type": "string", "description": "The name of the jurisdiction" - }, - "jurisdictionSummaryReportNotificationEmails": { - "type": "array", - "description": "List of email addresses for summary report notifications", - "items": { - "type": "string", - "format": "email" - } } } }, @@ -2745,7 +2736,6 @@ "required": [ "jurisdictionAdverseActionsNotificationEmails", "jurisdictionOperationsTeamEmails", - "jurisdictionSummaryReportNotificationEmails", "licenseeRegistrationEnabled" ], "type": "object", @@ -2775,17 +2765,6 @@ "licenseeRegistrationEnabled": { "type": "boolean", "description": "Denotes whether licensee registration is enabled" - }, - "jurisdictionSummaryReportNotificationEmails": { - "maxItems": 10, - "minItems": 1, - "uniqueItems": true, - "type": "array", - "description": "List of email addresses for summary report notifications", - "items": { - "type": "string", - "format": "email" - } } }, "additionalProperties": false @@ -4443,7 +4422,6 @@ "compactAdverseActionsNotificationEmails", "compactName", "compactOperationsTeamEmails", - "compactSummaryReportNotificationEmails", "configuredStates", "licenseeRegistrationEnabled" ], @@ -4482,14 +4460,6 @@ } } }, - "compactSummaryReportNotificationEmails": { - "type": "array", - "description": "List of email addresses for summary report notifications", - "items": { - "type": "string", - "format": "email" - } - }, "compactAdverseActionsNotificationEmails": { "type": "array", "description": "List of email addresses for adverse actions notifications", @@ -4524,7 +4494,6 @@ "required": [ "compactAdverseActionsNotificationEmails", "compactOperationsTeamEmails", - "compactSummaryReportNotificationEmails", "configuredStates", "licenseeRegistrationEnabled" ], @@ -4564,17 +4533,6 @@ "additionalProperties": false } }, - "compactSummaryReportNotificationEmails": { - "maxItems": 10, - "minItems": 1, - "uniqueItems": true, - "type": "array", - "description": "List of email addresses for summary report notifications", - "items": { - "type": "string", - "format": "email" - } - }, "compactAdverseActionsNotificationEmails": { "maxItems": 10, "minItems": 1, diff --git a/backend/cosmetology-app/docs/internal/postman/postman-collection.json b/backend/cosmetology-app/docs/internal/postman/postman-collection.json index 6c6647cff..006731546 100644 --- a/backend/cosmetology-app/docs/internal/postman/postman-collection.json +++ b/backend/cosmetology-app/docs/internal/postman/postman-collection.json @@ -444,7 +444,7 @@ "response": [ { "_postman_previewlanguage": "json", - "body": "{\n \"compactAbbr\": \"\",\n \"compactAdverseActionsNotificationEmails\": [\n \"\",\n \"\"\n ],\n \"compactName\": \"\",\n \"compactOperationsTeamEmails\": [\n \"\",\n \"\"\n ],\n \"compactSummaryReportNotificationEmails\": [\n \"\",\n \"\"\n ],\n \"configuredStates\": [\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"wa\"\n },\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"va\"\n }\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}", + "body": "{\n \"compactAbbr\": \"\",\n \"compactAdverseActionsNotificationEmails\": [\n \"\",\n \"\"\n ],\n \"compactName\": \"\",\n \"compactOperationsTeamEmails\": [\n \"\",\n \"\"\n ],\n \"configuredStates\": [\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"wa\"\n },\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"va\"\n }\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}", "code": 200, "cookie": [], "header": [ @@ -505,7 +505,7 @@ "language": "json" } }, - "raw": "{\n \"compactAdverseActionsNotificationEmails\": [\n \"\"\n ],\n \"compactOperationsTeamEmails\": [\n \"\"\n ],\n \"compactSummaryReportNotificationEmails\": [\n \"\"\n ],\n \"configuredStates\": [\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"wa\"\n },\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"ks\"\n }\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}" + "raw": "{\n \"compactAdverseActionsNotificationEmails\": [\n \"\"\n ],\n \"compactOperationsTeamEmails\": [\n \"\"\n ],\n \"configuredStates\": [\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"wa\"\n },\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"ks\"\n }\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}" }, "description": {}, "header": [ @@ -567,7 +567,7 @@ "language": "json" } }, - "raw": "{\n \"compactAdverseActionsNotificationEmails\": [\n \"\"\n ],\n \"compactOperationsTeamEmails\": [\n \"\"\n ],\n \"compactSummaryReportNotificationEmails\": [\n \"\"\n ],\n \"configuredStates\": [\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"wa\"\n },\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"ks\"\n }\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}" + "raw": "{\n \"compactAdverseActionsNotificationEmails\": [\n \"\"\n ],\n \"compactOperationsTeamEmails\": [\n \"\"\n ],\n \"configuredStates\": [\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"wa\"\n },\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"ks\"\n }\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}" }, "header": [ { @@ -760,7 +760,7 @@ "response": [ { "_postman_previewlanguage": "json", - "body": "{\n \"compact\": \"cosm\",\n \"jurisdictionAdverseActionsNotificationEmails\": [\n \"\",\n \"\"\n ],\n \"jurisdictionName\": \"\",\n \"jurisdictionOperationsTeamEmails\": [\n \"\",\n \"\"\n ],\n \"jurisdictionSummaryReportNotificationEmails\": [\n \"\",\n \"\"\n ],\n \"licenseeRegistrationEnabled\": \"\",\n \"postalAbbreviation\": \"\"\n}", + "body": "{\n \"compact\": \"cosm\",\n \"jurisdictionAdverseActionsNotificationEmails\": [\n \"\",\n \"\"\n ],\n \"jurisdictionName\": \"\",\n \"jurisdictionOperationsTeamEmails\": [\n \"\",\n \"\"\n ],\n \"licenseeRegistrationEnabled\": \"\",\n \"postalAbbreviation\": \"\"\n}", "code": 200, "cookie": [], "header": [ @@ -823,7 +823,7 @@ "language": "json" } }, - "raw": "{\n \"jurisdictionAdverseActionsNotificationEmails\": [\n \"\"\n ],\n \"jurisdictionOperationsTeamEmails\": [\n \"\"\n ],\n \"jurisdictionSummaryReportNotificationEmails\": [\n \"\"\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}" + "raw": "{\n \"jurisdictionAdverseActionsNotificationEmails\": [\n \"\"\n ],\n \"jurisdictionOperationsTeamEmails\": [\n \"\"\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}" }, "description": {}, "header": [ @@ -897,7 +897,7 @@ "language": "json" } }, - "raw": "{\n \"jurisdictionAdverseActionsNotificationEmails\": [\n \"\"\n ],\n \"jurisdictionOperationsTeamEmails\": [\n \"\"\n ],\n \"jurisdictionSummaryReportNotificationEmails\": [\n \"\"\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}" + "raw": "{\n \"jurisdictionAdverseActionsNotificationEmails\": [\n \"\"\n ],\n \"jurisdictionOperationsTeamEmails\": [\n \"\"\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}" }, "header": [ { diff --git a/backend/cosmetology-app/lambdas/nodejs/email-notification-service/README.md b/backend/cosmetology-app/lambdas/nodejs/email-notification-service/README.md index 92294d2cd..bfaa95acc 100644 --- a/backend/cosmetology-app/lambdas/nodejs/email-notification-service/README.md +++ b/backend/cosmetology-app/lambdas/nodejs/email-notification-service/README.md @@ -11,10 +11,8 @@ The lambda is intended to be invoked directly, rather than through an API endpoi recipientType: // must be one of the following | 'COMPACT_OPERATIONS_TEAM' // compactOperationsTeamEmails | 'COMPACT_ADVERSE_ACTIONS' // compactAdverseActionsNotificationEmails - | 'COMPACT_SUMMARY_REPORT' // compactSummaryReportNotificationEmails | 'JURISDICTION_OPERATIONS_TEAM' // jurisdictionOperationsTeamEmails | 'JURISDICTION_ADVERSE_ACTIONS' // jurisdictionAdverseActionsNotificationEmails - | 'JURISDICTION_SUMMARY_REPORT' // jurisdictionSummaryReportNotificationEmails | 'SPECIFIC'; // specificEmails provided in payload compact: string; // Compact identifier jurisdiction?: string; // Optional jurisdiction identifier, must be specified if sending to a Jurisdiction based email list diff --git a/backend/cosmetology-app/lambdas/nodejs/lib/models/compact.ts b/backend/cosmetology-app/lambdas/nodejs/lib/models/compact.ts index 29a0112e4..0c6c75d90 100644 --- a/backend/cosmetology-app/lambdas/nodejs/lib/models/compact.ts +++ b/backend/cosmetology-app/lambdas/nodejs/lib/models/compact.ts @@ -11,7 +11,6 @@ export interface Compact { compactAbbr: string; compactName: string; compactOperationsTeamEmails: string[]; - compactSummaryReportNotificationEmails: string[]; dateOfUpdate: string; type: string; } diff --git a/backend/cosmetology-app/lambdas/nodejs/lib/models/email-notification-service-event.ts b/backend/cosmetology-app/lambdas/nodejs/lib/models/email-notification-service-event.ts index 3352861b3..f4f73c20b 100644 --- a/backend/cosmetology-app/lambdas/nodejs/lib/models/email-notification-service-event.ts +++ b/backend/cosmetology-app/lambdas/nodejs/lib/models/email-notification-service-event.ts @@ -1,10 +1,8 @@ export type RecipientType = | 'COMPACT_OPERATIONS_TEAM' | 'COMPACT_ADVERSE_ACTIONS' - | 'COMPACT_SUMMARY_REPORT' | 'JURISDICTION_OPERATIONS_TEAM' | 'JURISDICTION_ADVERSE_ACTIONS' - | 'JURISDICTION_SUMMARY_REPORT' | 'SPECIFIC'; export interface EmailNotificationEvent { diff --git a/backend/cosmetology-app/lambdas/nodejs/lib/models/jurisdiction.ts b/backend/cosmetology-app/lambdas/nodejs/lib/models/jurisdiction.ts index 90b142188..a83df1eec 100644 --- a/backend/cosmetology-app/lambdas/nodejs/lib/models/jurisdiction.ts +++ b/backend/cosmetology-app/lambdas/nodejs/lib/models/jurisdiction.ts @@ -7,5 +7,4 @@ export interface IJurisdiction { compact: string; jurisdictionOperationsTeamEmails: string[]; jurisdictionAdverseActionsNotificationEmails: string[]; - jurisdictionSummaryReportNotificationEmails: string[]; } diff --git a/backend/cosmetology-app/lambdas/nodejs/tests/email-notification-service.test.ts b/backend/cosmetology-app/lambdas/nodejs/tests/email-notification-service.test.ts index cd6e72ebb..0307f907a 100644 --- a/backend/cosmetology-app/lambdas/nodejs/tests/email-notification-service.test.ts +++ b/backend/cosmetology-app/lambdas/nodejs/tests/email-notification-service.test.ts @@ -33,7 +33,6 @@ const SAMPLE_COMPACT_CONFIGURATION = { 'compactAbbr': { S: 'cosm' }, 'compactName': { S: 'Audiology and Speech Language Pathology' }, 'compactOperationsTeamEmails': { L: [{ S: 'operations@example.com' }]}, - 'compactSummaryReportNotificationEmails': { L: [{ S: 'summary@example.com' }]}, 'dateOfUpdate': { S: '2024-12-10T19:27:28+00:00' }, 'type': { S: 'compact' } }; @@ -42,7 +41,6 @@ const SAMPLE_JURISDICTION_CONFIGURATION = { 'pk': { S: 'cosm#CONFIGURATION' }, 'sk': { S: 'cosm#JURISDICTION#oh' }, 'jurisdictionName': { S: 'Ohio' }, - 'jurisdictionSummaryReportNotificationEmails': { L: [{ S: 'ohio@example.com' }]}, 'type': { S: 'jurisdiction' } }; diff --git a/backend/cosmetology-app/lambdas/nodejs/tests/lib/compact-configuration-client.test.ts b/backend/cosmetology-app/lambdas/nodejs/tests/lib/compact-configuration-client.test.ts index ed511eafd..2b3e5844e 100644 --- a/backend/cosmetology-app/lambdas/nodejs/tests/lib/compact-configuration-client.test.ts +++ b/backend/cosmetology-app/lambdas/nodejs/tests/lib/compact-configuration-client.test.ts @@ -17,7 +17,6 @@ const SAMPLE_COMPACT_CONFIGURATION = { 'compactAbbr': { S: 'cosm' }, 'compactName': { S: 'Audiology and Speech Language Pathology' }, 'compactOperationsTeamEmails': { L: [{ S: 'operations@example.com' }]}, - 'compactSummaryReportNotificationEmails': { L: [{ S: 'summary@example.com' }]}, 'dateOfUpdate': { S: '2024-12-10T19:27:28+00:00' }, 'type': { S: 'compact' } }; @@ -77,7 +76,6 @@ describe('CompactConfigurationClient', () => { compactAbbr: 'cosm', compactName: 'Audiology and Speech Language Pathology', compactOperationsTeamEmails: ['operations@example.com'], - compactSummaryReportNotificationEmails: ['summary@example.com'], dateOfUpdate: '2024-12-10T19:27:28+00:00', type: 'compact' }); diff --git a/backend/cosmetology-app/lambdas/nodejs/tests/lib/email/encumbrance-notification-service.test.ts b/backend/cosmetology-app/lambdas/nodejs/tests/lib/email/encumbrance-notification-service.test.ts index b4ec71e68..4f6336fbc 100644 --- a/backend/cosmetology-app/lambdas/nodejs/tests/lib/email/encumbrance-notification-service.test.ts +++ b/backend/cosmetology-app/lambdas/nodejs/tests/lib/email/encumbrance-notification-service.test.ts @@ -22,7 +22,6 @@ const SAMPLE_COMPACT_CONFIG: Compact = { compactAbbr: 'cosm', compactName: 'Audiology and Speech Language Pathology', compactOperationsTeamEmails: ['operations@example.com'], - compactSummaryReportNotificationEmails: ['summary@example.com'], dateOfUpdate: '2024-12-10T19:27:28+00:00', type: 'compact' }; @@ -34,8 +33,7 @@ const SAMPLE_JURISDICTION_CONFIG = { postalAbbreviation: 'oh', compact: 'cosm', jurisdictionOperationsTeamEmails: ['oh-ops@example.com'], - jurisdictionAdverseActionsNotificationEmails: ['oh-adverse@example.com'], - jurisdictionSummaryReportNotificationEmails: ['oh-summary@example.com'] + jurisdictionAdverseActionsNotificationEmails: ['oh-adverse@example.com'] }; const asSESClient = (mock: ReturnType) => diff --git a/backend/cosmetology-app/lambdas/nodejs/tests/lib/email/investigation-notification-service.test.ts b/backend/cosmetology-app/lambdas/nodejs/tests/lib/email/investigation-notification-service.test.ts index bfcd333ea..deb28c72a 100644 --- a/backend/cosmetology-app/lambdas/nodejs/tests/lib/email/investigation-notification-service.test.ts +++ b/backend/cosmetology-app/lambdas/nodejs/tests/lib/email/investigation-notification-service.test.ts @@ -22,7 +22,6 @@ const SAMPLE_COMPACT_CONFIG: Compact = { compactAbbr: 'cosm', compactName: 'Audiology and Speech Language Pathology', compactOperationsTeamEmails: ['operations@example.com'], - compactSummaryReportNotificationEmails: ['summary@example.com'], dateOfUpdate: '2024-12-10T19:27:28+00:00', type: 'compact' }; @@ -34,8 +33,7 @@ const SAMPLE_JURISDICTION_CONFIG = { postalAbbreviation: 'oh', compact: 'cosm', jurisdictionOperationsTeamEmails: ['oh-ops@example.com'], - jurisdictionAdverseActionsNotificationEmails: ['oh-adverse@example.com'], - jurisdictionSummaryReportNotificationEmails: ['oh-summary@example.com'] + jurisdictionAdverseActionsNotificationEmails: ['oh-adverse@example.com'] }; const asSESClient = (mock: ReturnType) => diff --git a/backend/cosmetology-app/lambdas/nodejs/tests/lib/jurisdiction-client.test.ts b/backend/cosmetology-app/lambdas/nodejs/tests/lib/jurisdiction-client.test.ts index 9b420ec1b..9ae718d47 100644 --- a/backend/cosmetology-app/lambdas/nodejs/tests/lib/jurisdiction-client.test.ts +++ b/backend/cosmetology-app/lambdas/nodejs/tests/lib/jurisdiction-client.test.ts @@ -34,9 +34,6 @@ const SAMPLE_JURISDICTION_ITEMS = [ } ] }, - 'jurisdictionSummaryReportNotificationEmails': { - 'L': [] - }, 'jurisprudenceRequirements': { 'M': { 'required': { @@ -77,9 +74,6 @@ const SAMPLE_JURISDICTION_ITEMS = [ } ] }, - 'jurisdictionSummaryReportNotificationEmails': { - 'L': [] - }, 'jurisprudenceRequirements': { 'M': { 'required': { diff --git a/backend/cosmetology-app/lambdas/nodejs/tests/sample-records.ts b/backend/cosmetology-app/lambdas/nodejs/tests/sample-records.ts index 724521ec1..db901627d 100644 --- a/backend/cosmetology-app/lambdas/nodejs/tests/sample-records.ts +++ b/backend/cosmetology-app/lambdas/nodejs/tests/sample-records.ts @@ -256,9 +256,6 @@ export const SAMPLE_JURISDICTION_CONFIGURATION = { } ] }, - 'jurisdictionSummaryReportNotificationEmails': { - 'L': [] - }, 'jurisprudenceRequirements': { 'M': { 'required': { @@ -283,7 +280,6 @@ export const SAMPLE_UNMARSHALLED_JURISDICTION_CONFIGURATION = { 'jurisdictionName': 'Ohio', 'jurisdictionOperationsTeamEmails': [ 'justin@inspiringapps.com' ], - 'jurisdictionSummaryReportNotificationEmails': [], 'jurisprudenceRequirements': { 'required': true }, @@ -304,7 +300,6 @@ export const SAMPLE_COMPACT_CONFIGURATION = { 'compactAbbr': { 'S': 'cosm' }, 'compactName': { 'S': 'Audiology and Speech Language Pathology' }, 'compactOperationsTeamEmails': { 'L': [{ 'S': 'compact-ops@example.com' }]}, - 'compactSummaryReportNotificationEmails': { 'L': [{ 'S': 'summary@example.com' }]}, 'dateOfUpdate': { 'S': '2024-12-10T19:27:28+00:00' }, 'type': { 'S': 'compact' } }; @@ -320,7 +315,6 @@ export const SAMPLE_UNMARSHALLED_COMPACT_CONFIGURATION = { 'compactAbbr': 'cosm', 'compactName': 'Audiology and Speech Language Pathology', 'compactOperationsTeamEmails': ['compact-ops@example.com'], - 'compactSummaryReportNotificationEmails': ['summary@example.com'], 'dateOfUpdate': '2024-12-10T19:27:28+00:00', 'type': 'compact' }; diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/compact/__init__.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/compact/__init__.py index 3314de6c4..78af488bf 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/compact/__init__.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/compact/__init__.py @@ -29,10 +29,6 @@ def compact_operations_team_emails(self) -> list[str] | None: def compact_adverse_actions_notification_emails(self) -> list[str] | None: return self.get('compactAdverseActionsNotificationEmails') - @property - def compact_summary_report_notification_emails(self) -> list[str] | None: - return self.get('compactSummaryReportNotificationEmails') - @property def licensee_registration_enabled(self): return self.get('licenseeRegistrationEnabled', False) @@ -67,10 +63,6 @@ def compactOperationsTeamEmails(self) -> list[str]: def compactAdverseActionsNotificationEmails(self) -> list[str]: return self._data.get('compactAdverseActionsNotificationEmails', []) - @property - def compactSummaryReportNotificationEmails(self) -> list[str]: - return self._data.get('compactSummaryReportNotificationEmails', []) - @property def licenseeRegistrationEnabled(self) -> bool: return self._data.get('licenseeRegistrationEnabled', False) diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/compact/api.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/compact/api.py index fe1847420..61743f8f7 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/compact/api.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/compact/api.py @@ -21,11 +21,6 @@ class CompactConfigurationResponseSchema(ForgivingSchema): required=True, allow_none=False, ) - compactSummaryReportNotificationEmails = List( - Email(required=True, allow_none=False), - required=True, - allow_none=False, - ) licenseeRegistrationEnabled = Boolean(required=True, allow_none=False) configuredStates = List(Nested(ConfiguredStateSchema()), required=True, allow_none=False) @@ -39,9 +34,6 @@ class PutCompactConfigurationRequestSchema(Schema): compactAdverseActionsNotificationEmails = List( Email(required=True, allow_none=False), required=True, allow_none=False, validate=Length(min=1) ) - compactSummaryReportNotificationEmails = List( - Email(required=True, allow_none=False), required=True, allow_none=False, validate=Length(min=1) - ) licenseeRegistrationEnabled = Boolean(required=True, allow_none=False) configuredStates = List(Nested(ConfiguredStateSchema()), required=True, allow_none=False) diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/compact/record.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/compact/record.py index bec69f9c1..db3e19fdf 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/compact/record.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/compact/record.py @@ -27,11 +27,6 @@ class CompactRecordSchema(BaseRecordSchema): required=True, allow_none=False, ) - compactSummaryReportNotificationEmails = List( - String(required=True, allow_none=False), - required=True, - allow_none=False, - ) licenseeRegistrationEnabled = Boolean(required=True, allow_none=False) # List of states that have submitted configurations and their live status configuredStates = List(Nested(ConfiguredStateSchema()), required=True, allow_none=False) diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/__init__.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/__init__.py index 6a59f2db5..aca2eebce 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/__init__.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/__init__.py @@ -37,10 +37,6 @@ def jurisdictionOperationsTeamEmails(self) -> list[str]: def jurisdictionAdverseActionsNotificationEmails(self) -> list[str]: return self._data.get('jurisdictionAdverseActionsNotificationEmails', []) - @property - def jurisdictionSummaryReportNotificationEmails(self) -> list[str]: - return self._data.get('jurisdictionSummaryReportNotificationEmails', []) - @property def licenseeRegistrationEnabled(self) -> bool: return self._data.get('licenseeRegistrationEnabled', False) diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/api.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/api.py index 54033c541..a1181b6c8 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/api.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/api.py @@ -44,11 +44,6 @@ class CompactJurisdictionConfigurationResponseSchema(ForgivingSchema): required=True, allow_none=False, ) - jurisdictionSummaryReportNotificationEmails = List( - Email(required=True, allow_none=False), - required=True, - allow_none=False, - ) licenseeRegistrationEnabled = Boolean(required=True, allow_none=False) @@ -65,6 +60,3 @@ class PutCompactJurisdictionConfigurationRequestSchema(Schema): jurisdictionAdverseActionsNotificationEmails = List( Email(required=True, allow_none=False), required=True, allow_none=False, validate=Length(min=1) ) - jurisdictionSummaryReportNotificationEmails = List( - Email(required=True, allow_none=False), required=True, allow_none=False, validate=Length(min=1) - ) diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/record.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/record.py index eb42183a0..4e2342323 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/record.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/record.py @@ -24,11 +24,6 @@ class JurisdictionRecordSchema(BaseRecordSchema): required=True, allow_none=False, ) - jurisdictionSummaryReportNotificationEmails = List( - Email(required=True, allow_none=False), - required=True, - allow_none=False, - ) licenseeRegistrationEnabled = Boolean(required=True, allow_none=False) # Generated fields diff --git a/backend/cosmetology-app/lambdas/python/common/common_test/test_data_generator.py b/backend/cosmetology-app/lambdas/python/common/common_test/test_data_generator.py index 981e071f7..5eb9dc04d 100644 --- a/backend/cosmetology-app/lambdas/python/common/common_test/test_data_generator.py +++ b/backend/cosmetology-app/lambdas/python/common/common_test/test_data_generator.py @@ -349,7 +349,6 @@ def generate_default_compact_configuration(value_overrides: dict | None = None) 'compactName': 'Cosmetology', 'compactOperationsTeamEmails': ['ops@example.com'], 'compactAdverseActionsNotificationEmails': ['adverse@example.com'], - 'compactSummaryReportNotificationEmails': ['summary@example.com'], 'licenseeRegistrationEnabled': True, 'configuredStates': [], } @@ -388,7 +387,6 @@ def generate_default_jurisdiction_configuration( 'jurisdictionName': 'Kentucky', 'jurisdictionOperationsTeamEmails': ['state-ops@example.com'], 'jurisdictionAdverseActionsNotificationEmails': ['state-adverse@example.com'], - 'jurisdictionSummaryReportNotificationEmails': ['state-summary@example.com'], 'licenseeRegistrationEnabled': True, } if value_overrides: diff --git a/backend/cosmetology-app/lambdas/python/common/tests/resources/dynamo/compact.json b/backend/cosmetology-app/lambdas/python/common/tests/resources/dynamo/compact.json index 6fc396768..da238515c 100644 --- a/backend/cosmetology-app/lambdas/python/common/tests/resources/dynamo/compact.json +++ b/backend/cosmetology-app/lambdas/python/common/tests/resources/dynamo/compact.json @@ -6,7 +6,6 @@ "compactName": "Cosmetology", "compactOperationsTeamEmails": [""], "compactAdverseActionsNotificationEmails": [""], - "compactSummaryReportNotificationEmails": [""], "licenseeRegistrationEnabled": true, "configuredStates": [ { diff --git a/backend/cosmetology-app/lambdas/python/common/tests/resources/dynamo/jurisdiction.json b/backend/cosmetology-app/lambdas/python/common/tests/resources/dynamo/jurisdiction.json index b4b274c34..80a72f445 100644 --- a/backend/cosmetology-app/lambdas/python/common/tests/resources/dynamo/jurisdiction.json +++ b/backend/cosmetology-app/lambdas/python/common/tests/resources/dynamo/jurisdiction.json @@ -7,7 +7,6 @@ "postalAbbreviation": "oh", "jurisdictionOperationsTeamEmails": ["some-operations-team@test.com"], "jurisdictionAdverseActionsNotificationEmails": ["some-adverse-actions-notification-team@test.com"], - "jurisdictionSummaryReportNotificationEmails": ["some-summary-report-notification-team@test.com"], "licenseeRegistrationEnabled": true, "dateOfUpdate": "2024-10-04T12:34:56+00:00" } diff --git a/backend/cosmetology-app/lambdas/python/compact-configuration/handlers/compact_configuration.py b/backend/cosmetology-app/lambdas/python/compact-configuration/handlers/compact_configuration.py index 6efcf858a..71b380e99 100644 --- a/backend/cosmetology-app/lambdas/python/compact-configuration/handlers/compact_configuration.py +++ b/backend/cosmetology-app/lambdas/python/compact-configuration/handlers/compact_configuration.py @@ -186,7 +186,6 @@ def _get_staff_users_compact_configuration(event: dict, context: LambdaContext): 'licenseeRegistrationEnabled': False, 'compactOperationsTeamEmails': [], 'compactAdverseActionsNotificationEmails': [], - 'compactSummaryReportNotificationEmails': [], 'configuredStates': [], } ).to_dict() @@ -356,7 +355,6 @@ def _get_staff_users_jurisdiction_configuration(event: dict, context: LambdaCont }, 'jurisdictionOperationsTeamEmails': [], 'jurisdictionAdverseActionsNotificationEmails': [], - 'jurisdictionSummaryReportNotificationEmails': [], 'licenseeRegistrationEnabled': False, } ).to_dict() diff --git a/backend/cosmetology-app/lambdas/python/compact-configuration/tests/function/test_compact_configuration.py b/backend/cosmetology-app/lambdas/python/compact-configuration/tests/function/test_compact_configuration.py index 98231ba26..fefb50523 100644 --- a/backend/cosmetology-app/lambdas/python/compact-configuration/tests/function/test_compact_configuration.py +++ b/backend/cosmetology-app/lambdas/python/compact-configuration/tests/function/test_compact_configuration.py @@ -308,7 +308,6 @@ def _when_testing_put_compact_configuration_with_existing_configuration(self): 'licenseeRegistrationEnabled': compact_config.licenseeRegistrationEnabled, 'compactOperationsTeamEmails': compact_config.compactOperationsTeamEmails, 'compactAdverseActionsNotificationEmails': compact_config.compactAdverseActionsNotificationEmails, - 'compactSummaryReportNotificationEmails': compact_config.compactSummaryReportNotificationEmails, 'configuredStates': compact_config.configuredStates, }, cls=ResponseEncoder, @@ -333,7 +332,6 @@ def _when_testing_put_compact_configuration(self): 'licenseeRegistrationEnabled': compact_config.licenseeRegistrationEnabled, 'compactOperationsTeamEmails': compact_config.compactOperationsTeamEmails, 'compactAdverseActionsNotificationEmails': compact_config.compactAdverseActionsNotificationEmails, - 'compactSummaryReportNotificationEmails': compact_config.compactSummaryReportNotificationEmails, 'configuredStates': compact_config.configuredStates, }, cls=ResponseEncoder, @@ -387,7 +385,6 @@ def test_get_compact_configuration_returns_empty_compact_configuration_if_no_con 'compactName': 'Cosmetology', 'compactOperationsTeamEmails': [], 'compactAdverseActionsNotificationEmails': [], - 'compactSummaryReportNotificationEmails': [], 'licenseeRegistrationEnabled': False, 'configuredStates': [], }, @@ -636,7 +633,6 @@ def _when_testing_put_jurisdiction_configuration(self, create_compact=True): { 'jurisdictionOperationsTeamEmails': jurisdiction_config.jurisdictionOperationsTeamEmails, 'jurisdictionAdverseActionsNotificationEmails': jurisdiction_config.jurisdictionAdverseActionsNotificationEmails, - 'jurisdictionSummaryReportNotificationEmails': jurisdiction_config.jurisdictionSummaryReportNotificationEmails, 'licenseeRegistrationEnabled': jurisdiction_config.licenseeRegistrationEnabled, }, cls=ResponseEncoder, @@ -711,7 +707,6 @@ def test_get_jurisdiction_configuration_returns_empty_jurisdiction_configuration 'jurisdictionAdverseActionsNotificationEmails': [], 'jurisdictionName': 'Kentucky', 'jurisdictionOperationsTeamEmails': [], - 'jurisdictionSummaryReportNotificationEmails': [], 'licenseeRegistrationEnabled': False, 'postalAbbreviation': 'ky', }, @@ -746,7 +741,6 @@ def test_get_jurisdiction_configuration_returns_configuration_if_exists(self): 'postalAbbreviation': test_jurisdiction_config.postalAbbreviation, 'jurisdictionOperationsTeamEmails': test_jurisdiction_config.jurisdictionOperationsTeamEmails, 'jurisdictionAdverseActionsNotificationEmails': test_jurisdiction_config.jurisdictionAdverseActionsNotificationEmails, - 'jurisdictionSummaryReportNotificationEmails': test_jurisdiction_config.jurisdictionSummaryReportNotificationEmails, 'licenseeRegistrationEnabled': test_jurisdiction_config.licenseeRegistrationEnabled, }, response_body, diff --git a/backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py b/backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py index d0660d535..7ad26e463 100644 --- a/backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py +++ b/backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py @@ -1063,7 +1063,6 @@ def get_compact_configuration_response_model(self) -> Model: 'compactName', 'compactOperationsTeamEmails', 'compactAdverseActionsNotificationEmails', - 'compactSummaryReportNotificationEmails', 'licenseeRegistrationEnabled', 'configuredStates', ], @@ -1082,11 +1081,6 @@ def get_compact_configuration_response_model(self) -> Model: description='List of email addresses for adverse actions notifications', items=JsonSchema(type=JsonSchemaType.STRING, format='email'), ), - 'compactSummaryReportNotificationEmails': JsonSchema( - type=JsonSchemaType.ARRAY, - description='List of email addresses for summary report notifications', - items=JsonSchema(type=JsonSchemaType.STRING, format='email'), - ), 'licenseeRegistrationEnabled': JsonSchema( type=JsonSchemaType.BOOLEAN, description='Denotes whether licensee registration is enabled', @@ -1130,7 +1124,6 @@ def put_compact_request_model(self) -> Model: required=[ 'compactOperationsTeamEmails', 'compactAdverseActionsNotificationEmails', - 'compactSummaryReportNotificationEmails', 'licenseeRegistrationEnabled', 'configuredStates', ], @@ -1151,14 +1144,6 @@ def put_compact_request_model(self) -> Model: unique_items=True, items=JsonSchema(type=JsonSchemaType.STRING, format='email'), ), - 'compactSummaryReportNotificationEmails': JsonSchema( - type=JsonSchemaType.ARRAY, - description='List of email addresses for summary report notifications', - min_items=1, - max_items=10, - unique_items=True, - items=JsonSchema(type=JsonSchemaType.STRING, format='email'), - ), 'licenseeRegistrationEnabled': JsonSchema( type=JsonSchemaType.BOOLEAN, description='Denotes whether licensee registration is enabled', @@ -1207,7 +1192,6 @@ def get_jurisdiction_response_model(self) -> Model: 'postalAbbreviation', 'jurisdictionOperationsTeamEmails', 'jurisdictionAdverseActionsNotificationEmails', - 'jurisdictionSummaryReportNotificationEmails', 'licenseeRegistrationEnabled', ], properties={ @@ -1234,11 +1218,6 @@ def get_jurisdiction_response_model(self) -> Model: description='List of email addresses for adverse actions notifications', items=JsonSchema(type=JsonSchemaType.STRING, format='email'), ), - 'jurisdictionSummaryReportNotificationEmails': JsonSchema( - type=JsonSchemaType.ARRAY, - description='List of email addresses for summary report notifications', - items=JsonSchema(type=JsonSchemaType.STRING, format='email'), - ), 'licenseeRegistrationEnabled': JsonSchema( type=JsonSchemaType.BOOLEAN, description='Denotes whether licensee registration is enabled', @@ -1265,7 +1244,6 @@ def put_jurisdiction_request_model(self) -> Model: required=[ 'jurisdictionOperationsTeamEmails', 'jurisdictionAdverseActionsNotificationEmails', - 'jurisdictionSummaryReportNotificationEmails', 'licenseeRegistrationEnabled', ], properties={ @@ -1285,14 +1263,6 @@ def put_jurisdiction_request_model(self) -> Model: unique_items=True, items=JsonSchema(type=JsonSchemaType.STRING, format='email'), ), - 'jurisdictionSummaryReportNotificationEmails': JsonSchema( - type=JsonSchemaType.ARRAY, - description='List of email addresses for summary report notifications', - min_items=1, - max_items=10, - unique_items=True, - items=JsonSchema(type=JsonSchemaType.STRING, format='email'), - ), 'licenseeRegistrationEnabled': JsonSchema( type=JsonSchemaType.BOOLEAN, description='Denotes whether licensee registration is enabled', diff --git a/backend/cosmetology-app/tests/resources/snapshots/GET_COMPACT_CONFIGURATION_RESPONSE_SCHEMA.json b/backend/cosmetology-app/tests/resources/snapshots/GET_COMPACT_CONFIGURATION_RESPONSE_SCHEMA.json index f3bf8e0d6..091491832 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/GET_COMPACT_CONFIGURATION_RESPONSE_SCHEMA.json +++ b/backend/cosmetology-app/tests/resources/snapshots/GET_COMPACT_CONFIGURATION_RESPONSE_SCHEMA.json @@ -24,14 +24,6 @@ }, "type": "array" }, - "compactSummaryReportNotificationEmails": { - "description": "List of email addresses for summary report notifications", - "items": { - "format": "email", - "type": "string" - }, - "type": "array" - }, "licenseeRegistrationEnabled": { "description": "Denotes whether licensee registration is enabled", "type": "boolean" @@ -75,7 +67,6 @@ "compactName", "compactOperationsTeamEmails", "compactAdverseActionsNotificationEmails", - "compactSummaryReportNotificationEmails", "licenseeRegistrationEnabled", "configuredStates" ], diff --git a/backend/cosmetology-app/tests/resources/snapshots/GET_JURISDICTION_CONFIGURATION_RESPONSE_SCHEMA.json b/backend/cosmetology-app/tests/resources/snapshots/GET_JURISDICTION_CONFIGURATION_RESPONSE_SCHEMA.json index 54897ae1f..1cb9a9447 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/GET_JURISDICTION_CONFIGURATION_RESPONSE_SCHEMA.json +++ b/backend/cosmetology-app/tests/resources/snapshots/GET_JURISDICTION_CONFIGURATION_RESPONSE_SCHEMA.json @@ -31,14 +31,6 @@ }, "type": "array" }, - "jurisdictionSummaryReportNotificationEmails": { - "description": "List of email addresses for summary report notifications", - "items": { - "format": "email", - "type": "string" - }, - "type": "array" - }, "licenseeRegistrationEnabled": { "description": "Denotes whether licensee registration is enabled", "type": "boolean" @@ -50,7 +42,6 @@ "postalAbbreviation", "jurisdictionOperationsTeamEmails", "jurisdictionAdverseActionsNotificationEmails", - "jurisdictionSummaryReportNotificationEmails", "licenseeRegistrationEnabled" ], "type": "object", diff --git a/backend/cosmetology-app/tests/resources/snapshots/PUT_COMPACT_CONFIGURATION_REQUEST_SCHEMA.json b/backend/cosmetology-app/tests/resources/snapshots/PUT_COMPACT_CONFIGURATION_REQUEST_SCHEMA.json index 0e4657936..bcfe3e22d 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/PUT_COMPACT_CONFIGURATION_REQUEST_SCHEMA.json +++ b/backend/cosmetology-app/tests/resources/snapshots/PUT_COMPACT_CONFIGURATION_REQUEST_SCHEMA.json @@ -23,17 +23,6 @@ "type": "array", "uniqueItems": true }, - "compactSummaryReportNotificationEmails": { - "description": "List of email addresses for summary report notifications", - "items": { - "format": "email", - "type": "string" - }, - "maxItems": 10, - "minItems": 1, - "type": "array", - "uniqueItems": true - }, "licenseeRegistrationEnabled": { "description": "Denotes whether licensee registration is enabled", "type": "boolean" @@ -76,7 +65,6 @@ "required": [ "compactOperationsTeamEmails", "compactAdverseActionsNotificationEmails", - "compactSummaryReportNotificationEmails", "licenseeRegistrationEnabled", "configuredStates" ], diff --git a/backend/cosmetology-app/tests/resources/snapshots/PUT_JURISDICTION_CONFIGURATION_REQUEST_SCHEMA.json b/backend/cosmetology-app/tests/resources/snapshots/PUT_JURISDICTION_CONFIGURATION_REQUEST_SCHEMA.json index cf7e87b39..7996e3d85 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/PUT_JURISDICTION_CONFIGURATION_REQUEST_SCHEMA.json +++ b/backend/cosmetology-app/tests/resources/snapshots/PUT_JURISDICTION_CONFIGURATION_REQUEST_SCHEMA.json @@ -23,17 +23,6 @@ "type": "array", "uniqueItems": true }, - "jurisdictionSummaryReportNotificationEmails": { - "description": "List of email addresses for summary report notifications", - "items": { - "format": "email", - "type": "string" - }, - "maxItems": 10, - "minItems": 1, - "type": "array", - "uniqueItems": true - }, "licenseeRegistrationEnabled": { "description": "Denotes whether licensee registration is enabled", "type": "boolean" @@ -42,7 +31,6 @@ "required": [ "jurisdictionOperationsTeamEmails", "jurisdictionAdverseActionsNotificationEmails", - "jurisdictionSummaryReportNotificationEmails", "licenseeRegistrationEnabled" ], "type": "object", diff --git a/backend/cosmetology-app/tests/smoke/compact_configuration_smoke_tests.py b/backend/cosmetology-app/tests/smoke/compact_configuration_smoke_tests.py index d0b2ccf23..194ec810a 100644 --- a/backend/cosmetology-app/tests/smoke/compact_configuration_smoke_tests.py +++ b/backend/cosmetology-app/tests/smoke/compact_configuration_smoke_tests.py @@ -139,7 +139,6 @@ def test_compact_configuration(): 'licenseeRegistrationEnabled': False, 'compactOperationsTeamEmails': [notification_email], 'compactAdverseActionsNotificationEmails': [notification_email], - 'compactSummaryReportNotificationEmails': [notification_email], 'configuredStates': [], } @@ -259,7 +258,6 @@ def test_jurisdiction_configuration(jurisdiction: str = 'ne', recreate_compact_c jurisdiction_config = { 'jurisdictionOperationsTeamEmails': [notification_email], 'jurisdictionAdverseActionsNotificationEmails': [notification_email], - 'jurisdictionSummaryReportNotificationEmails': [notification_email], 'licenseeRegistrationEnabled': True, } From bad27fbd865278383bdb206b0d54339e9fbc4d6b Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 17 Mar 2026 11:58:57 -0500 Subject: [PATCH 05/14] Formatter/linter --- .../data_model/provider_record_util.py | 7 +- .../test_schema/test_license.py | 22 +- .../test_schema/test_provider.py | 16 +- .../tests/unit/test_provider_record_util.py | 701 ++++++++++-------- .../lambdas/python/search/handlers/search.py | 4 +- .../tests/function/test_search_providers.py | 6 +- .../tests/unit/test_opensearch_client.py | 4 +- .../lambdas/python/search/utils.py | 6 +- 8 files changed, 426 insertions(+), 340 deletions(-) diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py index 5765afc9b..754054b6d 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py @@ -470,8 +470,11 @@ def generate_privileges_for_provider(self, include_inactive_privileges: bool = F not include_inactive_privileges and most_recent_license.compactEligibility != CompactEligibilityStatus.ELIGIBLE ): - logger.debug('skipping inactive license', - license_jurisdiction=most_recent_license.jurisdiction, license_type=most_recent_license.licenseType) + logger.debug( + 'skipping inactive license', + license_jurisdiction=most_recent_license.jurisdiction, + license_type=most_recent_license.licenseType, + ) continue most_recent_licenses_for_each_type.append(most_recent_license) diff --git a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py index c4414718c..afbe6baf7 100644 --- a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py +++ b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py @@ -348,15 +348,27 @@ def test_retains_all_general_response_fields(self): result = LicenseOpenSearchDocumentSchema().load(license_data) for field in [ - 'providerId', 'type', 'dateOfUpdate', 'compact', 'jurisdiction', - 'licenseType', 'licenseStatus', 'licenseNumber', 'givenName', 'familyName', - 'dateOfIssuance', 'dateOfExpiration', 'homeAddressStreet1', 'homeAddressCity', - 'homeAddressState', 'homeAddressPostalCode', + 'providerId', + 'type', + 'dateOfUpdate', + 'compact', + 'jurisdiction', + 'licenseType', + 'licenseStatus', + 'licenseNumber', + 'givenName', + 'familyName', + 'dateOfIssuance', + 'dateOfExpiration', + 'homeAddressStreet1', + 'homeAddressCity', + 'homeAddressState', + 'homeAddressPostalCode', ]: self.assertIn(field, result, f'Expected field {field} to be in loaded result') def test_expired_license_status_corrected_to_inactive(self): - """LicenseOpenSearchDocumentSchema should inherit expiration status correction from LicenseExpirationStatusMixin.""" + """LicenseOpenSearchDocumentSchema should inherit expiration status logic from LicenseExpirationStatusMixin.""" from cc_common.data_model.schema.license.api import LicenseOpenSearchDocumentSchema license_data = self._make_license_data(license_status='active', date_of_expiration='2020-01-01') diff --git a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py index 2fb2b77cd..1c2028506 100644 --- a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py +++ b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py @@ -72,14 +72,22 @@ def test_top_level_fields_match_general_response(self): result = ProviderOpenSearchDocumentSchema().load(data) for field in [ - 'providerId', 'type', 'dateOfUpdate', 'compact', 'licenseJurisdiction', - 'licenseStatus', 'compactEligibility', 'givenName', 'familyName', - 'dateOfExpiration', 'birthMonthDay', + 'providerId', + 'type', + 'dateOfUpdate', + 'compact', + 'licenseJurisdiction', + 'licenseStatus', + 'compactEligibility', + 'givenName', + 'familyName', + 'dateOfExpiration', + 'birthMonthDay', ]: self.assertIn(field, result, f'Expected field {field} to be in loaded result') def test_does_not_include_private_fields_at_top_level(self): - """ProviderOpenSearchDocumentSchema should NOT include top-level private fields like dateOfBirth or ssnLastFour.""" + """ProviderOpenSearchDocumentSchema should NOT include top-level private fields.""" from cc_common.data_model.schema.provider.api import ProviderOpenSearchDocumentSchema data = self._make_provider_data_with_license() diff --git a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py index 5245d4977..56c7af537 100644 --- a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py +++ b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py @@ -1,5 +1,5 @@ from datetime import date -from unittest.mock import MagicMock, patch, ANY +from unittest.mock import ANY, MagicMock, patch from uuid import UUID from tests import TstLambdas @@ -552,73 +552,90 @@ def test_single_license_returns_one_document(self): pur = ProviderUserRecords(records) docs = pur.generate_opensearch_documents() - self.assertEqual([{'birthMonthDay': '06-06', - 'compact': 'cosm', - 'compactEligibility': 'ineligible', - 'dateOfBirth': date(1985, 6, 6), - 'dateOfExpiration': date(2025, 4, 4), - 'dateOfUpdate': ANY, - 'familyName': 'Guðmundsdóttir', - 'givenName': 'Björk', - 'jurisdictionUploadedCompactEligibility': 'eligible', - 'jurisdictionUploadedLicenseStatus': 'active', - 'licenseJurisdiction': 'oh', - 'licenseStatus': 'inactive', - 'licenses': [{'adverseActions': [], - 'compact': 'cosm', - 'compactEligibility': 'eligible', - 'dateOfBirth': date(1985, 6, 6), - 'dateOfExpiration': date(2026, 4, 4), - 'dateOfIssuance': date(2010, 6, 6), - 'dateOfRenewal': date(2020, 4, 4), - 'dateOfUpdate': ANY, - 'emailAddress': 'björk@example.com', - 'familyName': 'Guðmundsdóttir', - 'givenName': 'Björk', - 'homeAddressCity': 'Columbus', - 'homeAddressPostalCode': '43004', - 'homeAddressState': 'oh', - 'homeAddressStreet1': '123 A St.', - 'homeAddressStreet2': 'Apt 321', - 'investigations': [], - 'jurisdiction': 'oh', - 'jurisdictionUploadedCompactEligibility': 'eligible', - 'jurisdictionUploadedLicenseStatus': 'active', - 'licenseNumber': 'A0608337260', - 'licenseStatus': 'active', - 'licenseStatusName': 'DEFINITELY_A_HUMAN', - 'licenseType': 'cosmetologist', - 'middleName': 'Gunnar', - 'phoneNumber': '+13213214321', - 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), - 'ssnLastFour': '1234', - 'type': 'license'}], - 'middleName': 'Gunnar', - 'privileges': [{'administratorSetStatus': 'active', - 'adverseActions': [], - 'compact': 'cosm', - 'dateOfExpiration': date(2026, 4, 4), - 'investigations': [], - 'jurisdiction': 'al', - 'licenseJurisdiction': 'oh', - 'licenseType': 'cosmetologist', - 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', - 'status': 'active', - 'type': 'privilege'}, - {'administratorSetStatus': 'active', - 'adverseActions': [], - 'compact': 'cosm', - 'dateOfExpiration': date(2026, 4, 4), - 'investigations': [], - 'jurisdiction': 'ky', - 'licenseJurisdiction': 'oh', - 'licenseType': 'cosmetologist', - 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', - 'status': 'active', - 'type': 'privilege'}], - 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), - 'ssnLastFour': '1234', - 'type': 'provider'}], docs) + self.assertEqual( + [ + { + 'birthMonthDay': '06-06', + 'compact': 'cosm', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [ + { + 'adverseActions': [], + 'compact': 'cosm', + 'compactEligibility': 'eligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2010, 6, 6), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'oh', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'cosmetologist', + 'middleName': 'Gunnar', + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license', + } + ], + 'middleName': 'Gunnar', + 'privileges': [ + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'al', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege', + }, + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'ky', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege', + }, + ], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider', + } + ], + docs, + ) def test_two_licenses_different_types_returns_two_documents(self): """Provider with two licenses of different types produces two documents. @@ -648,141 +665,170 @@ def test_two_licenses_different_types_returns_two_documents(self): pur = ProviderUserRecords(records) docs = pur.generate_opensearch_documents() - self.assertEqual([{'birthMonthDay': '06-06', - 'compact': 'cosm', - 'compactEligibility': 'ineligible', - 'dateOfBirth': date(1985, 6, 6), - 'dateOfExpiration': date(2025, 4, 4), - 'dateOfUpdate': ANY, - 'familyName': 'Guðmundsdóttir', - 'givenName': 'Björk', - 'jurisdictionUploadedCompactEligibility': 'eligible', - 'jurisdictionUploadedLicenseStatus': 'active', - 'licenseJurisdiction': 'oh', - 'licenseStatus': 'inactive', - 'licenses': [{'adverseActions': [], - 'compact': 'cosm', - 'compactEligibility': 'eligible', - 'dateOfBirth': date(1985, 6, 6), - 'dateOfExpiration': date(2026, 4, 4), - 'dateOfIssuance': date(2010, 6, 6), - 'dateOfRenewal': date(2020, 4, 4), - 'dateOfUpdate': ANY, - 'emailAddress': 'björk@example.com', - 'familyName': 'Guðmundsdóttir', - 'givenName': 'Björk', - 'homeAddressCity': 'Columbus', - 'homeAddressPostalCode': '43004', - 'homeAddressState': 'oh', - 'homeAddressStreet1': '123 A St.', - 'homeAddressStreet2': 'Apt 321', - 'investigations': [], - 'jurisdiction': 'al', - 'jurisdictionUploadedCompactEligibility': 'eligible', - 'jurisdictionUploadedLicenseStatus': 'active', - 'licenseNumber': 'A0608337260', - 'licenseStatus': 'active', - 'licenseStatusName': 'DEFINITELY_A_HUMAN', - 'licenseType': 'cosmetologist', - 'middleName': 'Gunnar', - 'phoneNumber': '+13213214321', - 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), - 'ssnLastFour': '1234', - 'type': 'license'}], - 'middleName': 'Gunnar', - 'privileges': [{'administratorSetStatus': 'active', - 'adverseActions': [], - 'compact': 'cosm', - 'dateOfExpiration': date(2026, 4, 4), - 'investigations': [], - 'jurisdiction': 'ky', - 'licenseJurisdiction': 'al', - 'licenseType': 'cosmetologist', - 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', - 'status': 'active', - 'type': 'privilege'}, - {'administratorSetStatus': 'active', - 'adverseActions': [], - 'compact': 'cosm', - 'dateOfExpiration': date(2026, 4, 4), - 'investigations': [], - 'jurisdiction': 'oh', - 'licenseJurisdiction': 'al', - 'licenseType': 'cosmetologist', - 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', - 'status': 'active', - 'type': 'privilege'}], - 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), - 'ssnLastFour': '1234', - 'type': 'provider'}, - {'birthMonthDay': '06-06', - 'compact': 'cosm', - 'compactEligibility': 'ineligible', - 'dateOfBirth': date(1985, 6, 6), - 'dateOfExpiration': date(2025, 4, 4), - 'dateOfUpdate': ANY, - 'familyName': 'Guðmundsdóttir', - 'givenName': 'Björk', - 'jurisdictionUploadedCompactEligibility': 'eligible', - 'jurisdictionUploadedLicenseStatus': 'active', - 'licenseJurisdiction': 'oh', - 'licenseStatus': 'inactive', - 'licenses': [{'adverseActions': [], - 'compact': 'cosm', - 'compactEligibility': 'ineligible', - 'dateOfBirth': date(1985, 6, 6), - 'dateOfExpiration': date(2026, 4, 4), - 'dateOfIssuance': date(2010, 6, 6), - 'dateOfRenewal': date(2020, 4, 4), - 'dateOfUpdate': ANY, - 'emailAddress': 'björk@example.com', - 'familyName': 'Guðmundsdóttir', - 'givenName': 'Björk', - 'homeAddressCity': 'Columbus', - 'homeAddressPostalCode': '43004', - 'homeAddressState': 'oh', - 'homeAddressStreet1': '123 A St.', - 'homeAddressStreet2': 'Apt 321', - 'investigations': [], - 'jurisdiction': 'oh', - 'jurisdictionUploadedCompactEligibility': 'ineligible', - 'jurisdictionUploadedLicenseStatus': 'active', - 'licenseNumber': 'A0608337260', - 'licenseStatus': 'active', - 'licenseStatusName': 'DEFINITELY_A_HUMAN', - 'licenseType': 'esthetician', - 'middleName': 'Gunnar', - 'phoneNumber': '+13213214321', - 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), - 'ssnLastFour': '1234', - 'type': 'license'}], - 'middleName': 'Gunnar', - # these privileges are inactive due to the home state license being ineligible - 'privileges': [{'administratorSetStatus': 'active', - 'adverseActions': [], - 'compact': 'cosm', - 'dateOfExpiration': date(2026, 4, 4), - 'investigations': [], - 'jurisdiction': 'al', - 'licenseJurisdiction': 'oh', - 'licenseType': 'esthetician', - 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', - 'status': 'inactive', - 'type': 'privilege'}, - {'administratorSetStatus': 'active', - 'adverseActions': [], - 'compact': 'cosm', - 'dateOfExpiration': date(2026, 4, 4), - 'investigations': [], - 'jurisdiction': 'ky', - 'licenseJurisdiction': 'oh', - 'licenseType': 'esthetician', - 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', - 'status': 'inactive', - 'type': 'privilege'}], - 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), - 'ssnLastFour': '1234', - 'type': 'provider'}], docs) + self.assertEqual( + [ + { + 'birthMonthDay': '06-06', + 'compact': 'cosm', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [ + { + 'adverseActions': [], + 'compact': 'cosm', + 'compactEligibility': 'eligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2010, 6, 6), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'al', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'cosmetologist', + 'middleName': 'Gunnar', + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license', + } + ], + 'middleName': 'Gunnar', + 'privileges': [ + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'ky', + 'licenseJurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege', + }, + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'oh', + 'licenseJurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege', + }, + ], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider', + }, + { + 'birthMonthDay': '06-06', + 'compact': 'cosm', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [ + { + 'adverseActions': [], + 'compact': 'cosm', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2010, 6, 6), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'oh', + 'jurisdictionUploadedCompactEligibility': 'ineligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'esthetician', + 'middleName': 'Gunnar', + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license', + } + ], + 'middleName': 'Gunnar', + # these privileges are inactive due to the home state license being ineligible + 'privileges': [ + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'al', + 'licenseJurisdiction': 'oh', + 'licenseType': 'esthetician', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'inactive', + 'type': 'privilege', + }, + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'ky', + 'licenseJurisdiction': 'oh', + 'licenseType': 'esthetician', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'inactive', + 'type': 'privilege', + }, + ], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider', + }, + ], + docs, + ) def test_privileges_assigned_only_to_home_license_document(self): """Privileges are only on the document whose license is the home license for its type.""" @@ -812,119 +858,142 @@ def test_privileges_assigned_only_to_home_license_document(self): pur = ProviderUserRecords(records) docs = pur.generate_opensearch_documents() - self.assertEqual([{'birthMonthDay': '06-06', - 'compact': 'cosm', - 'compactEligibility': 'ineligible', - 'dateOfBirth': date(1985, 6, 6), - 'dateOfExpiration': date(2025, 4, 4), - 'dateOfUpdate': ANY, - 'familyName': 'Guðmundsdóttir', - 'givenName': 'Björk', - 'jurisdictionUploadedCompactEligibility': 'eligible', - 'jurisdictionUploadedLicenseStatus': 'active', - 'licenseJurisdiction': 'oh', - 'licenseStatus': 'inactive', - 'licenses': [{'adverseActions': [], - 'compact': 'cosm', - 'compactEligibility': 'eligible', - 'dateOfBirth': date(1985, 6, 6), - 'dateOfExpiration': date(2026, 4, 4), - 'dateOfIssuance': date(2023, 1, 1), - 'dateOfRenewal': date(2020, 4, 4), - 'dateOfUpdate': ANY, - 'emailAddress': 'björk@example.com', - 'familyName': 'Guðmundsdóttir', - 'givenName': 'Björk', - 'homeAddressCity': 'Columbus', - 'homeAddressPostalCode': '43004', - 'homeAddressState': 'oh', - 'homeAddressStreet1': '123 A St.', - 'homeAddressStreet2': 'Apt 321', - 'investigations': [], - 'jurisdiction': 'al', - 'jurisdictionUploadedCompactEligibility': 'eligible', - 'jurisdictionUploadedLicenseStatus': 'active', - 'licenseNumber': 'A0608337260', - 'licenseStatus': 'active', - 'licenseStatusName': 'DEFINITELY_A_HUMAN', - 'licenseType': 'cosmetologist', - 'middleName': 'Gunnar', - 'phoneNumber': '+13213214321', - 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), - 'ssnLastFour': '1234', - 'type': 'license'}], - 'middleName': 'Gunnar', - 'privileges': [], - 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), - 'ssnLastFour': '1234', - 'type': 'provider'}, - {'birthMonthDay': '06-06', - 'compact': 'cosm', - 'compactEligibility': 'ineligible', - 'dateOfBirth': date(1985, 6, 6), - 'dateOfExpiration': date(2025, 4, 4), - 'dateOfUpdate': ANY, - 'familyName': 'Guðmundsdóttir', - 'givenName': 'Björk', - 'jurisdictionUploadedCompactEligibility': 'eligible', - 'jurisdictionUploadedLicenseStatus': 'active', - 'licenseJurisdiction': 'oh', - 'licenseStatus': 'inactive', - 'licenses': [{'adverseActions': [], - 'compact': 'cosm', - 'compactEligibility': 'eligible', - 'dateOfBirth': date(1985, 6, 6), - 'dateOfExpiration': date(2026, 4, 4), - 'dateOfIssuance': date(2024, 6, 1), - 'dateOfRenewal': date(2020, 4, 4), - 'dateOfUpdate': ANY, - 'emailAddress': 'björk@example.com', - 'familyName': 'Guðmundsdóttir', - 'givenName': 'Björk', - 'homeAddressCity': 'Columbus', - 'homeAddressPostalCode': '43004', - 'homeAddressState': 'oh', - 'homeAddressStreet1': '123 A St.', - 'homeAddressStreet2': 'Apt 321', - 'investigations': [], - 'jurisdiction': 'oh', - 'jurisdictionUploadedCompactEligibility': 'eligible', - 'jurisdictionUploadedLicenseStatus': 'active', - 'licenseNumber': 'A0608337260', - 'licenseStatus': 'active', - 'licenseStatusName': 'DEFINITELY_A_HUMAN', - 'licenseType': 'cosmetologist', - 'middleName': 'Gunnar', - 'phoneNumber': '+13213214321', - 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), - 'ssnLastFour': '1234', - 'type': 'license'}], - 'middleName': 'Gunnar', - 'privileges': [{'administratorSetStatus': 'active', - 'adverseActions': [], - 'compact': 'cosm', - 'dateOfExpiration': date(2026, 4, 4), - 'investigations': [], - 'jurisdiction': 'al', - 'licenseJurisdiction': 'oh', - 'licenseType': 'cosmetologist', - 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', - 'status': 'active', - 'type': 'privilege'}, - {'administratorSetStatus': 'active', - 'adverseActions': [], - 'compact': 'cosm', - 'dateOfExpiration': date(2026, 4, 4), - 'investigations': [], - 'jurisdiction': 'ky', - 'licenseJurisdiction': 'oh', - 'licenseType': 'cosmetologist', - 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', - 'status': 'active', - 'type': 'privilege'}], - 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), - 'ssnLastFour': '1234', - 'type': 'provider'}], docs) + self.assertEqual( + [ + { + 'birthMonthDay': '06-06', + 'compact': 'cosm', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [ + { + 'adverseActions': [], + 'compact': 'cosm', + 'compactEligibility': 'eligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2023, 1, 1), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'al', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'cosmetologist', + 'middleName': 'Gunnar', + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license', + } + ], + 'middleName': 'Gunnar', + 'privileges': [], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider', + }, + { + 'birthMonthDay': '06-06', + 'compact': 'cosm', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [ + { + 'adverseActions': [], + 'compact': 'cosm', + 'compactEligibility': 'eligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2024, 6, 1), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'oh', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'cosmetologist', + 'middleName': 'Gunnar', + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license', + } + ], + 'middleName': 'Gunnar', + 'privileges': [ + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'al', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege', + }, + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'cosm', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'ky', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege', + }, + ], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider', + }, + ], + docs, + ) def test_multiple_types_privileges_on_correct_home_licenses(self): """With two license types, each type's home license gets its own privileges.""" diff --git a/backend/cosmetology-app/lambdas/python/search/handlers/search.py b/backend/cosmetology-app/lambdas/python/search/handlers/search.py index ddb6c4cd6..72b765b42 100644 --- a/backend/cosmetology-app/lambdas/python/search/handlers/search.py +++ b/backend/cosmetology-app/lambdas/python/search/handlers/search.py @@ -249,6 +249,4 @@ def _validate_date_of_birth_permission(query: dict, compact: str, scopes: set[st :raises CCInvalidRequestException: If dateOfBirth is in the query and the caller lacks readPrivate permission """ if _query_references_field(query, 'dateOfBirth') and not _caller_has_read_private_scope(compact, scopes): - raise CCInvalidRequestException( - 'Searching by dateOfBirth requires readPrivate permission' - ) + raise CCInvalidRequestException('Searching by dateOfBirth requires readPrivate permission') diff --git a/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py b/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py index b3a322042..0b208f85f 100644 --- a/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py +++ b/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py @@ -493,9 +493,7 @@ def test_opensearch_request_error_returns_400_with_error_message(self, mock_open self.assertEqual(error_reason, body['message']) @patch('handlers.search.opensearch_client') - def test_search_with_date_of_birth_query_allowed_for_compact_level_read_private_scope( - self, mock_opensearch_client - ): + def test_search_with_date_of_birth_query_allowed_for_compact_level_read_private_scope(self, mock_opensearch_client): """Test that a query containing dateOfBirth succeeds when the caller has compact-level readPrivate scope.""" from handlers.search import search_api_handler @@ -522,7 +520,7 @@ def test_search_with_date_of_birth_query_allowed_for_compact_level_read_private_ def test_search_with_date_of_birth_query_allowed_for_jurisdiction_level_read_private_scope( self, mock_opensearch_client ): - """Test that a query containing dateOfBirth succeeds when the caller has a jurisdiction-level readPrivate scope.""" + """Test that a query containing dateOfBirth succeeds if the caller has jurisdiction-level readPrivate scope.""" from handlers.search import search_api_handler self._when_testing_mock_opensearch_client(mock_opensearch_client) diff --git a/backend/cosmetology-app/lambdas/python/search/tests/unit/test_opensearch_client.py b/backend/cosmetology-app/lambdas/python/search/tests/unit/test_opensearch_client.py index 96cfe7730..0c45bd272 100644 --- a/backend/cosmetology-app/lambdas/python/search/tests/unit/test_opensearch_client.py +++ b/backend/cosmetology-app/lambdas/python/search/tests/unit/test_opensearch_client.py @@ -563,9 +563,7 @@ def test_delete_provider_documents_raises_after_max_retries(self, mock_sleep): client, mock_internal_client = self._create_client_with_mock() - mock_internal_client.delete_by_query.side_effect = ConnectionTimeout( - 'Connection timed out', 503, 'some error' - ) + mock_internal_client.delete_by_query.side_effect = ConnectionTimeout('Connection timed out', 503, 'some error') with self.assertRaises(CCInternalException) as context: client.delete_provider_documents( diff --git a/backend/cosmetology-app/lambdas/python/search/utils.py b/backend/cosmetology-app/lambdas/python/search/utils.py index f6bdd639c..ff2f01197 100644 --- a/backend/cosmetology-app/lambdas/python/search/utils.py +++ b/backend/cosmetology-app/lambdas/python/search/utils.py @@ -19,9 +19,9 @@ def generate_provider_opensearch_documents(compact: str, provider_id: str) -> li Each document corresponds to one license. This is because the Cosmetology compact search returns results by license, so we need to index one document per license to support native pagination. - - Becuase of this, rather than just using the provider_id as the documentId, - we add a composite documentId that includes the jurisdiction and license type. + + Because of this, rather than just using the provider_id as the documentId, + we add a composite documentId that includes the jurisdiction and license type. This composite documentId is added after sanitization so that bulk_index can use it as the OpenSearch _id. :param compact: The compact abbreviation From 9a8b39146674ce8ed6d67a68487cd299182719a8 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 17 Mar 2026 12:38:07 -0500 Subject: [PATCH 06/14] PR feedback - check full request body for dob field --- .../lambdas/python/search/handlers/search.py | 14 +++--- .../tests/function/test_search_providers.py | 45 +++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/backend/cosmetology-app/lambdas/python/search/handlers/search.py b/backend/cosmetology-app/lambdas/python/search/handlers/search.py index 72b765b42..2e9c65171 100644 --- a/backend/cosmetology-app/lambdas/python/search/handlers/search.py +++ b/backend/cosmetology-app/lambdas/python/search/handlers/search.py @@ -63,8 +63,8 @@ def _search_providers(event: dict, context: LambdaContext): # noqa: ARG001 unus # Parse and validate the request body using the schema body = _parse_and_validate_request_body(event) - # If the query references dateOfBirth, verify the caller has readPrivate permission - _validate_date_of_birth_permission(body.get('query', {}), compact, get_event_scopes(event)) + # If the request body references dateOfBirth (e.g. in query or sort), verify readPrivate permission + _validate_date_of_birth_permission(body, compact, get_event_scopes(event)) # Build the OpenSearch search body search_body = _build_opensearch_search_body(body, size_override=MAX_PROVIDER_PAGE_SIZE) @@ -239,14 +239,14 @@ def _caller_has_read_private_scope(compact: str, scopes: set[str]) -> bool: return any(match(jurisdiction_scope_pattern, scope) for scope in scopes) -def _validate_date_of_birth_permission(query: dict, compact: str, scopes: set[str]) -> None: +def _validate_date_of_birth_permission(request_body: dict, compact: str, scopes: set[str]) -> None: """ - Validate that the caller has readPrivate permission if the query references dateOfBirth. + Validate that the caller has readPrivate permission if the request body references dateOfBirth. - :param query: The OpenSearch query body + :param request_body: Full search request body (query, sort, etc.) :param compact: The compact abbreviation :param scopes: The caller's scopes - :raises CCInvalidRequestException: If dateOfBirth is in the query and the caller lacks readPrivate permission + :raises CCInvalidRequestException: If dateOfBirth is referenced and the caller lacks readPrivate permission """ - if _query_references_field(query, 'dateOfBirth') and not _caller_has_read_private_scope(compact, scopes): + if _query_references_field(request_body, 'dateOfBirth') and not _caller_has_read_private_scope(compact, scopes): raise CCInvalidRequestException('Searching by dateOfBirth requires readPrivate permission') diff --git a/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py b/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py index 0b208f85f..c4ab92f6f 100644 --- a/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py +++ b/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py @@ -591,3 +591,48 @@ def test_search_with_nested_date_of_birth_query_rejected_without_read_private_sc self.assertEqual(400, response['statusCode']) body = json.loads(response['body']) self.assertIn('dateOfBirth', body['message']) + + @patch('handlers.search.opensearch_client') + def test_search_with_sort_by_date_of_birth_rejected_without_read_private_scope(self, mock_opensearch_client): + """Test that sort clause referencing dateOfBirth is rejected when caller lacks readPrivate scope.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + # Query does not reference dateOfBirth; only sort does + event = self._create_api_event( + 'cosm', + body={ + 'query': {'match_all': {}}, + 'sort': [{'providerId': 'asc'}, {'licenses.dateOfBirth': 'desc'}], + }, + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('dateOfBirth', body['message']) + self.assertIn('readPrivate', body['message']) + mock_opensearch_client.search.assert_not_called() + + @patch('handlers.search.opensearch_client') + def test_search_with_sort_by_date_of_birth_allowed_with_read_private_scope(self, mock_opensearch_client): + """Test that sort by dateOfBirth succeeds when caller has readPrivate scope.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + event = self._create_api_event( + 'cosm', + body={ + 'query': {'match_all': {}}, + 'sort': [{'licenses.dateOfBirth': 'desc'}, {'providerId': 'asc'}], + }, + scopes_override='openid email cosm/readGeneral cosm/readPrivate', + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(200, response['statusCode']) + mock_opensearch_client.search.assert_called_once() From 25e15c88c4ff9b5411ea519bb4793b16f837b1f7 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 17 Mar 2026 12:50:26 -0500 Subject: [PATCH 07/14] PR feedback - tighten private field check --- .../lambdas/python/search/handlers/search.py | 13 +++++++---- .../tests/function/test_search_providers.py | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/backend/cosmetology-app/lambdas/python/search/handlers/search.py b/backend/cosmetology-app/lambdas/python/search/handlers/search.py index 2e9c65171..7220c1cf6 100644 --- a/backend/cosmetology-app/lambdas/python/search/handlers/search.py +++ b/backend/cosmetology-app/lambdas/python/search/handlers/search.py @@ -203,15 +203,20 @@ def _build_opensearch_search_body(body: dict, size_override: int) -> dict: def _query_references_field(obj, field_name: str) -> bool: """ - Recursively check if any key in the query DSL references the given field name. + Recursively check if the query DSL references the given field name. + + Checks whether any key equals the field name (or is a qualified name like "licenses.dateOfBirth"), + or any string value equals the field name. :param obj: The object to check (dict, list, or scalar) - :param field_name: The field name to search for in dict keys - :return: True if the field name is found in any key + :param field_name: The field name to search for + :return: True if the field name is found as a key or string value """ if isinstance(obj, dict): for key, value in obj.items(): - if field_name in key: + if key == field_name or key.endswith('.' + field_name): + return True + if isinstance(value, str) and value == field_name: return True if _query_references_field(value, field_name): return True diff --git a/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py b/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py index c4ab92f6f..d9c474d63 100644 --- a/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py +++ b/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py @@ -592,6 +592,29 @@ def test_search_with_nested_date_of_birth_query_rejected_without_read_private_sc body = json.loads(response['body']) self.assertIn('dateOfBirth', body['message']) + @patch('handlers.search.opensearch_client') + def test_search_with_exists_field_date_of_birth_rejected_without_read_private_scope(self, mock_opensearch_client): + """Test that query with dateOfBirth as field value (e.g. exists) is rejected without readPrivate scope.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + # OpenSearch "exists" query references field by value: {"exists": {"field": "dateOfBirth"}} + event = self._create_api_event( + 'cosm', + body={ + 'query': {'exists': {'field': 'dateOfBirth'}}, + }, + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('dateOfBirth', body['message']) + self.assertIn('readPrivate', body['message']) + mock_opensearch_client.search.assert_not_called() + @patch('handlers.search.opensearch_client') def test_search_with_sort_by_date_of_birth_rejected_without_read_private_scope(self, mock_opensearch_client): """Test that sort clause referencing dateOfBirth is rejected when caller lacks readPrivate scope.""" From bdad7901b984bc6c4b12b4818a3bcf1839ffa11c Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Fri, 20 Mar 2026 13:53:29 -0500 Subject: [PATCH 08/14] Update csg email builder dependency to latest --- .../lambdas/nodejs/package.json | 2 +- .../cosmetology-app/lambdas/nodejs/yarn.lock | 140 +++++++++--------- 2 files changed, 71 insertions(+), 71 deletions(-) diff --git a/backend/cosmetology-app/lambdas/nodejs/package.json b/backend/cosmetology-app/lambdas/nodejs/package.json index bd05c7b09..29267c501 100644 --- a/backend/cosmetology-app/lambdas/nodejs/package.json +++ b/backend/cosmetology-app/lambdas/nodejs/package.json @@ -48,7 +48,7 @@ "@aws-sdk/client-s3": "^3.901.0", "@aws-sdk/client-sesv2": "^3.901.0", "@aws-sdk/util-dynamodb": "^3.901.0", - "@csg-org/email-builder": "^0.0.9-alpha.4", + "@csg-org/email-builder": "^0.0.12", "nodemailer": "^7.0.11", "zod": "^3.23.8" } diff --git a/backend/cosmetology-app/lambdas/nodejs/yarn.lock b/backend/cosmetology-app/lambdas/nodejs/yarn.lock index f707d671d..33d014084 100644 --- a/backend/cosmetology-app/lambdas/nodejs/yarn.lock +++ b/backend/cosmetology-app/lambdas/nodejs/yarn.lock @@ -1013,80 +1013,80 @@ resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.6.0.tgz#ec6cd237440700bc23ca23087f513c75508958b0" integrity sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA== -"@csg-org/block-avatar@^0.0.4-alpha.4": - version "0.0.4-alpha.4" - resolved "https://registry.yarnpkg.com/@csg-org/block-avatar/-/block-avatar-0.0.4-alpha.4.tgz#a0f0721605a93ca8a6958cf2cf540ec0b32e5170" - integrity sha512-HEIecvhXPv64TRws2BwVRq+UZQd835dFkxqMctfzhACcylBHVKQOD/9nnykkNRKojfKdt2rHCAY42VSj6AGy5Q== - -"@csg-org/block-button@^0.0.4-alpha.4": - version "0.0.4-alpha.4" - resolved "https://registry.yarnpkg.com/@csg-org/block-button/-/block-button-0.0.4-alpha.4.tgz#731c3bfd6523842649ba2523231e3d7e4346336a" - integrity sha512-+Pcn9gxJzeRU9SQbobuK1gQ9OajoU6HfJM/lL2n+diKc65eO+Xg+K2SQlFsQ9QqwXHA/Uo08ti25aMgHFpq//A== - -"@csg-org/block-columns-container@^0.0.4-alpha.4": - version "0.0.4-alpha.4" - resolved "https://registry.yarnpkg.com/@csg-org/block-columns-container/-/block-columns-container-0.0.4-alpha.4.tgz#f0992bb875b989d9b260f6a4275eaa233ed0aa51" - integrity sha512-YxEsjuTRViwzzTy/p+v4zB/meJYYYsndAnziIYQRTvrvI7ia7xCu7eIRKRyjcuwD5cZly8tdeylnOZbbtBaVlw== - -"@csg-org/block-container@^0.0.3-alpha.4": - version "0.0.3-alpha.4" - resolved "https://registry.yarnpkg.com/@csg-org/block-container/-/block-container-0.0.3-alpha.4.tgz#e70e2be93fec6077fc88a820f6264faa0749caac" - integrity sha512-S/4e4VqOAYmrTSLXy2p3kNMoj2lhLswuFTzAsUALMmAenC281MkLegsu40KCyuaPJFfhLfUEh059PjVA+T8YfQ== - -"@csg-org/block-divider@^0.0.5-alpha.4": - version "0.0.5-alpha.4" - resolved "https://registry.yarnpkg.com/@csg-org/block-divider/-/block-divider-0.0.5-alpha.4.tgz#03225dcb64f9bc6e61f7d2eede8f2600744e6bf9" - integrity sha512-4yuaKBfWiJnSgWKxNY0i9C+0hyNuRHfPYtxY/YExmTTJDIw7eaa4FUioLIebivkGuKgQ47onjITB27fzFYZIYQ== - -"@csg-org/block-heading@^0.0.4-alpha.4": - version "0.0.4-alpha.4" - resolved "https://registry.yarnpkg.com/@csg-org/block-heading/-/block-heading-0.0.4-alpha.4.tgz#e8aa496232d7ecd66aaf4ee280ff7474aaca3304" - integrity sha512-8FzCFvNaQLVgXNQSYB6re5KSDOiX2AX7R5k4pWKsX96pKCKCPNkJPNprrApLUTZpPiTOOpgzzUScMU1qCbCGQg== - -"@csg-org/block-html@^0.0.4-alpha.4": - version "0.0.4-alpha.4" - resolved "https://registry.yarnpkg.com/@csg-org/block-html/-/block-html-0.0.4-alpha.4.tgz#964d8c269b46b8d824502129f9496f052b550753" - integrity sha512-Pn6z+34vcEKi6y1+dSTiKgGNELzyJnm6Q7UTUN2fnCbYerkweyeDyuVe4LosdJB/6egYXrUS6+BT7Q56oY18HQ== - -"@csg-org/block-image@^0.0.6-alpha.4": - version "0.0.6-alpha.4" - resolved "https://registry.yarnpkg.com/@csg-org/block-image/-/block-image-0.0.6-alpha.4.tgz#b26fa9981d8aa4f1a533f4a1bc3926f12860dd9e" - integrity sha512-qIBIlsG+XrRPxwLUdZgRxMNGOdMzR83uacDIagFus+KxmzHM6CacYCmlXSYsRBTq+CEpAEEDpFTu5IDlOLR4Ew== - -"@csg-org/block-spacer@^0.0.4-alpha.4": - version "0.0.4-alpha.4" - resolved "https://registry.yarnpkg.com/@csg-org/block-spacer/-/block-spacer-0.0.4-alpha.4.tgz#59774709dcad8121575a6ce902a94145fdeb037b" - integrity sha512-eqgS/JjKNBq3FgVAFKhW8AwEp4+PannPTaWBWJgIyDDy0yV2wcQf26Op4Ey5ku8RayZegMFmXsV4WPsDdZ/jVg== - -"@csg-org/block-text@^0.0.7-alpha.4": - version "0.0.7-alpha.4" - resolved "https://registry.yarnpkg.com/@csg-org/block-text/-/block-text-0.0.7-alpha.4.tgz#e9d1b814dc95fb59474c100083ded2cc57775f24" - integrity sha512-0HjWrdK9Dv5UETYSesS867BFGjoMdgOmXm0kntdrjQp2JI8cHib/fHqq7iqI2OlYWsueq9XtLRAuPkEDytSXPg== +"@csg-org/block-avatar@^0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@csg-org/block-avatar/-/block-avatar-0.0.12.tgz#6035c940ea2f382b3985e1749bb3c4f63001ba71" + integrity sha512-2t4betqPbifBb46/JQElY3U098eKI0wS5DNHiy0pfluPHzlnpZeR2QSJ+q2Kt83juQ6dk3SQjI6+NYho5yZFoA== + +"@csg-org/block-button@^0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@csg-org/block-button/-/block-button-0.0.12.tgz#150b7ef22dd6ab55cf934714ef64c28ab5eb85ba" + integrity sha512-kSGAmd+8tPjOU0xXkKAdD+nSGxfZ1EsnyrpXcdqxvw46FbjK6TU9jgE1xE49x05wV1dZZ1ZT73U5iV89S5iboA== + +"@csg-org/block-columns-container@^0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@csg-org/block-columns-container/-/block-columns-container-0.0.12.tgz#5aba67ae2a460245af6b7120f044babeb3fc933c" + integrity sha512-NyaXMeHnvLul/TMGb5dy8hUJJb8Lid40Mn2WFjuW5aBDV47e2ruLS4tEHe+aqyNw3/bNkJnPfN9JpnjkNAacBQ== + +"@csg-org/block-container@^0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@csg-org/block-container/-/block-container-0.0.12.tgz#09d8ec87f4f5f2613dec5b1d92095b08afe2ec62" + integrity sha512-PSerba/XQhdl3fvqJaE65r8jk3Flx1Mlz9sFK7vAVUmE4lA28Es5pEJxA3TvjLsug7y2kT+xDuAkD/L05VaD4w== + +"@csg-org/block-divider@^0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@csg-org/block-divider/-/block-divider-0.0.12.tgz#2e968fdb6f33b0297aadac4891f2a9bd88c59fb1" + integrity sha512-aKrPizbYxyfC9t5ycj+ubBcjX4CzW75YELhr/U+RxIDmJ+JZ+uqZDdkoXB2eGVnbLWajwjZNbnjqrYpTwQCyEA== + +"@csg-org/block-heading@^0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@csg-org/block-heading/-/block-heading-0.0.12.tgz#e226d38290985993306164ed0a0d9a98f2cf33fa" + integrity sha512-+wj+isz6dZhTU0Vc4Wo2A2CHiN6QdotGTKO3B+pNdqoJdQycJmngu68x7a5jtvyAQa18ZGkR8g3Ho+Le7H2WOw== + +"@csg-org/block-html@^0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@csg-org/block-html/-/block-html-0.0.12.tgz#ba2eceec8dbaf8d06d2950a36784001b239b16b6" + integrity sha512-L8pABPRUa6nAjetIq3qiDSiJxl0qLVrIeG/ESy+7GcsI01Dk/CsF9u72biZAbnzGWD0pUYRLrpxK9okRnhJo5g== + +"@csg-org/block-image@^0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@csg-org/block-image/-/block-image-0.0.12.tgz#7ad8915c795f6cf560bb522e38788c6bac76dbbe" + integrity sha512-cTAfAQRrod1hjhzYoTrmM+g1nDG8mrqzRuo8kjZsMa5FCLQy3Q+etreDVqK371eObAlRY9qpNMfYIKdicF3y3A== + +"@csg-org/block-spacer@^0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@csg-org/block-spacer/-/block-spacer-0.0.12.tgz#0881c1b418362f32a9485bc7be5f8bfb5f2a2e61" + integrity sha512-gJpOgFYFwtmlUmdWqxdbqXbaodsr/vCTZleyY/rRSnLqWrXWrXcoYSh7qxVcFqLJfD0b2TOGRAfJHziS/TeYvQ== + +"@csg-org/block-text@^0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@csg-org/block-text/-/block-text-0.0.12.tgz#3704d86c0db28cf29e9ec9099fe50a946ed45ba5" + integrity sha512-vEEp2AIKDmSjFgCTw5jMnF/gKClhsoCkTmEEvMLYtvzbgwCyCmiAVyJJGoTbR+kb/xfg1oUNwI6H3gir1FEvLg== dependencies: marked "^12.0.2" sanitize-html "^2.17.0" -"@csg-org/document-core@^0.0.7-alpha.4": - version "0.0.7-alpha.4" - resolved "https://registry.yarnpkg.com/@csg-org/document-core/-/document-core-0.0.7-alpha.4.tgz#37ff7e13e0995570729c518e12479ecd06caafdb" - integrity sha512-lSefWvyrWiez2rNlGIAtELHWckjzh1KnzPo3m5+nHN7O6Krvx5W2RPlYQwKFjDbqHbZjuwgKLjn1LPhyYDQFHw== - -"@csg-org/email-builder@^0.0.9-alpha.4": - version "0.0.9" - resolved "https://registry.yarnpkg.com/@csg-org/email-builder/-/email-builder-0.0.9.tgz#dfeb32adc49885e82cfc9c4f8ccc49182630196e" - integrity sha512-bRkE124gA8lLMSr4sXnEOSwI1/ZyZo2mYJ7oy6ZdhaS/aZ9LVTT+k97gTHnUQtyJ7nEjWLPzdkO7O9XrdC9TYw== - dependencies: - "@csg-org/block-avatar" "^0.0.4-alpha.4" - "@csg-org/block-button" "^0.0.4-alpha.4" - "@csg-org/block-columns-container" "^0.0.4-alpha.4" - "@csg-org/block-container" "^0.0.3-alpha.4" - "@csg-org/block-divider" "^0.0.5-alpha.4" - "@csg-org/block-heading" "^0.0.4-alpha.4" - "@csg-org/block-html" "^0.0.4-alpha.4" - "@csg-org/block-image" "^0.0.6-alpha.4" - "@csg-org/block-spacer" "^0.0.4-alpha.4" - "@csg-org/block-text" "^0.0.7-alpha.4" - "@csg-org/document-core" "^0.0.7-alpha.4" +"@csg-org/document-core@^0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@csg-org/document-core/-/document-core-0.0.12.tgz#0e1d5f5e2224d7e1ed0146464efc9036945b78f8" + integrity sha512-mapbYZmUd+E37/Qghi80ffRqIEWLP5wpkjLbwDXUDyjSaaxK9lgnYVzqAQjp8isLQeWoSo+0qyJeDcLsd7EwcQ== + +"@csg-org/email-builder@^0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@csg-org/email-builder/-/email-builder-0.0.12.tgz#401a63ecdaa4c101a6e2548b5e9dde8a02209ba9" + integrity sha512-1heH3rpKc1qKJwrMylSWu6HB87j2n4WmWFQzEwKegSNENgdAgP2vRXiHbunqDI6qpMxrG+0SO6uBdT0BfEThTg== + dependencies: + "@csg-org/block-avatar" "^0.0.12" + "@csg-org/block-button" "^0.0.12" + "@csg-org/block-columns-container" "^0.0.12" + "@csg-org/block-container" "^0.0.12" + "@csg-org/block-divider" "^0.0.12" + "@csg-org/block-heading" "^0.0.12" + "@csg-org/block-html" "^0.0.12" + "@csg-org/block-image" "^0.0.12" + "@csg-org/block-spacer" "^0.0.12" + "@csg-org/block-text" "^0.0.12" + "@csg-org/document-core" "^0.0.12" "@cspotcode/source-map-support@^0.8.0": version "0.8.1" From 38cf67b1142b6827b7dfcd68db809653581e8b1b Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 24 Mar 2026 09:46:55 -0500 Subject: [PATCH 09/14] PR feedback - check for dateOfBirth keyword in any str value --- .../lambdas/python/search/handlers/search.py | 7 ++-- .../tests/function/test_search_providers.py | 33 ++++++++++++++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/backend/cosmetology-app/lambdas/python/search/handlers/search.py b/backend/cosmetology-app/lambdas/python/search/handlers/search.py index 7220c1cf6..9fdfa0823 100644 --- a/backend/cosmetology-app/lambdas/python/search/handlers/search.py +++ b/backend/cosmetology-app/lambdas/python/search/handlers/search.py @@ -206,18 +206,19 @@ def _query_references_field(obj, field_name: str) -> bool: Recursively check if the query DSL references the given field name. Checks whether any key equals the field name (or is a qualified name like "licenses.dateOfBirth"), - or any string value equals the field name. + or any string value equals the field name or ends with ".{field_name}" (including standalone list + items like ["dateOfBirth"]). :param obj: The object to check (dict, list, or scalar) :param field_name: The field name to search for :return: True if the field name is found as a key or string value """ + if isinstance(obj, str): + return obj == field_name or obj.endswith('.' + field_name) if isinstance(obj, dict): for key, value in obj.items(): if key == field_name or key.endswith('.' + field_name): return True - if isinstance(value, str) and value == field_name: - return True if _query_references_field(value, field_name): return True elif isinstance(obj, list): diff --git a/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py b/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py index d9c474d63..7bb957f40 100644 --- a/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py +++ b/backend/cosmetology-app/lambdas/python/search/tests/function/test_search_providers.py @@ -308,7 +308,9 @@ def test_search_returns_sanitized_providers(self, mock_opensearch_client): self.assertEqual(1, len(provider['licenses'])) self.assertEqual('oh', provider['licenses'][0]['jurisdiction']) self.assertEqual('cosmetologist', provider['licenses'][0]['licenseType']) - # Verify private fields were stripped + # Verify private fields were stripped (list/general view must not expose full DOB) + self.assertNotIn('dateOfBirth', provider) + self.assertNotIn('dateOfBirth', provider['licenses'][0]) self.assertNotIn('ssnLastFour', provider) self.assertNotIn('someNewField', provider) self.assertNotIn('emailAddress', provider) @@ -615,6 +617,35 @@ def test_search_with_exists_field_date_of_birth_rejected_without_read_private_sc self.assertIn('readPrivate', body['message']) mock_opensearch_client.search.assert_not_called() + @patch('handlers.search.opensearch_client') + def test_search_with_date_of_birth_string_in_list_rejected_without_read_private_scope( + self, mock_opensearch_client + ): + """dateOfBirth as a list element (e.g. multi_match fields) must not bypass readPrivate checks.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + event = self._create_api_event( + 'cosm', + body={ + 'query': { + 'multi_match': { + 'query': '1985', + 'fields': ['givenName', 'dateOfBirth'], + }, + }, + }, + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('dateOfBirth', body['message']) + self.assertIn('readPrivate', body['message']) + mock_opensearch_client.search.assert_not_called() + @patch('handlers.search.opensearch_client') def test_search_with_sort_by_date_of_birth_rejected_without_read_private_scope(self, mock_opensearch_client): """Test that sort clause referencing dateOfBirth is rejected when caller lacks readPrivate scope.""" From 708bade22c67e039ce8e0f48e5d9f80352bc712f Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 24 Mar 2026 09:50:58 -0500 Subject: [PATCH 10/14] Update fast-xml-parser dependency for security patches --- .../lambdas/nodejs/package.json | 2 +- .../cosmetology-app/lambdas/nodejs/yarn.lock | 32 +++++++++++++------ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/backend/cosmetology-app/lambdas/nodejs/package.json b/backend/cosmetology-app/lambdas/nodejs/package.json index 29267c501..22d753312 100644 --- a/backend/cosmetology-app/lambdas/nodejs/package.json +++ b/backend/cosmetology-app/lambdas/nodejs/package.json @@ -4,7 +4,7 @@ "type": "commonjs", "description": "NodeJS lambdas for Compact Connect", "resolutions": { - "fast-xml-parser": "5.3.6" + "fast-xml-parser": "5.5.7" }, "scripts": { "build": "tsc", diff --git a/backend/cosmetology-app/lambdas/nodejs/yarn.lock b/backend/cosmetology-app/lambdas/nodejs/yarn.lock index 33d014084..712c9cb92 100644 --- a/backend/cosmetology-app/lambdas/nodejs/yarn.lock +++ b/backend/cosmetology-app/lambdas/nodejs/yarn.lock @@ -3453,12 +3453,21 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== -fast-xml-parser@5.3.4, fast-xml-parser@5.3.6: - version "5.3.6" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz#85a69117ca156b1b3c52e426495b6de266cb6a4b" - integrity sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA== +fast-xml-builder@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz#0c407a1d9d5996336c0cd76f7ff785cac6413017" + integrity sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg== + dependencies: + path-expression-matcher "^1.1.3" + +fast-xml-parser@5.3.4, fast-xml-parser@5.5.7: + version "5.5.7" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz#e1ddc86662d808450a19cf2fb6ccc9c3c9933c5d" + integrity sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg== dependencies: - strnum "^2.1.2" + fast-xml-builder "^1.1.4" + path-expression-matcher "^1.1.3" + strnum "^2.2.0" fb-watchman@^2.0.0: version "2.0.2" @@ -4721,6 +4730,11 @@ path-exists@^4.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== +path-expression-matcher@^1.1.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz#9bdae3787f43b0857b0269e9caaa586c12c8abee" + integrity sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -5116,10 +5130,10 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -strnum@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.1.2.tgz#a5e00ba66ab25f9cafa3726b567ce7a49170937a" - integrity sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ== +strnum@^2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.2.tgz#f11fd94ab62b536ba2ecc615858f3747c2881b3f" + integrity sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA== supports-color@^7, supports-color@^7.1.0: version "7.2.0" From 6ebed1b4ff1677ea252cbca5699d94a3591feb93 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 26 Mar 2026 13:26:45 -0500 Subject: [PATCH 11/14] PR feedback - typo --- .../python/common/tests/unit/test_provider_record_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py index 56c7af537..e40755cc6 100644 --- a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py +++ b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py @@ -849,7 +849,7 @@ def test_privileges_assigned_only_to_home_license_document(self): 'licenseType': 'cosmetologist', 'dateOfExpiration': date(2026, 4, 4), 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, - # this license was issues more recently, so it should have the privileges associated with it. + # this license was issued more recently, so it should have the privileges associated with it. 'dateOfIssuance': date(2024, 6, 1), }, ] From d0e0feea5ee6c09a2a406b3613e93c30aa078bf1 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 26 Mar 2026 13:49:40 -0500 Subject: [PATCH 12/14] PR feedback - update OpenSearch document indexing details --- backend/cosmetology-app/docs/design/README.md | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/backend/cosmetology-app/docs/design/README.md b/backend/cosmetology-app/docs/design/README.md index d626e1000..bab22929e 100644 --- a/backend/cosmetology-app/docs/design/README.md +++ b/backend/cosmetology-app/docs/design/README.md @@ -382,10 +382,19 @@ The search infrastructure consists of several key components: 4. **Populate Handler**: A Lambda function for bulk indexing all provider data from DynamoDB 5. **Provider Update Ingest Handler**: A Lambda function for updating documents in OpenSearch whenever provider records are updated in DynamoDB. +### Document model (Cosmetology vs. JCC) + +Unlike the JCC CompactConnect model, which indexes **one OpenSearch document per provider** (with that provider’s licenses nested in a single document), Cosmetology indexes **one document per license**. Each document repeats the same top-level provider fields you would see on a provider detail response, while the `licenses` array contains **only the license represented by that document** (effectively one license entry per document). + +Cosmetology needs to support searching and listing **rows of license records** by license number in the search UI. OpenSearch pagination (`from`/`size`, `search_after`, etc.) applies to **documents**, not to entries inside a nested array. Splitting each license into its own document lets the UI paginate natively at license granularity. It also keeps the search API response model consistent across the compacts. + +Most practitioners only have one multi-state license, so this model does not significantly increase the size of storage used by the OpenSearch domain. + ### Index Structure -Provider documents are stored in compact-specific indices with the naming convention: `compact_{compact}_providers_{version}` -(e.g., `compact_aslp_providers_v1`). We use index aliases to provide a stable reference to the current version of each index, allowing read and write operations to be transparently redirected during planned index migrations or upgrades. This enables seamless index schema changes without requiring app code changes, as applications and APIs can continue to reference the alias rather than a specific index name. See [OpenSearch index alias documentation](https://docs.opensearch.org/latest/im-plugin/index-alias/) for more information. +Documents are stored in compact-specific indices with the naming convention: `compact_{compact} +_providers_{version}` +(e.g., `compact_cosm_providers_v1`). We use index aliases to provide a stable reference to the current version of each index, allowing read and write operations to be transparently redirected during planned index migrations or upgrades. This enables seamless index schema changes without requiring app code changes, as applications and APIs can continue to reference the alias rather than a specific index name. See [OpenSearch index alias documentation](https://docs.opensearch.org/latest/im-plugin/index-alias/) for more information. #### Index Management @@ -394,7 +403,7 @@ domain is first created. It ensures the indices/aliases exist with the correct m #### Index Mapping -Each provider document contains all information you would see from the provider detail api endpoint with `readGeneral` permission. See the [application code](../../lambdas/python/search/handlers/manage_opensearch_indices.py) for the current mapping definition. +Each indexed document corresponds to **one license** and uses the same overall shape as the provider detail API with `readGeneral` permission. See the [application code](../../lambdas/python/search/handlers/manage_opensearch_indices.py) for the current mapping definition. Document construction (one sanitized document per license, including composite `documentId`) is implemented in [search/utils.py](../../lambdas/python/search/utils.py). The index uses a custom ASCII-folding analyzer for name fields, which allows searching for names with international characters using their ASCII equivalents (e.g., searching "Jose" matches "José"). @@ -408,7 +417,7 @@ The Search API provides two endpoints for querying the OpenSearch domain: POST /v1/compacts/{compact}/providers/search ``` -Returns provider records matching the query. Response includes the full provider document with licenses and privileges. +Returns one result row per indexed document (one per license). Each hit is a full provider-shaped document for that license row (including the single license in `licenses` and generated privileges as applicable). ### Document Indexing @@ -422,7 +431,7 @@ OpenSearch. This function is invoked manually through the AWS Console for: The function: 1. Scans the provider table using the `providerDateOfUpdate` GSI 2. Retrieves complete provider records for each provider -3. Sanitizes data using `ProviderGeneralResponseSchema` +3. Expands each provider into **one OpenSearch document per license** (sanitized via `ProviderOpenSearchDocumentSchema`) 4. Bulk indexes documents **Resumable Processing**: If the function approaches the 15-minute Lambda timeout, it returns pagination information in the @@ -442,11 +451,11 @@ The function: 3. The DynamoDB stream handler queries the data and indexes the change into OpenSearch after the ~30 second delay of sitting in SQS 4. The `populate_provider_documents` Lambda function finally indexes the stale data into OpenSearch, overwriting the change indexed by the DynamoDB stream handler -For this reason, it is recommended that this process be run during a period of low traffic. Given that it is a one-time process to initially populate the table, the risk is low and if needed, the Lambda function can be run again to synchronize all the provider documents. +For this reason, it is recommended that this process be run during a period of low traffic. Given that it is a one-time process to initially populate the table, the risk is low and if needed, the Lambda function can be run again to synchronize all indexed documents. #### Updates via DynamoDB Streams -To keep the OpenSearch index synchronized with changes in the provider DynamoDB table, the system uses DynamoDB Streams to capture all modifications made to provide records (see [AWS documentation](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html)). This ensures that provider documents in OpenSearch are updated automatically whenever records are created, modified, or deleted in the provider table. +To keep the OpenSearch index synchronized with changes in the provider DynamoDB table, the system uses DynamoDB Streams to capture all modifications made to provider records (see [AWS documentation](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html)). This ensures that the corresponding license documents in OpenSearch are updated automatically whenever records are created, modified, or deleted in the provider table. **Architecture Flow:** From 701e734e242b4f9948de0fef98c3da9737679422 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 26 Mar 2026 14:07:44 -0500 Subject: [PATCH 13/14] Update python dependencies to latest --- .../cognito-backup/requirements-dev.txt | 24 +++--- .../python/cognito-backup/requirements.txt | 6 +- .../python/common/requirements-dev.txt | 73 +++++++++++-------- .../lambdas/python/common/requirements.txt | 14 ++-- .../requirements-dev.txt | 20 ++--- .../custom-resources/requirements-dev.txt | 20 ++--- .../python/data-events/requirements-dev.txt | 20 ++--- .../disaster-recovery/requirements-dev.txt | 20 ++--- .../provider-data-v1/requirements-dev.txt | 20 ++--- .../python/search/requirements-dev.txt | 20 ++--- .../lambdas/python/search/requirements.txt | 10 +-- .../staff-user-pre-token/requirements-dev.txt | 20 ++--- .../python/staff-users/requirements-dev.txt | 22 +++--- backend/cosmetology-app/requirements-dev.txt | 26 +++---- backend/cosmetology-app/requirements.txt | 12 +-- 15 files changed, 168 insertions(+), 159 deletions(-) diff --git a/backend/cosmetology-app/lambdas/python/cognito-backup/requirements-dev.txt b/backend/cosmetology-app/lambdas/python/cognito-backup/requirements-dev.txt index 2eeb7f2b6..a9830bddb 100644 --- a/backend/cosmetology-app/lambdas/python/cognito-backup/requirements-dev.txt +++ b/backend/cosmetology-app/lambdas/python/cognito-backup/requirements-dev.txt @@ -4,25 +4,25 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/cognito-backup/requirements-dev.in # -aws-lambda-powertools==3.24.0 +aws-lambda-powertools==3.26.0 # via -r lambdas/python/cognito-backup/requirements-dev.in -boto3==1.42.34 +boto3==1.42.76 # via # -r lambdas/python/cognito-backup/requirements-dev.in # moto -botocore==1.42.34 +botocore==1.42.76 # via # -r lambdas/python/cognito-backup/requirements-dev.in # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests -cryptography==46.0.3 +cryptography==46.0.6 # via # joserfc # moto @@ -37,13 +37,13 @@ jmespath==1.1.0 # aws-lambda-powertools # boto3 # botocore -joserfc==1.6.1 +joserfc==1.6.3 # via moto markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[cognitoidp,s3]==5.1.20 +moto[cognitoidp,s3]==5.1.22 # via -r lambdas/python/cognito-backup/requirements-dev.in packaging==26.0 # via pytest @@ -65,11 +65,11 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.0 # via # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -82,7 +82,7 @@ urllib3==2.6.3 # botocore # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.7 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/cosmetology-app/lambdas/python/cognito-backup/requirements.txt b/backend/cosmetology-app/lambdas/python/cognito-backup/requirements.txt index e9c49dbf9..d09196eab 100644 --- a/backend/cosmetology-app/lambdas/python/cognito-backup/requirements.txt +++ b/backend/cosmetology-app/lambdas/python/cognito-backup/requirements.txt @@ -4,11 +4,11 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/cognito-backup/requirements.in # -aws-lambda-powertools==3.24.0 +aws-lambda-powertools==3.26.0 # via -r lambdas/python/cognito-backup/requirements.in -boto3==1.42.34 +boto3==1.42.76 # via -r lambdas/python/cognito-backup/requirements.in -botocore==1.42.34 +botocore==1.42.76 # via # -r lambdas/python/cognito-backup/requirements.in # boto3 diff --git a/backend/cosmetology-app/lambdas/python/common/requirements-dev.txt b/backend/cosmetology-app/lambdas/python/common/requirements-dev.txt index 938c03016..78890be15 100644 --- a/backend/cosmetology-app/lambdas/python/common/requirements-dev.txt +++ b/backend/cosmetology-app/lambdas/python/common/requirements-dev.txt @@ -8,7 +8,7 @@ annotated-types==0.7.0 # via pydantic antlr4-python3-runtime==4.13.2 # via moto -attrs==25.4.0 +attrs==26.1.0 # via # jsonschema # referencing @@ -18,31 +18,31 @@ aws-sam-translator==1.103.0 # moto aws-xray-sdk==2.15.0 # via moto -boto3==1.42.34 +boto3==1.42.76 # via # aws-sam-translator # moto -boto3-stubs[full]==1.42.34 +boto3-stubs[full]==1.42.76 # via -r lambdas/python/common/requirements-dev.in -boto3-stubs-full==1.42.34 +boto3-stubs-full==1.42.76 # via boto3-stubs -botocore==1.42.34 +botocore==1.42.76 # via # aws-xray-sdk # boto3 # moto # s3transfer -botocore-stubs==1.42.34 +botocore-stubs==1.42.41 # via boto3-stubs -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography cfn-lint==1.41.0 # via moto -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests -cryptography==46.0.3 +cryptography==46.0.6 # via # -r lambdas/python/common/requirements-dev.in # joserfc @@ -51,7 +51,7 @@ docker==7.1.0 # via moto faker==37.12.0 # via -r lambdas/python/common/requirements-dev.in -graphql-core==3.2.7 +graphql-core==3.2.8 # via moto idna==3.11 # via requests @@ -61,21 +61,21 @@ jmespath==1.1.0 # via # boto3 # botocore -joserfc==1.6.1 +joserfc==1.6.3 # via moto jsonpatch==1.33 # via cfn-lint -jsonpath-ng==1.7.0 +jsonpath-ng==1.8.0 # via moto -jsonpointer==3.0.0 +jsonpointer==3.1.1 # via jsonpatch -jsonschema==4.26.0 +jsonschema==4.24.1 # via # aws-sam-translator # moto # openapi-schema-validator # openapi-spec-validator -jsonschema-path==0.3.4 +jsonschema-path==0.4.5 # via openapi-spec-validator jsonschema-specifications==2025.9.1 # via @@ -87,22 +87,20 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[all]==5.1.20 +moto[all]==5.1.22 # via -r lambdas/python/common/requirements-dev.in mpmath==1.3.0 # via sympy -multipart==1.3.0 +multipart==1.3.1 # via moto networkx==3.6.1 # via cfn-lint -openapi-schema-validator==0.6.3 +openapi-schema-validator==0.8.1 # via openapi-spec-validator -openapi-spec-validator==0.7.2 +openapi-spec-validator==0.8.4 # via moto -pathable==0.4.4 +pathable==0.5.0 # via jsonschema-path -ply==3.11 - # via jsonpath-ng py-partiql-parser==0.6.3 # via moto pycparser==3.0 @@ -111,34 +109,43 @@ pydantic==2.12.4 # via # aws-sam-translator # moto + # openapi-schema-validator + # openapi-spec-validator + # pydantic-settings pydantic-core==2.41.5 # via pydantic +pydantic-settings==2.13.1 + # via + # openapi-schema-validator + # openapi-spec-validator pyparsing==3.3.2 # via moto python-dateutil==2.9.0.post0 # via # botocore # moto +python-dotenv==1.2.2 + # via pydantic-settings pyyaml==6.0.3 # via # cfn-lint # jsonschema-path # moto # responses -referencing==0.36.2 +referencing==0.37.0 # via # jsonschema # jsonschema-path # jsonschema-specifications -regex==2026.1.15 + # openapi-schema-validator +regex==2026.2.28 # via cfn-lint -requests==2.32.5 +requests==2.33.0 # via # docker - # jsonschema-path # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto rfc3339-validator==0.1.4 # via openapi-schema-validator @@ -154,7 +161,7 @@ six==1.17.0 # rfc3339-validator sympy==1.14.0 # via cfn-lint -types-awscrt==0.31.1 +types-awscrt==0.31.3 # via botocore-stubs types-s3transfer==0.16.0 # via boto3-stubs @@ -166,7 +173,9 @@ typing-extensions==4.15.0 # pydantic-core # typing-inspection typing-inspection==0.4.2 - # via pydantic + # via + # pydantic + # pydantic-settings tzdata==2025.3 # via faker urllib3==2.6.3 @@ -175,11 +184,11 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.7 # via moto -wrapt==2.0.1 +wrapt==2.1.2 # via aws-xray-sdk -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto # The following packages are considered to be unsafe in a requirements file: diff --git a/backend/cosmetology-app/lambdas/python/common/requirements.txt b/backend/cosmetology-app/lambdas/python/common/requirements.txt index 00633f799..80d4acacd 100644 --- a/backend/cosmetology-app/lambdas/python/common/requirements.txt +++ b/backend/cosmetology-app/lambdas/python/common/requirements.txt @@ -8,23 +8,23 @@ argon2-cffi==25.1.0 # via -r lambdas/python/common/requirements.in argon2-cffi-bindings==25.1.0 # via argon2-cffi -aws-lambda-powertools==3.24.0 +aws-lambda-powertools==3.26.0 # via -r lambdas/python/common/requirements.in -boto3==1.42.34 +boto3==1.42.76 # via -r lambdas/python/common/requirements.in -botocore==1.42.34 +botocore==1.42.76 # via # boto3 # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via # argon2-cffi-bindings # cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests -cryptography==46.0.3 +cryptography==46.0.6 # via -r lambdas/python/common/requirements.in idna==3.11 # via requests @@ -41,7 +41,7 @@ pycparser==3.0 # via cffi python-dateutil==2.9.0.post0 # via botocore -requests==2.32.5 +requests==2.33.0 # via -r lambdas/python/common/requirements.in s3transfer==0.16.0 # via boto3 diff --git a/backend/cosmetology-app/lambdas/python/compact-configuration/requirements-dev.txt b/backend/cosmetology-app/lambdas/python/compact-configuration/requirements-dev.txt index 5ae718d36..18ec47fa2 100644 --- a/backend/cosmetology-app/lambdas/python/compact-configuration/requirements-dev.txt +++ b/backend/cosmetology-app/lambdas/python/compact-configuration/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/compact-configuration/requirements-dev.in # -boto3==1.42.34 +boto3==1.42.76 # via moto -botocore==1.42.34 +botocore==1.42.76 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests -cryptography==46.0.3 +cryptography==46.0.6 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.1.20 +moto[dynamodb,s3]==5.1.22 # via -r lambdas/python/compact-configuration/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -47,12 +47,12 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.0 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -64,7 +64,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.7 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/cosmetology-app/lambdas/python/custom-resources/requirements-dev.txt b/backend/cosmetology-app/lambdas/python/custom-resources/requirements-dev.txt index 0da85c5f1..9135054c4 100644 --- a/backend/cosmetology-app/lambdas/python/custom-resources/requirements-dev.txt +++ b/backend/cosmetology-app/lambdas/python/custom-resources/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/custom-resources/requirements-dev.in # -boto3==1.42.34 +boto3==1.42.76 # via moto -botocore==1.42.34 +botocore==1.42.76 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests -cryptography==46.0.3 +cryptography==46.0.6 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.1.20 +moto[dynamodb,s3]==5.1.22 # via -r lambdas/python/custom-resources/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -47,12 +47,12 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.0 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -64,7 +64,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.7 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/cosmetology-app/lambdas/python/data-events/requirements-dev.txt b/backend/cosmetology-app/lambdas/python/data-events/requirements-dev.txt index f7465902c..6a6828dad 100644 --- a/backend/cosmetology-app/lambdas/python/data-events/requirements-dev.txt +++ b/backend/cosmetology-app/lambdas/python/data-events/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/data-events/requirements-dev.in # -boto3==1.42.34 +boto3==1.42.76 # via moto -botocore==1.42.34 +botocore==1.42.76 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests -cryptography==46.0.3 +cryptography==46.0.6 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.1.20 +moto[dynamodb,s3]==5.1.22 # via -r lambdas/python/data-events/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -47,12 +47,12 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.0 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -64,7 +64,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.7 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/cosmetology-app/lambdas/python/disaster-recovery/requirements-dev.txt b/backend/cosmetology-app/lambdas/python/disaster-recovery/requirements-dev.txt index d3f57b027..3acabd513 100644 --- a/backend/cosmetology-app/lambdas/python/disaster-recovery/requirements-dev.txt +++ b/backend/cosmetology-app/lambdas/python/disaster-recovery/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/disaster-recovery/requirements-dev.in # -boto3==1.42.34 +boto3==1.42.76 # via moto -botocore==1.42.34 +botocore==1.42.76 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests -cryptography==46.0.3 +cryptography==46.0.6 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.1.20 +moto[dynamodb,s3]==5.1.22 # via -r lambdas/python/disaster-recovery/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -47,12 +47,12 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.0 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -64,7 +64,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.7 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/cosmetology-app/lambdas/python/provider-data-v1/requirements-dev.txt b/backend/cosmetology-app/lambdas/python/provider-data-v1/requirements-dev.txt index d1819cd74..f6bc9a6f6 100644 --- a/backend/cosmetology-app/lambdas/python/provider-data-v1/requirements-dev.txt +++ b/backend/cosmetology-app/lambdas/python/provider-data-v1/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/provider-data-v1/requirements-dev.in # -boto3==1.42.34 +boto3==1.42.76 # via moto -botocore==1.42.34 +botocore==1.42.76 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests -cryptography==46.0.3 +cryptography==46.0.6 # via moto docker==7.1.0 # via moto @@ -35,7 +35,7 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.1.20 +moto[dynamodb,s3]==5.1.22 # via -r lambdas/python/provider-data-v1/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -49,12 +49,12 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.0 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -68,7 +68,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.7 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/cosmetology-app/lambdas/python/search/requirements-dev.txt b/backend/cosmetology-app/lambdas/python/search/requirements-dev.txt index f478cfc14..73d1914a4 100644 --- a/backend/cosmetology-app/lambdas/python/search/requirements-dev.txt +++ b/backend/cosmetology-app/lambdas/python/search/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/search/requirements-dev.in # -boto3==1.42.34 +boto3==1.42.76 # via moto -botocore==1.42.34 +botocore==1.42.76 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests -cryptography==46.0.3 +cryptography==46.0.6 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[dynamodb]==5.1.20 +moto[dynamodb]==5.1.22 # via -r lambdas/python/search/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -45,12 +45,12 @@ python-dateutil==2.9.0.post0 # moto pyyaml==6.0.3 # via responses -requests==2.32.5 +requests==2.33.0 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -62,7 +62,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.7 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/cosmetology-app/lambdas/python/search/requirements.txt b/backend/cosmetology-app/lambdas/python/search/requirements.txt index 13e8f3a95..444c4079f 100644 --- a/backend/cosmetology-app/lambdas/python/search/requirements.txt +++ b/backend/cosmetology-app/lambdas/python/search/requirements.txt @@ -4,15 +4,15 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/search/requirements.in # -certifi==2026.1.4 +certifi==2026.2.25 # via # opensearch-py # requests -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests events==0.5 # via opensearch-py -grpcio==1.76.0 +grpcio==1.78.0 # via opensearch-protobufs idna==3.11 # via requests @@ -20,11 +20,11 @@ opensearch-protobufs==0.19.0 # via opensearch-py opensearch-py==3.1.0 # via -r lambdas/python/search/requirements.in -protobuf==6.33.4 +protobuf==7.34.1 # via opensearch-protobufs python-dateutil==2.9.0.post0 # via opensearch-py -requests==2.32.5 +requests==2.33.0 # via opensearch-py six==1.17.0 # via python-dateutil diff --git a/backend/cosmetology-app/lambdas/python/staff-user-pre-token/requirements-dev.txt b/backend/cosmetology-app/lambdas/python/staff-user-pre-token/requirements-dev.txt index 3caa872c7..692c16221 100644 --- a/backend/cosmetology-app/lambdas/python/staff-user-pre-token/requirements-dev.txt +++ b/backend/cosmetology-app/lambdas/python/staff-user-pre-token/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/staff-user-pre-token/requirements-dev.in # -boto3==1.42.34 +boto3==1.42.76 # via moto -botocore==1.42.34 +botocore==1.42.76 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests -cryptography==46.0.3 +cryptography==46.0.6 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.1.20 +moto[dynamodb,s3]==5.1.22 # via -r lambdas/python/staff-user-pre-token/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -47,12 +47,12 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.0 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -64,7 +64,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.7 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/cosmetology-app/lambdas/python/staff-users/requirements-dev.txt b/backend/cosmetology-app/lambdas/python/staff-users/requirements-dev.txt index 5c8240b13..559e40ce2 100644 --- a/backend/cosmetology-app/lambdas/python/staff-users/requirements-dev.txt +++ b/backend/cosmetology-app/lambdas/python/staff-users/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/staff-users/requirements-dev.in # -boto3==1.42.34 +boto3==1.42.76 # via moto -botocore==1.42.34 +botocore==1.42.76 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests -cryptography==46.0.3 +cryptography==46.0.6 # via # joserfc # moto @@ -33,13 +33,13 @@ jmespath==1.1.0 # via # boto3 # botocore -joserfc==1.6.1 +joserfc==1.6.3 # via moto markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[cognitoidp,dynamodb,s3]==5.1.20 +moto[cognitoidp,dynamodb,s3]==5.1.22 # via -r lambdas/python/staff-users/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -53,12 +53,12 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.0 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -72,7 +72,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.7 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/cosmetology-app/requirements-dev.txt b/backend/cosmetology-app/requirements-dev.txt index 78f375389..63d3fee25 100644 --- a/backend/cosmetology-app/requirements-dev.txt +++ b/backend/cosmetology-app/requirements-dev.txt @@ -6,29 +6,29 @@ # boolean-py==5.0 # via license-expression -build==1.4.0 +build==1.4.2 # via pip-tools cachecontrol[filecache]==0.14.4 # via # cachecontrol # pip-audit -certifi==2026.1.4 +certifi==2026.2.25 # via requests -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests click==8.3.1 # via pip-tools -coverage[toml]==7.13.2 +coverage[toml]==7.13.5 # via # -r requirements-dev.in # pytest-cov -cyclonedx-python-lib==11.6.0 +cyclonedx-python-lib==11.7.0 # via pip-audit defusedxml==0.7.1 # via py-serializable faker==37.12.0 # via -r requirements-dev.in -filelock==3.20.3 +filelock==3.25.2 # via cachecontrol idna==3.11 # via requests @@ -57,9 +57,9 @@ pip-audit==2.10.0 # via -r requirements-dev.in pip-requirements-parser==32.0.1 # via pip-audit -pip-tools==7.5.2 +pip-tools==7.5.3 # via -r requirements-dev.in -platformdirs==4.5.1 +platformdirs==4.9.4 # via pip-audit pluggy==1.6.0 # via @@ -81,19 +81,19 @@ pytest==9.0.2 # via # -r requirements-dev.in # pytest-cov -pytest-cov==7.0.0 +pytest-cov==7.1.0 # via -r requirements-dev.in -requests==2.32.5 +requests==2.33.0 # via # cachecontrol # pip-audit -rich==14.3.1 +rich==14.3.3 # via pip-audit -ruff==0.14.14 +ruff==0.15.8 # via -r requirements-dev.in sortedcontainers==2.4.0 # via cyclonedx-python-lib -tomli==2.4.0 +tomli==2.4.1 # via pip-audit tomli-w==1.2.0 # via pip-audit diff --git a/backend/cosmetology-app/requirements.txt b/backend/cosmetology-app/requirements.txt index a775c2cfc..2ee69e199 100644 --- a/backend/cosmetology-app/requirements.txt +++ b/backend/cosmetology-app/requirements.txt @@ -10,13 +10,13 @@ attrs==25.4.0 # jsii aws-cdk-asset-awscli-v1==2.2.263 # via aws-cdk-lib -aws-cdk-asset-node-proxy-agent-v6==2.1.0 +aws-cdk-asset-node-proxy-agent-v6==2.1.1 # via aws-cdk-lib -aws-cdk-aws-lambda-python-alpha==2.236.0a0 +aws-cdk-aws-lambda-python-alpha==2.244.0a0 # via -r requirements.in -aws-cdk-cloud-assembly-schema==48.20.0 +aws-cdk-cloud-assembly-schema==52.2.0 # via aws-cdk-lib -aws-cdk-lib==2.236.0 +aws-cdk-lib==2.244.0 # via # -r requirements.in # aws-cdk-aws-lambda-python-alpha @@ -25,7 +25,7 @@ cattrs==25.3.0 # via jsii cdk-nag==2.37.55 # via -r requirements.in -constructs==10.4.5 +constructs==10.6.0 # via # -r requirements.in # aws-cdk-aws-lambda-python-alpha @@ -33,7 +33,7 @@ constructs==10.4.5 # cdk-nag importlib-resources==6.5.2 # via jsii -jsii==1.126.0 +jsii==1.127.0 # via # aws-cdk-asset-awscli-v1 # aws-cdk-asset-node-proxy-agent-v6 From 17e0f621f5494da31ba2241eae242634dc755e29 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 26 Mar 2026 14:23:56 -0500 Subject: [PATCH 14/14] resolve dep conflict --- .../cosmetology-app/lambdas/python/common/requirements-dev.in | 2 ++ .../cosmetology-app/lambdas/python/common/requirements-dev.txt | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/cosmetology-app/lambdas/python/common/requirements-dev.in b/backend/cosmetology-app/lambdas/python/common/requirements-dev.in index 676fc10bc..a00545ae5 100644 --- a/backend/cosmetology-app/lambdas/python/common/requirements-dev.in +++ b/backend/cosmetology-app/lambdas/python/common/requirements-dev.in @@ -1,3 +1,5 @@ +# Keep attrs on 25.x to match root requirements.txt (jsii/cattrs); avoids pip-sync conflicts. +attrs>=25.4,<26 moto[all]>=5.0.12, <6 boto3-stubs[full] Faker>=37, <38 diff --git a/backend/cosmetology-app/lambdas/python/common/requirements-dev.txt b/backend/cosmetology-app/lambdas/python/common/requirements-dev.txt index 78890be15..f5cc42655 100644 --- a/backend/cosmetology-app/lambdas/python/common/requirements-dev.txt +++ b/backend/cosmetology-app/lambdas/python/common/requirements-dev.txt @@ -8,8 +8,9 @@ annotated-types==0.7.0 # via pydantic antlr4-python3-runtime==4.13.2 # via moto -attrs==26.1.0 +attrs==25.4.0 # via + # -r lambdas/python/common/requirements-dev.in # jsonschema # referencing aws-sam-translator==1.103.0