import json import pytest from unittest.mock import MagicMock from src.app import _onboard_store, _send_batched_emails, handler from src.fivetran_client import ConnectionSetupState def _make_store( shop_domain="test.myshopify.com", schema_name="shopify_test", merch_company=None, selling_country=None, rep_owner=None, alt_myshopify_domain=None, custom_domain=None, ) -> dict: return { "shop_domain": shop_domain, "schema_name": schema_name, "merch_company": merch_company, "selling_country": selling_country, "rep_owner": rep_owner, "alt_myshopify_domain": alt_myshopify_domain, "custom_domain": custom_domain, } @pytest.fixture(autouse=True) def patch_get_connection(mocker, mock_sf): mocker.patch("src.app.get_connection", return_value=mock_sf) @pytest.fixture(autouse=True) def patch_metrics(mocker): mocker.patch("src.app.lambda_metric") class TestOnboardStore: def test_existing_schema_reuses_connector_without_create(self, mock_ft_instance, mock_sf): mock_ft_instance.get_connection_status.return_value = MagicMock( status=MagicMock(setup_state=ConnectionSetupState.INCOMPLETE) ) existing = {"shopify_test": "existing_conn_id"} store = _make_store() _onboard_store(store, mock_ft_instance, mock_sf, existing) mock_ft_instance.create_shopify_connector.assert_not_called() mock_ft_instance.get_connection_status.assert_called_once_with("existing_conn_id") def test_new_connector_not_connected_issues_card_and_writes_pending_oauth(self, mocker, mock_ft_instance, mock_sf): mock_state_write = mocker.patch("src.app.upsert_state") store = _make_store() status, email_data = _onboard_store(store, mock_ft_instance, mock_sf, {}) assert status == "pending_oauth" mock_ft_instance.create_shopify_connector.assert_called_once() mock_ft_instance.get_connect_card.assert_called_once() mock_state_write.assert_called_once() call_data = mock_state_write.call_args[0][1] assert call_data["status"] == "pending_oauth" assert call_data["connect_card_uri"] is not None def test_connector_already_connected_writes_pending_configure_without_card(self, mocker, mock_ft_instance, mock_sf): from tests.factories import ShopifyConnectionFactory mock_ft_instance.create_shopify_connector.return_value = ShopifyConnectionFactory.connected() mock_ft_instance.get_connection_status.return_value = ShopifyConnectionFactory.connected() mock_state_write = mocker.patch("src.app.upsert_state") store = _make_store() status, _ = _onboard_store(store, mock_ft_instance, mock_sf, {}) assert status == "pending_configure" mock_ft_instance.get_connect_card.assert_not_called() call_data = mock_state_write.call_args[0][1] assert call_data["status"] == "pending_configure" def test_new_connector_added_to_existing_by_schema_after_creation(self, mock_ft_instance, mock_sf): existing: dict = {} store = _make_store() _onboard_store(store, mock_ft_instance, mock_sf, existing) assert "shopify_test" in existing def test_within_batch_dedup_reuses_connector_from_existing_by_schema(self, mock_ft_instance, mock_sf): from tests.factories import ShopifyConnectionFactory created_conn = ShopifyConnectionFactory.incomplete(id="new_conn_id") mock_ft_instance.create_shopify_connector.return_value = created_conn mock_ft_instance.get_connection_status.return_value = created_conn existing: dict = {} store = _make_store() _onboard_store(store, mock_ft_instance, mock_sf, existing) _onboard_store(store, mock_ft_instance, mock_sf, existing) assert mock_ft_instance.create_shopify_connector.call_count == 1 def test_store_metadata_passed_through_to_state_write(self, mocker, mock_ft_instance, mock_sf): mock_state_write = mocker.patch("src.app.upsert_state") store = _make_store( merch_company="Ceremony of Roses", selling_country="GB", rep_owner="COR", alt_myshopify_domain="alt.myshopify.com", custom_domain="myband.com", ) _onboard_store(store, mock_ft_instance, mock_sf, {}) call_data = mock_state_write.call_args[0][1] assert call_data["merch_company"] == "Ceremony of Roses" assert call_data["selling_country"] == "GB" assert call_data["rep_owner"] == "COR" assert call_data["alt_myshopify_domain"] == "alt.myshopify.com" assert call_data["custom_domain"] == "myband.com" class TestHandler: def _sqs_event(self, *stores): return {"Records": [{"body": json.dumps(s), "messageId": f"msg-{i}"} for i, s in enumerate(stores)]} def test_happy_path_returns_empty_failures(self, mock_ft_class): event = self._sqs_event(_make_store()) result = handler(event, None) assert result == {"batchItemFailures": []} def test_failed_record_added_to_batch_failures(self, mock_ft_class): mock_ft_class.get_connection_status.side_effect = Exception("Fivetran error") event = self._sqs_event(_make_store()) result = handler(event, None) assert result["batchItemFailures"] == [{"itemIdentifier": "msg-0"}] def test_failed_record_without_message_id_raises(self, mock_ft_class): mock_ft_class.get_connection_status.side_effect = Exception("Fivetran error") event = {"Records": [{"body": json.dumps(_make_store())}]} with pytest.raises(Exception, match="Fivetran error"): handler(event, None) def test_second_record_failure_does_not_affect_first(self, mock_ft_class): def side_effect_after_first(connector_id): if mock_ft_class.get_connection_status.call_count == 1: from tests.factories import ShopifyConnectionFactory return ShopifyConnectionFactory.incomplete() raise Exception("second record fails") mock_ft_class.get_connection_status.side_effect = side_effect_after_first event = self._sqs_event( _make_store(shop_domain="store-a.myshopify.com", schema_name="shopify_store_a"), _make_store(shop_domain="store-b.myshopify.com", schema_name="shopify_store_b"), ) result = handler(event, None) assert len(result["batchItemFailures"]) == 1 assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-1" def test_empty_event_returns_empty_failures(self, mock_ft_class): result = handler({"Records": []}, None) assert result == {"batchItemFailures": []} def test_email_failure_does_not_fail_handler(self, mocker, mock_ft_class): mocker.patch("src.app._send_batched_emails", side_effect=Exception("SES down")) store = _make_store(merch_company="CoR", selling_country="GB") result = handler(self._sqs_event(store), None) assert result == {"batchItemFailures": []} class TestSendBatchedEmails: @pytest.fixture def mock_ses(self, mocker): mock_cls = mocker.patch("src.app.SESClient") mock_instance = MagicMock() mock_instance.send_email.return_value = "msg-ses-001" mock_cls.return_value = mock_instance return mock_instance @pytest.fixture def mock_get_contacts(self, mocker): return mocker.patch("src.app.get_contacts") def _email_data(self, shop_domain="store.myshopify.com", company="CoR", country="GB"): return { "shop_domain": shop_domain, "connect_card_uri": "https://fivetran.com/card/abc", "link_expires_at": "2026-07-09T10:00:00+00:00", "merch_company": company, "selling_country": country, } def _contacts_array(self): return [{"NAME": "Alice Smith", "EMAIL": "alice@cor.com", "ROLE": "Manager", "COMPANY": "CoR"}] def test_empty_list_does_nothing(self, mock_sf, mock_ses, mock_get_contacts): _send_batched_emails([], mock_sf) mock_ses.send_email.assert_not_called() mock_get_contacts.assert_not_called() def test_store_missing_company_is_skipped(self, mock_sf, mock_ses, mock_get_contacts): data = self._email_data() data["merch_company"] = None _send_batched_emails([data], mock_sf) mock_ses.send_email.assert_not_called() mock_get_contacts.assert_not_called() def test_store_missing_country_is_skipped(self, mock_sf, mock_ses, mock_get_contacts): data = self._email_data() data["selling_country"] = None _send_batched_emails([data], mock_sf) mock_ses.send_email.assert_not_called() mock_get_contacts.assert_not_called() def test_happy_path_sends_one_email(self, mock_sf, mock_ses, mock_get_contacts): mock_get_contacts.return_value = self._contacts_array() _send_batched_emails([self._email_data()], mock_sf) mock_ses.send_email.assert_called_once() def test_two_stores_same_batch_produce_one_email(self, mock_sf, mock_ses, mock_get_contacts): mock_get_contacts.return_value = self._contacts_array() data = [ self._email_data(shop_domain="a.myshopify.com"), self._email_data(shop_domain="b.myshopify.com"), ] _send_batched_emails(data, mock_sf) mock_ses.send_email.assert_called_once() assert "2 store(s)" in mock_ses.send_email.call_args.kwargs["subject"] def test_different_countries_produce_separate_emails(self, mock_sf, mock_ses, mock_get_contacts): mock_get_contacts.return_value = self._contacts_array() data = [ self._email_data(country="GB"), self._email_data(country="EU"), ] _send_batched_emails(data, mock_sf) assert mock_ses.send_email.call_count == 2 def test_no_contacts_found_skips_email(self, mock_sf, mock_ses, mock_get_contacts): mock_get_contacts.return_value = None _send_batched_emails([self._email_data()], mock_sf) mock_ses.send_email.assert_not_called() def test_contacts_with_no_valid_emails_skips_email(self, mock_sf, mock_ses, mock_get_contacts): mock_get_contacts.return_value = [{"NAME": "Alice", "EMAIL": "not-valid", "ROLE": "x", "COMPANY": "y"}] _send_batched_emails([self._email_data()], mock_sf) mock_ses.send_email.assert_not_called() def test_ses_failure_on_one_batch_does_not_stop_other_batches(self, mock_sf, mock_ses, mock_get_contacts): mock_get_contacts.return_value = self._contacts_array() mock_ses.send_email.side_effect = [Exception("SES timeout"), "msg-ses-002"] data = [ self._email_data(country="GB"), self._email_data(country="EU"), ] _send_batched_emails(data, mock_sf) assert mock_ses.send_email.call_count == 2 def test_snowflake_failure_on_one_batch_does_not_stop_other_batches(self, mock_sf, mock_ses, mock_get_contacts): mock_get_contacts.side_effect = [Exception("Snowflake down"), self._contacts_array()] data = [ self._email_data(country="GB"), self._email_data(country="EU"), ] _send_batched_emails(data, mock_sf) mock_ses.send_email.assert_called_once() def test_source_arn_passed_through_when_configured(self, mocker, mock_sf, mock_ses, mock_get_contacts): """SES_SOURCE_ARN from config must be forwarded to send_email so cross-account sending works.""" mocker.patch("src.app.config.SES_SOURCE_ARN", "arn:aws:ses:us-east-1:123456789012:identity/sender@example.com") mock_get_contacts.return_value = self._contacts_array() _send_batched_emails([self._email_data()], mock_sf) assert mock_ses.send_email.call_args.kwargs["source_arn"] == ( "arn:aws:ses:us-east-1:123456789012:identity/sender@example.com" ) def test_source_arn_is_none_when_not_configured(self, mocker, mock_sf, mock_ses, mock_get_contacts): """When SES_SOURCE_ARN is unset, source_arn=None is passed (same-account sending).""" mocker.patch("src.app.config.SES_SOURCE_ARN", None) mock_get_contacts.return_value = self._contacts_array() _send_batched_emails([self._email_data()], mock_sf) assert mock_ses.send_email.call_args.kwargs["source_arn"] is None