ForcePilot/backend/test/unit/channels/test_zalo_oa_comprehensive.py

1554 lines
57 KiB
Python
Raw Normal View History

from __future__ import annotations
import json
import time
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from yuxi.channels.adapters.zalo_oa.commands import (
CommandRegistry,
extract_command,
get_command_list,
get_command_registry,
handle_command,
register_command,
)
from yuxi.channels.adapters.zalo_oa.dedup import EventDeduplicator, EventState
from yuxi.channels.adapters.zalo_oa.security import (
DMPolicy,
check_dm_allowed,
collect_security_warnings,
load_allowlist,
normalize_allow_entry,
resolve_dm_policy,
)
from yuxi.channels.adapters.zalo_oa.chunking import chunk_text
from yuxi.channels.adapters.zalo_oa.messaging import (
build_target_id,
looks_like_user_id,
normalize_messaging_target,
)
from yuxi.channels.adapters.zalo_oa.cache import SentMessageCache
from yuxi.channels.adapters.zalo_oa.pairing import (
PairingStore,
build_pairing_message,
build_pairing_notification,
build_pairing_success_message,
PAIRING_CODE_TTL_SEC,
)
from yuxi.channels.adapters.zalo_oa.webhook_ratelimit import (
WebhookRateLimiter,
build_rate_limit_key,
resolve_client_ip,
)
from yuxi.channels.adapters.zalo_oa.approval import (
build_approval_request,
check_approval_required,
normalize_approver_id,
resolve_approvers,
)
from yuxi.channels.adapters.zalo_oa.audit import AuditEventType, AuditLogger
from yuxi.channels.adapters.zalo_oa.accounts import (
get_default_account_id,
list_account_ids,
resolve_account,
)
from yuxi.channels.adapters.zalo_oa.webhook_anomaly import (
WebhookAnomalyTracker,
ALERT_THRESHOLD_401,
ALERT_THRESHOLD_400,
ALERT_THRESHOLD_413,
ALERT_THRESHOLD_429,
)
from yuxi.channels.adapters.zalo_oa.message_actions import (
SUPPORTED_ACTIONS,
build_action_handler,
describe_actions,
is_action_supported,
)
from yuxi.channels.adapters.zalo_oa.signature import verify_zalo_oa_signature
from yuxi.channels.adapters.zalo_oa.session import (
SessionRouter,
resolve_thread_key,
resolve_session_params,
get_session_router,
)
from yuxi.channels.adapters.zalo_oa.formatter import ZaloOAMessageFormatter
from yuxi.channels.adapters.zalo_oa.outbound_media import (
OutboundMediaHost,
cleanup_media_cache,
get_media,
resolve_attachment,
store_media,
)
from yuxi.channels.adapters.zalo_oa.config_schema import ZaloOAConfig, ZaloOAAccountConfig
from yuxi.channels.adapters.zalo_oa.polling import ZaloOAPoller
from yuxi.channels.adapters.zalo_oa.voice import ZaloOAVoice
from yuxi.channels.adapters.zalo_oa.normalizer import SkipMessageError, ZaloOAEventNormalizer
from yuxi.channels.models import (
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelType,
ChatType,
EventType,
MessageType,
)
# ==============================================================================
# Commands
# ==============================================================================
class TestCommandRegistry:
def setup_method(self):
self.registry = CommandRegistry()
def test_default_commands_registered(self):
assert self.registry.is_registered("help")
assert self.registry.is_registered("status")
assert self.registry.is_registered("ping")
def test_register_new_command(self):
self.registry.register("echo", "Echo text back")
assert self.registry.is_registered("echo")
def test_unregister_command(self):
self.registry.register("temp_cmd", "Temporary")
self.registry.unregister("temp_cmd")
assert not self.registry.is_registered("temp_cmd")
def test_cannot_unregister_builtin_commands(self):
self.registry.unregister("help")
assert self.registry.is_registered("help")
self.registry.unregister("status")
assert self.registry.is_registered("status")
self.registry.unregister("ping")
assert self.registry.is_registered("ping")
def test_get_list_returns_all_commands(self):
cmd_list = self.registry.get_list()
names = [c["command"] for c in cmd_list]
assert "/help" in names
assert "/status" in names
assert "/ping" in names
@pytest.mark.asyncio
async def test_handle_unknown_command(self):
result = await self.registry.handle("unknown", "", {})
assert result is None
@pytest.mark.asyncio
async def test_handle_help(self):
result = await self.registry.handle("help", "", {})
assert result
assert "Available commands:" in result
@pytest.mark.asyncio
async def test_handle_status(self):
context = {"oa_name": "TestOA", "status": "connected", "dm_policy": "open"}
result = await self.registry.handle("status", "", context)
assert "TestOA" in result
assert "connected" in result
assert "open" in result
@pytest.mark.asyncio
async def test_handle_ping(self):
result = await self.registry.handle("ping", "", {})
assert result == "pong"
@pytest.mark.asyncio
async def test_handle_with_extra_handlers(self):
async def custom_handler(args, ctx):
return f"custom:{args}"
result = await self.registry.handle("custom_cmd", "hello", {}, {"custom_cmd": custom_handler})
assert result == "custom:hello"
@pytest.mark.asyncio
async def test_handle_with_args(self):
self.registry.register("echo", "Echo text back")
result = await self.registry.handle("echo", "hello world", {})
assert result == "Command /echo executed"
def test_case_insensitive_lookup(self):
self.registry.register("UpperCase", "Test")
assert self.registry.is_registered("UpperCase")
assert self.registry.is_registered("UPPERCASE")
def test_case_insensitive_lookup_uppercase(self):
self.registry.register("echo", "Echo text back")
assert self.registry.is_registered("ECHO")
assert self.registry.is_registered("Echo")
class TestExtractCommand:
def test_extract_slash_command(self):
cmd, args = extract_command("/help")
assert cmd == "help"
assert args == ""
def test_extract_slash_command_with_args(self):
cmd, args = extract_command("/echo hello world")
assert cmd == "echo"
assert args == "hello world"
def test_extract_exclamation_command(self):
cmd, args = extract_command("!status")
assert cmd == "status"
assert args == ""
def test_extract_exclamation_with_args(self):
cmd, args = extract_command("!ping test arg")
assert cmd == "ping"
assert args == "test arg"
def test_no_command_prefix(self):
cmd, args = extract_command("hello world")
assert cmd is None
assert args == "hello world"
def test_no_command_prefix_leading_space(self):
cmd, args = extract_command(" hello")
assert cmd is None
assert args == "hello"
def test_empty_string(self):
cmd, args = extract_command("")
assert cmd is None
assert args == ""
def test_only_slash(self):
cmd, args = extract_command("/")
assert cmd == ""
assert args == ""
def test_only_slash_with_space(self):
cmd, args = extract_command("/ ")
assert cmd == ""
assert args == ""
def test_command_case_insensitive(self):
cmd, args = extract_command("/HELP")
assert cmd == "help"
class TestGlobalCommandFunctions:
@pytest.mark.asyncio
async def test_handle_command_global(self):
context = {"oa_name": "TestOA"}
result = await handle_command("ping", "", context)
assert result == "pong"
@pytest.mark.asyncio
async def test_get_command_list_global(self):
cmd_list = get_command_list()
assert len(cmd_list) >= 3
def test_register_command_global(self):
register_command("global_test", "Test global registration")
registry = get_command_registry()
assert registry.is_registered("global_test")
registry.unregister("global_test")
# ==============================================================================
# Deduplication
# ==============================================================================
class TestEventDeduplicator:
def setup_method(self):
self.dedup = EventDeduplicator(window_ms=60000)
def test_claim_first_time(self):
payload = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}}
assert self.dedup.claim(payload) is True
def test_claim_duplicate_within_window(self):
payload = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m2"}}
assert self.dedup.claim(payload) is True
assert self.dedup.claim(payload) is False
def test_different_senders_not_deduplicated(self):
p1 = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}}
p2 = {"event_name": "user_send_text", "sender": {"id": "u2"}, "message": {"msg_id": "m1"}}
assert self.dedup.claim(p1) is True
assert self.dedup.claim(p2) is True
def test_different_msg_ids_not_deduplicated(self):
p1 = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}}
p2 = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m2"}}
assert self.dedup.claim(p1) is True
assert self.dedup.claim(p2) is True
def test_commit_changes_state(self):
payload = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}}
self.dedup.claim(payload)
self.dedup.commit(payload)
assert self.dedup.is_committed(payload) is True
def test_is_committed_before_commit(self):
payload = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}}
self.dedup.claim(payload)
assert self.dedup.is_committed(payload) is False
def test_release_removes_event(self):
payload = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}}
self.dedup.claim(payload)
self.dedup.release(payload)
assert self.dedup.is_committed(payload) is False
def test_claim_after_release(self):
payload = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}}
assert self.dedup.claim(payload) is True
self.dedup.release(payload)
assert self.dedup.claim(payload) is True
def test_window_expiry_allows_reclaim(self):
dedup = EventDeduplicator(window_ms=1)
payload = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}}
assert dedup.claim(payload) is True
time.sleep(0.002)
assert dedup.claim(payload) is True
def test_events_without_sender(self):
payload = {"event_name": "test_event", "sender": {}, "message": {"msg_id": ""}}
assert self.dedup.claim(payload) is True
def test_events_without_message(self):
payload = {"event_name": "test_event", "sender": {"id": "u1"}}
assert self.dedup.claim(payload) is True
# ==============================================================================
# Security
# ==============================================================================
class TestDMPolicy:
def test_open_policy_allows_all(self):
assert check_dm_allowed("any_user", DMPolicy.OPEN, set()) is True
def test_disabled_policy_denies_all(self):
assert check_dm_allowed("any_user", DMPolicy.DISABLED, set()) is False
def test_allowlist_policy_match(self):
assert check_dm_allowed("user1", DMPolicy.ALLOWLIST, {"user1"}) is True
def test_allowlist_policy_no_match(self):
assert check_dm_allowed("user2", DMPolicy.ALLOWLIST, {"user1"}) is False
def test_allowlist_policy_wildcard(self):
assert check_dm_allowed("any_user", DMPolicy.ALLOWLIST, {"*"}) is True
def test_pairing_policy_match(self):
assert check_dm_allowed("user1", DMPolicy.PAIRING, {"user1"}) is True
def test_pairing_policy_no_match(self):
assert check_dm_allowed("user2", DMPolicy.PAIRING, {"user1"}) is False
def test_pairing_policy_wildcard(self):
assert check_dm_allowed("any_user", DMPolicy.PAIRING, {"*"}) is True
def test_resolve_dm_policy_open(self):
assert resolve_dm_policy({"dm_policy": "open"}) == DMPolicy.OPEN
def test_resolve_dm_policy_pairing(self):
assert resolve_dm_policy({"dm_policy": "pairing"}) == DMPolicy.PAIRING
def test_resolve_dm_policy_allowlist(self):
assert resolve_dm_policy({"dm_policy": "allowlist"}) == DMPolicy.ALLOWLIST
def test_resolve_dm_policy_disabled(self):
assert resolve_dm_policy({"dm_policy": "disabled"}) == DMPolicy.DISABLED
def test_resolve_dm_policy_default(self):
assert resolve_dm_policy({}) == DMPolicy.OPEN
def test_resolve_dm_policy_unknown(self):
assert resolve_dm_policy({"dm_policy": "unknown"}) == DMPolicy.OPEN
def test_resolve_dm_policy_dmPolicy_fallback(self):
assert resolve_dm_policy({"dmPolicy": "disabled"}) == DMPolicy.DISABLED
class TestLoadAllowlist:
def test_normal_allowlist(self):
config = {"allowFrom": ["user1", "user2"]}
result = load_allowlist(config)
assert result == {"user1", "user2"}
def test_empty_allowlist(self):
result = load_allowlist({})
assert result == set()
def test_wildcard_only(self):
config = {"allowFrom": ["*"]}
result = load_allowlist(config)
assert result == {"*"}
def test_wildcard_precedence(self):
config = {"allowFrom": ["user1", "*", "user2"]}
result = load_allowlist(config)
assert result == {"*"}
def test_prefix_normalization_zoa(self):
config = {"allowFrom": ["zoa:user1"]}
result = load_allowlist(config)
assert "user1" in result
def test_prefix_normalization_zalo_oa(self):
config = {"allowFrom": ["zalo_oa:user2"]}
result = load_allowlist(config)
assert "user2" in result
def test_non_list_allow_from(self):
config = {"allowFrom": "not_a_list"}
result = load_allowlist(config)
assert result == set()
class TestNormalizeAllowEntry:
def test_zoa_prefix(self):
assert normalize_allow_entry("zoa:user1") == "user1"
def test_zalo_oa_prefix(self):
assert normalize_allow_entry("zalo_oa:user1") == "user1"
def test_no_prefix(self):
assert normalize_allow_entry("user1") == "user1"
def test_case_insensitive_prefix(self):
assert normalize_allow_entry("ZOA:User1") == "User1"
assert normalize_allow_entry("ZALO_OA:User1") == "User1"
class TestCollectSecurityWarnings:
def test_open_policy_warning(self):
warnings = collect_security_warnings({"dm_policy": "open"})
assert any(w["type"] == "dm_policy_open" for w in warnings)
def test_empty_allowlist_warning(self):
warnings = collect_security_warnings({"dm_policy": "allowlist", "allowFrom": []})
assert any(w["type"] == "empty_allowlist" for w in warnings)
def test_pairing_no_allowlist_info(self):
warnings = collect_security_warnings({"dm_policy": "pairing", "allowFrom": []})
assert any(w["type"] == "pairing_no_preset" for w in warnings)
def test_wildcard_info(self):
warnings = collect_security_warnings({"dm_policy": "allowlist", "allowFrom": ["*"]})
assert any(w["type"] == "allowlist_wildcard" for w in warnings)
def test_no_webhook_url_info(self):
warnings = collect_security_warnings({"dm_policy": "open"})
assert any(w["type"] == "webhook_not_configured" for w in warnings)
def test_no_mac_key_warning(self):
warnings = collect_security_warnings({"dm_policy": "open"})
assert any(w["type"] == "webhook_mac_key_missing" for w in warnings)
def test_with_webhook_and_mac_key_no_warnings(self):
warnings = collect_security_warnings(
{"dm_policy": "allowlist", "allowFrom": ["user1"], "webhook": {"url": "https://ex.com", "mac_key": "key1"}}
)
assert not any(w["type"] in ("webhook_not_configured", "webhook_mac_key_missing") for w in warnings)
# ==============================================================================
# Chunking
# ==============================================================================
class TestChunkText:
def test_short_text_no_chunking(self):
result = chunk_text("hello", 100)
assert result == ["hello"]
def test_chunk_by_paragraph_boundary(self):
text = "A" * 100 + "\n\n" + "B" * 100
result = chunk_text(text, 150)
assert len(result) == 2
assert result[0].strip() == "A" * 100
assert result[1].strip() == "B" * 100
def test_chunk_by_newline_and_comma(self):
text = "Hello, World. This is" + "X" * 300
result = chunk_text(text, 300)
assert len(result) >= 1
def test_chunk_by_space(self):
text = "A " * 200 + "B" * 200
result = chunk_text(text, 200)
assert len(result) > 1
def test_chunk_hard_split(self):
text = "X" * 500
result = chunk_text(text, 100)
assert len(result) == 5
for chunk in result:
assert len(chunk) <= 100
def test_empty_string(self):
result = chunk_text("", 100)
assert result == [""]
def test_max_chars_equals_length(self):
text = "hello world"
result = chunk_text(text, len(text))
assert result == ["hello world"]
def test_chunk_by_sentence_boundary(self):
text = "Hello there. How are you? " + "X" * 200
result = chunk_text(text, 200)
assert len(result) >= 1
def test_all_chunks_within_limit(self):
text = "A" * 2500
result = chunk_text(text, 500)
for chunk in result:
assert len(chunk) <= 500
def test_multiple_paragraphs(self):
paragraphs = ["P" * 100 for _ in range(5)]
text = "\n\n".join(paragraphs)
result = chunk_text(text, 250)
assert len(result) >= 3
# ==============================================================================
# Messaging
# ==============================================================================
class TestMessaging:
def test_normalize_zalo_oa_prefix(self):
assert normalize_messaging_target("zalo_oa:12345") == "12345"
def test_normalize_zoa_prefix(self):
assert normalize_messaging_target("zoa:12345") == "12345"
def test_normalize_zl_prefix(self):
assert normalize_messaging_target("zl:12345") == "12345"
def test_normalize_case_insensitive(self):
assert normalize_messaging_target("ZALO_OA:12345") == "12345"
def test_no_prefix(self):
assert normalize_messaging_target("12345") == "12345"
def test_empty_string(self):
assert normalize_messaging_target("") == ""
def test_looks_like_user_id_numeric(self):
assert looks_like_user_id("123456") is True
def test_looks_like_user_id_zero(self):
assert looks_like_user_id("0") is True
def test_looks_like_user_id_not_numeric(self):
assert looks_like_user_id("abc123") is False
def test_looks_like_user_id_empty(self):
assert looks_like_user_id("") is False
def test_build_target_id(self):
assert build_target_id("12345") == "zalo_oa:12345"
# ==============================================================================
# Sent Message Cache
# ==============================================================================
class TestSentMessageCache:
def setup_method(self):
self.cache = SentMessageCache(ttl_sec=3600)
def test_put_and_get(self):
self.cache.put("msg1", "user1", "hello")
entry = self.cache.get("msg1")
assert entry["message_id"] == "msg1"
assert entry["recipient"] == "user1"
assert entry["content"] == "hello"
def test_get_nonexistent(self):
assert self.cache.get("nonexistent") is None
def test_remove(self):
self.cache.put("msg1", "user1", "hello")
self.cache.remove("msg1")
assert self.cache.get("msg1") is None
def test_ttl_expiry(self):
cache = SentMessageCache(ttl_sec=0)
cache.put("msg1", "user1", "hello")
assert cache.get("msg1") is None
def test_metadata_storage(self):
self.cache.put("msg1", "user1", "hello", metadata={"key": "value"})
entry = self.cache.get("msg1")
assert entry["metadata"] == {"key": "value"}
def test_size_property(self):
assert self.cache.size == 0
self.cache.put("msg1", "user1", "hello")
assert self.cache.size == 1
def test_multiple_entries(self):
for i in range(5):
self.cache.put(f"msg{i}", f"user{i}", f"content{i}")
assert self.cache.size == 5
def test_cleanup_on_put(self):
cache = SentMessageCache(ttl_sec=0)
cache.put("msg1", "user1", "hello")
cache.put("msg2", "user2", "world")
assert cache.size == 0
# ==============================================================================
# Pairing
# ==============================================================================
class TestPairingStore:
def setup_method(self):
self.store = PairingStore()
def test_generate_code_length(self):
code = self.store.generate_code("user1")
assert len(code) == 8
assert code == code.upper()
def test_verify_correct_code(self):
code = self.store.generate_code("user1")
assert self.store.verify_code(code, "user1") is True
def test_verify_wrong_user(self):
code = self.store.generate_code("user1")
assert self.store.verify_code(code, "user2") is False
def test_verify_wrong_code(self):
self.store.generate_code("user1")
assert self.store.verify_code("WRONG001", "user1") is False
def test_code_cannot_be_used_twice(self):
code = self.store.generate_code("user1")
assert self.store.verify_code(code, "user1") is True
assert self.store.verify_code(code, "user1") is False
def test_expired_code(self):
code = self.store.generate_code("user1")
self.store._codes[code]["created_at"] = time.time() - PAIRING_CODE_TTL_SEC - 1
assert self.store.verify_code(code, "user1") is False
def test_cleanup_expired(self):
code1 = self.store.generate_code("user1")
code2 = self.store.generate_code("user2")
self.store._codes[code2]["created_at"] = time.time() - PAIRING_CODE_TTL_SEC - 1
self.store.cleanup_expired()
assert code2 not in self.store._codes
assert code1 in self.store._codes
def test_generate_multiple_codes(self):
codes = [self.store.generate_code(f"user{i}") for i in range(5)]
assert len(set(codes)) == 5
class TestPairingMessages:
def test_build_pairing_message(self):
msg = build_pairing_message("ABC12345", "TestOA")
assert "Hello from TestOA" in msg
assert "ABC12345" in msg
assert "expires" in msg
def test_build_pairing_message_no_oa_name(self):
msg = build_pairing_message("ABC12345")
assert "Welcome" in msg
assert "ABC12345" in msg
def test_build_pairing_notification(self):
msg = build_pairing_notification("user1", "TestOA")
assert "user1" in msg
assert "TestOA" in msg
def test_build_pairing_notification_no_name(self):
msg = build_pairing_notification("user1", "")
assert "user1" in msg
def test_build_pairing_success(self):
msg = build_pairing_success_message("TestOA")
assert "Pairing successful" in msg
assert "TestOA" in msg
# ==============================================================================
# Webhook Rate Limiter
# ==============================================================================
class TestWebhookRateLimiter:
def setup_method(self):
self.limiter = WebhookRateLimiter(window_ms=60000, max_requests=5)
def test_initial_allow(self):
for i in range(5):
assert self.limiter.is_allowed(f"key_{i}") is True
def test_rate_limit_exceeded(self):
key = "test_key"
for _ in range(5):
assert self.limiter.is_allowed(key) is True
assert self.limiter.is_allowed(key) is False
def test_different_keys_independent(self):
for _ in range(5):
assert self.limiter.is_allowed("key_a") is True
assert self.limiter.is_allowed("key_b") is True
def test_cleanup_removes_empty_buckets(self):
assert self.limiter.is_allowed("temp_key") is True
self.limiter.cleanup()
def test_window_expiry(self):
limiter = WebhookRateLimiter(window_ms=1, max_requests=3)
key = "fast_key"
for _ in range(3):
assert limiter.is_allowed(key) is True
assert limiter.is_allowed(key) is False
time.sleep(0.002)
assert limiter.is_allowed(key) is True
def test_large_max_requests(self):
limiter = WebhookRateLimiter(max_requests=1000)
for i in range(500):
assert limiter.is_allowed(f"key_{i}") is True
class TestRateLimitKeyAndIP:
def test_build_key_with_ip(self):
key = build_rate_limit_key("user1", "192.168.1.1")
assert key == "user1:192.168.1.1"
def test_build_key_without_ip(self):
key = build_rate_limit_key("user1")
assert key == "user1"
def test_build_key_empty_ip(self):
key = build_rate_limit_key("user1", "")
assert key == "user1"
def test_resolve_client_ip_none_headers(self):
assert resolve_client_ip(None) == ""
def test_resolve_client_ip_x_forwarded_for(self):
headers = {"x-forwarded-for": "10.0.0.1, 10.0.0.2"}
assert resolve_client_ip(headers) == "10.0.0.1"
def test_resolve_client_ip_x_real_ip(self):
headers = {"x-real-ip": "10.0.0.5"}
assert resolve_client_ip(headers) == "10.0.0.5"
def test_resolve_client_ip_no_headers(self):
headers = {}
assert resolve_client_ip(headers) == ""
def test_resolve_client_ip_with_trusted_proxies(self):
headers = {"x-forwarded-for": "10.0.0.1, 10.0.0.2"}
result = resolve_client_ip(headers, ["10.0.0.1"])
assert result == "10.0.0.2"
def test_resolve_client_ip_all_trusted(self):
headers = {"x-forwarded-for": "10.0.0.1"}
result = resolve_client_ip(headers, ["10.0.0.1"])
assert result == "10.0.0.1"
def test_resolve_client_ip_empty_forwarded(self):
headers = {"x-forwarded-for": ""}
assert resolve_client_ip(headers) == ""
# ==============================================================================
# Approval
# ==============================================================================
class TestApproval:
def test_check_approval_required_disabled(self):
config = {"requireExecApproval": False}
assert check_approval_required("send", config) is False
def test_check_approval_required_enabled(self):
config = {"requireExecApproval": True}
assert check_approval_required("send", config) is True
def test_check_approval_required_exempt(self):
config = {"requireExecApproval": True, "approvalExemptActions": ["send"]}
assert check_approval_required("send", config) is False
def test_check_approval_required_not_exempt(self):
config = {"requireExecApproval": True, "approvalExemptActions": ["ping"]}
assert check_approval_required("send", config) is True
def test_build_approval_request(self):
result = build_approval_request("send", "user1", {"key": "val"})
assert result["action"] == "send"
assert result["requester_id"] == "user1"
assert result["status"] == "pending"
assert result["params"] == {"key": "val"}
def test_build_approval_request_no_params(self):
result = build_approval_request("send", "user1")
assert result["params"] == {}
def test_resolve_approvers_empty(self):
result = resolve_approvers({})
assert result == []
def test_resolve_approvers_from_config(self):
config = {"approvers": ["user1", "zoa:user2"]}
result = resolve_approvers(config)
assert result == ["user1", "user2"]
def test_resolve_approvers_from_allowlist(self):
config = {"allowFrom": ["user1", "zalo_oa:user2"]}
result = resolve_approvers(config)
assert result == ["user1", "user2"]
def test_resolve_approvers_with_exec_name(self):
config = {"execApprovers": ["user1", "user2"]}
result = resolve_approvers(config)
assert "user1" in result
def test_normalize_approver_id_zoa(self):
assert normalize_approver_id("zoa:user1") == "user1"
def test_normalize_approver_id_zalo_oa(self):
assert normalize_approver_id("zalo_oa:user1") == "user1"
def test_normalize_approver_id_no_prefix(self):
assert normalize_approver_id("user1") == "user1"
# ==============================================================================
# Audit Logger
# ==============================================================================
class TestAuditLogger:
def setup_method(self):
self.audit = AuditLogger(enabled=True, log_level="info")
def test_record_event(self):
self.audit.record(AuditEventType.MESSAGE_SENT, {"recipient": "user1"})
assert self.audit.total_entries == 1
def test_record_string_event_type(self):
self.audit.record("custom.event", {"key": "val"})
assert self.audit.total_entries == 1
def test_disabled_logger_ignores_records(self):
audit = AuditLogger(enabled=False)
audit.record(AuditEventType.MESSAGE_SENT)
assert audit.total_entries == 0
def test_get_entries_all(self):
self.audit.record(AuditEventType.MESSAGE_SENT)
self.audit.record(AuditEventType.MESSAGE_RECEIVED)
entries = self.audit.get_entries()
assert len(entries) == 2
def test_get_entries_filtered(self):
self.audit.record(AuditEventType.MESSAGE_SENT)
self.audit.record(AuditEventType.TOKEN_REFRESHED)
entries = self.audit.get_entries(event_type=AuditEventType.MESSAGE_SENT)
assert len(entries) == 1
assert entries[0]["event_type"] == AuditEventType.MESSAGE_SENT
def test_get_summary(self):
self.audit.record(AuditEventType.MESSAGE_SENT)
self.audit.record(AuditEventType.MESSAGE_SENT)
self.audit.record(AuditEventType.AUTH_FAILURE)
summary = self.audit.get_summary()
assert summary["total_entries"] == 3
assert summary["event_counts"]["message.sent"] == 2
assert summary["event_counts"]["auth.failure"] == 1
def test_max_entries_limit(self):
audit = AuditLogger(max_entries=5)
for i in range(10):
audit.record(AuditEventType.MESSAGE_SENT, {"index": i})
assert audit.total_entries == 5
def test_entry_has_timestamp(self):
self.audit.record(AuditEventType.MESSAGE_SENT)
entries = self.audit.get_entries()
assert "timestamp" in entries[0]
def test_all_event_types_defined(self):
assert AuditEventType.MESSAGE_SENT == "message.sent"
assert AuditEventType.MESSAGE_RECEIVED == "message.received"
assert AuditEventType.MESSAGE_FAILED == "message.failed"
assert AuditEventType.MESSAGE_RECALLED == "message.recalled"
assert AuditEventType.TOKEN_REFRESHED == "token.refreshed"
assert AuditEventType.TOKEN_REFRESH_FAILED == "token.refresh_failed"
assert AuditEventType.AUTH_FAILURE == "auth.failure"
assert AuditEventType.WEBOOK_RECEIVED == "webhook.received"
assert AuditEventType.WEBOOK_RATE_LIMITED == "webhook.rate_limited"
assert AuditEventType.PAIRING_CREATED == "pairing.created"
assert AuditEventType.PAIRING_VERIFIED == "pairing.verified"
assert AuditEventType.CONFIG_CHANGED == "config.changed"
assert AuditEventType.CONNECTION_STATE == "connection.state"
# ==============================================================================
# Accounts
# ==============================================================================
class TestAccounts:
def test_list_account_ids_no_accounts(self):
result = list_account_ids({"app_id": "test"})
assert result == ["default"]
def test_list_account_ids_empty_accounts(self):
result = list_account_ids({"accounts": {}})
assert result == ["default"]
def test_list_account_ids_with_accounts(self):
config = {"accounts": {"acct1": {}, "acct2": {}}}
result = list_account_ids(config)
assert sorted(result) == ["acct1", "acct2"]
def test_list_account_ids_non_dict(self):
result = list_account_ids({"accounts": "invalid"})
assert result == ["default"]
def test_get_default_account_id_no_default(self):
result = get_default_account_id({"app_id": "test"})
assert result == "default"
def test_get_default_account_id_with_default(self):
config = {"defaultAccount": "prod", "accounts": {"prod": {"app_id": "x"}, "staging": {"app_id": "y"}}}
result = get_default_account_id(config)
assert result == "prod"
def test_get_default_account_id_default_account_key(self):
config = {"default_account": "staging", "accounts": {"prod": {}, "staging": {}}}
result = get_default_account_id(config)
assert result == "staging"
def test_resolve_account_no_accounts(self):
config = {"app_id": "test", "secret_key": "key"}
result = resolve_account(config)
assert result["app_id"] == "test"
def test_resolve_account_with_accounts(self):
config = {
"dm_policy": "open",
"accounts": {"acct1": {"app_id": "id1", "secret_key": "key1", "name": "Account 1"}},
}
result = resolve_account(config)
assert result["app_id"] == "id1"
assert result["name"] == "Account 1"
def test_resolve_account_with_default_account(self):
config = {
"defaultAccount": "prod",
"accounts": {
"prod": {"app_id": "prod_id", "name": "Production"},
"staging": {"app_id": "staging_id", "name": "Staging"},
},
}
result = resolve_account(config)
assert result["app_id"] == "prod_id"
def test_resolve_account_specific_id(self):
config = {
"accounts": {
"prod": {"app_id": "prod_id", "name": "Production"},
"staging": {"app_id": "staging_id", "name": "Staging"},
},
}
result = resolve_account(config, account_id="staging")
assert result["app_id"] == "staging_id"
def test_resolve_account_top_level_merge(self):
config = {"dm_policy": "disabled", "webhook": {"url": "https://ex.com"}, "accounts": {"acct1": {"app_id": "id1"}}}
result = resolve_account(config)
assert result["dm_policy"] == "disabled"
assert result["app_id"] == "id1"
# ==============================================================================
# Webhook Anomaly Tracker
# ==============================================================================
class TestWebhookAnomalyTracker:
def setup_method(self):
self.tracker = WebhookAnomalyTracker()
def test_record_anomaly(self):
self.tracker.record(400, "10.0.0.1", "/webhook", "Bad request")
assert self.tracker.total_anomalies == 1
def test_total_anomalies_count(self):
for i in range(5):
self.tracker.record(400, f"10.0.0.{i}")
assert self.tracker.total_anomalies == 5
def test_get_anomalies(self):
self.tracker.record(401, "10.0.0.1")
self.tracker.record(413, "10.0.0.2")
anomalies = self.tracker.get_anomalies()
assert len(anomalies) == 2
def test_get_alerts_initially_empty(self):
alerts = self.tracker.get_alerts()
assert isinstance(alerts, list)
def test_alert_thresholds_defined(self):
assert ALERT_THRESHOLD_401 == 10
assert ALERT_THRESHOLD_400 == 20
assert ALERT_THRESHOLD_413 == 5
assert ALERT_THRESHOLD_429 == 10
def test_record_with_minimal_args(self):
self.tracker.record(200)
assert self.tracker.total_anomalies == 1
# ==============================================================================
# Message Actions
# ==============================================================================
class TestMessageActions:
def test_supported_actions_list(self):
assert "send" in SUPPORTED_ACTIONS
assert "sticker" in SUPPORTED_ACTIONS
assert "list" in SUPPORTED_ACTIONS
assert "broadcast" in SUPPORTED_ACTIONS
assert "unsend" in SUPPORTED_ACTIONS
def test_all_actions_supported(self):
for action in SUPPORTED_ACTIONS:
assert is_action_supported(action) is True
def test_unsupported_action(self):
assert is_action_supported("nonexistent") is False
def test_describe_actions_returns_all(self):
actions = describe_actions()
action_names = [a["action"] for a in actions]
for supported in SUPPORTED_ACTIONS:
assert supported in action_names
def test_build_action_handler_send(self):
formatter = MagicMock()
formatter._build_text.return_value = {"text": "hello"}
result = build_action_handler("send", {"recipient_id": "user1", "content": "hello"}, formatter)
formatter._build_text.assert_called_once_with("user1", "hello")
def test_build_action_handler_sticker(self):
formatter = MagicMock()
formatter._build_sticker.return_value = {"sticker": "123"}
result = build_action_handler("sticker", {"recipient_id": "user1", "sticker_id": "123"}, formatter)
formatter._build_sticker.assert_called_once()
def test_build_action_handler_list(self):
formatter = MagicMock()
formatter.build_list.return_value = {"list": "test"}
result = build_action_handler(
"list", {"recipient_id": "user1", "elements": [{"title": "Item"}], "buttons": None}, formatter
)
formatter.build_list.assert_called_once()
def test_build_action_handler_broadcast(self):
result = build_action_handler("broadcast", {"content": "Hello all"}, MagicMock())
assert result is None
def test_build_action_handler_unsend_no_client(self):
result = build_action_handler("unsend", {"message_id": "m1", "user_id": "u1"}, None, None, client=None)
assert result is None
def test_build_action_handler_unsend_with_client(self):
result = build_action_handler("unsend", {"message_id": "m1", "user_id": "u1"}, None, None, client="mock")
assert result["_unsend_message_id"] == "m1"
assert result["_unsend_user_id"] == "u1"
def test_build_action_handler_unknown(self):
result = build_action_handler("unknown", {}, MagicMock())
assert result is None
# ==============================================================================
# Session Router (Extended)
# ==============================================================================
class TestSessionRouterExtended:
def setup_method(self):
self.router = SessionRouter(ttl_sec=3600)
def test_resolve_bind_key(self):
key = self.router.resolve_bind_key("agent1")
assert key == "agent:agent1:zalo_oa:default"
def test_resolve_bind_key_custom_account(self):
key = self.router.resolve_bind_key("agent1", "prod")
assert key == "agent:agent1:zalo_oa:prod"
def test_bind_and_check(self):
self.router.bind_agent_account("agent1", "default")
assert self.router.is_bound("agent1", "default") is True
def test_unbind(self):
self.router.bind_agent_account("agent1", "default")
self.router.unbind_agent_account("agent1", "default")
assert self.router.is_bound("agent1", "default") is False
def test_binding_ttl_expiry(self):
router = SessionRouter(ttl_sec=3600)
router.bind_agent_account("agent1", "default")
for bind_key, entry in list(router._bindings.items()):
entry["created_at"] = time.time() - 99999
assert router.is_bound("agent1", "default") is False
def test_session_set_and_get(self):
thread_key = self.router.resolve_thread_key("agent1", "user1")
self.router.set_session(thread_key, {"data": "test_value"})
session = self.router.get_session(thread_key)
assert session == {"data": "test_value"}
def test_session_clear(self):
thread_key = self.router.resolve_thread_key("agent1", "user1")
self.router.set_session(thread_key, {"data": "test"})
self.router.clear_session(thread_key)
assert self.router.get_session(thread_key) is None
def test_get_bound_sessions(self):
self.router.bind_agent_account("agent1", "default")
thread_key = self.router.resolve_thread_key("agent1", "user1")
self.router.set_session(thread_key, {"follower_id": "user1"})
sessions = self.router.get_bound_sessions("agent1", "default")
assert len(sessions) >= 1
assert sessions[0]["follower_id"] == "user1"
def test_get_bound_sessions_no_bound(self):
sessions = self.router.get_bound_sessions("agent1", "default")
assert sessions == []
def test_cleanup_expired_count(self):
thread_key = self.router.resolve_thread_key("agent1", "expired_user")
self.router.set_session(thread_key, {"data": "test"})
self.router._sessions[thread_key]["created_at"] = time.time() - 99999
removed = self.router.cleanup_expired()
assert removed >= 1
def test_binding_count(self):
assert self.router.binding_count == 0
self.router.bind_agent_account("agent1", "default")
assert self.router.binding_count == 1
def test_get_binding(self):
self.router.bind_agent_account("agent1", "default", {"extra": "info"})
binding = self.router.get_binding("agent1", "default")
assert binding["agent_id"] == "agent1"
assert binding["data"] == {"extra": "info"}
def test_get_binding_expired(self):
self.router.bind_agent_account("agent1", "default")
for bind_key, entry in list(self.router._bindings.items()):
entry["created_at"] = time.time() - 99999
assert self.router.get_binding("agent1", "default") is None
def test_resolve_session_params_default(self):
params = self.router.resolve_session_params("user1")
assert params["chat_type"] == ChatType.DIRECT
assert params["channel_chat_id"] == "user1"
def test_resolve_session_params_full(self):
params = self.router.resolve_session_params("user1", sender_name="John", avatar="url", dm_policy="open", account_id="prod")
assert params["sender_name"] == "John"
assert params["avatar"] == "url"
assert params["dm_policy"] == "open"
assert params["account_id"] == "prod"
class TestGlobalSessionFunctions:
def test_resolve_session_params_global(self):
params = resolve_session_params("user1", sender_name="Test")
assert params["chat_type"] == ChatType.DIRECT
def test_get_session_router(self):
router = get_session_router()
assert isinstance(router, SessionRouter)
# ==============================================================================
# Formatter (Extended)
# ==============================================================================
class TestFormatterExtended:
def setup_method(self):
self.formatter = ZaloOAMessageFormatter()
def _make_response(self, content, message_type=MessageType.TEXT, metadata=None):
identity = ChannelIdentity(
channel_id="zalo_oa", channel_type=ChannelType.ZALO_OA,
channel_user_id="follower_001", channel_chat_id="oa_001",
)
return ChannelResponse(identity=identity, content=content, message_type=message_type, metadata=metadata or {})
def test_build_button_url(self):
button = self.formatter.build_button_url("Open", "https://example.com")
assert button["title"] == "Open"
assert button["type"] == "oa.open.url"
assert button["payload"]["url"] == "https://example.com"
def test_build_button_query(self):
button = self.formatter.build_button_query("Query", "search_term")
assert button["title"] == "Query"
assert button["type"] == "oa.query.show"
assert button["payload"] == "search_term"
def test_build_button_hide(self):
button = self.formatter.build_button_hide()
assert button["type"] == "oa.query.hide"
def test_build_button_hide_custom_title(self):
button = self.formatter.build_button_hide("Đóng")
assert button["title"] == "Đóng"
def test_build_inline_keyboard(self):
buttons = [{"title": "Click", "type": "oa.open.url", "payload": {"url": "https://ex.com"}}]
result = self.formatter.build_inline_keyboard("user1", "Choose:", buttons)
assert result["recipient"]["user_id"] == "user1"
assert result["message"]["text"] == "Choose:"
assert result["message"]["attachment"]["payload"]["template_type"] == "button"
def test_build_image_template_basic(self):
result = self.formatter.build_image_template("user1", "att_001")
assert result["recipient"]["user_id"] == "user1"
assert result["message"]["attachment"]["payload"]["elements"][0]["attachment_id"] == "att_001"
def test_build_image_template_with_title_subtitle(self):
result = self.formatter.build_image_template("user1", "att_001", title="My Image", subtitle="Beautiful")
elem = result["message"]["attachment"]["payload"]["elements"][0]
assert elem["title"] == "My Image"
assert elem["subtitle"] == "Beautiful"
def test_build_image_template_with_buttons(self):
btn = [{"title": "View", "type": "oa.open.url", "payload": {"url": "https://ex.com"}}]
result = self.formatter.build_image_template("user1", "att_001", buttons=btn)
assert "buttons" in result["message"]["attachment"]["payload"]
def test_build_text_template_basic(self):
result = self.formatter.build_text_template("user1", "Hello")
elem = result["message"]["attachment"]["payload"]["elements"][0]
assert elem["title"] == "Hello"
def test_build_text_template_full(self):
btn = [self.formatter.build_button_url("Open", "https://ex.com")]
result = self.formatter.build_text_template("user1", "Title", subtitle="Sub", description="Desc", buttons=btn)
elem = result["message"]["attachment"]["payload"]["elements"][0]
assert elem["title"] == "Title"
assert elem["subtitle"] == "Sub"
assert elem["description"] == "Desc"
def test_format_sticker(self):
response = self._make_response("", message_type=MessageType.STICKER, metadata={"sticker_id": "stk_001"})
result = self.formatter.format(response)
elem = result["message"]["attachment"]["payload"]["elements"][0]
assert elem["media_type"] == "sticker"
assert elem["attachment_id"] == "stk_001"
def test_format_location_via_format(self):
response = self._make_response("", message_type=MessageType.LOCATION, metadata={"lat": "10.0", "lon": "106.0"})
result = self.formatter.format(response)
elem = result["message"]["attachment"]["payload"]["elements"][0]
assert elem["media_type"] == "location"
assert elem["latitude"] == 10.0
assert elem["longitude"] == 106.0
# ==============================================================================
# Outbound Media Host
# ==============================================================================
class TestOutboundMediaHost:
def setup_method(self):
self.host = OutboundMediaHost(ttl_sec=300, max_entries=10)
def test_store_and_get(self):
ref_id = self.host.store("att_001", "image")
entry = self.host.get(ref_id)
assert entry["attachment_id"] == "att_001"
assert entry["media_type"] == "image"
def test_get_nonexistent(self):
assert self.host.get("nonexistent") is None
def test_ttl_expiry(self):
host = OutboundMediaHost(ttl_sec=0)
ref_id = host.store("att_001", "image")
assert host.get(ref_id) is None
def test_resolve_attachment_id(self):
ref_id = self.host.store("att_001", "image")
assert self.host.resolve_attachment_id(ref_id) == "att_001"
def test_resolve_attachment_id_nonexistent(self):
assert self.host.resolve_attachment_id("nonexistent") is None
def test_remove(self):
ref_id = self.host.store("att_001", "image")
self.host.remove(ref_id)
assert self.host.get(ref_id) is None
def test_active_count(self):
assert self.host.active_count == 0
self.host.store("att_001", "image")
assert self.host.active_count == 1
def test_max_entries_eviction(self):
host = OutboundMediaHost(max_entries=3)
for i in range(5):
host.store(f"att_{i}", "image")
assert host.active_count <= 3
def test_cleanup_expired(self):
host = OutboundMediaHost(ttl_sec=0)
for i in range(3):
host.store(f"att_{i}", "image")
removed = host.cleanup_expired()
assert removed == 3
def test_store_with_metadata(self):
ref_id = self.host.store("att_001", "image", {"filename": "test.jpg"})
entry = self.host.get(ref_id)
assert entry["metadata"]["filename"] == "test.jpg"
class TestGlobalOutboundMediaFunctions:
def test_store_get_cycle(self):
ref_id = store_media("att_global", "image")
entry = get_media(ref_id)
assert entry["attachment_id"] == "att_global"
def test_resolve_attachment_func(self):
ref_id = store_media("att_global", "image")
assert resolve_attachment(ref_id) == "att_global"
def test_cleanup_media_cache(self):
store_media("att_temp", "image")
removed = cleanup_media_cache()
assert isinstance(removed, int)
# ==============================================================================
# Config Schema
# ==============================================================================
class TestConfigSchema:
def test_zalo_oa_config_defaults(self):
config = ZaloOAConfig()
assert config.enabled is True
assert config.dm_policy == "open"
assert config.polling_enabled is True
assert config.voice_tts_enabled is False
def test_zalo_oa_account_config_defaults(self):
config = ZaloOAAccountConfig()
assert config.app_id == ""
assert config.secret_key == ""
assert config.enabled is True
assert config.dm_policy == "open"
assert config.polling_enabled is True
assert config.voice_tts_enabled is False
assert config.heartbeat_interval_sec == 30
def test_to_flat_config_no_accounts(self):
config = ZaloOAConfig(dm_policy="allowlist", enabled=False)
flat = config.to_flat_config()
assert flat["dm_policy"] == "allowlist"
assert flat["enabled"] is False
def test_to_flat_config_with_accounts(self):
config = ZaloOAConfig(
dm_policy="allowlist",
accounts={"prod": ZaloOAAccountConfig(app_id="app_id_1", secret_key="secret_1", dm_policy="allowlist", name="Production")},
)
flat = config.to_flat_config()
assert flat["app_id"] == "app_id_1"
assert flat["dm_policy"] == "allowlist"
def test_config_field_limits(self):
config = ZaloOAConfig(
rate_limit_window_ms=60000,
rate_limit_max_requests=60,
health_check_ttl_sec=120,
dedup_window_ms=500000,
)
assert config.rate_limit_window_ms == 60000
assert config.rate_limit_max_requests == 60
assert config.health_check_ttl_sec == 120
assert config.dedup_window_ms == 500000
# ==============================================================================
# Polling
# ==============================================================================
class TestPollingExtended:
def test_poller_initial_metrics(self):
poller = ZaloOAPoller(client=None, config={"polling_interval_sec": 10, "polling_timeout_ms": 5000})
metrics = poller.get_poll_metrics()
assert metrics["running"] is False
assert metrics["poll_count"] == 0
assert metrics["error_count"] == 0
assert metrics["interval_sec"] == 10
assert metrics["timeout_ms"] == 5000
def test_poller_with_callback(self):
cb = MagicMock()
poller = ZaloOAPoller(client=None, message_callback=cb)
assert poller._message_callback == cb
def test_poller_last_poll_at_initial(self):
poller = ZaloOAPoller(client=None)
metrics = poller.get_poll_metrics()
assert metrics["last_poll_at"] is None
def test_poller_follower_delta_initial(self):
poller = ZaloOAPoller(client=None)
metrics = poller.get_poll_metrics()
assert metrics["follower_delta_total"] == 0
# ==============================================================================
# Voice
# ==============================================================================
class TestVoiceExtended:
def test_voice_defaults(self):
voice = ZaloOAVoice()
assert voice.enabled is False
assert voice.synthesis_target == "voice-note"
def test_voice_enabled_with_api_url(self):
voice = ZaloOAVoice(config={"voice_tts_enabled": True, "voice_tts_api_url": "https://api.tts.com"})
assert voice.enabled is True
def test_voice_disabled_without_api_url_but_enabled(self):
voice = ZaloOAVoice(config={"voice_tts_enabled": True})
assert voice.enabled is False
def test_voice_no_config(self):
voice = ZaloOAVoice()
assert voice.enabled is False
@pytest.mark.asyncio
async def test_synthesize_and_send_not_enabled(self):
voice = ZaloOAVoice()
result = await voice.synthesize_and_send("hello", "user1", None)
assert result.success is False
assert "not configured" in result.error
def test_voice_custom_timeout(self):
voice = ZaloOAVoice(config={"voice_tts_enabled": True, "voice_tts_api_url": "https://api.com", "voice_tts_timeout_sec": 60})
assert voice.enabled is True
# ==============================================================================
# Normalizer - Additional Edge Cases
# ==============================================================================
class TestNormalizerEdgeCases:
def setup_method(self):
self.normalizer = ZaloOAEventNormalizer()
def test_user_submit_form(self):
payload = {
"event_name": "user_submit_form",
"sender": {"id": "u1"},
"recipient": {"id": "oa1"},
"message": {"text": "Form submitted with data"},
"timestamp": "1",
}
result = self.normalizer.normalize(payload)
assert result.event_type == EventType.CARD_ACTION
assert "Form submitted" in result.content
def test_user_click_button(self):
payload = {
"event_name": "user_click_button",
"sender": {"id": "u1"},
"recipient": {"id": "oa1"},
"message": {"payload": {"label": "Click me"}},
"timestamp": "1",
}
result = self.normalizer.normalize(payload)
assert result.event_type == EventType.CARD_ACTION
assert result.content == "Click me"
def test_user_send_gif(self):
payload = {
"event_name": "user_send_gif",
"sender": {"id": "u1"},
"recipient": {"id": "oa1"},
"message": {"msg_id": "gif1", "attachments": [{"type": "image", "payload": {"url": "https://ex.com/gif.gif", "id": "g1"}}]},
"timestamp": "1",
}
result = self.normalizer.normalize(payload)
assert result.message_type == MessageType.IMAGE
def test_text_with_mentions(self):
payload = {
"event_name": "user_send_text",
"sender": {"id": "u1"},
"recipient": {"id": "oa1"},
"message": {"msg_id": "m1", "text": "@user1 @user2 hello there"},
"timestamp": "1",
}
result = self.normalizer.normalize(payload)
assert "[mentions:" in result.content
assert "user1" in result.content
assert "user2" in result.content
def test_unknown_event_name_defaults_to_text(self):
payload = {
"event_name": "user_send_unknown",
"sender": {"id": "u1"},
"recipient": {"id": "oa1"},
"message": {"msg_id": "m1", "text": "unknown type"},
"timestamp": "1",
}
result = self.normalizer.normalize(payload)
assert result.message_type == MessageType.TEXT
assert result.event_type == EventType.MESSAGE_RECEIVED
def test_missing_fields_handled_gracefully(self):
payload = {"event_name": "follow"}
result = self.normalizer.normalize(payload)
assert result.content == "Follower followed OA"
assert result.identity.channel_user_id == "unknown"
assert result.identity.channel_chat_id == ""
class TestSkipMessageError:
def test_can_be_raised_and_caught(self):
try:
raise SkipMessageError("test skip")
except SkipMessageError as e:
assert str(e) == "test skip"