test_meta_ads_catalog.py
1 """Meta Ads 商品カタログ & DPA ユニットテスト 2 3 CatalogMixin を _get/_post/_delete をモックしてテストする。 4 """ 5 6 from __future__ import annotations 7 8 from unittest.mock import AsyncMock 9 10 import pytest 11 12 from mureo.meta_ads._catalog import CatalogMixin 13 14 15 # --------------------------------------------------------------------------- 16 # ヘルパー: CatalogMixin をテスト可能にするモッククラス 17 # --------------------------------------------------------------------------- 18 19 20 def _make_catalog_client() -> CatalogMixin: 21 """Mixinにモック _get/_post/_delete/_ad_account_id を付与したインスタンスを生成""" 22 23 class MockClient(CatalogMixin): 24 def __init__(self) -> None: 25 self._ad_account_id = "act_123" 26 self._get = AsyncMock(return_value={"data": []}) 27 self._post = AsyncMock(return_value={"id": "new_id"}) 28 self._delete = AsyncMock(return_value={"success": True}) 29 30 return MockClient() 31 32 33 # =========================================================================== 34 # CatalogMixin テスト 35 # =========================================================================== 36 37 38 @pytest.mark.unit 39 class TestCatalogMixin: 40 @pytest.fixture() 41 def client(self) -> CatalogMixin: 42 return _make_catalog_client() 43 44 # --- カタログ管理 --- 45 46 @pytest.mark.asyncio 47 async def test_list_catalogs(self, client: CatalogMixin) -> None: 48 """カタログ一覧を取得できること""" 49 client._get = AsyncMock( 50 return_value={ 51 "data": [ 52 {"id": "catalog_1", "name": "ECカタログ"}, 53 {"id": "catalog_2", "name": "季節商品"}, 54 ] 55 } 56 ) 57 result = await client.list_catalogs("biz_001") 58 assert len(result) == 2 59 assert result[0]["id"] == "catalog_1" 60 client._get.assert_called_once() 61 call_args = client._get.call_args 62 assert "/biz_001/owned_product_catalogs" in call_args[0][0] 63 64 @pytest.mark.asyncio 65 async def test_create_catalog(self, client: CatalogMixin) -> None: 66 """カタログを作成できること""" 67 client._post = AsyncMock(return_value={"id": "catalog_new"}) 68 result = await client.create_catalog("biz_001", "新カタログ") 69 assert result["id"] == "catalog_new" 70 client._post.assert_called_once() 71 call_args = client._post.call_args 72 assert "/biz_001/owned_product_catalogs" in call_args[0][0] 73 data = call_args[1].get("data") or call_args[0][1] 74 assert data["name"] == "新カタログ" 75 76 @pytest.mark.asyncio 77 async def test_get_catalog(self, client: CatalogMixin) -> None: 78 """カタログ詳細を取得できること""" 79 client._get = AsyncMock( 80 return_value={"id": "catalog_1", "name": "ECカタログ", "product_count": 150} 81 ) 82 result = await client.get_catalog("catalog_1") 83 assert result["id"] == "catalog_1" 84 assert result["name"] == "ECカタログ" 85 client._get.assert_called_once() 86 call_args = client._get.call_args 87 assert "/catalog_1" in call_args[0][0] 88 89 @pytest.mark.asyncio 90 async def test_delete_catalog(self, client: CatalogMixin) -> None: 91 """カタログを削除できること""" 92 client._delete = AsyncMock(return_value={"success": True}) 93 result = await client.delete_catalog("catalog_1") 94 assert result["success"] is True 95 client._delete.assert_called_once() 96 call_args = client._delete.call_args 97 assert "/catalog_1" in call_args[0][0] 98 99 # --- 商品管理 --- 100 101 @pytest.mark.asyncio 102 async def test_list_products(self, client: CatalogMixin) -> None: 103 """商品一覧を取得できること""" 104 client._get = AsyncMock( 105 return_value={ 106 "data": [ 107 {"id": "prod_1", "name": "サンプル商品"}, 108 {"id": "prod_2", "name": "テスト商品"}, 109 ] 110 } 111 ) 112 result = await client.list_products("catalog_1") 113 assert len(result) == 2 114 assert result[0]["id"] == "prod_1" 115 client._get.assert_called_once() 116 call_args = client._get.call_args 117 assert "/catalog_1/products" in call_args[0][0] 118 params = call_args[0][1] 119 assert params["limit"] == 100 120 121 @pytest.mark.asyncio 122 async def test_list_products_custom_limit(self, client: CatalogMixin) -> None: 123 """商品一覧をカスタムlimitで取得できること""" 124 client._get = AsyncMock(return_value={"data": []}) 125 await client.list_products("catalog_1", limit=10) 126 call_args = client._get.call_args 127 params = call_args[0][1] 128 assert params["limit"] == 10 129 130 @pytest.mark.asyncio 131 async def test_add_product(self, client: CatalogMixin) -> None: 132 """商品を追加できること""" 133 product_data = { 134 "retailer_id": "SKU-001", 135 "name": "サンプル商品", 136 "description": "商品説明", 137 "availability": "in stock", 138 "condition": "new", 139 "price": "1000 JPY", 140 "url": "https://example.com/product/001", 141 "image_url": "https://example.com/images/001.jpg", 142 "brand": "ブランドA", 143 "category": "衣類 > トップス", 144 } 145 client._post = AsyncMock(return_value={"id": "prod_new"}) 146 result = await client.add_product("catalog_1", product_data) 147 assert result["id"] == "prod_new" 148 client._post.assert_called_once() 149 call_args = client._post.call_args 150 assert "/catalog_1/products" in call_args[0][0] 151 data = call_args[1].get("data") or call_args[0][1] 152 assert data["retailer_id"] == "SKU-001" 153 assert data["name"] == "サンプル商品" 154 155 @pytest.mark.asyncio 156 async def test_get_product(self, client: CatalogMixin) -> None: 157 """商品詳細を取得できること""" 158 client._get = AsyncMock( 159 return_value={ 160 "id": "prod_1", 161 "name": "サンプル商品", 162 "price": "1000 JPY", 163 } 164 ) 165 result = await client.get_product("prod_1") 166 assert result["id"] == "prod_1" 167 assert result["name"] == "サンプル商品" 168 client._get.assert_called_once() 169 call_args = client._get.call_args 170 assert "/prod_1" in call_args[0][0] 171 172 @pytest.mark.asyncio 173 async def test_update_product(self, client: CatalogMixin) -> None: 174 """商品を更新できること""" 175 updates = {"name": "更新商品", "price": "2000 JPY"} 176 client._post = AsyncMock(return_value={"success": True}) 177 result = await client.update_product("prod_1", updates) 178 assert result["success"] is True 179 client._post.assert_called_once() 180 call_args = client._post.call_args 181 assert "/prod_1" in call_args[0][0] 182 data = call_args[1].get("data") or call_args[0][1] 183 assert data["name"] == "更新商品" 184 assert data["price"] == "2000 JPY" 185 186 @pytest.mark.asyncio 187 async def test_delete_product(self, client: CatalogMixin) -> None: 188 """商品を削除できること""" 189 client._delete = AsyncMock(return_value={"success": True}) 190 result = await client.delete_product("prod_1") 191 assert result["success"] is True 192 client._delete.assert_called_once() 193 call_args = client._delete.call_args 194 assert "/prod_1" in call_args[0][0] 195 196 # --- フィード管理 --- 197 198 @pytest.mark.asyncio 199 async def test_list_product_feeds(self, client: CatalogMixin) -> None: 200 """フィード一覧を取得できること""" 201 client._get = AsyncMock( 202 return_value={ 203 "data": [ 204 {"id": "feed_1", "name": "メインフィード"}, 205 ] 206 } 207 ) 208 result = await client.list_product_feeds("catalog_1") 209 assert len(result) == 1 210 assert result[0]["id"] == "feed_1" 211 client._get.assert_called_once() 212 call_args = client._get.call_args 213 assert "/catalog_1/product_feeds" in call_args[0][0] 214 215 @pytest.mark.asyncio 216 async def test_create_product_feed(self, client: CatalogMixin) -> None: 217 """フィードを作成できること""" 218 client._post = AsyncMock(return_value={"id": "feed_new"}) 219 result = await client.create_product_feed( 220 "catalog_1", 221 "日次フィード", 222 "https://example.com/feed.xml", 223 schedule="DAILY", 224 ) 225 assert result["id"] == "feed_new" 226 client._post.assert_called_once() 227 call_args = client._post.call_args 228 assert "/catalog_1/product_feeds" in call_args[0][0] 229 data = call_args[1].get("data") or call_args[0][1] 230 assert data["name"] == "日次フィード" 231 assert data["schedule"]["url"] == "https://example.com/feed.xml" 232 assert data["schedule"]["interval"] == "DAILY" 233 234 # --- エラーケース --- 235 236 @pytest.mark.asyncio 237 async def test_api_error(self, client: CatalogMixin) -> None: 238 """APIエラー時にRuntimeErrorが送出されること""" 239 client._get = AsyncMock( 240 side_effect=RuntimeError( 241 "Meta API リクエストに失敗しました (status=400, path=/biz_001/owned_product_catalogs)" 242 ) 243 ) 244 with pytest.raises(RuntimeError, match="Meta API"): 245 await client.list_catalogs("biz_001")