import pytest from unittest.mock import MagicMock from src.app import ( _handle_pending_configure, _handle_pending_oauth, _handle_pending_sync, handler, ) from tests.factories import ShopifyConnectionFactory def _make_store( shop_domain="test.myshopify.com", connector_id="conn_abc", schema_name="shopify_test", status="pending_oauth", link_expires_at=None, merch_company=None, selling_country=None, rep_owner=None, ) -> dict: return { "shop_domain": shop_domain, "connector_id": connector_id, "schema_name": schema_name, "status": status, "link_expires_at": link_expires_at, "merch_company": merch_company, "selling_country": selling_country, "rep_owner": rep_owner, "alt_myshopify_domain": None, "custom_domain": None, } @pytest.fixture(autouse=True) def patch_sleep(mocker): mocker.patch("src.app._sleep") @pytest.fixture(autouse=True) def patch_metrics(mocker): mocker.patch("src.app.lambda_metric") @pytest.fixture def patch_ft_class(mocker, mock_ft): mock_cls = mocker.patch("src.app.FivetranClient") mock_cls.return_value.__enter__ = MagicMock(return_value=mock_ft) mock_cls.return_value.__exit__ = MagicMock(return_value=False) return mock_ft @pytest.fixture(autouse=True) def patch_snowflake(mocker, mock_sf): mocker.patch("src.app.get_connection", return_value=mock_sf) mocker.patch("src.app.get_stores_by_statuses", return_value=[]) mocker.patch("src.app.set_status_by_connector_id") mocker.patch("src.app.upsert_state") mocker.patch("src.app.upsert_registry") mocker.patch("src.app.shop_table_exists", return_value=False) mocker.patch("src.app.get_store_info", return_value=(None, None, None, None)) mocker.patch("src.app.resolve_gp", return_value=(None, None)) mocker.patch("src.app.resolve_vendor_id", return_value=(None, None, None, None)) mocker.patch("src.app.set_synced_to_snowflake") @pytest.fixture def patch_enabled_tables(mocker): mocker.patch("src.app._load_enabled_tables", return_value=frozenset({"SHOP", "ORDER"})) class TestHandlePendingOAuth: def test_connected_connector_sets_pending_configure(self, mocker, mock_ft, mock_sf): mock_ft.get_connection_status.return_value = ShopifyConnectionFactory.connected() mock_set_status = mocker.patch("src.app.set_status_by_connector_id") mocker.patch("src.app._age_days", return_value=5) store = _make_store() result = _handle_pending_oauth(mock_ft, mock_sf, store) assert result == "pending_configure" mock_set_status.assert_called_once_with(mock_sf, "conn_abc", "pending_configure", connected_at=mocker.ANY) def test_broken_connector_returns_pending_oauth_without_card_refresh(self, mocker, mock_ft, mock_sf): mock_ft.get_connection_status.return_value = ShopifyConnectionFactory.broken() mocker.patch("src.app._age_days", return_value=5) store = _make_store() result = _handle_pending_oauth(mock_ft, mock_sf, store) assert result == "pending_oauth" mock_ft.get_connect_card.assert_not_called() def test_expired_oauth_sets_oauth_timeout(self, mocker, mock_ft, mock_sf): mock_ft.get_connection_status.return_value = ShopifyConnectionFactory.incomplete() mocker.patch("src.app._age_days", return_value=15) mock_set_status = mocker.patch("src.app.set_status_by_connector_id") store = _make_store() result = _handle_pending_oauth(mock_ft, mock_sf, store) assert result == "oauth_timeout" mock_set_status.assert_called_once_with(mock_sf, "conn_abc", "oauth_timeout", error_message=mocker.ANY) mock_ft.get_connect_card.assert_not_called() def test_expired_link_triggers_card_refresh(self, mocker, mock_ft, mock_sf): mock_ft.get_connection_status.return_value = ShopifyConnectionFactory.incomplete() mocker.patch("src.app._age_days", return_value=5) mock_upsert = mocker.patch("src.app.upsert_state") # Link expired one day ago from datetime import datetime, timedelta, UTC expired = (datetime.now(UTC) - timedelta(days=1)).isoformat() store = _make_store(link_expires_at=expired) result = _handle_pending_oauth(mock_ft, mock_sf, store) assert result == "pending_oauth" mock_ft.get_connect_card.assert_called_once() mock_upsert.assert_called_once() def test_valid_link_not_expired_skips_card_refresh(self, mocker, mock_ft, mock_sf): mock_ft.get_connection_status.return_value = ShopifyConnectionFactory.incomplete() mocker.patch("src.app._age_days", return_value=5) mocker.patch("src.app.upsert_state") from datetime import datetime, timedelta, UTC future = (datetime.now(UTC) + timedelta(hours=12)).isoformat() store = _make_store(link_expires_at=future) result = _handle_pending_oauth(mock_ft, mock_sf, store) assert result == "pending_oauth" mock_ft.get_connect_card.assert_not_called() class TestHandlePendingConfigure: @pytest.fixture(autouse=True) def patch_configure(self, mocker): mocker.patch("src.app._configure", return_value=5) mocker.patch("src.app.shop_table_exists", return_value=False) mocker.patch("src.app.upsert_registry") mocker.patch("src.app.set_status_by_connector_id") mocker.patch("src.app.set_synced_to_snowflake") mocker.patch("src.app._upsert_registry_on_complete") def test_broken_connector_sets_broken(self, mocker, mock_ft, mock_sf): mock_ft.get_connection_status.return_value = ShopifyConnectionFactory.broken() mock_set_status = mocker.patch("src.app.set_status_by_connector_id") enabled_tables = frozenset({"SHOP"}) result = _handle_pending_configure(mock_ft, mock_sf, _make_store(), enabled_tables) assert result == "broken" mock_set_status.assert_called_once_with(mock_sf, "conn_abc", "broken", error_message=mocker.ANY) def test_incomplete_connector_reverts_to_pending_oauth(self, mocker, mock_ft, mock_sf): mock_ft.get_connection_status.return_value = ShopifyConnectionFactory.incomplete() mock_set_status = mocker.patch("src.app.set_status_by_connector_id") enabled_tables = frozenset({"SHOP"}) result = _handle_pending_configure(mock_ft, mock_sf, _make_store(), enabled_tables) assert result == "pending_oauth" mock_set_status.assert_called_once_with(mock_sf, "conn_abc", "pending_oauth") def test_connected_with_shop_table_returns_sync_complete(self, mocker, mock_ft, mock_sf): mock_ft.get_connection_status.return_value = ShopifyConnectionFactory.connected(name="shopify_test") mocker.patch("src.app.shop_table_exists", return_value=True) mocker.patch("src.app.set_status_by_connector_id") enabled_tables = frozenset({"SHOP"}) result = _handle_pending_configure(mock_ft, mock_sf, _make_store(), enabled_tables) assert result == "sync_complete" def test_connected_without_shop_table_returns_pending_sync(self, mock_ft, mock_sf): mock_ft.get_connection_status.return_value = ShopifyConnectionFactory.connected(name="shopify_test") enabled_tables = frozenset({"SHOP"}) result = _handle_pending_configure(mock_ft, mock_sf, _make_store(), enabled_tables) assert result == "pending_sync" class TestHandlePendingSync: def test_shop_table_present_returns_sync_complete(self, mocker, mock_ft, mock_sf): mock_ft.get_connection_status.return_value = ShopifyConnectionFactory.connected(name="shopify_test") mocker.patch("src.app.shop_table_exists", return_value=True) mocker.patch("src.app.set_status_by_connector_id") mocker.patch("src.app.set_synced_to_snowflake") mocker.patch("src.app._upsert_registry_on_complete") result = _handle_pending_sync(mock_ft, mock_sf, _make_store()) assert result == "sync_complete" def test_shop_table_absent_stays_pending_sync(self, mocker, mock_ft, mock_sf): mock_ft.get_connection_status.return_value = ShopifyConnectionFactory.connected(name="shopify_test") mocker.patch("src.app.shop_table_exists", return_value=False) result = _handle_pending_sync(mock_ft, mock_sf, _make_store()) assert result == "pending_sync" class TestHandlerInvocationBudget: def test_stores_deferred_when_budget_nearly_exhausted(self, mocker, mock_sf, patch_ft_class, patch_enabled_tables): mocker.patch("src.app.get_connection", return_value=mock_sf) mocker.patch( "src.app.get_stores_by_statuses", return_value=[ _make_store( shop_domain=f"store-{i}.myshopify.com", connector_id=f"conn-{i}", schema_name=f"shopify_store_{i}" ) for i in range(5) ], ) patch_ft_class.get_connection_status.return_value = ShopifyConnectionFactory.incomplete() mocker.patch("src.app.set_status_by_connector_id") mocker.patch("src.app.upsert_state") # Context with very little remaining time (50s, well below 120s reserve) low_budget_ctx = MagicMock() low_budget_ctx.get_remaining_time_in_millis.return_value = 50_000 result = handler({}, low_budget_ctx) assert result["deferred"] == 5 def test_stores_processed_normally_with_sufficient_budget( self, mocker, mock_sf, patch_ft_class, patch_enabled_tables, mock_context ): mocker.patch("src.app.get_connection", return_value=mock_sf) mocker.patch( "src.app.get_stores_by_statuses", return_value=[ _make_store(shop_domain="store-a.myshopify.com", connector_id="conn-a", schema_name="shopify_store_a"), ], ) patch_ft_class.get_connection_status.return_value = ShopifyConnectionFactory.incomplete() mocker.patch("src.app._age_days", return_value=5) mocker.patch("src.app.upsert_state") result = handler({}, mock_context) assert result["deferred"] == 0 assert result["pending_oauth"] == 1 class TestHandlerStoreWithNoConnectorId: def test_store_without_connector_id_is_skipped(self, mocker, mock_sf, patch_ft_class, patch_enabled_tables): bad_store = _make_store() bad_store["connector_id"] = None mocker.patch("src.app.get_connection", return_value=mock_sf) mocker.patch("src.app.get_stores_by_statuses", return_value=[bad_store]) result = handler({}, MagicMock(get_remaining_time_in_millis=lambda: 300_000)) assert result["error"] == 0 patch_ft_class.get_connection_status.assert_not_called()