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 73daa1eeb..5030729a0 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 @@ -482,18 +482,14 @@ def generate_privileges_for_provider(self, include_inactive_privileges: bool = F inv_records = self.get_investigation_records_for_privilege( jurisdiction, license_type_abbr, include_closed=False ) - if ( - not is_eligible - and not include_inactive_privileges - and not privilege_aa - and not inv_records - ): - logger.debug('Not returning a privilege for this jurisdiction because the home ' - 'license is not compact eligible and there are no matching privilege adverse ' - 'actions or open investigations.', - jurisdiction=jurisdiction, - home_jurisdiction=home_jurisdiction, - license_type_abbr=license_type_abbr, + if not is_eligible and not include_inactive_privileges and not privilege_aa and not inv_records: + logger.debug( + 'Not returning a privilege for this jurisdiction because the home ' + 'license is not compact eligible and there are no matching privilege adverse ' + 'actions or open investigations.', + jurisdiction=jurisdiction, + home_jurisdiction=home_jurisdiction, + license_type_abbr=license_type_abbr, ) continue privilege_dict = { diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/adverse_action/api.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/adverse_action/api.py index e4948db2b..dd61d8e8a 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/adverse_action/api.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/adverse_action/api.py @@ -24,8 +24,11 @@ class AdverseActionPostRequestSchema(ForgivingSchema): encumbranceEffectiveDate = Date(required=True, allow_none=False) encumbranceType = EncumbranceTypeField(required=True, allow_none=False) + # in the case of Cosmetology, we only allow one category, but we are keeping this as a list for compatibility + # with the existing code base, and to allow the potential of supporting multiple categories should this be needed + # in the future clinicalPrivilegeActionCategories = List( - ClinicalPrivilegeActionCategoryField(), required=True, allow_none=False, validate=Length(min=1) + ClinicalPrivilegeActionCategoryField(), required=True, allow_none=False, validate=Length(equal=1) ) diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/common.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/common.py index 84441666c..957b2ae88 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/common.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/common.py @@ -378,37 +378,17 @@ class EncumbranceType(CCEnum): Enum for the allowed types of encumbrances """ - FINE = 'fine' - REPRIMAND = 'reprimand' - REQUIRED_SUPERVISION = 'required supervision' - COMPLETION_OF_CONTINUING_EDUCATION = 'completion of continuing education' - PUBLIC_REPRIMAND = 'public reprimand' - PROBATION = 'probation' - INJUNCTIVE_ACTION = 'injunctive action' SUSPENSION = 'suspension' REVOCATION = 'revocation' - DENIAL = 'denial' SURRENDER_OF_LICENSE = 'surrender of license' - MODIFICATION_OF_PREVIOUS_ACTION_EXTENSION = 'modification of previous action-extension' - MODIFICATION_OF_PREVIOUS_ACTION_REDUCTION = 'modification of previous action-reduction' - OTHER_MONITORING = 'other monitoring' - OTHER_ADJUDICATED_ACTION_NOT_LISTED = 'other adjudicated action not listed' class ClinicalPrivilegeActionCategory(CCEnum): - """ - Enum for the category of clinical privileges actions, as defined by NPDB: - https://www.npdb.hrsa.gov/software/CodeLists.pdf, Tables 41-45 - """ + """Enum for adverse action clinical privilege action categories.""" - NON_COMPLIANCE = 'Non-compliance With Requirements' - CRIMINAL_CONVICTION = 'Criminal Conviction or Adjudication' - CONFIDENTIALITY_VIOLATION = 'Confidentiality, Consent or Disclosure Violations' - MISCONDUCT_ABUSE = 'Misconduct or Abuse' - FRAUD = 'Fraud, Deception, or Misrepresentation' - UNSAFE_PRACTICE = 'Unsafe Practice or Substandard Care' - IMPROPER_SUPERVISION = 'Improper Supervision or Allowing Unlicensed Practice' - OTHER = 'Other' + FRAUD = 'fraud' + CONSUMER_HARM = 'consumer harm' + OTHER = 'other' class ChangeHashMixin: diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/email_service_client.py b/backend/cosmetology-app/lambdas/python/common/cc_common/email_service_client.py index c53d3e29e..755a130b9 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/email_service_client.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/email_service_client.py @@ -91,36 +91,6 @@ def _invoke_lambda(self, payload: dict[str, Any]) -> dict[str, Any]: self._logger.error(error_message, payload=payload, exception=str(e)) raise CCInternalException(error_message) from e - def send_license_encumbrance_provider_notification_email( - self, - *, - compact: str, - provider_email: str, - template_variables: EncumbranceNotificationTemplateVariables, - ) -> dict[str, str]: - """ - Send a license encumbrance notification email to a provider. - - :param compact: Compact name - :param provider_email: Email address of the provider - :param template_variables: Template variables for the email - :return: Response from the email notification service - """ - payload = { - 'compact': compact, - 'template': 'licenseEncumbranceProviderNotification', - 'recipientType': 'SPECIFIC', - 'specificEmails': [provider_email], - 'templateVariables': { - 'providerFirstName': template_variables.provider_first_name, - 'providerLastName': template_variables.provider_last_name, - 'encumberedJurisdiction': template_variables.encumbered_jurisdiction, - 'licenseType': template_variables.license_type, - 'effectiveStartDate': template_variables.effective_date.strftime('%B %d, %Y'), - }, - } - return self._invoke_lambda(payload) - def send_license_encumbrance_state_notification_email( self, *, @@ -155,36 +125,6 @@ def send_license_encumbrance_state_notification_email( } return self._invoke_lambda(payload) - def send_license_encumbrance_lifting_provider_notification_email( - self, - *, - compact: str, - provider_email: str, - template_variables: EncumbranceNotificationTemplateVariables, - ) -> dict[str, str]: - """ - Send a license encumbrance lifting notification email to a provider. - - :param compact: Compact name - :param provider_email: Email address of the provider - :param template_variables: Template variables for the email - :return: Response from the email notification service - """ - payload = { - 'compact': compact, - 'template': 'licenseEncumbranceLiftingProviderNotification', - 'recipientType': 'SPECIFIC', - 'specificEmails': [provider_email], - 'templateVariables': { - 'providerFirstName': template_variables.provider_first_name, - 'providerLastName': template_variables.provider_last_name, - 'liftedJurisdiction': template_variables.encumbered_jurisdiction, - 'licenseType': template_variables.license_type, - 'effectiveLiftDate': template_variables.effective_date.strftime('%B %d, %Y'), - }, - } - return self._invoke_lambda(payload) - def send_license_encumbrance_lifting_state_notification_email( self, *, @@ -219,36 +159,6 @@ def send_license_encumbrance_lifting_state_notification_email( } return self._invoke_lambda(payload) - def send_privilege_encumbrance_provider_notification_email( - self, - *, - compact: str, - provider_email: str, - template_variables: EncumbranceNotificationTemplateVariables, - ) -> dict[str, str]: - """ - Send a privilege encumbrance notification email to a provider. - - :param compact: Compact name - :param provider_email: Email address of the provider - :param template_variables: Template variables for the email - :return: Response from the email notification service - """ - payload = { - 'compact': compact, - 'template': 'privilegeEncumbranceProviderNotification', - 'recipientType': 'SPECIFIC', - 'specificEmails': [provider_email], - 'templateVariables': { - 'providerFirstName': template_variables.provider_first_name, - 'providerLastName': template_variables.provider_last_name, - 'encumberedJurisdiction': template_variables.encumbered_jurisdiction, - 'licenseType': template_variables.license_type, - 'effectiveStartDate': template_variables.effective_date.strftime('%B %d, %Y'), - }, - } - return self._invoke_lambda(payload) - def send_privilege_encumbrance_state_notification_email( self, *, @@ -283,36 +193,6 @@ def send_privilege_encumbrance_state_notification_email( } return self._invoke_lambda(payload) - def send_privilege_encumbrance_lifting_provider_notification_email( - self, - *, - compact: str, - provider_email: str, - template_variables: EncumbranceNotificationTemplateVariables, - ) -> dict[str, str]: - """ - Send a privilege encumbrance lifting notification email to a provider. - - :param compact: Compact name - :param provider_email: Email address of the provider - :param template_variables: Template variables for the email - :return: Response from the email notification service - """ - payload = { - 'compact': compact, - 'template': 'privilegeEncumbranceLiftingProviderNotification', - 'recipientType': 'SPECIFIC', - 'specificEmails': [provider_email], - 'templateVariables': { - 'providerFirstName': template_variables.provider_first_name, - 'providerLastName': template_variables.provider_last_name, - 'liftedJurisdiction': template_variables.encumbered_jurisdiction, - 'licenseType': template_variables.license_type, - 'effectiveLiftDate': template_variables.effective_date.strftime('%B %d, %Y'), - }, - } - return self._invoke_lambda(payload) - def send_privilege_encumbrance_lifting_state_notification_email( self, *, diff --git a/backend/cosmetology-app/lambdas/python/common/common_test/test_constants.py b/backend/cosmetology-app/lambdas/python/common/common_test/test_constants.py index 36d936fe9..2b5a50bf0 100644 --- a/backend/cosmetology-app/lambdas/python/common/common_test/test_constants.py +++ b/backend/cosmetology-app/lambdas/python/common/common_test/test_constants.py @@ -92,7 +92,7 @@ DEFAULT_ACTION_AGAINST_PRIVILEGE = 'privilege' DEFAULT_BLOCKS_FUTURE_PRIVILEGES = True DEFAULT_ENCUMBRANCE_TYPE = 'suspension' -DEFAULT_CLINICAL_PRIVILEGE_ACTION_CATEGORY = 'Unsafe Practice or Substandard Care' +DEFAULT_CLINICAL_PRIVILEGE_ACTION_CATEGORY = 'fraud' DEFAULT_CREATION_EFFECTIVE_DATE = '2024-02-15' DEFAULT_CREATION_DATE = '2024-02-15T10:30:00+00:00' DEFAULT_AA_SUBMITTING_USER_ID = '12a6377e-c3a5-40e5-bca5-317ec854c556' diff --git a/backend/cosmetology-app/lambdas/python/common/tests/resources/api/adverse-action-post.json b/backend/cosmetology-app/lambdas/python/common/tests/resources/api/adverse-action-post.json index bbd70db8a..d7ecff1b8 100644 --- a/backend/cosmetology-app/lambdas/python/common/tests/resources/api/adverse-action-post.json +++ b/backend/cosmetology-app/lambdas/python/common/tests/resources/api/adverse-action-post.json @@ -1,5 +1,5 @@ { "encumbranceEffectiveDate": "2023-01-15", "encumbranceType": "suspension", - "clinicalPrivilegeActionCategories": ["Unsafe Practice or Substandard Care"] + "clinicalPrivilegeActionCategories": ["fraud"] } diff --git a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_adverse_action.py b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_adverse_action.py index 12a22302f..d23ec014b 100644 --- a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_adverse_action.py +++ b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_adverse_action.py @@ -111,7 +111,7 @@ def test_adverse_action_data_class_outputs_expected_database_object(self): 'actionAgainst': 'privilege', 'adverseActionId': '98765432-9876-9876-9876-987654321098', 'encumbranceType': 'suspension', - 'clinicalPrivilegeActionCategories': ['Unsafe Practice or Substandard Care'], + 'clinicalPrivilegeActionCategories': ['fraud'], 'compact': 'cosm', 'creationDate': '2024-11-08T23:59:59+00:00', 'effectiveStartDate': '2024-02-15', diff --git a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_investigation.py b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_investigation.py index f2289ef7e..c3e33204f 100644 --- a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_investigation.py +++ b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_investigation.py @@ -143,7 +143,7 @@ def test_validate_patch_with_encumbrance(self): 'encumbrance': { 'encumbranceEffectiveDate': '2024-03-15', 'encumbranceType': 'suspension', - 'clinicalPrivilegeActionCategories': ['Unsafe Practice or Substandard Care'], + 'clinicalPrivilegeActionCategories': ['consumer harm'], } } result = InvestigationPatchRequestSchema().load(investigation_data) diff --git a/backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/encumbrance.py b/backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/encumbrance.py index a946550f1..6f0d53abd 100644 --- a/backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/encumbrance.py +++ b/backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/encumbrance.py @@ -36,6 +36,13 @@ ) +def _ensure_jurisdiction_live(compact: str, jurisdiction: str) -> None: + live_jurisdictions = config.live_compact_jurisdictions.get(compact, []) + normalized = jurisdiction.lower() + if normalized not in {j.lower() for j in live_jurisdictions}: + raise CCInvalidRequestException('Jurisdiction is not live in this compact') + + @api_handler @authorize_state_level_only_action(action=CCPermissionsAction.ADMIN) def encumbrance_handler(event: dict, context: LambdaContext) -> dict: @@ -146,6 +153,7 @@ def handle_privilege_encumbrance(event: dict) -> dict: # Parse event parameters compact = event['pathParameters']['compact'] jurisdiction = event['pathParameters']['jurisdiction'] + _ensure_jurisdiction_live(compact, jurisdiction) provider_id = to_uuid(event['pathParameters']['providerId'], 'Invalid providerId provided') license_type_abbr = event['pathParameters']['licenseType'].lower() submitting_user = _get_submitting_user_id(event) @@ -202,6 +210,7 @@ def handle_license_encumbrance(event: dict) -> dict: # Parse event parameters compact = event['pathParameters']['compact'] jurisdiction = event['pathParameters']['jurisdiction'] + _ensure_jurisdiction_live(compact, jurisdiction) provider_id = to_uuid(event['pathParameters']['providerId'], 'Invalid providerId provided') license_type_abbr = event['pathParameters']['licenseType'].lower() submitting_user = _get_submitting_user_id(event) @@ -230,6 +239,7 @@ def handle_privilege_encumbrance_lifting(event: dict) -> dict: compact = event['pathParameters']['compact'] provider_id = to_uuid(event['pathParameters']['providerId'], 'Invalid providerId provided') jurisdiction = event['pathParameters']['jurisdiction'] + _ensure_jurisdiction_live(compact, jurisdiction) license_type_abbreviation = event['pathParameters']['licenseType'].lower() encumbrance_id = to_uuid(event['pathParameters']['encumbranceId'], 'Invalid encumbranceId provided') @@ -282,6 +292,7 @@ def handle_license_encumbrance_lifting(event: dict) -> dict: compact = event['pathParameters']['compact'] provider_id = to_uuid(event['pathParameters']['providerId'], 'Invalid providerId provided') jurisdiction = event['pathParameters']['jurisdiction'] + _ensure_jurisdiction_live(compact, jurisdiction) license_type_abbreviation = event['pathParameters']['licenseType'].lower() encumbrance_id = to_uuid(event['pathParameters']['encumbranceId'], 'Invalid encumbranceId provided') diff --git a/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_encumbrance.py b/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_encumbrance.py index 2830ae4d2..ff8e5eff5 100644 --- a/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_encumbrance.py +++ b/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_encumbrance.py @@ -9,7 +9,6 @@ DEFAULT_AA_SUBMITTING_USER_ID, DEFAULT_COMPACT, DEFAULT_DATE_OF_UPDATE_TIMESTAMP, - DEFAULT_ENCUMBRANCE_TYPE, DEFAULT_LICENSE_JURISDICTION, DEFAULT_LICENSE_TYPE, DEFAULT_LICENSE_TYPE_ABBREVIATION, @@ -50,7 +49,7 @@ def _generate_test_body(): 'encumbranceEffectiveDate': TEST_ENCUMBRANCE_EFFECTIVE_DATE, # These Enums are expected to be `str` type, so we'll directly access their .value 'encumbranceType': EncumbranceType.SUSPENSION.value, - 'clinicalPrivilegeActionCategories': [ClinicalPrivilegeActionCategory.UNSAFE_PRACTICE.value], + 'clinicalPrivilegeActionCategories': [ClinicalPrivilegeActionCategory.FRAUD.value], } @@ -59,6 +58,12 @@ def _generate_test_body(): class TestPostPrivilegeEncumbrance(TstFunction): """Test suite for privilege encumbrance endpoints.""" + def setUp(self): + super().setUp() + self.set_live_compact_jurisdictions_for_test( + {'cosm': [DEFAULT_LICENSE_JURISDICTION, DEFAULT_PRIVILEGE_JURISDICTION]} + ) + def _when_testing_privilege_encumbrance(self, body_overrides: dict | None = None): self.test_data_generator.put_default_provider_record_in_provider_table() self.test_data_generator.put_default_license_record_in_provider_table() @@ -127,7 +132,6 @@ def test_privilege_encumbrance_handler_adds_adverse_action_record_in_provider_da default_adverse_action_encumbrance = self.test_data_generator.generate_default_adverse_action( value_overrides={ 'adverseActionId': item['adverseActionId'], - 'encumbranceType': DEFAULT_ENCUMBRANCE_TYPE, 'effectiveStartDate': date.fromisoformat(TEST_ENCUMBRANCE_EFFECTIVE_DATE), 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, } @@ -240,6 +244,12 @@ def test_privilege_encumbrance_handler_handles_event_publishing_failure(self, mo class TestPostLicenseEncumbrance(TstFunction): """Test suite for license encumbrance endpoints.""" + def setUp(self): + super().setUp() + self.set_live_compact_jurisdictions_for_test( + {'cosm': [DEFAULT_LICENSE_JURISDICTION, DEFAULT_PRIVILEGE_JURISDICTION]} + ) + def _when_testing_valid_license_encumbrance(self, body_overrides: dict | None = None): self.test_data_generator.put_default_provider_record_in_provider_table() test_license_record = self.test_data_generator.put_default_license_record_in_provider_table() @@ -308,7 +318,6 @@ def test_license_encumbrance_handler_adds_adverse_action_record_in_provider_data value_overrides={ 'actionAgainst': 'license', 'adverseActionId': item['adverseActionId'], - 'encumbranceType': DEFAULT_ENCUMBRANCE_TYPE, 'effectiveStartDate': date.fromisoformat(TEST_ENCUMBRANCE_EFFECTIVE_DATE), 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, } @@ -453,6 +462,12 @@ def test_license_encumbrance_handler_returns_400_if_encumbrance_date_in_future(s class TestPatchPrivilegeEncumbranceLifting(TstFunction): """Test suite for privilege encumbrance lifting endpoints.""" + def setUp(self): + super().setUp() + self.set_live_compact_jurisdictions_for_test( + {'cosm': [DEFAULT_LICENSE_JURISDICTION, DEFAULT_PRIVILEGE_JURISDICTION]} + ) + def _setup_privilege_with_adverse_action( self, adverse_action_overrides=None, @@ -766,6 +781,12 @@ def test_privilege_encumbrance_lifting_handler_handles_event_publishing_failure( class TestPatchLicenseEncumbranceLifting(TstFunction): """Test suite for license encumbrance lifting endpoints.""" + def setUp(self): + super().setUp() + self.set_live_compact_jurisdictions_for_test( + {'cosm': [DEFAULT_LICENSE_JURISDICTION, DEFAULT_PRIVILEGE_JURISDICTION]} + ) + def _setup_license_with_adverse_action(self, adverse_action_overrides=None, license_overrides=None): """Helper method to set up a license with an adverse action for testing.""" self.test_data_generator.put_default_provider_record_in_provider_table( diff --git a/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_investigation.py b/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_investigation.py index 14992b274..62d8fcbfd 100644 --- a/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_investigation.py +++ b/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_investigation.py @@ -44,7 +44,7 @@ def _generate_test_investigation_close_with_encumbrance_body(): 'encumbranceEffectiveDate': TEST_ENCUMBRANCE_EFFECTIVE_DATE, # These Enums are expected to be `str` type, so we'll directly access their .value 'encumbranceType': EncumbranceType.SUSPENSION.value, - 'clinicalPrivilegeActionCategories': [ClinicalPrivilegeActionCategory.UNSAFE_PRACTICE.value], + 'clinicalPrivilegeActionCategories': [ClinicalPrivilegeActionCategory.FRAUD.value], }, } diff --git a/backend/cosmetology-app/stacks/api_lambda_stack/provider_management.py b/backend/cosmetology-app/stacks/api_lambda_stack/provider_management.py index 5e8ea4d95..cb9749020 100644 --- a/backend/cosmetology-app/stacks/api_lambda_stack/provider_management.py +++ b/backend/cosmetology-app/stacks/api_lambda_stack/provider_management.py @@ -320,6 +320,7 @@ def _add_provider_encumbrance_handler( ) self.persistent_stack.provider_table.grant_read_write_data(handler) self.persistent_stack.staff_users.user_table.grant_read_data(handler) + self.persistent_stack.compact_configuration_table.grant_read_data(handler) self.data_event_bus.grant_put_events_to(handler) NagSuppressions.add_resource_suppressions_by_path( 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 e4972ccf1..29d27519b 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 @@ -648,11 +648,7 @@ def _provider_detail_response_schema(self): ), 'dateOfUpdate': JsonSchema(type=JsonSchemaType.STRING, format='date-time'), 'encumbranceType': JsonSchema(type=JsonSchemaType.STRING), - 'clinicalPrivilegeActionCategories': JsonSchema( - type=JsonSchemaType.ARRAY, - description='The categories of clinical privilege action', - items=JsonSchema(type=JsonSchemaType.STRING), - ), + 'clinicalPrivilegeActionCategories': self._clinical_privilege_action_categories_schema, # noqa: E501 'liftingUser': JsonSchema(type=JsonSchemaType.STRING), }, ), @@ -782,11 +778,7 @@ def _provider_detail_response_schema(self): ), 'dateOfUpdate': JsonSchema(type=JsonSchemaType.STRING, format='date-time'), 'encumbranceType': JsonSchema(type=JsonSchemaType.STRING), - 'clinicalPrivilegeActionCategories': JsonSchema( - type=JsonSchemaType.ARRAY, - description='The categories of clinical privilege action', - items=JsonSchema(type=JsonSchemaType.STRING), - ), + 'clinicalPrivilegeActionCategories': self._clinical_privilege_action_categories_schema, # noqa: E501 'liftingUser': JsonSchema(type=JsonSchemaType.STRING), }, ), @@ -825,6 +817,17 @@ def _update_type_schema(self) -> JsonSchema: ], ) + @property + def _clinical_privilege_action_categories_schema(self) -> JsonSchema: + return JsonSchema( + type=JsonSchemaType.ARRAY, + description='The categories of clinical privilege action', + items=JsonSchema( + type=JsonSchemaType.STRING, + enum=['fraud', 'consumer harm', 'other'], + ), + ) + @property def _encumbrance_request_schema(self) -> JsonSchema: """Common schema for encumbrance request data used in both POST and PATCH investigation endpoints""" @@ -841,11 +844,7 @@ def _encumbrance_request_schema(self) -> JsonSchema: pattern=cc_api.YMD_FORMAT, ), 'encumbranceType': self._encumbrance_type_schema, - 'clinicalPrivilegeActionCategories': JsonSchema( - type=JsonSchemaType.ARRAY, - description='The categories of clinical privilege action', - items=JsonSchema(type=JsonSchemaType.STRING), - ), + 'clinicalPrivilegeActionCategories': self._clinical_privilege_action_categories_schema, }, ) @@ -856,21 +855,9 @@ def _encumbrance_type_schema(self) -> JsonSchema: type=JsonSchemaType.STRING, description='The type of encumbrance', enum=[ - 'fine', - 'reprimand', - 'required supervision', - 'completion of continuing education', - 'public reprimand', - 'probation', - 'injunctive action', 'suspension', 'revocation', - 'denial', 'surrender of license', - 'modification of previous action-extension', - 'modification of previous action-reduction', - 'other monitoring', - 'other adjudicated action not listed', ], ) diff --git a/backend/cosmetology-app/stacks/search_api_stack/v1_api/api_model.py b/backend/cosmetology-app/stacks/search_api_stack/v1_api/api_model.py index ee098ace3..3bdbc74ab 100644 --- a/backend/cosmetology-app/stacks/search_api_stack/v1_api/api_model.py +++ b/backend/cosmetology-app/stacks/search_api_stack/v1_api/api_model.py @@ -358,7 +358,10 @@ def _adverse_action_general_schema(self): 'encumbranceType': JsonSchema(type=JsonSchemaType.STRING), 'clinicalPrivilegeActionCategories': JsonSchema( type=JsonSchemaType.ARRAY, - items=JsonSchema(type=JsonSchemaType.STRING), + items=JsonSchema( + type=JsonSchemaType.STRING, + enum=['fraud', 'consumer harm', 'other'], + ), ), 'liftingUser': JsonSchema(type=JsonSchemaType.STRING), 'submittingUser': JsonSchema(type=JsonSchemaType.STRING), diff --git a/backend/cosmetology-app/tests/resources/snapshots/GET_PROVIDER_RESPONSE_SCHEMA.json b/backend/cosmetology-app/tests/resources/snapshots/GET_PROVIDER_RESPONSE_SCHEMA.json index a8ac71ab5..491eabab4 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/GET_PROVIDER_RESPONSE_SCHEMA.json +++ b/backend/cosmetology-app/tests/resources/snapshots/GET_PROVIDER_RESPONSE_SCHEMA.json @@ -473,6 +473,11 @@ "clinicalPrivilegeActionCategories": { "description": "The categories of clinical privilege action", "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], "type": "string" }, "type": "array" @@ -994,6 +999,11 @@ "clinicalPrivilegeActionCategories": { "description": "The categories of clinical privilege action", "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], "type": "string" }, "type": "array" diff --git a/backend/cosmetology-app/tests/resources/snapshots/LICENSE_ENCUMBRANCE_REQUEST_SCHEMA.json b/backend/cosmetology-app/tests/resources/snapshots/LICENSE_ENCUMBRANCE_REQUEST_SCHEMA.json index 84b644b6c..7aea9a0f2 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/LICENSE_ENCUMBRANCE_REQUEST_SCHEMA.json +++ b/backend/cosmetology-app/tests/resources/snapshots/LICENSE_ENCUMBRANCE_REQUEST_SCHEMA.json @@ -11,27 +11,20 @@ "encumbranceType": { "description": "The type of encumbrance", "enum": [ - "fine", - "reprimand", - "required supervision", - "completion of continuing education", - "public reprimand", - "probation", - "injunctive action", "suspension", "revocation", - "denial", - "surrender of license", - "modification of previous action-extension", - "modification of previous action-reduction", - "other monitoring", - "other adjudicated action not listed" + "surrender of license" ], "type": "string" }, "clinicalPrivilegeActionCategories": { "description": "The categories of clinical privilege action", "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], "type": "string" }, "type": "array" diff --git a/backend/cosmetology-app/tests/resources/snapshots/PATCH_LICENSE_INVESTIGATION_REQUEST_SCHEMA.json b/backend/cosmetology-app/tests/resources/snapshots/PATCH_LICENSE_INVESTIGATION_REQUEST_SCHEMA.json index 6a60e169e..0078eabf5 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/PATCH_LICENSE_INVESTIGATION_REQUEST_SCHEMA.json +++ b/backend/cosmetology-app/tests/resources/snapshots/PATCH_LICENSE_INVESTIGATION_REQUEST_SCHEMA.json @@ -19,27 +19,20 @@ "encumbranceType": { "description": "The type of encumbrance", "enum": [ - "fine", - "reprimand", - "required supervision", - "completion of continuing education", - "public reprimand", - "probation", - "injunctive action", "suspension", "revocation", - "denial", - "surrender of license", - "modification of previous action-extension", - "modification of previous action-reduction", - "other monitoring", - "other adjudicated action not listed" + "surrender of license" ], "type": "string" }, "clinicalPrivilegeActionCategories": { "description": "The categories of clinical privilege action", "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], "type": "string" }, "type": "array" diff --git a/backend/cosmetology-app/tests/resources/snapshots/PATCH_PRIVILEGE_INVESTIGATION_REQUEST_SCHEMA.json b/backend/cosmetology-app/tests/resources/snapshots/PATCH_PRIVILEGE_INVESTIGATION_REQUEST_SCHEMA.json index 6a60e169e..0078eabf5 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/PATCH_PRIVILEGE_INVESTIGATION_REQUEST_SCHEMA.json +++ b/backend/cosmetology-app/tests/resources/snapshots/PATCH_PRIVILEGE_INVESTIGATION_REQUEST_SCHEMA.json @@ -19,27 +19,20 @@ "encumbranceType": { "description": "The type of encumbrance", "enum": [ - "fine", - "reprimand", - "required supervision", - "completion of continuing education", - "public reprimand", - "probation", - "injunctive action", "suspension", "revocation", - "denial", - "surrender of license", - "modification of previous action-extension", - "modification of previous action-reduction", - "other monitoring", - "other adjudicated action not listed" + "surrender of license" ], "type": "string" }, "clinicalPrivilegeActionCategories": { "description": "The categories of clinical privilege action", "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], "type": "string" }, "type": "array" diff --git a/backend/cosmetology-app/tests/resources/snapshots/PRIVILEGE_ENCUMBRANCE_REQUEST_SCHEMA.json b/backend/cosmetology-app/tests/resources/snapshots/PRIVILEGE_ENCUMBRANCE_REQUEST_SCHEMA.json index 84b644b6c..7aea9a0f2 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/PRIVILEGE_ENCUMBRANCE_REQUEST_SCHEMA.json +++ b/backend/cosmetology-app/tests/resources/snapshots/PRIVILEGE_ENCUMBRANCE_REQUEST_SCHEMA.json @@ -11,27 +11,20 @@ "encumbranceType": { "description": "The type of encumbrance", "enum": [ - "fine", - "reprimand", - "required supervision", - "completion of continuing education", - "public reprimand", - "probation", - "injunctive action", "suspension", "revocation", - "denial", - "surrender of license", - "modification of previous action-extension", - "modification of previous action-reduction", - "other monitoring", - "other adjudicated action not listed" + "surrender of license" ], "type": "string" }, "clinicalPrivilegeActionCategories": { "description": "The categories of clinical privilege action", "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], "type": "string" }, "type": "array" diff --git a/backend/cosmetology-app/tests/resources/snapshots/PROVIDER_USER_RESPONSE_SCHEMA.json b/backend/cosmetology-app/tests/resources/snapshots/PROVIDER_USER_RESPONSE_SCHEMA.json index 652e27c64..75b75f573 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/PROVIDER_USER_RESPONSE_SCHEMA.json +++ b/backend/cosmetology-app/tests/resources/snapshots/PROVIDER_USER_RESPONSE_SCHEMA.json @@ -606,6 +606,11 @@ "clinicalPrivilegeActionCategories": { "description": "The categories of clinical privilege action", "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], "type": "string" }, "type": "array" @@ -1511,6 +1516,11 @@ "clinicalPrivilegeActionCategories": { "description": "The categories of clinical privilege action", "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], "type": "string" }, "type": "array" @@ -2178,4 +2188,4 @@ ], "type": "object", "$schema": "http://json-schema.org/draft-04/schema#" -} \ No newline at end of file +} diff --git a/backend/cosmetology-app/tests/smoke/README.md b/backend/cosmetology-app/tests/smoke/README.md new file mode 100644 index 000000000..3d81bae30 --- /dev/null +++ b/backend/cosmetology-app/tests/smoke/README.md @@ -0,0 +1,142 @@ +# Smoke Tests + +This directory contains smoke tests for the Compact Connect Cosmetology API. Smoke tests are end-to-end integration tests that run against a test environment to verify that critical functionality works as expected. + +## Overview + +Smoke tests validate that key features of the Compact Connect API are working correctly in a test environment. They make real API calls and interact with actual AWS services (DynamoDB, Cognito, etc.) to ensure the system behaves correctly end-to-end. + +## Prerequisites + +Before running smoke tests, you must complete the following setup: + +### 1. Sandbox/Test Environment + +You must have access to a deployed sandbox environment of the Compact Connect Cosmetology API. The sandbox should be deployed with the following configuration: + +- **Security Profile**: Your `cdk.context.json` file must have `"security_profile": "VULNERABLE"` set. This allows the smoke tests to create users programmatically using the boto3 Cognito client. +- +### 2. AWS Credentials + +Ensure your AWS credentials are configured with appropriate permissions to: +- Access DynamoDB tables in the sandbox environment +- Access Cognito user pools in the sandbox environment +- Access other AWS services used by the smoke tests + +1. Configure your AWS profile to use SSO: + ```bash + aws configure sso + ``` + Follow the prompts to set up your SSO profile using the values from your IAM identity center login + (see [AWS CLI SSO Configuration](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html#sso-configure-profile-token-auto-sso)) + +2. Log in to AWS SSO: + ```bash + aws sso login --profile + ``` + +3. Set your AWS profile environment variable (if not using the default profile): + ```bash + export AWS_PROFILE= + ``` + +### 3. Python Dependencies + +Install the required Python packages. The smoke tests use the same dependencies as the main codebase. Ensure you have: +- Python 3.x +- All dependencies from the project's requirements files + +### 4. Upload Test License Record + +You must have a test license record uploaded in your sandbox environment to generate a provider record. This license/provider will be used by the smoke tests to perform various tests. Once you have uploaded the license record into your environment, you will need to look up the provider id generated for that record in the Provider DynamoDB table and set it in your environment variables (see Environment Variables Setup below). + + +## Environment Variables Setup + +1. **Copy the example environment file:** + ```bash + cp smoke_tests_env_example.json smoke_tests_env.json + ``` + +2. **Edit `smoke_tests_env.json`** with your sandbox environment values: + + **Required Variables:** + - `CC_TEST_API_BASE_URL`: Base URL for the Compact Connect API (e.g., `https://api.sandbox.compactconnect.org`) + - `CC_TEST_STATE_API_BASE_URL`: Base URL for the state API + - `CC_TEST_STATE_AUTH_URL`: OAuth2 token endpoint for state authentication + - `CC_TEST_COGNITO_STATE_AUTH_USER_POOL_ID`: Cognito user pool ID for state auth + - `CC_TEST_PROVIDER_DYNAMO_TABLE_NAME`: DynamoDB table name for provider data + - `CC_TEST_COMPACT_CONFIGURATION_DYNAMO_TABLE_NAME`: DynamoDB table name for compact configuration + - `CC_TEST_DATA_EVENT_DYNAMO_TABLE_NAME`: DynamoDB table name for data events + - `CC_TEST_STAFF_USER_DYNAMO_TABLE_NAME`: DynamoDB table name for staff users + - `CC_TEST_COGNITO_STAFF_USER_POOL_ID`: Cognito user pool ID for staff users + - `CC_TEST_COGNITO_STAFF_USER_POOL_CLIENT_ID`: Cognito client ID for staff users + - `CC_TEST_PROVIDER_ID`: Provider id of your test provider user + - `ENVIRONMENT_NAME`: Name of your sandbox environment + - `AWS_DEFAULT_REGION`: AWS region where your sandbox is deployed (e.g., `us-east-1`) + + **Optional Variables (for specific tests):** + - `CC_TEST_ROLLBACK_STEP_FUNCTION_ARN`: Step function ARN for rollback tests + - `CC_TEST_RATE_LIMITING_DYNAMO_TABLE_NAME`: DynamoDB table name for rate limiting + - `CC_TEST_SSN_DYNAMO_TABLE_NAME`: DynamoDB table name for SSN data + - `CC_TEST_GET_PROVIDER_SSN_LAMBDA_NAME`: Lambda function name for SSN retrieval + +3. **Important:** Never commit `smoke_tests_env.json` to version control. It contains sensitive credentials and should be in `.gitignore`. + +## Running Smoke Tests + +### Running Individual Test Files + +Each test file can be run independently from the cosmetology-app folder: + +```bash +# Navigate to the compact-connect directory +cd backend/cosmetology-app + +# Run a specific test file +python3 tests/smoke/encumbrance_smoke_tests.py +``` + +## Special Test Requirements + +### Tests Creating Test Data + +Many tests create temporary test data (staff users, configurations, etc.) and clean it up automatically. However, if a test fails partway through, you may need to manually clean up test data. + +## Troubleshooting + +### Common Issues + +1. **"ResourceNotFoundException" when accessing DynamoDB tables** + - Verify that your `smoke_tests_env.json` has the correct table names for your sandbox environment + - Ensure your AWS credentials have permissions to access the tables + - Check that the tables exist in the specified region + +2. **"Failed to authenticate" or Cognito errors** + - Check that `security_profile: "VULNERABLE"` is set in your `cdk.context.json` + + +### Triage Test Failures + +If a test fails, you can consider the following steps to triage the cause of the failures: + +1. Review CloudWatch logs for Lambda functions that were invoked +2. Check DynamoDB tables directly using the AWS Console or CLI +3. Check Cognito user pools to see if test users were created + +## Contributing + +When adding new smoke tests: + +1. Follow the existing pattern in other test files +2. Use `SmokeTestFailureException` for test failures +3. Include cleanup logic for any test data created +4. Add appropriate docstrings explaining what the test does +5. Update this README with information about your new test if there are any special requirements + +## Additional Resources + +- See individual test files for specific requirements and usage examples +- Check `smoke_common.py` for shared utilities and helper functions +- Review `config.py` to understand how environment variables are loaded + 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 194ec810a..0d62948d0 100644 --- a/backend/cosmetology-app/tests/smoke/compact_configuration_smoke_tests.py +++ b/backend/cosmetology-app/tests/smoke/compact_configuration_smoke_tests.py @@ -213,7 +213,7 @@ def test_compact_configuration(): delete_test_staff_user(test_email, user_sub, compact) -def test_jurisdiction_configuration(jurisdiction: str = 'ne', recreate_compact_config: bool = False): +def test_jurisdiction_configuration(jurisdiction: str = 'az', recreate_compact_config: bool = False): """ Test that a state admin can update and retrieve jurisdiction configuration. @@ -437,4 +437,6 @@ def test_jurisdiction_configuration(jurisdiction: str = 'ne', recreate_compact_c load_smoke_test_env() test_active_member_jurisdictions() test_compact_configuration() - test_jurisdiction_configuration() + # for the smoke tests, we set two jurisdictions to live for license and privilege smoke tests + test_jurisdiction_configuration('az') + test_jurisdiction_configuration('al') diff --git a/backend/cosmetology-app/tests/smoke/config.py b/backend/cosmetology-app/tests/smoke/config.py index b43b3e2d2..a86ee8bde 100644 --- a/backend/cosmetology-app/tests/smoke/config.py +++ b/backend/cosmetology-app/tests/smoke/config.py @@ -69,28 +69,8 @@ def cognito_staff_user_pool_id(self): return os.environ['CC_TEST_COGNITO_STAFF_USER_POOL_ID'] @property - def cognito_provider_user_client_id(self): - return os.environ['CC_TEST_COGNITO_PROVIDER_USER_POOL_CLIENT_ID'] - - @property - def cognito_provider_user_pool_id(self): - return os.environ['CC_TEST_COGNITO_PROVIDER_USER_POOL_ID'] - - @property - def test_provider_user_username(self): - return os.environ['CC_TEST_PROVIDER_USER_USERNAME'] - - @property - def test_provider_user_password(self): - return os.environ['CC_TEST_PROVIDER_USER_PASSWORD'] - - @property - def sandbox_authorize_net_api_login_id(self): - return os.environ['SANDBOX_AUTHORIZE_NET_API_LOGIN_ID'] - - @property - def sandbox_authorize_net_transaction_key(self): - return os.environ['SANDBOX_AUTHORIZE_NET_TRANSACTION_KEY'] + def test_provider_id(self): + return os.environ['CC_TEST_PROVIDER_ID'] @property def smoke_test_notification_email(self): diff --git a/backend/cosmetology-app/tests/smoke/encumbrance_smoke_tests.py b/backend/cosmetology-app/tests/smoke/encumbrance_smoke_tests.py index 3dfbd5296..5c5d701e4 100644 --- a/backend/cosmetology-app/tests/smoke/encumbrance_smoke_tests.py +++ b/backend/cosmetology-app/tests/smoke/encumbrance_smoke_tests.py @@ -4,15 +4,17 @@ This script tests the end-to-end encumbrance workflow for both licenses and privileges, including setting encumbrances and lifting them through the API endpoints. + +This script assumes your test environment has a live jurisdiction for generating at least one privilege +record. You can set the value of the live jurisdiction in the LIVE_JURISDICTION constant """ import time import requests -from purchasing_privileges_smoke_tests import test_purchasing_privilege from smoke_common import ( SmokeTestFailureException, - call_provider_users_me_endpoint, + call_provider_details_endpoint, config, create_test_staff_user, delete_test_staff_user, @@ -24,6 +26,9 @@ logger, ) +ENCUMBRANCE_SMOKE_COMPACT = 'cosm' +LIVE_JURISDICTION = 'az' + def clean_adverse_actions(): """ @@ -31,8 +36,7 @@ def clean_adverse_actions(): """ logger.info('Cleaning up existing adverse action records...') - # Get all provider database records - all_records = get_all_provider_database_records() + all_records = get_all_provider_database_records(ENCUMBRANCE_SMOKE_COMPACT, config.test_provider_id) # Filter for adverse action records adverse_action_records = [record for record in all_records if record.get('type') == 'adverseAction'] @@ -53,8 +57,7 @@ def clean_adverse_actions(): def _remove_encumbered_status_from_license_and_provider(): - # Get all provider database records - all_records = get_all_provider_database_records() + all_records = get_all_provider_database_records(ENCUMBRANCE_SMOKE_COMPACT, config.test_provider_id) for record in all_records: if record.get('type') == 'license' or record.get('type') == 'provider': @@ -72,53 +75,43 @@ def _remove_encumbered_status_from_license_and_provider(): def setup_test_environment(): """ - Set up the test environment by cleaning adverse actions and purchasing a privilege. + Set up the test environment by cleaning any previous adverse actions. """ logger.info('Setting up test environment...') - # Clean up any existing adverse actions clean_adverse_actions() # remove encumbered status from license and provider if present _remove_encumbered_status_from_license_and_provider() - # Purchase a privilege to ensure we have one to test with - logger.info('Purchasing a privilege for testing...') - test_purchasing_privilege() - logger.info('Test environment setup complete') class EncumbranceTestHelper: """Helper class to manage encumbrance test operations with pre-configured staff users and URLs.""" - def __init__(self, provider_data: dict): + def __init__(self): """ Initialize the helper with provider data and set up all necessary resources. - - :param provider_data: Result from call_provider_users_me_endpoint() """ - self.provider_data = provider_data - self.compact = provider_data['compact'] - self.provider_id = provider_data['providerId'] + # Get provider data + self.compact = ENCUMBRANCE_SMOKE_COMPACT + self.provider_id = config.test_provider_id - # Get jurisdiction information from Nebraska privilege (smoke tests purchase privilege in NE) + # Get jurisdiction information from privilege # Query database directly for privilege records provider_user_records = get_provider_user_records(self.compact, self.provider_id) - # Find the Nebraska privilege - ne_privileges = provider_user_records.get_privilege_records( - filter_condition=lambda priv: priv.jurisdiction == 'ne' - ) + # Get license record + provider_license = provider_user_records.find_best_license_in_current_known_licenses() - if not ne_privileges: - raise SmokeTestFailureException('Nebraska privilege not found for provider') + if not provider_license: + raise SmokeTestFailureException('License not found for provider') - privilege_record = ne_privileges[0] - self.privilege_jurisdiction = privilege_record.jurisdiction - self.license_jurisdiction = privilege_record.licenseJurisdiction - self.license_type = privilege_record.licenseType - self.license_type_abbreviation = privilege_record.licenseTypeAbbreviation + self.privilege_jurisdiction = LIVE_JURISDICTION + self.license_jurisdiction = provider_license.jurisdiction + self.license_type = provider_license.licenseType + self.license_type_abbreviation = provider_license.licenseTypeAbbreviation # Track created users for cleanup self.created_staff_users = [] @@ -147,6 +140,14 @@ def _create_license_jurisdiction_staff_user(self) -> dict: return {'email': email, 'user_sub': user_sub, 'headers': headers} + def get_provider_details(self) -> dict: + """Get provider details for the smoke-test provider.""" + return call_provider_details_endpoint( + self.get_license_staff_admin_headers(), + self.compact, + self.provider_id, + ) + def get_privilege_staff_admin_headers(self) -> dict: """Get authentication headers for privilege jurisdiction staff user.""" return self.privilege_jurisdiction_staff_user['headers'] @@ -258,82 +259,6 @@ def validate_license_encumbered_state(self, expected_status: str = 'encumbered') return license_record - def validate_privilege_encumbered_state( - self, expected_status: str = 'encumbered', max_wait_time: int = 60, check_interval: int = 10 - ): - """ - Validate that the privilege encumberedStatus matches the expected value. - - This method will poll the provider records every check_interval seconds - for up to max_wait_time seconds, checking if the privilege has the expected - encumberedStatus. This accounts for eventual consistency in downstream processing. - - :param expected_status: The expected encumberedStatus value ('licenseEncumbered', 'unencumbered', etc.) - :param max_wait_time: Maximum time to wait in seconds (default: 60) - :param check_interval: Time between checks in seconds (default: 10) - - :raises: - :class:`~smoke_common.SmokeTestFailureException`: If the privilege status doesn't match within max_wait_time - """ - logger.info( - f'Validating privilege encumbered status is "{expected_status}" ' - f'for jurisdiction "{self.privilege_jurisdiction}"...' - ) - - start_time = time.time() - attempts = 0 - max_attempts = max_wait_time // check_interval - - while attempts < max_attempts: - attempts += 1 - - try: - # Get current provider records directly from DynamoDB - provider_user_records = get_provider_user_records(self.compact, self.provider_id) - - # Find the privilege that matches the license jurisdiction and type - matching_privilege = provider_user_records.get_specific_privilege_record( - self.privilege_jurisdiction, self.license_type_abbreviation - ) - - if not matching_privilege: - logger.warning( - f'Attempt {attempts}/{max_attempts}: No privilege found matching jurisdiction ' - f'"{self.privilege_jurisdiction}" and license type "{self.license_type_abbreviation}"' - ) - else: - logger.info('matching privilege found', matching_privilege=matching_privilege) - actual_status = matching_privilege.encumberedStatus - logger.info( - f'Attempt {attempts}/{max_attempts}: Privilege encumberedStatus is "{actual_status}", ' - f'expecting "{expected_status}"' - ) - - if actual_status == expected_status: - elapsed_time = time.time() - start_time - logger.info( - f'✅ Privilege encumbered status validation successful after {elapsed_time:.1f} seconds' - ) - return matching_privilege - - # If not the last attempt, wait before trying again - if attempts < max_attempts: - logger.info(f'Waiting {check_interval} seconds before next check...') - time.sleep(check_interval) - - except Exception as e: # noqa: BLE001 - logger.warning(f'Attempt {attempts}/{max_attempts}: Error checking privilege status: {e}') - if attempts < max_attempts: - time.sleep(check_interval) - - # If we get here, validation failed - elapsed_time = time.time() - start_time - raise SmokeTestFailureException( - f'Privilege encumbered status validation failed after {elapsed_time:.1f} seconds. ' - f'Expected "{expected_status}" but status did not update within {max_wait_time} seconds. ' - f'This suggests the downstream processing is not working correctly.' - ) - def validate_provider_encumbered_state(self, expected_status: str = 'encumbered'): """Validate provider encumbered status.""" # Get all provider records directly from DynamoDB @@ -429,6 +354,39 @@ def verify_privilege_adverse_action_matches_request(self, request_payload: dict) privilege_adverse_actions = self.get_privilege_adverse_actions() return self.verify_adverse_action_matches_request(privilege_adverse_actions, request_payload) + def get_privilege_adverse_action_by_id(self, adverse_action_id: str): + privilege_adverse_actions = self.get_privilege_adverse_actions() + matching_actions = [aa for aa in privilege_adverse_actions if aa['adverseActionId'] == adverse_action_id] + if not matching_actions: + raise SmokeTestFailureException(f'No matching adverse action found for ID: {adverse_action_id}') + return matching_actions[0] + + def verify_privilege_adverse_action_not_lifted(self, adverse_action_id: str) -> None: + """ + Verify that a privilege adverse action has not been lifted. + + :param adverse_action_id: The id of the adverse action + :return: The matching adverse action record + """ + matching_adverse_action = self.get_privilege_adverse_action_by_id(adverse_action_id) + lift_date = matching_adverse_action.get('effectiveLiftDate') + if lift_date is not None: + raise SmokeTestFailureException( + f'Adverse action has unexpected lift date for ID: {adverse_action_id}. effectiveLiftDate: {lift_date}' + ) + + def verify_privilege_adverse_action_lifted(self, adverse_action_id: str) -> None: + """ + Verify that a privilege adverse action has been lifted. + + :param adverse_action_id: The id of the adverse action + :return: The matching adverse action record + """ + matching_adverse_action = self.get_privilege_adverse_action_by_id(adverse_action_id) + lift_date = matching_adverse_action.get('effectiveLiftDate') + if lift_date is None: + raise SmokeTestFailureException(f'Adverse action is missing expected lift date for ID:{adverse_action_id}') + def _generate_license_encumbrance_url(self, encumbrance_id: str = None): """Generate license encumbrance URL.""" base_url = ( @@ -480,20 +438,17 @@ def test_license_encumbrance_workflow(): """ Test the complete license encumbrance workflow: 1. Encumber a license twice - 2. Verify that the associated privilege is also encumbered with a 'licenseEncumbered' encumberedStatus - 3. Encumber privilege and ensure it is updated to an 'encumbered' encumberedStatus - 4. Lift one encumbrance (license should remain encumbered) - 5. Lift the final encumbrance (license should become unencumbered) - 6. Verify that the associated privilege is still encumbered (has an 'encumbered' encumberedStatus) - 7. Lift encumbrance from privilege - 8. Verify privilege is unencumbered + 2. Encumber privilege and ensure it is updated to an 'encumbered' encumberedStatus + 3. Lift one encumbrance (license should remain encumbered) + 4. Lift the final encumbrance (license should become unencumbered) + 5. Verify that the associated privilege is still encumbered (has an 'encumbered' encumberedStatus) + 6. Lift encumbrance from privilege + 7. Verify privilege is unencumbered """ logger.info('Starting license encumbrance workflow test...') # remove adverse action records from previous tests clean_adverse_actions() - # Get provider data and create helper - provider_data = call_provider_users_me_endpoint() - helper = EncumbranceTestHelper(provider_data) + helper = EncumbranceTestHelper() try: # Step 1: Encumber the license twice @@ -502,7 +457,7 @@ def test_license_encumbrance_workflow(): encumbrance_body = { 'encumbranceEffectiveDate': '2024-11-11', 'encumbranceType': 'surrender of license', - 'clinicalPrivilegeActionCategories': ['Fraud, Deception, or Misrepresentation'], + 'clinicalPrivilegeActionCategories': ['fraud'], } # First encumbrance @@ -555,16 +510,11 @@ def test_license_encumbrance_workflow(): helper.verify_license_adverse_action_matches_request(encumbrance_body) logger.info('First license encumbrance verified successfully') - # Step 2: Verify that the associated privilege is also encumbered with 'licenseEncumbered' status - logger.info('Verifying associated privilege is encumbered...') - helper.validate_privilege_encumbered_state('licenseEncumbered') - logger.info('Verified privilege is encumbered with licenseEncumbered status') - # Second encumbrance second_encumbrance_body = { 'encumbranceEffectiveDate': '2025-01-01', - 'encumbranceType': 'denial', - 'clinicalPrivilegeActionCategories': ['Unsafe Practice or Substandard Care'], + 'encumbranceType': 'suspension', + 'clinicalPrivilegeActionCategories': ['consumer harm'], } helper.encumber_license(second_encumbrance_body) logger.info('Second license encumbrance created successfully') @@ -584,30 +534,22 @@ def test_license_encumbrance_workflow(): helper.verify_license_adverse_action_matches_request(second_encumbrance_body) logger.info('Second license encumbrance verified successfully') - # Step 3: Encumber Privilege + # Step 2: Encumber privilege privilege_encumbrance_body = { 'encumbranceEffectiveDate': '2025-05-09', - 'encumbranceType': 'reprimand', - 'clinicalPrivilegeActionCategories': ['Unsafe Practice or Substandard Care', 'Misconduct or Abuse'], + 'encumbranceType': 'suspension', + 'clinicalPrivilegeActionCategories': ['other'], } helper.encumber_privilege(privilege_encumbrance_body) logger.info('Privilege encumbrance created successfully') - # privilege should now be encumbered - helper.validate_privilege_encumbered_state( - expected_status='encumbered', - # only need to check once - max_wait_time=1, - check_interval=1, - ) - # Verify the privilege adverse action matches the request payload helper.verify_privilege_adverse_action_matches_request(privilege_encumbrance_body) logger.info('Privilege encumbrance verified successfully') - # Step 4: Lift first encumbrance (license should remain encumbered) - logger.info('Step 4: Lifting first license encumbrance...') + # Step 3: Lift first encumbrance (license should remain encumbered) + logger.info('Step 3: Lifting first license encumbrance...') lift_body = { 'effectiveLiftDate': '2025-05-05', @@ -630,8 +572,8 @@ def test_license_encumbrance_workflow(): # this keeps the lifting events isolated from each other helper.wait_for_downstream_processing() - # Step 5: Lift final encumbrance (license should become unencumbered) - logger.info('Step 5: Lifting final license encumbrance...') + # Step 4: Lift final encumbrance (license should become unencumbered) + logger.info('Step 4: Lifting final license encumbrance...') lift_body = { 'effectiveLiftDate': '2025-05-25', @@ -646,15 +588,8 @@ def test_license_encumbrance_workflow(): # Verify provider is still encumbered (due to privilege encumbrance) helper.validate_provider_encumbered_state('encumbered') - # Step 6: Verify that the associated privilege is still encumbered + # Step 5: Verify that the associated privilege is still encumbered logger.info('Verifying associated privilege is still encumbered...') - helper.validate_privilege_encumbered_state( - expected_status='encumbered', - # only check once - max_wait_time=1, - check_interval=1, - ) - logger.info('Verified privilege is still encumbered after lifting all license encumbrances') privilege_adverse_actions = helper.get_privilege_adverse_actions() @@ -664,21 +599,18 @@ def test_license_encumbrance_workflow(): ) privilege_adverse_action_id = privilege_adverse_actions[0]['adverseActionId'] + helper.verify_privilege_adverse_action_not_lifted(privilege_adverse_action_id) - # Step 7: Lift the privilege encumbrance - logger.info('Step 7: Lifting privilege encumbrance...') + # Step 6: Lift the privilege encumbrance + logger.info('Step 6: Lifting privilege encumbrance...') lift_body = {'effectiveLiftDate': '2023-01-25'} helper.lift_privilege_encumbrance(lift_body, privilege_adverse_action_id) logger.info('Privilege encumbrance lifted successfully') - # Step 8: Verify privilege becomes 'unencumbered' - logger.info('Step 8: Verifying privilege becomes unencumbered...') - helper.validate_privilege_encumbered_state( - expected_status='unencumbered', - # should be instantly set to unencumbered - max_wait_time=1, - check_interval=1, - ) + # Step 7: Verify privilege becomes 'unencumbered' + logger.info('Step 7: Verifying privilege becomes unencumbered...') + helper.verify_privilege_adverse_action_lifted(adverse_action_id=privilege_adverse_action_id) + helper.verify_privilege_adverse_action_lifted(privilege_adverse_action_id) logger.info('Verified privilege is now unencumbered') logger.info('License encumbrance workflow test completed successfully') @@ -702,11 +634,8 @@ def test_privilege_encumbrance_workflow(): 3. Lift the final encumbrance (privilege should become unencumbered) """ logger.info('Starting privilege encumbrance workflow test...') - # clean adverse actions from previous test clean_adverse_actions() - # Get provider data and create helper - provider_data = call_provider_users_me_endpoint() - helper = EncumbranceTestHelper(provider_data) + helper = EncumbranceTestHelper() try: # Step 1: Encumber the privilege twice @@ -714,23 +643,15 @@ def test_privilege_encumbrance_workflow(): encumbrance_body = { 'encumbranceEffectiveDate': '2024-12-12', - 'encumbranceType': 'fine', - 'clinicalPrivilegeActionCategories': ['Fraud, Deception, or Misrepresentation'], + 'encumbranceType': 'revocation', + 'clinicalPrivilegeActionCategories': ['fraud'], } # First encumbrance helper.encumber_privilege(encumbrance_body) logger.info('First privilege encumbrance created successfully') - # Verify provider state after first encumbrance - helper.validate_privilege_encumbered_state( - expected_status='encumbered', - # only need to check once - max_wait_time=1, - check_interval=1, - ) - - # Check provider status to ensure it is encumbered as well + # Check provider status to ensure it shows encumbered status helper.validate_provider_encumbered_state('encumbered') # Verify adverse action exists @@ -749,8 +670,8 @@ def test_privilege_encumbrance_workflow(): # Second encumbrance second_encumbrance_body = { 'encumbranceEffectiveDate': '2025-02-02', - 'encumbranceType': 'completion of continuing education', - 'clinicalPrivilegeActionCategories': ['Unsafe Practice or Substandard Care'], + 'encumbranceType': 'suspension', + 'clinicalPrivilegeActionCategories': ['consumer harm'], } helper.encumber_privilege(second_encumbrance_body) logger.info('Second privilege encumbrance created successfully') @@ -782,8 +703,11 @@ def test_privilege_encumbrance_workflow(): helper.lift_privilege_encumbrance(lift_body, first_adverse_action_id) logger.info('First privilege encumbrance lifted successfully') - # Verify privilege is still encumbered - helper.validate_privilege_encumbered_state('encumbered') + # Verify first privilege encumbrance is lifted + helper.verify_privilege_adverse_action_lifted(first_adverse_action_id) + + # Verify second privilege is still encumbered + helper.verify_privilege_adverse_action_not_lifted(second_adverse_action_id) # Also verify the provider record is still encumbered helper.validate_provider_encumbered_state('encumbered') @@ -804,8 +728,8 @@ def test_privilege_encumbrance_workflow(): helper.lift_privilege_encumbrance(lift_body, second_adverse_action_id) logger.info('Final privilege encumbrance lifted successfully') - # Verify privilege is now unencumbered - helper.validate_privilege_encumbered_state('unencumbered') + # Verify second encumbrance is now lifted + helper.verify_privilege_adverse_action_lifted(second_adverse_action_id) # Also verify the provider record is now unencumbered helper.validate_provider_encumbered_state('unencumbered') @@ -817,153 +741,6 @@ def test_privilege_encumbrance_workflow(): helper.cleanup_staff_users() -def test_privilege_encumbrance_status_changes_with_license_encumbrance_workflow(): - """ - Test privilege encumbrance status values that can occur in the various encumbrance scenarios: - 1. Encumber a privilege directly - 2. Encumber the associated license - 3. Verify privilege remains 'encumbered' (not 'licenseEncumbered') - 4. Lift the privilege encumbrance - 5. Verify privilege becomes 'licenseEncumbered' - 6. Lift license encumbrance and verify privilege encumbrance is lifted automatically and set to 'unencumbered' - """ - logger.info('Starting complex privilege and license encumbrance workflow test...') - - # Get provider data and create helper - provider_data = call_provider_users_me_endpoint() - helper = EncumbranceTestHelper(provider_data) - - try: - # Step 1: Encumber the privilege directly - logger.info('Step 1: Creating privilege encumbrance...') - privilege_encumbrance_body = { - 'encumbranceEffectiveDate': '2024-01-15', - 'encumbranceType': 'probation', - 'clinicalPrivilegeActionCategories': [ - 'Unsafe Practice or Substandard Care', - 'Non-compliance With Requirements', - 'Fraud, Deception, or Misrepresentation', - ], - } - - helper.encumber_privilege(privilege_encumbrance_body) - logger.info('Privilege encumbrance created successfully') - - # Verify privilege is encumbered - helper.validate_privilege_encumbered_state( - expected_status='encumbered', - # should be instantly set to encumbered - max_wait_time=1, - check_interval=1, - ) - - # Verify the privilege adverse action matches the request payload - helper.verify_privilege_adverse_action_matches_request(privilege_encumbrance_body) - logger.info('Verified privilege is directly encumbered') - - # Step 2: Encumber the associated license - logger.info('Step 2: Creating license encumbrance...') - license_encumbrance_body = { - 'encumbranceEffectiveDate': '2024-01-20', - 'encumbranceType': 'suspension', - 'clinicalPrivilegeActionCategories': [ - 'Criminal Conviction or Adjudication', - 'Improper Supervision or Allowing Unlicensed Practice', - 'Other', - ], - } - - helper.encumber_license(license_encumbrance_body) - logger.info('License encumbrance created successfully') - - # Verify the license adverse action matches the request payload - helper.verify_license_adverse_action_matches_request(license_encumbrance_body) - - # wait 1 minute for downstream processing to complete - # to ensure it doesn't change the privilege record - helper.wait_for_downstream_processing() - - # Step 3: Verify privilege remains 'encumbered' (not 'licenseEncumbered') - logger.info('Step 3: Verifying privilege remains directly encumbered...') - helper.validate_privilege_encumbered_state( - expected_status='encumbered', - # only need to check once - max_wait_time=1, - check_interval=1, - ) - logger.info('Verified privilege remains directly encumbered (not licenseEncumbered)') - - # Get the privilege adverse action ID for lifting - privilege_adverse_actions = helper.get_privilege_adverse_actions() - - if len(privilege_adverse_actions) != 1: - raise SmokeTestFailureException( - f'Expected 1 privilege adverse action, found: {len(privilege_adverse_actions)}' - ) - - privilege_adverse_action_id = privilege_adverse_actions[0]['adverseActionId'] - - # Step 4: Lift the privilege encumbrance - logger.info('Step 4: Lifting privilege encumbrance...') - lift_body = {'effectiveLiftDate': '2024-01-25'} - helper.lift_privilege_encumbrance(lift_body, privilege_adverse_action_id) - logger.info('Privilege encumbrance lifted successfully') - - # Step 5: Verify privilege becomes 'licenseEncumbered' - logger.info('Step 5: Verifying privilege becomes licenseEncumbered...') - helper.validate_privilege_encumbered_state( - expected_status='licenseEncumbered', - # should be instantly set to licenseEncumbered - max_wait_time=1, - check_interval=1, - ) - logger.info('Verified privilege is now licenseEncumbered') - - # Get the license adverse action ID for lifting - license_adverse_actions = helper.get_license_adverse_actions() - - if len(license_adverse_actions) != 1: - raise SmokeTestFailureException(f'Expected 1 license adverse action, found: {len(license_adverse_actions)}') - - license_adverse_action_id = license_adverse_actions[0]['adverseActionId'] - - # Step 6: Lift the license encumbrance - logger.info('Step 6: Lifting license encumbrance...') - lift_body = {'effectiveLiftDate': '2024-01-30'} - helper.lift_license_encumbrance(lift_body, license_adverse_action_id) - logger.info('License encumbrance lifted successfully') - - # Wait for downstream processing to complete, including notification handlers - # This prevents race conditions where notification handlers might still be processing - logger.info('Waiting for downstream processing and notification handlers to complete...') - helper.wait_for_downstream_processing() - - # Step 7: Verify privilege becomes 'unencumbered' - logger.info('Step 7: Verifying privilege becomes unencumbered...') - helper.validate_privilege_encumbered_state(expected_status='unencumbered') - logger.info('Verified privilege is now fully unencumbered') - - # Final verification: Check that provider is also unencumbered - provider_user_records = get_provider_user_records(helper.compact, helper.provider_id) - provider_record = provider_user_records.get_provider_record() - - if provider_record.encumberedStatus != 'unencumbered': - raise SmokeTestFailureException( - f"Provider encumberedStatus should be 'unencumbered', got: {provider_record.encumberedStatus}" - ) - - if provider_record.compactEligibility != 'eligible': - raise SmokeTestFailureException( - f"Provider compactEligibility should be 'eligible', got: {provider_record.compactEligibility}" - ) - - logger.info('Complex privilege and license encumbrance workflow test completed successfully') - - finally: - # Clean up all created staff users - helper.cleanup_staff_users() - - def run_encumbrance_smoke_tests(): """ Run the complete suite of encumbrance smoke tests. @@ -974,9 +751,6 @@ def run_encumbrance_smoke_tests(): # Setup test environment setup_test_environment() - # Run privilege and license encumbrance tests - test_privilege_encumbrance_status_changes_with_license_encumbrance_workflow() - # Run license encumbrance tests test_license_encumbrance_workflow() diff --git a/backend/cosmetology-app/tests/smoke/investigation_smoke_tests.py b/backend/cosmetology-app/tests/smoke/investigation_smoke_tests.py index 3beebdc21..80fe82a43 100755 --- a/backend/cosmetology-app/tests/smoke/investigation_smoke_tests.py +++ b/backend/cosmetology-app/tests/smoke/investigation_smoke_tests.py @@ -11,7 +11,7 @@ import requests from smoke_common import ( SmokeTestFailureException, - call_provider_users_me_endpoint, + call_provider_details_endpoint, config, create_test_staff_user, delete_test_staff_user, @@ -23,6 +23,13 @@ logger, ) +INVESTIGATION_SMOKE_COMPACT = 'cosm' + + +def _fetch_provider_details(auth_headers: dict) -> dict: + """Staff GET provider details for the smoke-test provider (CC_TEST_PROVIDER_ID).""" + return call_provider_details_endpoint(auth_headers, INVESTIGATION_SMOKE_COMPACT, config.test_provider_id) + def clean_investigation_records(): """ @@ -142,7 +149,7 @@ def _get_privilege_data_from_provider_response(provider_data: dict, jurisdiction ) -def _verify_no_investigation_exists(record_type: str, jurisdiction: str, license_type: str): +def _verify_no_investigation_exists(record_type: str, jurisdiction: str, license_type: str, auth_headers: dict): """ Verify that no open investigation records exist in the database and no investigation status or objects on the record. @@ -150,6 +157,7 @@ def _verify_no_investigation_exists(record_type: str, jurisdiction: str, license :param record_type: 'privilege' or 'license' :param jurisdiction: The jurisdiction of the record :param license_type: The license type of the record + :param auth_headers: Staff user auth headers """ # Check database for open investigation records all_records = get_all_provider_database_records() @@ -161,7 +169,7 @@ def _verify_no_investigation_exists(record_type: str, jurisdiction: str, license raise SmokeTestFailureException('Open investigation already exists before creation test') # Check API for investigation status - provider_data = call_provider_users_me_endpoint() + provider_data = _fetch_provider_details(auth_headers) if record_type == 'privilege': record_data = _get_privilege_data_from_provider_response(provider_data, jurisdiction, license_type) @@ -181,13 +189,14 @@ def _verify_no_investigation_exists(record_type: str, jurisdiction: str, license raise SmokeTestFailureException('Investigation objects still exist in API response') -def _verify_investigation_exists(record_type: str, jurisdiction: str, license_type: str): +def _verify_investigation_exists(record_type: str, jurisdiction: str, license_type: str, auth_headers: dict): """ Verify that an open investigation exists and the record has investigation status. :param record_type: 'privilege' or 'license' :param jurisdiction: The jurisdiction of the record :param license_type: The license type of the record + :param auth_headers: Staff Bearer auth headers :return: The investigation ID """ # Check database for investigation records @@ -206,7 +215,7 @@ def _verify_investigation_exists(record_type: str, jurisdiction: str, license_ty raise SmokeTestFailureException(f'No open {record_type} investigation found to close') # Check API for investigation status - provider_data = call_provider_users_me_endpoint() + provider_data = _fetch_provider_details(auth_headers) if record_type == 'privilege': record_data = _get_privilege_data_from_provider_response(provider_data, jurisdiction, license_type) @@ -232,14 +241,14 @@ def test_create_privilege_investigation(auth_headers): """Test creating a privilege investigation.""" logger.info('Testing privilege investigation creation...') - provider_data = call_provider_users_me_endpoint() + provider_data = _fetch_provider_details(auth_headers) provider_id = provider_data['providerId'] compact = provider_data['compact'] jurisdiction = provider_data['privileges'][0]['jurisdiction'] license_type = provider_data['privileges'][0]['licenseType'] license_type_abbreviation = get_license_type_abbreviation(license_type) - _verify_no_investigation_exists('privilege', jurisdiction, license_type) + _verify_no_investigation_exists('privilege', jurisdiction, license_type, auth_headers) # Create investigation (empty body required) response = requests.post( @@ -260,21 +269,21 @@ def test_create_privilege_investigation(auth_headers): # Wait for the investigation to be processed and DynamoDB eventual consistency time.sleep(5) - _verify_investigation_exists('privilege', jurisdiction, license_type) + _verify_investigation_exists('privilege', jurisdiction, license_type, auth_headers) def test_create_license_investigation(auth_headers): """Test creating a license investigation.""" logger.info('Testing license investigation creation...') - provider_data = call_provider_users_me_endpoint() + provider_data = _fetch_provider_details(auth_headers) provider_id = provider_data['providerId'] compact = provider_data['compact'] jurisdiction = provider_data['licenseJurisdiction'] license_type = provider_data['licenses'][0]['licenseType'] license_type_abbreviation = get_license_type_abbreviation(license_type) - _verify_no_investigation_exists('license', jurisdiction, license_type) + _verify_no_investigation_exists('license', jurisdiction, license_type, auth_headers) # Create investigation (empty body required) response = requests.post( @@ -295,21 +304,21 @@ def test_create_license_investigation(auth_headers): # Wait for the investigation to be processed and DynamoDB eventual consistency time.sleep(5) - _verify_investigation_exists('license', jurisdiction, license_type) + _verify_investigation_exists('license', jurisdiction, license_type, auth_headers) def test_close_privilege_investigation(auth_headers): """Test closing a privilege investigation.""" logger.info('Testing privilege investigation closing...') - provider_data = call_provider_users_me_endpoint() + provider_data = _fetch_provider_details(auth_headers) provider_id = provider_data['providerId'] compact = provider_data['compact'] jurisdiction = provider_data['privileges'][0]['jurisdiction'] license_type = provider_data['privileges'][0]['licenseType'] license_type_abbreviation = get_license_type_abbreviation(license_type) - investigation_id = _verify_investigation_exists('privilege', jurisdiction, license_type) + investigation_id = _verify_investigation_exists('privilege', jurisdiction, license_type, auth_headers) # Close investigation (no encumbrance) response = requests.patch( @@ -330,21 +339,21 @@ def test_close_privilege_investigation(auth_headers): # Wait for the investigation to be processed and DynamoDB eventual consistency time.sleep(5) - _verify_no_investigation_exists('privilege', jurisdiction, license_type) + _verify_no_investigation_exists('privilege', jurisdiction, license_type, auth_headers) def test_close_license_investigation(auth_headers): """Test closing a license investigation.""" logger.info('Testing license investigation closing...') - provider_data = call_provider_users_me_endpoint() + provider_data = _fetch_provider_details(auth_headers) provider_id = provider_data['providerId'] compact = provider_data['compact'] jurisdiction = provider_data['licenseJurisdiction'] license_type = provider_data['licenses'][0]['licenseType'] license_type_abbreviation = get_license_type_abbreviation(license_type) - investigation_id = _verify_investigation_exists('license', jurisdiction, license_type) + investigation_id = _verify_investigation_exists('license', jurisdiction, license_type, auth_headers) # Close investigation (no encumbrance) response = requests.patch( @@ -365,14 +374,14 @@ def test_close_license_investigation(auth_headers): # Wait for the investigation to be processed and DynamoDB eventual consistency time.sleep(5) - _verify_no_investigation_exists('license', jurisdiction, license_type) + _verify_no_investigation_exists('license', jurisdiction, license_type, auth_headers) def test_close_privilege_investigation_with_encumbrance(auth_headers): """Test closing a privilege investigation with encumbrance creation.""" logger.info('Testing privilege investigation closing with encumbrance...') - provider_data = call_provider_users_me_endpoint() + provider_data = _fetch_provider_details(auth_headers) provider_id = provider_data['providerId'] compact = provider_data['compact'] jurisdiction = provider_data['privileges'][0]['jurisdiction'] @@ -380,7 +389,7 @@ def test_close_privilege_investigation_with_encumbrance(auth_headers): license_type_abbreviation = get_license_type_abbreviation(license_type) # Verify initial state: an open investigation should exist - investigation_id = _verify_investigation_exists('privilege', jurisdiction, license_type) + investigation_id = _verify_investigation_exists('privilege', jurisdiction, license_type, auth_headers) # Verify privilege is not already encumbered (no adverse actions) privilege_data = _get_privilege_data_from_provider_response(provider_data, jurisdiction, license_type) @@ -395,8 +404,8 @@ def test_close_privilege_investigation_with_encumbrance(auth_headers): 'action': 'close', 'encumbrance': { 'encumbranceEffectiveDate': '2024-01-15', - 'encumbranceType': 'fine', - 'clinicalPrivilegeActionCategories': ['Unsafe Practice or Substandard Care'], + 'encumbranceType': 'revocation', + 'clinicalPrivilegeActionCategories': ['consumer harm'], }, } @@ -418,9 +427,9 @@ def test_close_privilege_investigation_with_encumbrance(auth_headers): # Wait for the investigation to be processed and DynamoDB eventual consistency time.sleep(5) - _verify_no_investigation_exists('privilege', jurisdiction, license_type) + _verify_no_investigation_exists('privilege', jurisdiction, license_type, auth_headers) # Verify encumbrance was created (adverse action exists) - provider_data = call_provider_users_me_endpoint() + provider_data = _fetch_provider_details(auth_headers) privilege_data = _get_privilege_data_from_provider_response(provider_data, jurisdiction, license_type) if not privilege_data.get('adverseActions'): @@ -452,8 +461,11 @@ def main(): staff_user_sub = create_test_staff_user( email=staff_user_email, compact='cosm', - jurisdiction='ne', - permissions={'actions': {'admin'}, 'jurisdictions': {'ne': {'admin'}, 'co': {'admin'}, 'ky': {'admin'}}}, + jurisdiction='az', + permissions={ + 'actions': {'admin'}, + 'jurisdictions': {'al': {'admin'}, 'az': {'admin'}, 'co': {'admin'}, 'ky': {'admin'}}, + }, ) # Get staff user auth headers once for reuse diff --git a/backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py b/backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py index 7dbc37c21..41f6fe1e8 100644 --- a/backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py +++ b/backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py @@ -4,12 +4,15 @@ from datetime import UTC, datetime, timedelta import requests -from config import logger +from config import config, logger from smoke_common import ( SmokeTestFailureException, + create_test_app_client, create_test_staff_user, + delete_test_app_client, delete_test_staff_user, get_api_base_url, + get_client_auth_headers, get_data_events_dynamodb_table, get_provider_user_dynamodb_table, get_staff_user_auth_headers, @@ -18,20 +21,20 @@ MOCK_SSN = '999-99-9999' COMPACT = 'cosm' -JURISDICTION = 'ne' +JURISDICTION = 'az' TEST_PROVIDER_GIVEN_NAME = 'Joe' TEST_PROVIDER_FAMILY_NAME = 'Dokes' -# This script can be run locally to test the license upload/ingest flow against a sandbox environment -# of the Compact Connect API. -# Your sandbox account must be deployed with the "security_profile": "VULNERABLE" setting in your cdk.context.json -# To run this script, create a smoke_tests_env.json file in the same directory as this script using the -# 'smoke_tests_env_example.json' file as a template. +# This script can be run locally to test the license upload/ingest flow against a sandbox environment. +# License POST uses the state API (CC_TEST_STATE_API_BASE_URL) with a short-lived Cognito app client +# (CC_TEST_STATE_AUTH_URL, CC_TEST_COGNITO_STATE_AUTH_USER_POOL_ID); provider query/GET use the internal API +# (CC_TEST_API_BASE_URL) with a staff user. Configure smoke_tests_env.json from smoke_tests_env_example.json. # Note that by design, developers do not have the ability to delete records from the SSN DynamoDB table, # so this script does not delete the created SSN records as part of cleanup. TEST_STAFF_USER_EMAIL = 'testStaffUserLicenseUploader@smokeTestFakeEmail.com' +TEST_APP_CLIENT_NAME = 'test-license-upload-smoke-client' def _cleanup_test_generated_records(provider_id: str, license_ingest_record_response: dict): @@ -57,19 +60,18 @@ def _cleanup_test_generated_records(provider_id: str, license_ingest_record_resp logger.info('Successfully deleted license ingest record from data events table') -def upload_licenses_record(): +def upload_licenses_record(license_upload_auth_headers: dict): """ Verifies that a license record can be uploaded to the Compact Connect API and the appropriate records are created in the provider table as well as the data events table. - Step 1: Upload a license record through the POST '/v1/compacts/cosm/jurisdictions/ne/licenses' endpoint. - Step 2: Verify the provider records are added by querying the API. + Step 1: Upload a license via the state API (POST .../licenses) using state app client credentials. + Step 2: Verify the provider records are added by querying the internal staff API. Step 3: Verify the license record is recorded in the data events table. """ + staff_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) - headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) - - # Step 1: Upload a license record through the POST '/v1/compacts/cosm/jurisdictions/ne/licenses' endpoint. + # Step 1: State-authenticated license upload (see stacks/state_api_stack). post_body = [ { 'licenseNumber': 'A0608337260', @@ -90,10 +92,10 @@ def upload_licenses_record(): ] post_response = requests.post( - url=get_api_base_url() + f'/v1/compacts/{COMPACT}/jurisdictions/{JURISDICTION}/licenses', - headers=headers, + url=f'{config.state_api_base_url}/v1/compacts/{COMPACT}/jurisdictions/{JURISDICTION}/licenses', + headers=license_upload_auth_headers, json=post_body, - timeout=10, + timeout=60, ) if post_response.status_code != 200: @@ -112,7 +114,7 @@ def upload_licenses_record(): query_response = requests.post( url=get_api_base_url() + f'/v1/compacts/{COMPACT}/providers/query', - headers=headers, + headers=staff_headers, json=query_body, timeout=10, ) @@ -147,7 +149,7 @@ def upload_licenses_record(): # Now get the provider details to verify the license record provider_details_response = requests.get( url=get_api_base_url() + f'/v1/compacts/{COMPACT}/providers/{provider_id}', - headers=headers, + headers=staff_headers, timeout=10, ) @@ -171,7 +173,7 @@ def upload_licenses_record(): # Step 3: Verify the license record is recorded in the data events table. # we don't loop here because the record should be available in the data events table by the time the - # provider table record is available + # provider table record is available. We use a consistent read to ensure that we get the latest record. data_events_table = get_data_events_dynamodb_table() event_time = datetime.now(tz=UTC) start_time = event_time - timedelta(minutes=15) @@ -179,10 +181,11 @@ def upload_licenses_record(): license_ingest_record_response = data_events_table.query( KeyConditionExpression='pk = :pk AND sk BETWEEN :start_time AND :end_time', ExpressionAttributeValues={ - ':pk': 'COMPACT#cosm#JURISDICTION#ne', + ':pk': f'COMPACT#{COMPACT}#JURISDICTION#{JURISDICTION}', ':start_time': f'TYPE#license.ingest#TIME#{int(start_time.timestamp())}', ':end_time': f'TYPE#license.ingest#TIME#{int(event_time.timestamp())}', }, + ConsistentRead=True, ) if not license_ingest_record_response.get('Items'): @@ -200,18 +203,28 @@ def upload_licenses_record(): if __name__ == '__main__': load_smoke_test_env() - # Create staff user with permission to upload licenses - test_user_sub = create_test_staff_user( - email=TEST_STAFF_USER_EMAIL, - compact=COMPACT, - jurisdiction=JURISDICTION, - permissions={'actions': {'admin'}, 'jurisdictions': {JURISDICTION: {'write', 'admin'}}}, - ) + + test_user_sub = None + client_id = None try: - upload_licenses_record() + # Create staff user with permission to query providers (internal API) + test_user_sub = create_test_staff_user( + email=TEST_STAFF_USER_EMAIL, + compact=COMPACT, + jurisdiction=JURISDICTION, + permissions={'actions': {'admin'}, 'jurisdictions': {JURISDICTION: {'write', 'admin'}}}, + ) + client_credentials = create_test_app_client(TEST_APP_CLIENT_NAME, COMPACT, JURISDICTION) + client_id = client_credentials['client_id'] + client_secret = client_credentials['client_secret'] + license_upload_headers = get_client_auth_headers(client_id, client_secret, COMPACT, JURISDICTION) + upload_licenses_record(license_upload_headers) logger.info('License record upload smoke test passed') except SmokeTestFailureException as e: logger.error(f'License record upload smoke test failed: {str(e)}') finally: - # Clean up the test staff user - delete_test_staff_user(TEST_STAFF_USER_EMAIL, user_sub=test_user_sub, compact=COMPACT) + if client_id: + delete_test_app_client(client_id) + if test_user_sub: + # Clean up the test staff user + delete_test_staff_user(TEST_STAFF_USER_EMAIL, user_sub=test_user_sub, compact=COMPACT) diff --git a/backend/cosmetology-app/tests/smoke/rollback_license_upload_smoke_tests.py b/backend/cosmetology-app/tests/smoke/rollback_license_upload_smoke_tests.py index 2c92f4889..37319334e 100644 --- a/backend/cosmetology-app/tests/smoke/rollback_license_upload_smoke_tests.py +++ b/backend/cosmetology-app/tests/smoke/rollback_license_upload_smoke_tests.py @@ -2,6 +2,7 @@ #!/usr/bin/env python3 import json import time +import uuid from datetime import UTC, datetime, timedelta import boto3 @@ -22,23 +23,41 @@ load_smoke_test_env, ) +""" +Test to verify that license records can be rolled back using rollback step function + +Note that these tests upload license records into the system +""" + COMPACT = 'cosm' -JURISDICTION = 'ne' +JURISDICTION = 'az' TEST_STAFF_USER_EMAIL = 'testStaffUserLicenseRollback@smokeTestFakeEmail.com' TEST_APP_CLIENT_NAME = 'test-license-rollback-client' LICENSE_TYPE = 'cosmetologist' # Test configuration -NUM_LICENSES_TO_UPLOAD = 300 -BATCH_SIZE = 100 # Upload in batches of 100 +NUM_LICENSES_TO_UPLOAD = 100 +BATCH_SIZE = 100 # Upload in batches of 100 (single batch at default scale) +# First upload: API returns after SQS enqueue; provider rows appear as preprocess + ingest drain (queue + batch size). +FIRST_UPLOAD_PROVIDER_INGEST_MAX_WAIT_SEC = 180 +# Second upload re-uses existing providers; wait_for_all_providers_created returns immediately while ingest +# (SQS/Lambda) may still be writing license_update rows — buffer before polling, then retry below. +SECOND_UPLOAD_INGEST_BUFFER_SEC = 30 +LICENSE_UPDATE_VERIFY_MAX_RETRIES = 12 +LICENSE_UPDATE_VERIFY_RETRY_SLEEP_SEC = 20 # Global list to track all provider IDs for cleanup ALL_PROVIDER_IDS = [] def upload_test_license_batch( - auth_headers: dict, batch_start_index: int, batch_size: int, street_address: str = '123 Test Street' + auth_headers: dict, + batch_start_index: int, + batch_size: int, + street_address: str = '123 Test Street', + *, + family_name: str, ): """ Upload a batch of test license records. @@ -57,17 +76,16 @@ def upload_test_license_batch( 'licenseNumber': f'ROLLBACK-TEST-{i:04d}', 'homeAddressPostalCode': '68001', 'givenName': f'TestProvider{i:04d}', - # keep the family name consistent so we can query for all the providers which requires an exact - # match on the family name - 'familyName': 'RollbackTest', + # Per-run family name isolates the provider query from leftover "RollbackTest*" rows in shared sandboxes. + 'familyName': family_name, 'homeAddressStreet1': street_address, 'dateOfBirth': '1985-01-01', 'dateOfIssuance': '2020-01-01', - 'ssn': f'999-50-{i:04d}', # Incrementing SSN with padded zeros + 'ssn': f'555-50-{i:04d}', # Incrementing SSN with padded zeros 'licenseType': LICENSE_TYPE, 'dateOfExpiration': '2050-12-10', - 'homeAddressState': 'NE', - 'homeAddressCity': 'Omaha', + 'homeAddressState': 'AZ', + 'homeAddressCity': 'Phoenix', 'compactEligibility': 'eligible', 'licenseStatus': 'active', } @@ -96,7 +114,12 @@ def upload_test_license_batch( def upload_test_licenses( - auth_headers: dict, num_licenses: int, batch_size: int, street_address: str = '123 Test Street' + auth_headers: dict, + num_licenses: int, + batch_size: int, + street_address: str = '123 Test Street', + *, + family_name: str, ): """ Upload test license records in batches. @@ -113,7 +136,9 @@ def upload_test_licenses( for batch_start in range(0, num_licenses, batch_size): current_batch_size = min(batch_size, num_licenses - batch_start) - batch_licenses = upload_test_license_batch(auth_headers, batch_start, current_batch_size, street_address) + batch_licenses = upload_test_license_batch( + auth_headers, batch_start, current_batch_size, street_address, family_name=family_name + ) all_licenses.extend(batch_licenses) # Small delay between batches to avoid rate limiting @@ -142,42 +167,50 @@ def verify_license_update_records_created(provider_ids, retry_count: int = 0): provider_ids_to_retry.append(provider_id) if provider_ids_to_retry: - if retry_count >= 3: + if retry_count >= LICENSE_UPDATE_VERIFY_MAX_RETRIES: raise SmokeTestFailureException( - f'failed to find license update records for {len(provider_ids_to_retry)} providers after 3 retries' + f'failed to find license update records for {len(provider_ids_to_retry)} providers after ' + f'{LICENSE_UPDATE_VERIFY_MAX_RETRIES} retries ' + f'({LICENSE_UPDATE_VERIFY_RETRY_SLEEP_SEC}s between retries)' ) - time.sleep(10) - logger.info(f'retrying {len(provider_ids_to_retry)} providers after 10 seconds...') + time.sleep(LICENSE_UPDATE_VERIFY_RETRY_SLEEP_SEC) + logger.info( + f'retrying {len(provider_ids_to_retry)} providers after {LICENSE_UPDATE_VERIFY_RETRY_SLEEP_SEC} seconds...' + ) verify_license_update_records_created(provider_ids_to_retry, retry_count + 1) else: logger.info('all license update records found') -def wait_for_all_providers_created(staff_headers: dict, expected_count: int, max_wait_time: int = 120): +def wait_for_all_providers_created( + staff_headers: dict, + expected_count: int, + max_wait_time: int = 120, + *, + family_name: str, +): """ Wait for all provider records to be created from uploaded licenses. :param staff_headers: Authentication headers for staff user :param expected_count: Expected number of providers to be created - :param max_wait_time: Maximum time to wait in seconds (default: 900 = 15 minutes) - :return: List of provider IDs that were created + :param max_wait_time: Maximum time to wait in seconds (default: 120) + :return: List of all provider IDs matching family_name+jurisdiction (length should match expected_count when + the run is isolated via a unique family_name). """ - logger.info(f'Waiting for {expected_count} provider records to be created...') + logger.info(f'Waiting for {expected_count} provider records to be created (familyName={family_name})...') start_time = time.time() check_interval = 5 - # Query using the common family name prefix 'RollbackTest' - # The API will return all providers with family names starting with this prefix - - last_key = None - page_num = 1 all_provider_ids: set[str] = set() while time.time() - start_time < max_wait_time: + page_num = 1 + last_key = None # Collect all providers across all pages while True: query_body = { - 'query': {'familyName': 'RollbackTest'}, + 'query': {'familyName': family_name, 'jurisdiction': JURISDICTION}, 'pagination': {'pageSize': 100}, } if last_key: @@ -219,12 +252,19 @@ def wait_for_all_providers_created(staff_headers: dict, expected_count: int, max num_found = len(all_provider_ids) logger.info( - f'Found {num_found}/{expected_count} providers with family name "RollbackTest" (across {page_num} pages)' + f'Found {num_found}/{expected_count} providers with family name "{family_name}" ' + f'in jurisdiction "{JURISDICTION}" (across {page_num} pages)' ) - if num_found >= expected_count: + if num_found > expected_count: + raise SmokeTestFailureException( + f'More providers ({num_found}) than uploads ({expected_count}) matched the query; ' + f'(family_name="{family_name}", jurisdiction="{JURISDICTION}", compact="{COMPACT}"). ' + ) + + if num_found == expected_count: logger.info(f'All {expected_count} providers found!') - return list(all_provider_ids) # Return only the expected count + return list(all_provider_ids) elapsed = time.time() - start_time if elapsed < max_wait_time: @@ -341,41 +381,6 @@ def get_rollback_results_from_s3(results_s3_key: str): return results -def create_privilege_for_provider(provider_id: str, compact: str): - """ - Manually create a privilege record for a provider to test skip conditions. - - :param provider_id: The provider ID to create privilege for - :param compact: The compact abbreviation - """ - from datetime import date - - # Create a privilege record for a different jurisdiction (e.g., 'co' for Colorado) - privilege_jurisdiction = 'co' - license_type_abbr = 'cos' - - privilege_record = { - 'pk': f'{compact}#PROVIDER#{provider_id}', - 'sk': f'{compact}#PROVIDER#privilege/{privilege_jurisdiction}/{license_type_abbr}#', - 'type': 'privilege', - 'providerId': provider_id, - 'compact': compact, - 'jurisdiction': privilege_jurisdiction, - 'licenseJurisdiction': JURISDICTION, - 'licenseType': LICENSE_TYPE, - 'dateOfIssuance': datetime.now(tz=UTC).isoformat(), - 'dateOfRenewal': datetime.now(tz=UTC).isoformat(), - 'dateOfExpiration': date(2050, 12, 10).isoformat(), - 'dateOfUpdate': datetime.now(tz=UTC).isoformat(), - 'privilegeId': f'{license_type_abbr.upper()}-{privilege_jurisdiction.upper()}-12345', - 'administratorSetStatus': 'active', - 'compactTransactionId': 'test-transaction-12345', - } - - config.provider_user_dynamodb_table.put_item(Item=privilege_record) - logger.info(f'Created privilege record for provider {provider_id}') - - def create_encumbrance_update_for_provider(provider_id: str, compact: str, license_jurisdiction: str): """ Manually create a license encumbrance update record to test skip conditions. @@ -509,7 +514,9 @@ def verify_rollback_results(results: dict, expected_provider_count: int, expecte # Verify we got the expected number of reverted providers if num_reverted != expected_provider_count: - logger.warning(f'Expected {expected_provider_count} reverted providers but found {num_reverted}') + raise SmokeTestFailureException( + f'Expected {expected_provider_count} reverted providers but found {num_reverted}' + ) # Verify the reverted provider has the expected structure for i, summary in enumerate(reverted): @@ -613,6 +620,9 @@ def rollback_license_upload_smoke_test(): # Get authentication headers using app client auth_headers = get_client_auth_headers(client_id, client_secret, COMPACT, JURISDICTION) + run_family_name = f'RollbackTest-{uuid.uuid4().hex[:8]}' + logger.info(f'Run-scoped familyName for uploads and queries: {run_family_name}') + # Step 1: Upload test licenses (first time) logger.info('=' * 80) logger.info('STEP 1: Uploading test licenses (first time)') @@ -624,6 +634,7 @@ def rollback_license_upload_smoke_test(): NUM_LICENSES_TO_UPLOAD, BATCH_SIZE, street_address='123 Test Street', + family_name=run_family_name, ) first_upload_end_time = datetime.now(tz=UTC) logger.info( @@ -635,7 +646,12 @@ def rollback_license_upload_smoke_test(): logger.info('Waiting for first upload providers and license records to be created...') logger.info('=' * 80) time.sleep(10) - wait_for_all_providers_created(staff_headers, len(uploaded_licenses)) + wait_for_all_providers_created( + staff_headers, + len(uploaded_licenses), + max_wait_time=FIRST_UPLOAD_PROVIDER_INGEST_MAX_WAIT_SEC, + family_name=run_family_name, + ) logger.info('✅ All first upload license records have been created') # Step 2: Upload test licenses again with different address to create update records @@ -648,6 +664,7 @@ def rollback_license_upload_smoke_test(): NUM_LICENSES_TO_UPLOAD, BATCH_SIZE, street_address='456 Updated Street', + family_name=run_family_name, ) logger.info('Second upload completed - update records should be created') @@ -657,7 +674,14 @@ def rollback_license_upload_smoke_test(): logger.info('STEP 3: Waiting for provider records and update records to be created') logger.info('=' * 80) - provider_ids = wait_for_all_providers_created(staff_headers, len(uploaded_licenses)) + logger.info(f'Waiting {SECOND_UPLOAD_INGEST_BUFFER_SEC}s for second-upload ingest') + time.sleep(SECOND_UPLOAD_INGEST_BUFFER_SEC) + + provider_ids = wait_for_all_providers_created( + staff_headers, + len(uploaded_licenses), + family_name=run_family_name, + ) # Store all provider IDs globally for cleanup ALL_PROVIDER_IDS = provider_ids.copy() @@ -669,19 +693,9 @@ def rollback_license_upload_smoke_test(): logger.info(f'Found {len(provider_ids)} provider records') - # Step 4: Create privilege for first provider (should be skipped in rollback) - logger.info('=' * 80) - logger.info('STEP 4: Creating privilege for first provider to test skip condition') - logger.info('=' * 80) - - first_provider_id = provider_ids[0] - create_privilege_for_provider(first_provider_id, COMPACT) - skipped_provider_ids.append(first_provider_id) - logger.info(f'Created privilege for provider {first_provider_id} - should be skipped in rollback') - - # Step 5: Create encumbrance update for second provider (should be skipped in rollback) + # Step 4: Create encumbrance update for second provider (should be skipped in rollback) logger.info('=' * 80) - logger.info('STEP 5: Creating encumbrance update for second provider to test skip condition') + logger.info('STEP 4: Creating encumbrance update for second provider to test skip condition') logger.info('=' * 80) second_provider_id = provider_ids[1] @@ -693,9 +707,9 @@ def rollback_license_upload_smoke_test(): logger.info('Waiting briefly for test records to propagate...') time.sleep(5) - # Step 6: Start rollback step function + # Step 5: Start rollback step function logger.info('=' * 80) - logger.info('STEP 6: Starting rollback step function') + logger.info('STEP 5: Starting rollback step function') logger.info('=' * 80) rollback_start = first_upload_start_time @@ -710,18 +724,18 @@ def rollback_license_upload_smoke_test(): end_datetime=rollback_end, ) - # Step 7: Wait for step function completion + # Step 6: Wait for step function completion logger.info('=' * 80) - logger.info('STEP 7: Waiting for step function to complete') + logger.info('STEP 6: Waiting for step function to complete') logger.info('=' * 80) status, output = wait_for_step_function_completion(execution_arn) logger.info(f'Step function output: {json.dumps(output, indent=2)}') - # Step 8: Retrieve and verify results from S3 + # Step 7: Retrieve and verify results from S3 logger.info('=' * 80) - logger.info('STEP 8: Retrieving and verifying results from S3') + logger.info('STEP 7: Retrieving and verifying results from S3') logger.info('=' * 80) results_s3_key = output.get('resultsS3Key') @@ -730,21 +744,21 @@ def rollback_license_upload_smoke_test(): results = get_rollback_results_from_s3(results_s3_key) - # Expect all providers reverted except for the 2 skipped - expected_reverted = NUM_LICENSES_TO_UPLOAD - 2 - expected_skipped = 2 + # Expect all providers reverted except for the 1 skipped + expected_reverted = NUM_LICENSES_TO_UPLOAD - 1 + expected_skipped = 1 verify_rollback_results(results, expected_reverted, expected_skipped) - # Step 9: Verify providers deleted from database (except the 2 skipped ones) + # Step 8: Verify providers deleted from database (except the skipped one) logger.info('=' * 80) - logger.info('STEP 9: Verifying providers were deleted from database') + logger.info('STEP 8: Verifying providers were deleted from database') logger.info('=' * 80) verify_providers_deleted_from_database(results, COMPACT) - # Step 10: Clean up the 2 skipped provider records + # Step 9: Clean up the skipped provider records logger.info('=' * 80) - logger.info('STEP 10: Cleaning up skipped provider records') + logger.info('STEP 9: Cleaning up skipped provider records') logger.info('=' * 80) delete_all_provider_records(skipped_provider_ids, COMPACT) diff --git a/backend/cosmetology-app/tests/smoke/smoke_common.py b/backend/cosmetology-app/tests/smoke/smoke_common.py index 9bec1b3ff..0ea4264d2 100644 --- a/backend/cosmetology-app/tests/smoke/smoke_common.py +++ b/backend/cosmetology-app/tests/smoke/smoke_common.py @@ -164,17 +164,6 @@ def get_user_tokens(email, password=_TEST_STAFF_USER_PASSWORD, is_staff=False): raise e -def get_provider_user_auth_headers_cached(): - provider_token = os.environ.get('TEST_PROVIDER_USER_ID_TOKEN') - if not provider_token: - tokens = get_user_tokens(config.test_provider_user_username, config.test_provider_user_password, is_staff=False) - os.environ['TEST_PROVIDER_USER_ID_TOKEN'] = tokens['IdToken'] - - return { - 'Authorization': 'Bearer ' + os.environ['TEST_PROVIDER_USER_ID_TOKEN'], - } - - def get_staff_user_auth_headers(username: str, password: str = _TEST_STAFF_USER_PASSWORD): tokens = get_user_tokens(username, password, is_staff=True) return { @@ -226,50 +215,36 @@ def load_smoke_test_env(): os.environ.update(env_vars) -def call_provider_users_me_endpoint(): - """Get the provider data from the GET '/v1/provider-users/me' endpoint. +def call_provider_details_endpoint(headers: dict, compact: str, provider_id: str) -> dict: + """GET /v1/compacts/{compact}/providers/{provider_id} with staff (or other) auth headers.""" + url = f'{config.api_base_url}/v1/compacts/{compact}/providers/{provider_id}' + response = requests.get(url=url, headers=headers, timeout=10) - If a 403 response is received, the token will be refreshed and the request retried once. - - :return: The response body JSON - :raises SmokeTestFailureException: If the request fails after retry - """ - # Get the provider data from the GET '/v1/provider-users/me' endpoint. - get_provider_data_response = requests.get( - url=config.api_base_url + '/v1/provider-users/me', headers=get_provider_user_auth_headers_cached(), timeout=10 - ) - - # If we get a 403, the token may have expired - refresh it and retry once - if get_provider_data_response.status_code == 403: - logger.info('Received 403 response, refreshing provider user token and retrying...') - # Clear the cached token to force a refresh - if 'TEST_PROVIDER_USER_ID_TOKEN' in os.environ: - del os.environ['TEST_PROVIDER_USER_ID_TOKEN'] - - # Retry with fresh token - get_provider_data_response = requests.get( - url=config.api_base_url + '/v1/provider-users/me', - headers=get_provider_user_auth_headers_cached(), - timeout=10, - ) + if response.status_code != 200: + try: + body = response.json() + except Exception: # noqa: BLE001 + body = response.text + raise SmokeTestFailureException(f'Failed to GET provider details. Response: {body}') + return response.json() - if get_provider_data_response.status_code != 200: - raise SmokeTestFailureException(f'Failed to GET provider data. Response: {get_provider_data_response.json()}') - # return the response body - return get_provider_data_response.json() +def get_all_provider_database_records(compact: str = 'cosm', provider_id: str = config.test_provider_id): -def get_all_provider_database_records(): - # get the provider id and compact from the response - response = call_provider_users_me_endpoint() - provider_id = response['providerId'] - compact = response['compact'] - # query the provider database for all records - query_result = config.provider_user_dynamodb_table.query( - KeyConditionExpression=Key('pk').eq(f'{compact}#PROVIDER#{provider_id}') - ) + items: list = [] + last_evaluated_key = None + while True: + pagination = {'ExclusiveStartKey': last_evaluated_key} if last_evaluated_key else {} + query_result = config.provider_user_dynamodb_table.query( + KeyConditionExpression=Key('pk').eq(f'{compact}#PROVIDER#{provider_id}'), + **pagination, + ) + items.extend(query_result.get('Items', [])) + last_evaluated_key = query_result.get('LastEvaluatedKey') + if not last_evaluated_key: + break - return query_result['Items'] + return items def get_provider_user_records(compact: str, provider_id: str) -> ProviderUserRecords: @@ -323,7 +298,7 @@ def upload_license_record(staff_headers: dict, compact: str, jurisdiction: str, 'ssn': '999-99-9999', 'licenseType': 'cosmetologist', 'dateOfExpiration': '2050-01-01', - 'homeAddressState': 'ne', + 'homeAddressState': 'AZ', 'homeAddressCity': 'Omaha', 'licenseStatus': 'active', 'compactEligibility': 'eligible', diff --git a/backend/cosmetology-app/tests/smoke/smoke_tests_env_example.json b/backend/cosmetology-app/tests/smoke/smoke_tests_env_example.json index 9b12be036..98b5c0dd5 100644 --- a/backend/cosmetology-app/tests/smoke/smoke_tests_env_example.json +++ b/backend/cosmetology-app/tests/smoke/smoke_tests_env_example.json @@ -12,13 +12,8 @@ "CC_TEST_STAFF_USER_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-StaffUserTable1234", "CC_TEST_COGNITO_STAFF_USER_POOL_ID": "us-east-1_12345", "CC_TEST_COGNITO_STAFF_USER_POOL_CLIENT_ID": "72612345", - "CC_TEST_COGNITO_PROVIDER_USER_POOL_ID": "us-east-1_12345", - "CC_TEST_COGNITO_PROVIDER_USER_POOL_CLIENT_ID": "72612345", - "CC_TEST_PROVIDER_USER_USERNAME": "example@example.com", - "CC_TEST_PROVIDER_USER_PASSWORD": "examplePassword", + "CC_TEST_PROVIDER_ID": "exampleProviderId", "ENVIRONMENT_NAME": "sandboxEnvironmentNamePlaceholder", - "SANDBOX_AUTHORIZE_NET_API_LOGIN_ID": "your_sandbox_api_login_id", - "SANDBOX_AUTHORIZE_NET_TRANSACTION_KEY": "your_sandbox_transaction_key", "CC_TEST_SMOKE_TEST_NOTIFICATION_EMAIL": "smoke-test-notifications@example.com", "CC_TEST_ROLLBACK_STEP_FUNCTION_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:Sandbox-DisasterRecoveryStack-LicenseUploadRollbackStateMachine" } diff --git a/backend/cosmetology-app/tests/smoke/ssn_read_throttling_smoke_tests.py b/backend/cosmetology-app/tests/smoke/ssn_read_throttling_smoke_tests.py index 9101dea46..c98e44544 100644 --- a/backend/cosmetology-app/tests/smoke/ssn_read_throttling_smoke_tests.py +++ b/backend/cosmetology-app/tests/smoke/ssn_read_throttling_smoke_tests.py @@ -18,7 +18,7 @@ ) COMPACT = 'cosm' -JURISDICTION = 'ne' +JURISDICTION = 'az' TEST_PROVIDER_GIVEN_NAME = 'Joe' TEST_PROVIDER_FAMILY_NAME = 'Dokes'