diff --git a/queue_services/strr-email/src/strr_email/__init__.py b/queue_services/strr-email/src/strr_email/__init__.py index 1b720e8d3..080809a77 100644 --- a/queue_services/strr-email/src/strr_email/__init__.py +++ b/queue_services/strr-email/src/strr_email/__init__.py @@ -39,6 +39,7 @@ import sentry_sdk from sentry_sdk.integrations.flask import FlaskIntegration from strr_api import db +from strr_api.common.utils import setup_pg8000_graceful_shutdown from .config import Config from .config import ProdConfig @@ -63,6 +64,9 @@ def create_app(environment: Config = ProdConfig, **_kwargs) -> Flask: if app.config.get("POD_NAMESPACE", None) == "Testing": Migrate(app, db) + with app.app_context(): + setup_pg8000_graceful_shutdown(db.engine) + gcp_queue.init_app(app) register_endpoints(app) diff --git a/queue_services/strr-email/tests/integration/test_queue_email.py b/queue_services/strr-email/tests/integration/test_queue_email.py index fbb71c7ae..c9121f0cf 100644 --- a/queue_services/strr-email/tests/integration/test_queue_email.py +++ b/queue_services/strr-email/tests/integration/test_queue_email.py @@ -265,3 +265,121 @@ def notify_callback(request): assert stored.notify_reference assert stored.registration_id == registration.id assert stored.status == InteractionStatus.SENT + + +def test_email_no_cloud_event_data(client, queue_envelope, simple_cloud_event): + """Cloud event with no data is a no-op returning 200.""" + ce = simple_cloud_event(type="email", data={}) + envelope = queue_envelope(cloud_event=ce) + response = client.post("/", json=envelope) + assert response.status_code == HTTPStatus.OK + + +def test_email_application_not_found(client, queue_envelope, simple_cloud_event): + """Returns 404 when the application_number in the event does not exist.""" + data = { + "email_type": "HOST_AUTO_APPROVED", + "application_number": "NONEXISTENT-APP-12345", + } + ce = simple_cloud_event(type="email", data=data) + envelope = queue_envelope(cloud_event=ce) + response = client.post("/", json=envelope) + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_email_registration_not_found(client, queue_envelope, simple_cloud_event): + """Returns 404 when the registration_number in the event does not exist.""" + data = { + "email_type": "HOST_RENEWAL_REMINDER", + "registration_number": "NONEXISTENT-REG-99999", + } + ce = simple_cloud_event(type="email", data=data) + envelope = queue_envelope(cloud_event=ce) + response = client.post("/", json=envelope) + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.conf( + KEYCLOAK_AUTH_TOKEN_URL="http://my-auth-url", + NOTIFY_SVC_URL="http://my-notify-mock", + NOTIFY_API_TIMEOUT=30, + EMAIL_HOUSING_RECIPIENT_EMAIL="ops@gov.bc.ca", +) +@responses.activate +def test_email_notify_api_failure( + app, + client, + session, + simple_cloud_event, + queue_envelope, + setup_parents, + inject_config, +): + """Returns an error status when the notify API call fails.""" + responses.add( + responses.POST, + app.config.get("KEYCLOAK_AUTH_TOKEN_URL"), + json={"access_token": "token"}, + status=200, + ) + responses.add( + responses.POST, + app.config.get("NOTIFY_SVC_URL"), + json={"message": "Service unavailable"}, + status=503, + ) + + registration = create_registration(session, setup_parents) + + data = { + "email_type": "HOST_REGISTRATION_CANCELLED", + "registration_number": registration.registration_number, + } + ce = simple_cloud_event(type="email", data=data) + envelope = queue_envelope(cloud_event=ce) + + response = client.post("/", json=envelope) + assert response.status_code == 503 + + +@pytest.mark.conf( + KEYCLOAK_AUTH_TOKEN_URL="http://my-auth-url", + NOTIFY_SVC_URL="http://my-notify-mock", + NOTIFY_API_TIMEOUT=30, + EMAIL_HOUSING_RECIPIENT_EMAIL="ops@gov.bc.ca", +) +@responses.activate +def test_email_host_registration_cancelled( + app, + client, + session, + simple_cloud_event, + queue_envelope, + setup_parents, + inject_config, +): + """Returns 200 and calls notify-api directly for non-renewal email types.""" + responses.add( + responses.POST, + app.config.get("KEYCLOAK_AUTH_TOKEN_URL"), + json={"access_token": "token"}, + status=200, + ) + responses.add( + responses.POST, + app.config.get("NOTIFY_SVC_URL"), + json={"id": "notif-123"}, + status=200, + ) + + registration = create_registration(session, setup_parents) + + data = { + "email_type": "HOST_REGISTRATION_CANCELLED", + "registration_number": registration.registration_number, + } + ce = simple_cloud_event(type="email", data=data) + envelope = queue_envelope(cloud_event=ce) + + response = client.post("/", json=envelope) + assert response.status_code == HTTPStatus.OK diff --git a/queue_services/strr-email/tests/unit/test_email_listener_utils.py b/queue_services/strr-email/tests/unit/test_email_listener_utils.py new file mode 100644 index 000000000..b7fc1eb9c --- /dev/null +++ b/queue_services/strr-email/tests/unit/test_email_listener_utils.py @@ -0,0 +1,211 @@ +"""Unit tests for email_listener utility functions (no Flask or DB required).""" + +import pytest +from simple_cloudevent import SimpleCloudEvent +from strr_api.models import Registration + +from strr_email.resources.email_listener import ( + _get_address_detail, + _get_client_recipients, + _get_expiry_date, + _get_rental_nickname, + _get_service_provider, + dict_keys_to_snake_case, + get_email_info, +) + + +class TestDictKeysToSnakeCase: + def test_converts_camel_case(self): + result = dict_keys_to_snake_case({"emailType": "HOST", "applicationNumber": "APP-001"}) + assert result == {"email_type": "HOST", "application_number": "APP-001"} + + def test_already_snake_case_unchanged(self): + result = dict_keys_to_snake_case({"email_type": "HOST"}) + assert result == {"email_type": "HOST"} + + def test_empty_dict(self): + assert dict_keys_to_snake_case({}) == {} + + def test_preserves_none_values(self): + result = dict_keys_to_snake_case({"customContent": None, "registrationNumber": "H123"}) + assert result["custom_content"] is None + assert result["registration_number"] == "H123" + + def test_single_word_key(self): + result = dict_keys_to_snake_case({"type": "HOST"}) + assert result == {"type": "HOST"} + + +class TestGetEmailInfo: + def test_returns_email_info_from_valid_data(self): + ce = SimpleCloudEvent( + id="id", + source="src", + subject="sub", + type="email", + data={"emailType": "HOST_RENEWAL_REMINDER", "registrationNumber": "H123"}, + ) + info = get_email_info(ce) + assert info is not None + assert info.email_type == "HOST_RENEWAL_REMINDER" + assert info.registration_number == "H123" + + def test_returns_none_when_no_data(self): + ce = SimpleCloudEvent(id="id", source="src", subject="sub", type="email") + assert get_email_info(ce) is None + + def test_returns_none_when_data_is_not_dict(self): + ce = SimpleCloudEvent( + id="id", source="src", subject="sub", type="email", data="not-a-dict" + ) + assert get_email_info(ce) is None + + def test_converts_camel_case_fields(self): + ce = SimpleCloudEvent( + id="id", + source="src", + subject="sub", + type="email", + data={"emailType": "NOC", "applicationNumber": "APP-999", "customContent": "hello"}, + ) + info = get_email_info(ce) + assert info.email_type == "NOC" + assert info.application_number == "APP-999" + assert info.custom_content == "hello" + + +class TestGetAddressDetail: + def test_host_returns_requested_field(self): + app_dict = {"registration": {"unitAddress": {"streetNumber": "123"}}} + result = _get_address_detail(app_dict, Registration.RegistrationType.HOST, "streetNumber") + assert result == "123" + + def test_non_host_returns_empty_string(self): + app_dict = {"registration": {"unitAddress": {"streetNumber": "123"}}} + result = _get_address_detail( + app_dict, Registration.RegistrationType.PLATFORM, "streetNumber" + ) + assert result == "" + + def test_missing_field_returns_empty_string(self): + app_dict = {"registration": {"unitAddress": {}}} + result = _get_address_detail(app_dict, Registration.RegistrationType.HOST, "unitNumber") + assert result == "" + + def test_strata_hotel_returns_empty_string(self): + app_dict = {"registration": {"unitAddress": {"streetNumber": "456"}}} + result = _get_address_detail( + app_dict, Registration.RegistrationType.STRATA_HOTEL, "streetNumber" + ) + assert result == "" + + +class TestGetExpiryDate: + def test_formats_valid_iso_date(self): + app_dict = {"header": {"registrationEndDate": "2025-12-15T00:00:00+00:00"}} + result = _get_expiry_date(app_dict) + assert "December" in result + assert "2025" in result + + def test_returns_empty_when_no_date(self): + assert _get_expiry_date({"header": {}}) == "" + + def test_returns_empty_when_no_header(self): + assert _get_expiry_date({}) == "" + + def test_returns_empty_when_date_is_none(self): + assert _get_expiry_date({"header": {"registrationEndDate": None}}) == "" + + +class TestGetServiceProvider: + def test_platform_returns_legal_name(self): + app_dict = {"registration": {"businessDetails": {"legalName": "Acme Rentals Ltd."}}} + result = _get_service_provider(app_dict, Registration.RegistrationType.PLATFORM) + assert result == "Acme Rentals Ltd." + + def test_host_returns_empty_string(self): + app_dict = {"registration": {"businessDetails": {"legalName": "Acme Rentals Ltd."}}} + result = _get_service_provider(app_dict, Registration.RegistrationType.HOST) + assert result == "" + + def test_strata_hotel_returns_empty_string(self): + app_dict = {"registration": {"businessDetails": {"legalName": "Acme"}}} + result = _get_service_provider(app_dict, Registration.RegistrationType.STRATA_HOTEL) + assert result == "" + + +class TestGetClientRecipients: + def test_host_returns_primary_contact_email(self): + app_dict = { + "registration": { + "registrationType": Registration.RegistrationType.HOST.value, + "primaryContact": {"emailAddress": "host@example.com"}, + } + } + result = _get_client_recipients(app_dict) + assert result == "host@example.com" + + def test_host_with_property_manager_contact_email(self): + app_dict = { + "registration": { + "registrationType": Registration.RegistrationType.HOST.value, + "primaryContact": {"emailAddress": "host@example.com"}, + "propertyManager": {"contact": {"emailAddress": "pm@example.com"}}, + } + } + result = _get_client_recipients(app_dict) + assert "host@example.com" in result + assert "pm@example.com" in result + + def test_host_with_property_manager_business_email(self): + app_dict = { + "registration": { + "registrationType": Registration.RegistrationType.HOST.value, + "primaryContact": {"emailAddress": "host@example.com"}, + "propertyManager": { + "business": {"primaryContact": {"emailAddress": "biz@example.com"}} + }, + } + } + result = _get_client_recipients(app_dict) + assert "host@example.com" in result + assert "biz@example.com" in result + + def test_platform_returns_empty_string(self): + app_dict = { + "registration": { + "registrationType": Registration.RegistrationType.PLATFORM.value, + } + } + assert _get_client_recipients(app_dict) == "" + + def test_strata_hotel_returns_empty_string(self): + app_dict = { + "registration": { + "registrationType": Registration.RegistrationType.STRATA_HOTEL.value, + } + } + assert _get_client_recipients(app_dict) == "" + + +class TestGetRentalNickname: + def test_host_with_nickname_returns_nickname(self): + app_dict = {"registration": {"unitAddress": {"nickname": "Beach House"}}} + result = _get_rental_nickname(app_dict, Registration.RegistrationType.HOST) + assert result == "Beach House" + + def test_host_without_nickname_returns_none(self): + app_dict = {"registration": {"unitAddress": {}}} + result = _get_rental_nickname(app_dict, Registration.RegistrationType.HOST) + assert result is None + + def test_platform_returns_none(self): + app_dict = {"registration": {"unitAddress": {"nickname": "Beach House"}}} + result = _get_rental_nickname(app_dict, Registration.RegistrationType.PLATFORM) + assert result is None + + def test_strata_hotel_returns_none(self): + app_dict = {"registration": {"unitAddress": {"nickname": "Resort"}}} + result = _get_rental_nickname(app_dict, Registration.RegistrationType.STRATA_HOTEL) + assert result is None diff --git a/queue_services/strr-pay/src/strr_pay/__init__.py b/queue_services/strr-pay/src/strr_pay/__init__.py index ddb3ed438..91b19bc6a 100644 --- a/queue_services/strr-pay/src/strr_pay/__init__.py +++ b/queue_services/strr-pay/src/strr_pay/__init__.py @@ -39,6 +39,7 @@ from flask import Flask from strr_api import db +from strr_api.common.utils import setup_pg8000_graceful_shutdown from .config import Config from .config import ProdConfig @@ -52,6 +53,10 @@ def create_app(environment: Config = ProdConfig, **_kwargs) -> Flask: app.config.from_object(environment) db.init_app(app) + + with app.app_context(): + setup_pg8000_graceful_shutdown(db.engine) + gcp_queue.init_app(app) register_endpoints(app) diff --git a/strr-api/src/strr_api/common/utils.py b/strr-api/src/strr_api/common/utils.py index 491ef431f..6560cac01 100644 --- a/strr-api/src/strr_api/common/utils.py +++ b/strr-api/src/strr_api/common/utils.py @@ -1,8 +1,36 @@ """Common utility functions.""" +import logging + +from sqlalchemy import event from strr_api.models import Address from strr_api.requests import SBCMailingAddress +def setup_pg8000_graceful_shutdown(engine) -> None: + """Suppress pg8000 InterfaceError on connection close during Cloud Run scale-down.""" + if getattr(engine, "driver", None) != "pg8000": + return + try: + from pg8000.exceptions import InterfaceError as _interface_error + except ImportError: + _interface_error = None + + @event.listens_for(engine, "connect") + def on_connect(dbapi_conn, _connection_record): + orig_close = dbapi_conn.close + + def safe_close(): + try: + orig_close() + except Exception as exc: + if _interface_error and isinstance(exc, _interface_error): + logging.getLogger(__name__).debug("Suppressed pg8000 InterfaceError on teardown.") + else: + raise + + dbapi_conn.close = safe_close + + def compare_addresses(property_address: Address, sbc_address: SBCMailingAddress): """Compare property address with sbc address."""