test_meta_ads_conversions.py
1 """Meta Ads Conversions API (CAPI) ユニットテスト 2 3 send_event / send_purchase_event / send_lead_event および 4 ハッシュ化ユーティリティ(hash_email, hash_phone, normalize_user_data)をテストする。 5 """ 6 7 from __future__ import annotations 8 9 import hashlib 10 from unittest.mock import AsyncMock 11 12 import pytest 13 14 from mureo.meta_ads._hash_utils import ( 15 hash_email, 16 hash_phone, 17 normalize_user_data, 18 ) 19 from mureo.meta_ads._conversions import ConversionsMixin 20 21 22 # --------------------------------------------------------------------------- 23 # ヘルパー: ConversionsMixinをテスト可能にするモッククラス 24 # --------------------------------------------------------------------------- 25 26 27 def _make_conversions_client() -> ConversionsMixin: 28 """ConversionsMixinにモック _post を付与したインスタンスを生成""" 29 30 class MockClient(ConversionsMixin): 31 def __init__(self) -> None: 32 self._post = AsyncMock( # type: ignore[assignment] 33 return_value={"events_received": 1, "fbtrace_id": "trace123"} 34 ) 35 36 return MockClient() 37 38 39 # =========================================================================== 40 # hash_email テスト 41 # =========================================================================== 42 43 44 @pytest.mark.unit 45 class TestHashEmail: 46 def test_basic(self) -> None: 47 """メールをSHA-256ハッシュ化""" 48 result = hash_email("Test@Example.COM") 49 expected = hashlib.sha256("test@example.com".encode()).hexdigest() 50 assert result == expected 51 52 def test_strips_whitespace(self) -> None: 53 """前後の空白を除去してからハッシュ化""" 54 result = hash_email(" user@test.com ") 55 expected = hashlib.sha256("user@test.com".encode()).hexdigest() 56 assert result == expected 57 58 59 # =========================================================================== 60 # hash_phone テスト 61 # =========================================================================== 62 63 64 @pytest.mark.unit 65 class TestHashPhone: 66 def test_basic(self) -> None: 67 """電話番号を数字のみに正規化してSHA-256ハッシュ化""" 68 result = hash_phone("+81-90-1234-5678") 69 expected = hashlib.sha256("819012345678".encode()).hexdigest() 70 assert result == expected 71 72 def test_strips_spaces_and_parens(self) -> None: 73 """スペース・括弧・ハイフンを除去""" 74 result = hash_phone("(090) 1234-5678") 75 expected = hashlib.sha256("09012345678".encode()).hexdigest() 76 assert result == expected 77 78 79 # =========================================================================== 80 # normalize_user_data テスト 81 # =========================================================================== 82 83 84 @pytest.mark.unit 85 class TestNormalizeUserData: 86 def test_hashes_em_and_ph(self) -> None: 87 """em(email)とph(phone)を自動ハッシュ化""" 88 user_data = { 89 "em": "user@example.com", 90 "ph": "+81901234567", 91 "client_ip_address": "1.2.3.4", 92 "client_user_agent": "Mozilla/5.0", 93 } 94 result = normalize_user_data(user_data) 95 # emとphはハッシュ化される 96 assert result["em"] == hash_email("user@example.com") 97 assert result["ph"] == hash_phone("+81901234567") 98 # 非PIIフィールドはそのまま 99 assert result["client_ip_address"] == "1.2.3.4" 100 assert result["client_user_agent"] == "Mozilla/5.0" 101 102 def test_already_hashed_skips(self) -> None: 103 """既にSHA-256ハッシュ済み(64文字の16進数)ならスキップ""" 104 already_hashed = hashlib.sha256("test@example.com".encode()).hexdigest() 105 user_data = { 106 "em": already_hashed, 107 "ph": hashlib.sha256("09012345678".encode()).hexdigest(), 108 } 109 result = normalize_user_data(user_data) 110 assert result["em"] == already_hashed 111 assert result["ph"] == user_data["ph"] 112 113 def test_list_values_hashed(self) -> None: 114 """emやphがリスト形式の場合も各要素をハッシュ化""" 115 user_data = { 116 "em": ["user1@example.com", "user2@example.com"], 117 } 118 result = normalize_user_data(user_data) 119 assert isinstance(result["em"], list) 120 assert result["em"][0] == hash_email("user1@example.com") 121 assert result["em"][1] == hash_email("user2@example.com") 122 123 def test_handles_fn_ln_and_other_pii_fields(self) -> None: 124 """fn(名), ln(姓)等のPIIフィールドもハッシュ化""" 125 user_data = { 126 "fn": "Taro", 127 "ln": "Yamada", 128 "ct": "Tokyo", 129 "st": "Tokyo", 130 "zp": "1000001", 131 "country": "jp", 132 } 133 result = normalize_user_data(user_data) 134 assert result["fn"] == hashlib.sha256("taro".encode()).hexdigest() 135 assert result["ln"] == hashlib.sha256("yamada".encode()).hexdigest() 136 assert result["ct"] == hashlib.sha256("tokyo".encode()).hexdigest() 137 assert result["st"] == hashlib.sha256("tokyo".encode()).hexdigest() 138 assert result["zp"] == hashlib.sha256("1000001".encode()).hexdigest() 139 assert result["country"] == hashlib.sha256("jp".encode()).hexdigest() 140 141 142 # =========================================================================== 143 # ConversionsMixin.send_event テスト 144 # =========================================================================== 145 146 147 @pytest.mark.unit 148 class TestSendEvent: 149 @pytest.fixture() 150 def client(self) -> ConversionsMixin: 151 return _make_conversions_client() 152 153 @pytest.mark.asyncio 154 async def test_send_event(self, client: ConversionsMixin) -> None: 155 """正常にイベントを送信""" 156 events = [ 157 { 158 "event_name": "Purchase", 159 "event_time": 1700000000, 160 "action_source": "website", 161 "user_data": { 162 "em": hashlib.sha256("test@example.com".encode()).hexdigest(), 163 "client_ip_address": "1.2.3.4", 164 }, 165 "custom_data": {"currency": "USD", "value": 100.0}, 166 } 167 ] 168 result = await client.send_event("pixel123", events) 169 170 assert result["events_received"] == 1 171 client._post.assert_called_once() # type: ignore[union-attr] 172 call_args = client._post.call_args # type: ignore[union-attr] 173 assert "/pixel123/events" in call_args[0][0] 174 175 # dataパラメータにイベントが含まれる 176 post_data = call_args[1].get("data") or call_args[0][1] 177 assert "data" in post_data 178 179 @pytest.mark.asyncio 180 async def test_send_event_with_test_code(self, client: ConversionsMixin) -> None: 181 """テストイベントコード付きで送信""" 182 events = [ 183 { 184 "event_name": "Lead", 185 "event_time": 1700000000, 186 "action_source": "website", 187 "user_data": {"client_ip_address": "1.2.3.4"}, 188 } 189 ] 190 result = await client.send_event( 191 "pixel123", events, test_event_code="TEST12345" 192 ) 193 194 assert result["events_received"] == 1 195 call_args = client._post.call_args # type: ignore[union-attr] 196 post_data = call_args[1].get("data") or call_args[0][1] 197 assert post_data.get("test_event_code") == "TEST12345" 198 199 @pytest.mark.asyncio 200 async def test_send_event_api_error(self, client: ConversionsMixin) -> None: 201 """APIエラー時にRuntimeErrorを伝搬""" 202 client._post = AsyncMock( # type: ignore[assignment] 203 side_effect=RuntimeError("Meta API request failed") 204 ) 205 events = [ 206 { 207 "event_name": "Purchase", 208 "event_time": 1700000000, 209 "action_source": "website", 210 "user_data": {"client_ip_address": "1.2.3.4"}, 211 } 212 ] 213 with pytest.raises(RuntimeError, match="Meta API"): 214 await client.send_event("pixel123", events) 215 216 217 # =========================================================================== 218 # ConversionsMixin.send_purchase_event テスト 219 # =========================================================================== 220 221 222 @pytest.mark.unit 223 class TestSendPurchaseEvent: 224 @pytest.fixture() 225 def client(self) -> ConversionsMixin: 226 return _make_conversions_client() 227 228 @pytest.mark.asyncio 229 async def test_send_purchase_event(self, client: ConversionsMixin) -> None: 230 """購入イベントを正しい形式で送信""" 231 user_data = { 232 "em": "buyer@example.com", 233 "client_ip_address": "1.2.3.4", 234 } 235 result = await client.send_purchase_event( 236 pixel_id="pixel123", 237 event_time=1700000000, 238 user_data=user_data, 239 currency="JPY", 240 value=9800.0, 241 content_ids=["product_001"], 242 event_source_url="https://example.com/checkout", 243 ) 244 245 assert result["events_received"] == 1 246 call_args = client._post.call_args # type: ignore[union-attr] 247 post_data = call_args[1].get("data") or call_args[0][1] 248 249 # dataフィールドのイベント確認 250 import json 251 252 events = json.loads(post_data["data"]) 253 event = events[0] 254 assert event["event_name"] == "Purchase" 255 assert event["event_time"] == 1700000000 256 assert event["action_source"] == "website" 257 assert event["event_source_url"] == "https://example.com/checkout" 258 assert event["custom_data"]["currency"] == "JPY" 259 assert event["custom_data"]["value"] == 9800.0 260 assert event["custom_data"]["content_ids"] == ["product_001"] 261 262 263 # =========================================================================== 264 # ConversionsMixin.send_lead_event テスト 265 # =========================================================================== 266 267 268 @pytest.mark.unit 269 class TestSendLeadEvent: 270 @pytest.fixture() 271 def client(self) -> ConversionsMixin: 272 return _make_conversions_client() 273 274 @pytest.mark.asyncio 275 async def test_send_lead_event(self, client: ConversionsMixin) -> None: 276 """リードイベントを正しい形式で送信""" 277 user_data = { 278 "em": "lead@example.com", 279 "client_ip_address": "10.0.0.1", 280 } 281 result = await client.send_lead_event( 282 pixel_id="pixel456", 283 event_time=1700001000, 284 user_data=user_data, 285 event_source_url="https://example.com/contact", 286 test_event_code="TEST99", 287 ) 288 289 assert result["events_received"] == 1 290 call_args = client._post.call_args # type: ignore[union-attr] 291 post_data = call_args[1].get("data") or call_args[0][1] 292 293 import json 294 295 events = json.loads(post_data["data"]) 296 event = events[0] 297 assert event["event_name"] == "Lead" 298 assert event["event_time"] == 1700001000 299 assert event["action_source"] == "website" 300 assert event["event_source_url"] == "https://example.com/contact" 301 assert post_data.get("test_event_code") == "TEST99"