"""Tests for the message-type normalization parser (Task 1.6). The parser must be total — it never raises on weird input — because every incoming WeChat record runs through it during a scrape, and one malformed type code must not abort the whole pull. Unknown codes pass through as ``"unknown"`` so the message is still ingested (and recoverable via ``raw_json``); only the ``-2`` date-divider code maps to ``"divider"``, which the scraper uses as its drop signal. """ from __future__ import annotations import pytest from app.scraper.parsers import ( coerce_int_or_none, normalize_wechat_type, normalize_whatsapp_type, ) @pytest.mark.parametrize( "raw,expected", [ # Phase-0 confirmed codes (see docs/superpowers/specs/phase0-findings.md §2). (1, "text"), (-2, "divider"), # date separator — NOT a real message (scraper drops it) (2, "image"), # image / sticker (webp) — file/fileTh are direct URLs (3, "image_legacy"), # old image format — file is an opaque ID (needs another endpoint) (4, "video"), # mp4 — file is a direct URL (10, "system"), # system / link / card / invite — non-media, skip download # Still-unconfirmed codes — pass through as "unknown", never dropped. # (6, 9, 13, 15, 21, 22 — voice/file/transfer/redpacket/quote/etc; raw_json retains the code.) (6, "unknown"), (9, "unknown"), (34, "unknown"), # voice (legacy WeChat code, not yet seen in yunke) (49, "unknown"), # file / link / transfer (10000, "unknown"), # Stringified ints (the upstream API may send either form). ("1", "text"), ("-2", "divider"), ("2", "image"), ("3", "image_legacy"), ("4", "video"), ("10", "system"), ("34", "unknown"), ], ) def test_normalize_wechat_type_maps_known_and_unknown(raw, expected): assert normalize_wechat_type(raw) == expected def test_normalize_wechat_type_never_raises(): """Whatever we throw at it, it returns a string — never raises.""" for raw in (None, "", "junk", "1.5", [], {}, object(), float("nan")): out = normalize_wechat_type(raw) assert isinstance(out, str) assert out == "unknown" # =========================================================================== # WhatsApp message-type normalization (M3) # =========================================================================== @pytest.mark.parametrize( "raw,expected", [ # Phase-0 confirmed WhatsApp codes (see data dictionary §3): (0, "text"), (1, "image"), # image / media (-2, "divider"), # date separator — NOT a real message (scraper drops it) # Unconfirmed / other media codes — pass through as "unknown", never dropped. (2, "unknown"), (3, "unknown"), (13, "unknown"), (100, "unknown"), # Stringified ints (the upstream API may send either form). ("0", "text"), ("1", "image"), ("-2", "divider"), ("2", "unknown"), ], ) def test_normalize_whatsapp_type_maps_known_and_unknown(raw, expected): assert normalize_whatsapp_type(raw) == expected def test_normalize_whatsapp_type_never_raises(): """Whatever we throw at it, it returns a string — never raises.""" for raw in (None, "", "junk", "1.5", [], {}, object(), float("nan")): out = normalize_whatsapp_type(raw) assert isinstance(out, str) assert out == "unknown" # =========================================================================== # coerce_int_or_none — shared scraper helper (M3) # =========================================================================== def test_coerce_int_or_none_handles_documented_cases(): """``type_raw``-style fields: str -> int; None preserved; 0 preserved.""" assert coerce_int_or_none(None) is None assert coerce_int_or_none(0) == 0 assert coerce_int_or_none(2) == 2 assert coerce_int_or_none(-2) == -2 assert coerce_int_or_none("0") == 0 assert coerce_int_or_none("2") == 2 assert coerce_int_or_none("-2") == -2 def test_coerce_int_or_none_returns_none_for_non_numeric_string(): """A non-numeric value (e.g. a label) -> None.""" assert coerce_int_or_none("text") is None assert coerce_int_or_none("") is None assert coerce_int_or_none("unknown") is None