/ tests / test_meta_ads_catalog.py
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")