1272 lines
52 KiB
Python
1272 lines
52 KiB
Python
"""Tests for the LINE platform adapter plugin.
|
||
|
||
Covers the seven synthesis areas from the PR review:
|
||
|
||
1. webhook signature verification (HMAC-SHA256, base64) + tampering rejection
|
||
2. inbound chat-id resolution for user / group / room sources
|
||
3. three-allowlist gating (users / groups / rooms / allow_all)
|
||
4. inbound dedup via webhookEventId
|
||
5. RequestCache state machine (PENDING → READY → DELIVERED, ERROR)
|
||
6. Markdown stripping with URL preservation + LINE-sized chunking
|
||
7. send routing: reply token preferred → push fallback → batched at 5/call
|
||
8. register() metadata + standalone_send shape
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import base64
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import logging
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
import pytest
|
||
|
||
from tests.gateway._plugin_adapter_loader import load_plugin_adapter
|
||
|
||
# Load plugins/platforms/line/adapter.py under plugin_adapter_line so it
|
||
# cannot collide with sibling platform-plugin tests in the same xdist worker.
|
||
_line = load_plugin_adapter("line")
|
||
|
||
verify_line_signature = _line.verify_line_signature
|
||
strip_markdown_preserving_urls = _line.strip_markdown_preserving_urls
|
||
split_for_line = _line.split_for_line
|
||
build_postback_button_message = _line.build_postback_button_message
|
||
_resolve_chat = _line._resolve_chat
|
||
_allowed_for_source = _line._allowed_for_source
|
||
_is_system_bypass = _line._is_system_bypass
|
||
RequestCache = _line.RequestCache
|
||
State = _line.State
|
||
LineAdapter = _line.LineAdapter
|
||
register = _line.register
|
||
check_requirements = _line.check_requirements
|
||
validate_config = _line.validate_config
|
||
_standalone_send = _line._standalone_send
|
||
_env_enablement = _line._env_enablement
|
||
_MessageDeduplicator = _line._MessageDeduplicator
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. Signature verification
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestSignature:
|
||
|
||
def _sign(self, body: bytes, secret: str) -> str:
|
||
digest = hmac.new(secret.encode(), body, hashlib.sha256).digest()
|
||
return base64.b64encode(digest).decode()
|
||
|
||
def test_valid_signature_passes(self):
|
||
body = b'{"events": []}'
|
||
sig = self._sign(body, "secret")
|
||
assert verify_line_signature(body, sig, "secret")
|
||
|
||
def test_tampered_body_rejected(self):
|
||
body = b'{"events": []}'
|
||
sig = self._sign(body, "secret")
|
||
assert not verify_line_signature(body + b" ", sig, "secret")
|
||
|
||
def test_wrong_secret_rejected(self):
|
||
body = b'{"events": []}'
|
||
sig = self._sign(body, "secret")
|
||
assert not verify_line_signature(body, sig, "different")
|
||
|
||
def test_empty_signature_rejected(self):
|
||
assert not verify_line_signature(b"x", "", "secret")
|
||
|
||
def test_empty_secret_rejected(self):
|
||
assert not verify_line_signature(b"x", "AAAA", "")
|
||
|
||
def test_garbage_signature_rejected(self):
|
||
assert not verify_line_signature(b"hello", "not base64 at all!!", "s")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. Chat-id / source resolution
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestSourceResolution:
|
||
|
||
def test_user_source(self):
|
||
chat_id, ctype = _resolve_chat({"type": "user", "userId": "U123"})
|
||
assert chat_id == "U123"
|
||
assert ctype == "dm"
|
||
|
||
def test_group_source(self):
|
||
chat_id, ctype = _resolve_chat({"type": "group", "groupId": "C456", "userId": "U123"})
|
||
assert chat_id == "C456"
|
||
assert ctype == "group"
|
||
|
||
def test_room_source(self):
|
||
chat_id, ctype = _resolve_chat({"type": "room", "roomId": "R789", "userId": "U123"})
|
||
assert chat_id == "R789"
|
||
assert ctype == "room"
|
||
|
||
def test_unknown_source_falls_back_to_dm(self):
|
||
chat_id, ctype = _resolve_chat({"type": "weird"})
|
||
assert chat_id == ""
|
||
assert ctype == "dm"
|
||
|
||
def test_empty_source(self):
|
||
chat_id, ctype = _resolve_chat({})
|
||
assert chat_id == ""
|
||
assert ctype == "dm"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. Three-allowlist gating
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestAllowlist:
|
||
|
||
def test_allow_all_short_circuits(self):
|
||
for src in [
|
||
{"type": "user", "userId": "Ufoo"},
|
||
{"type": "group", "groupId": "Cfoo"},
|
||
{"type": "room", "roomId": "Rfoo"},
|
||
]:
|
||
assert _allowed_for_source(src, allow_all=True, user_ids=set(), group_ids=set(), room_ids=set())
|
||
|
||
def test_user_in_allowlist_passes(self):
|
||
src = {"type": "user", "userId": "Uok"}
|
||
assert _allowed_for_source(src, allow_all=False, user_ids={"Uok"}, group_ids=set(), room_ids=set())
|
||
|
||
def test_user_not_in_allowlist_rejected(self):
|
||
src = {"type": "user", "userId": "Uother"}
|
||
assert not _allowed_for_source(src, allow_all=False, user_ids={"Uok"}, group_ids=set(), room_ids=set())
|
||
|
||
def test_group_uses_group_list_not_user_list(self):
|
||
src = {"type": "group", "groupId": "Cok", "userId": "Uany"}
|
||
assert _allowed_for_source(src, allow_all=False, user_ids={"Uany"}, group_ids={"Cok"}, room_ids=set())
|
||
assert not _allowed_for_source(src, allow_all=False, user_ids={"Uany"}, group_ids=set(), room_ids=set())
|
||
|
||
def test_room_uses_room_list(self):
|
||
src = {"type": "room", "roomId": "Rok"}
|
||
assert _allowed_for_source(src, allow_all=False, user_ids=set(), group_ids=set(), room_ids={"Rok"})
|
||
assert not _allowed_for_source(src, allow_all=False, user_ids=set(), group_ids=set(), room_ids=set())
|
||
|
||
def test_unknown_type_rejected(self):
|
||
src = {"type": "weird"}
|
||
assert not _allowed_for_source(src, allow_all=False, user_ids=set(), group_ids=set(), room_ids=set())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. Inbound dedup
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestDedup:
|
||
|
||
def test_first_event_not_duplicate(self):
|
||
d = _MessageDeduplicator()
|
||
assert not d.is_duplicate("evt1")
|
||
|
||
def test_repeat_event_marked_duplicate(self):
|
||
d = _MessageDeduplicator()
|
||
d.is_duplicate("evt1")
|
||
assert d.is_duplicate("evt1")
|
||
|
||
def test_blank_id_not_treated_as_duplicate(self):
|
||
d = _MessageDeduplicator()
|
||
# Blank IDs should always pass through (don't lock out unidentifiable events).
|
||
assert not d.is_duplicate("")
|
||
assert not d.is_duplicate("")
|
||
|
||
def test_lru_eviction_under_pressure(self):
|
||
d = _MessageDeduplicator(max_size=10)
|
||
for i in range(20):
|
||
d.is_duplicate(f"evt{i}")
|
||
# Exact eviction order isn't specified, but the cap must be enforced.
|
||
# Insert one more and assert the bookkeeping doesn't grow without bound.
|
||
d.is_duplicate("evt20")
|
||
assert len(d._seen) <= 20 # bounded — exact cap depends on eviction policy
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5. RequestCache state machine
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestRequestCache:
|
||
|
||
def test_register_pending_is_pending(self):
|
||
c = RequestCache()
|
||
rid = c.register_pending("Uchat")
|
||
assert c.get(rid).state is State.PENDING
|
||
assert c.get(rid).chat_id == "Uchat"
|
||
|
||
def test_set_ready_transitions(self):
|
||
c = RequestCache()
|
||
rid = c.register_pending("Uchat")
|
||
c.set_ready(rid, "the answer")
|
||
assert c.get(rid).state is State.READY
|
||
assert c.get(rid).payload == "the answer"
|
||
|
||
def test_set_error_transitions(self):
|
||
c = RequestCache()
|
||
rid = c.register_pending("Uchat")
|
||
c.set_error(rid, "boom")
|
||
assert c.get(rid).state is State.ERROR
|
||
assert c.get(rid).payload == "boom"
|
||
|
||
def test_mark_delivered_from_ready(self):
|
||
c = RequestCache()
|
||
rid = c.register_pending("Uchat")
|
||
c.set_ready(rid, "x")
|
||
c.mark_delivered(rid)
|
||
assert c.get(rid).state is State.DELIVERED
|
||
|
||
def test_mark_delivered_from_error(self):
|
||
c = RequestCache()
|
||
rid = c.register_pending("Uchat")
|
||
c.set_error(rid, "x")
|
||
c.mark_delivered(rid)
|
||
assert c.get(rid).state is State.DELIVERED
|
||
|
||
def test_set_ready_on_delivered_is_noop(self):
|
||
c = RequestCache()
|
||
rid = c.register_pending("Uchat")
|
||
c.set_ready(rid, "first")
|
||
c.mark_delivered(rid)
|
||
c.set_ready(rid, "second")
|
||
# DELIVERED is terminal — no further mutation
|
||
assert c.get(rid).payload == "first"
|
||
assert c.get(rid).state is State.DELIVERED
|
||
|
||
def test_find_pending_for_chat(self):
|
||
c = RequestCache()
|
||
rid_a = c.register_pending("Ua")
|
||
rid_b = c.register_pending("Ub")
|
||
assert c.find_pending_for_chat("Ua") == rid_a
|
||
assert c.find_pending_for_chat("Ub") == rid_b
|
||
assert c.find_pending_for_chat("Uc") is None
|
||
c.set_ready(rid_a, "x")
|
||
# No longer PENDING — should not be found
|
||
assert c.find_pending_for_chat("Ua") is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 6. Markdown stripping + chunking
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestMarkdownAndChunking:
|
||
|
||
def test_bold_stripped(self):
|
||
assert strip_markdown_preserving_urls("**hello**") == "hello"
|
||
|
||
def test_italic_stripped(self):
|
||
assert strip_markdown_preserving_urls("*hello*") == "hello"
|
||
|
||
def test_inline_code_unfenced(self):
|
||
assert strip_markdown_preserving_urls("run `ls -la`") == "run ls -la"
|
||
|
||
def test_link_preserved_with_url(self):
|
||
out = strip_markdown_preserving_urls("see [here](https://x.com)")
|
||
assert "https://x.com" in out
|
||
assert "here (https://x.com)" in out
|
||
|
||
def test_heading_prefix_stripped(self):
|
||
out = strip_markdown_preserving_urls("# Title\n## Sub")
|
||
assert out == "Title\nSub"
|
||
|
||
def test_bullet_marker_replaced(self):
|
||
out = strip_markdown_preserving_urls("- a\n- b")
|
||
assert out == "• a\n• b"
|
||
|
||
def test_code_fence_content_kept(self):
|
||
# Source files often contain code snippets — the agent should still
|
||
# see the content as plain text, just without backticks.
|
||
md = "```python\nprint('hi')\n```"
|
||
out = strip_markdown_preserving_urls(md)
|
||
assert "print('hi')" in out
|
||
assert "```" not in out
|
||
|
||
def test_split_short_returns_single_chunk(self):
|
||
assert split_for_line("hi") == ["hi"]
|
||
|
||
def test_split_long_chunks_at_paragraph_boundary(self):
|
||
text = "para1\n\npara2\n\npara3"
|
||
chunks = split_for_line(text, max_chars=8)
|
||
assert all(len(c) <= 8 for c in chunks), chunks
|
||
assert len(chunks) >= 2
|
||
|
||
def test_split_caps_at_five_chunks(self):
|
||
# 1000 paragraphs of 100 chars each — must cap at 5 LINE bubbles.
|
||
text = "\n\n".join(["x" * 100 for _ in range(1000)])
|
||
chunks = split_for_line(text)
|
||
assert len(chunks) <= 5
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 7. Send routing (reply -> push fallback, batching, system-bypass)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestSendRouting:
|
||
|
||
@pytest.fixture
|
||
def adapter(self, monkeypatch):
|
||
for key in ("LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET", "LINE_PUBLIC_URL", "LINE_HOME_CHANNEL", "LINE_PORT"):
|
||
monkeypatch.delenv(key, raising=False)
|
||
from gateway.config import PlatformConfig
|
||
cfg = PlatformConfig(enabled=True, extra={
|
||
"channel_access_token": "tok",
|
||
"channel_secret": "sec",
|
||
})
|
||
ad = LineAdapter(cfg)
|
||
ad._client = MagicMock()
|
||
ad._client.reply = AsyncMock()
|
||
ad._client.push = AsyncMock()
|
||
monkeypatch.setattr(ad, "_verified_email_target_if_bound", lambda _chat_id, email: email)
|
||
return ad
|
||
|
||
def test_system_bypass_recognized(self):
|
||
assert _is_system_bypass("⚡ Interrupting current run")
|
||
assert _is_system_bypass("⏳ Queued — agent is busy")
|
||
assert _is_system_bypass("⏩ Steered toward new task")
|
||
assert not _is_system_bypass("Hello world")
|
||
assert not _is_system_bypass("")
|
||
|
||
def test_send_uses_reply_when_token_present(self, adapter):
|
||
import time as _time
|
||
adapter._reply_tokens["Uchat"] = ("rt-token", _time.time() + 30)
|
||
result = asyncio.run(adapter.send("Uchat", "hello"))
|
||
assert result.success
|
||
adapter._client.reply.assert_called_once()
|
||
adapter._client.push.assert_not_called()
|
||
# Token consumed (single-use)
|
||
assert "Uchat" not in adapter._reply_tokens
|
||
|
||
def test_send_falls_back_to_push_when_no_token(self, adapter):
|
||
result = asyncio.run(adapter.send("Uchat", "hello"))
|
||
assert result.success
|
||
adapter._client.push.assert_called_once()
|
||
adapter._client.reply.assert_not_called()
|
||
|
||
def test_send_falls_back_to_push_when_reply_fails(self, adapter):
|
||
import time as _time
|
||
adapter._reply_tokens["Uchat"] = ("rt-token", _time.time() + 30)
|
||
adapter._client.reply.side_effect = RuntimeError("expired")
|
||
result = asyncio.run(adapter.send("Uchat", "hello"))
|
||
assert result.success
|
||
adapter._client.reply.assert_called_once()
|
||
adapter._client.push.assert_called_once()
|
||
|
||
def test_send_returns_failure_when_push_fails(self, adapter):
|
||
adapter._client.push.side_effect = RuntimeError("network")
|
||
result = asyncio.run(adapter.send("Uchat", "hello"))
|
||
assert not result.success
|
||
assert "network" in result.error
|
||
|
||
def test_recovery_probe_replays_recent_delivery_via_push(self, adapter):
|
||
adapter._client.loading = AsyncMock()
|
||
adapter.handle_message = AsyncMock()
|
||
adapter._remember_visible_delivery("Uchat", "最新結果在這裡", surface="reply")
|
||
|
||
event = {
|
||
"replyToken": "rt-1",
|
||
"source": {"type": "user", "userId": "Uchat"},
|
||
"message": {"id": "m1", "type": "text", "text": "?"},
|
||
}
|
||
|
||
asyncio.run(adapter._handle_message_event(event))
|
||
|
||
adapter._client.push.assert_called_once()
|
||
adapter.handle_message.assert_not_called()
|
||
|
||
def test_recovery_probe_replays_pending_notice_via_push(self, adapter):
|
||
adapter._client.loading = AsyncMock()
|
||
adapter.handle_message = AsyncMock()
|
||
rid = adapter._cache.register_pending("Uchat")
|
||
adapter._pending_buttons["Uchat"] = rid
|
||
adapter._cache.get(rid).payload = {
|
||
"kind": "email_fallback_pending",
|
||
"notice": "目前任務還在處理中;完成後會寄到你的驗證 Email。",
|
||
}
|
||
|
||
event = {
|
||
"replyToken": "rt-1",
|
||
"source": {"type": "user", "userId": "Uchat"},
|
||
"message": {"id": "m1", "type": "text", "text": "妳還好嗎?"},
|
||
}
|
||
|
||
asyncio.run(adapter._handle_message_event(event))
|
||
|
||
adapter._client.push.assert_called_once()
|
||
adapter.handle_message.assert_not_called()
|
||
|
||
def test_config_copy_overrides_env_copy(self, monkeypatch):
|
||
monkeypatch.setenv("LINE_PENDING_TEXT", "env pending")
|
||
monkeypatch.setenv("LINE_DELIVERED_TEXT", "env delivered")
|
||
monkeypatch.setenv("LINE_BUTTON_LABEL", "env button")
|
||
from gateway.config import PlatformConfig
|
||
|
||
cfg = PlatformConfig(enabled=True, extra={
|
||
"channel_access_token": "tok",
|
||
"channel_secret": "sec",
|
||
"pending_text": "config pending",
|
||
"delivered_text": "config delivered",
|
||
"button_label": "config button",
|
||
})
|
||
ad = LineAdapter(cfg)
|
||
|
||
assert ad.pending_text == "config pending"
|
||
assert ad.delivered_text == "config delivered"
|
||
assert ad.button_label == "config button"
|
||
|
||
def test_send_quota_failure_falls_back_to_verified_email(self, adapter, monkeypatch):
|
||
sent = {}
|
||
|
||
async def _fake_email_send(_cfg, chat_id, message, **kwargs):
|
||
sent["chat_id"] = chat_id
|
||
sent["message"] = message
|
||
sent["kwargs"] = kwargs
|
||
return {"success": True}
|
||
|
||
adapter._client.push.side_effect = RuntimeError(
|
||
'LINE push 429: {"message":"You have reached your monthly limit."}'
|
||
)
|
||
monkeypatch.setattr(adapter, "_verified_email_for_chat", lambda _chat_id: "dk96@bremen.com.tw")
|
||
monkeypatch.setattr(_line, "_email_standalone_send", _fake_email_send)
|
||
|
||
result = asyncio.run(adapter.send("Uchat", "hello"))
|
||
|
||
assert result.success
|
||
assert result.raw_response["kind"] == "email_fallback"
|
||
assert result.raw_response["email"] == "dk96@bremen.com.tw"
|
||
assert "LINE Push 額度已滿" in result.raw_response["notice"]
|
||
assert sent["chat_id"] == "dk96@bremen.com.tw"
|
||
assert "這次先改用 Email 交付結果" in sent["message"]
|
||
assert sent["kwargs"]["subject"] == "LINE 任務結果已改由 Email 交付"
|
||
assert sent["kwargs"]["from_name"] == "不來梅的艾瑪"
|
||
assert sent["message"].endswith("hello")
|
||
|
||
def test_send_quota_failure_passes_media_files_to_email_fallback(self, adapter, monkeypatch):
|
||
sent = {}
|
||
|
||
async def _fake_email_send(_cfg, chat_id, message, **kwargs):
|
||
sent["chat_id"] = chat_id
|
||
sent["message"] = message
|
||
sent["kwargs"] = kwargs
|
||
return {"success": True}
|
||
|
||
adapter._client.push.side_effect = RuntimeError(
|
||
'LINE push 429: {"message":"You have reached your monthly limit."}'
|
||
)
|
||
monkeypatch.setattr(adapter, "_verified_email_for_chat", lambda _chat_id: "dk96@bremen.com.tw")
|
||
monkeypatch.setattr(_line, "_email_standalone_send", _fake_email_send)
|
||
|
||
metadata = {
|
||
"line_quota_fallback_media_files": ["/tmp/result.pptx", "/tmp/result.pdf"],
|
||
"line_quota_fallback_force_document": True,
|
||
}
|
||
result = asyncio.run(adapter.send("Uchat", "hello", metadata=metadata))
|
||
|
||
assert result.success
|
||
assert sent["chat_id"] == "dk96@bremen.com.tw"
|
||
assert sent["kwargs"]["media_files"] == ["/tmp/result.pptx", "/tmp/result.pdf"]
|
||
assert sent["kwargs"]["force_document"] is True
|
||
assert adapter.should_route_poststream_files_to_email("Uchat") is True
|
||
|
||
|
||
def test_send_email_file_batch_fallback_attaches_files(self, adapter, monkeypatch):
|
||
sent = {}
|
||
|
||
async def _fake_email_send(_cfg, chat_id, message, **kwargs):
|
||
sent["chat_id"] = chat_id
|
||
sent["message"] = message
|
||
sent["kwargs"] = kwargs
|
||
return {"success": True}
|
||
|
||
monkeypatch.setattr(adapter, "_verified_email_for_chat", lambda _chat_id: "dk96@bremen.com.tw")
|
||
monkeypatch.setattr(_line, "_email_standalone_send", _fake_email_send)
|
||
monkeypatch.setattr(
|
||
adapter,
|
||
"_build_public_download_links",
|
||
lambda _paths: [
|
||
("result.pptx", "https://x.example.com/line/media/t1/result.pptx"),
|
||
("result.pdf", "https://x.example.com/line/media/t2/result.pdf"),
|
||
],
|
||
)
|
||
|
||
ok = asyncio.run(
|
||
adapter.send_email_file_batch_fallback(
|
||
"Uchat",
|
||
["/tmp/result.pptx", "/tmp/result.pdf"],
|
||
)
|
||
)
|
||
|
||
assert ok is True
|
||
assert sent["chat_id"] == "dk96@bremen.com.tw"
|
||
assert sent["kwargs"]["media_files"] == ["/tmp/result.pptx", "/tmp/result.pdf"]
|
||
assert sent["kwargs"]["force_document"] is True
|
||
assert "本信同時附上附件與下載連結" in sent["message"]
|
||
assert "下載連結(點一下即可下載)" in sent["message"]
|
||
assert "- result.pptx" in sent["message"]
|
||
assert "https://x.example.com/line/media/t1/result.pptx" in sent["message"]
|
||
|
||
def test_send_email_file_batch_fallback_blocks_when_live_binding_differs(self, adapter, monkeypatch):
|
||
monkeypatch.setattr(adapter, "_verified_email_for_chat", lambda _chat_id: "wrong@bremen.com.tw")
|
||
monkeypatch.setattr(adapter, "_verified_email_target_if_bound", lambda _chat_id, _email: None)
|
||
|
||
ok = asyncio.run(
|
||
adapter.send_email_file_batch_fallback(
|
||
"Uchat",
|
||
["/tmp/result.pptx"],
|
||
)
|
||
)
|
||
|
||
assert ok is False
|
||
|
||
def test_send_quota_fallback_blocks_when_live_binding_differs(self, adapter, monkeypatch):
|
||
monkeypatch.setattr(adapter, "_verified_email_for_chat", lambda _chat_id: "wrong@bremen.com.tw")
|
||
monkeypatch.setattr(adapter, "_verified_email_target_if_bound", lambda _chat_id, _email: None)
|
||
|
||
payload = asyncio.run(adapter._send_email_quota_fallback("Uchat", "hello"))
|
||
|
||
assert payload is None
|
||
|
||
def test_append_download_links_uses_readable_multiline_format(self, adapter):
|
||
message = adapter._append_download_links(
|
||
"這是結果",
|
||
[("result.pptx", "https://x.example.com/downloads/token1")],
|
||
)
|
||
|
||
assert "下載連結(點一下即可下載)" in message
|
||
assert "- result.pptx" in message
|
||
assert "https://x.example.com/downloads/token1" in message
|
||
assert ": https://x.example.com/downloads/token1" not in message
|
||
|
||
def test_send_email_file_batch_fallback_size_limit_sends_single_notice(self, adapter, monkeypatch):
|
||
sends = []
|
||
|
||
async def _fake_email_send(_cfg, chat_id, message, **kwargs):
|
||
sends.append((chat_id, message, kwargs))
|
||
if len(sends) == 1:
|
||
return {
|
||
"error": "Email send failed: (552, b\"5.3.4 Your message exceeded Google's message size limits. ... MaxSizeError\")"
|
||
}
|
||
return {"success": True}
|
||
|
||
monkeypatch.setattr(adapter, "_verified_email_for_chat", lambda _chat_id: "dk96@bremen.com.tw")
|
||
monkeypatch.setattr(_line, "_email_standalone_send", _fake_email_send)
|
||
monkeypatch.setattr(
|
||
adapter,
|
||
"_build_public_download_links",
|
||
lambda _paths: [
|
||
("result.pptx", "https://x.example.com/line/media/t1/result.pptx"),
|
||
("result.pdf", "https://x.example.com/line/media/t2/result.pdf"),
|
||
],
|
||
)
|
||
|
||
ok = asyncio.run(
|
||
adapter.send_email_file_batch_fallback(
|
||
"Uchat",
|
||
["/tmp/result.pptx", "/tmp/result.pdf"],
|
||
)
|
||
)
|
||
|
||
assert ok is True
|
||
assert len(sends) == 2
|
||
assert sends[0][2]["media_files"] == ["/tmp/result.pptx", "/tmp/result.pdf"]
|
||
assert "附件總大小超過 Email 限制" in sends[1][1]
|
||
assert "https://x.example.com/line/media/t1/result.pptx" in sends[1][1]
|
||
|
||
def test_final_quota_fallback_size_limit_retries_without_attachments(self, adapter, monkeypatch):
|
||
sends = []
|
||
|
||
async def _fake_email_send(_cfg, chat_id, message, **kwargs):
|
||
sends.append((chat_id, message, kwargs))
|
||
if len(sends) == 1:
|
||
return {
|
||
"error": "Email send failed: (552, b\"5.3.4 Your message exceeded Google's message size limits. ... MaxSizeError\")"
|
||
}
|
||
return {"success": True}
|
||
|
||
monkeypatch.setattr(adapter, "_verified_email_for_chat", lambda _chat_id: "dk96@bremen.com.tw")
|
||
monkeypatch.setattr(_line, "_email_standalone_send", _fake_email_send)
|
||
monkeypatch.setattr(
|
||
adapter,
|
||
"_build_public_download_links",
|
||
lambda _paths: [
|
||
("result.pptx", "https://x.example.com/line/media/t1/result.pptx"),
|
||
("result.pdf", "https://x.example.com/line/media/t2/result.pdf"),
|
||
],
|
||
)
|
||
|
||
payload = asyncio.run(
|
||
adapter._send_email_quota_fallback(
|
||
"Uchat",
|
||
"final answer",
|
||
mode="final",
|
||
media_files=["/tmp/result.pptx", "/tmp/result.pdf"],
|
||
force_document=True,
|
||
)
|
||
)
|
||
|
||
assert payload is not None
|
||
assert payload["kind"] == "email_fallback"
|
||
assert payload["attachments_omitted_reason"] == "size_limit"
|
||
assert payload["omitted_filenames"] == ["result.pptx", "result.pdf"]
|
||
assert payload["download_links"] == [
|
||
"https://x.example.com/line/media/t1/result.pptx",
|
||
"https://x.example.com/line/media/t2/result.pdf",
|
||
]
|
||
assert len(sends) == 2
|
||
assert sends[0][2]["media_files"] == ["/tmp/result.pptx", "/tmp/result.pdf"]
|
||
assert "檔案總大小超過 Email 限制" in sends[1][1]
|
||
assert "https://x.example.com/line/media/t1/result.pptx" in sends[0][1]
|
||
|
||
def test_non_conversational_heartbeat_quota_fallback_sends_one_pending_email(self, adapter, monkeypatch):
|
||
sent_messages = []
|
||
|
||
async def _fake_email_send(_cfg, chat_id, message, **kwargs):
|
||
sent_messages.append((chat_id, message, kwargs))
|
||
return {"success": True}
|
||
|
||
rid = adapter._cache.register_pending("Uchat")
|
||
adapter._pending_buttons["Uchat"] = rid
|
||
adapter._client.push.side_effect = RuntimeError(
|
||
'LINE push 429: {"message":"You have reached your monthly limit."}'
|
||
)
|
||
monkeypatch.setattr(adapter, "_verified_email_for_chat", lambda _chat_id: "dk96@bremen.com.tw")
|
||
monkeypatch.setattr(_line, "_email_standalone_send", _fake_email_send)
|
||
|
||
metadata = {"non_conversational": True}
|
||
first = asyncio.run(adapter.send("Uchat", "⏳ Working — 5 min — receiving stream response", metadata=metadata))
|
||
second = asyncio.run(adapter.send("Uchat", "⏳ Working — 6 min — receiving stream response", metadata=metadata))
|
||
|
||
assert first.success and second.success
|
||
assert first.raw_response["kind"] == "email_fallback_pending"
|
||
assert second.raw_response["kind"] == "email_fallback_pending"
|
||
assert len(sent_messages) == 1
|
||
assert sent_messages[0][0] == "dk96@bremen.com.tw"
|
||
assert "等完成後,我會再把最終結果寄到你的驗證 Email" in sent_messages[0][1]
|
||
assert adapter._cache.get(rid).state is State.PENDING
|
||
assert adapter.should_route_poststream_files_to_email("Uchat") is True
|
||
assert adapter._client.push.await_count == 1
|
||
|
||
def test_non_conversational_heartbeat_flag_uses_pending_mode_without_working_prefix(self, adapter, monkeypatch):
|
||
sent_messages = []
|
||
|
||
async def _fake_email_send(_cfg, chat_id, message, **kwargs):
|
||
sent_messages.append((chat_id, message, kwargs))
|
||
return {"success": True}
|
||
|
||
rid = adapter._cache.register_pending("Uchat")
|
||
adapter._pending_buttons["Uchat"] = rid
|
||
adapter._client.push.side_effect = RuntimeError(
|
||
'LINE push 429: {"message":"You have reached your monthly limit."}'
|
||
)
|
||
monkeypatch.setattr(adapter, "_verified_email_for_chat", lambda _chat_id: "dk96@bremen.com.tw")
|
||
monkeypatch.setattr(_line, "_email_standalone_send", _fake_email_send)
|
||
|
||
metadata = {"non_conversational": True, "long_running_heartbeat": True}
|
||
first = asyncio.run(adapter.send("Uchat", "still on it", metadata=metadata))
|
||
second = asyncio.run(adapter.send("Uchat", "still on it", metadata=metadata))
|
||
|
||
assert first.success and second.success
|
||
assert first.raw_response["kind"] == "email_fallback_pending"
|
||
assert second.raw_response["kind"] == "email_fallback_pending"
|
||
assert len(sent_messages) == 1
|
||
assert "以下是完整回覆" not in sent_messages[0][1]
|
||
|
||
def test_non_conversational_non_heartbeat_quota_message_is_suppressed(self, adapter, monkeypatch, caplog):
|
||
async def _fake_email_send(_cfg, chat_id, message, **kwargs):
|
||
raise AssertionError("should not email non-heartbeat internal notices")
|
||
|
||
adapter._client.push.side_effect = RuntimeError(
|
||
'LINE push 429: {"message":"You have reached your monthly limit."}'
|
||
)
|
||
monkeypatch.setattr(adapter, "_is_push_quota_error", lambda _exc: True)
|
||
monkeypatch.setattr(_line, "_email_standalone_send", _fake_email_send)
|
||
|
||
with caplog.at_level(logging.INFO):
|
||
result = asyncio.run(
|
||
adapter.send(
|
||
"Uchat",
|
||
"💾 Self-improvement review: Memory updated",
|
||
metadata={"non_conversational": True, "gateway_shutdown_notification": True},
|
||
)
|
||
)
|
||
|
||
assert result.success is True
|
||
assert result.raw_response["kind"] == "suppressed_non_conversational_quota_fallback"
|
||
assert result.raw_response["reason"] == "non_heartbeat_non_conversational"
|
||
assert result.raw_response["gateway_shutdown_notification"] is True
|
||
assert result.raw_response["notice"] == _line.DEFAULT_SUPPRESSED_NON_CONVERSATIONAL_QUOTA_NOTICE
|
||
assert "suppressing email quota fallback for non-conversational notification" in caplog.text
|
||
|
||
def test_pending_postback_replays_email_wait_notice_after_heartbeat_quota_fallback(self, adapter, monkeypatch):
|
||
async def _fake_email_send(_cfg, chat_id, message, **kwargs):
|
||
return {"success": True}
|
||
|
||
rid = adapter._cache.register_pending("Uchat")
|
||
adapter._pending_buttons["Uchat"] = rid
|
||
adapter._client.push.side_effect = RuntimeError(
|
||
'LINE push 429: {"message":"You have reached your monthly limit."}'
|
||
)
|
||
monkeypatch.setattr(adapter, "_verified_email_for_chat", lambda _chat_id: "dk96@bremen.com.tw")
|
||
monkeypatch.setattr(_line, "_email_standalone_send", _fake_email_send)
|
||
|
||
metadata = {"non_conversational": True}
|
||
heartbeat = asyncio.run(
|
||
adapter.send(
|
||
"Uchat",
|
||
"⏳ Working — 5 min — receiving stream response",
|
||
metadata=metadata,
|
||
)
|
||
)
|
||
|
||
assert heartbeat.success
|
||
assert heartbeat.raw_response["kind"] == "email_fallback_pending"
|
||
entry = adapter._cache.get(rid)
|
||
assert entry is not None
|
||
assert entry.state is State.PENDING
|
||
assert entry.payload["kind"] == "email_fallback_pending"
|
||
|
||
event = {
|
||
"replyToken": "rt-1",
|
||
"source": {"type": "user", "userId": "Uchat"},
|
||
"postback": {"data": json.dumps({"action": "show_response", "request_id": rid})},
|
||
}
|
||
asyncio.run(adapter._handle_postback_event(event))
|
||
|
||
sent_messages = adapter._client.reply.call_args.args[1]
|
||
assert "LINE Push 額度已滿,所以這次無法直接在 LINE 傳送進度通知" in sent_messages[0]["text"]
|
||
assert "等完成後,我會再把最終結果寄到你的驗證 Email" in sent_messages[0]["text"]
|
||
|
||
def test_send_pending_button_caches_response(self, adapter):
|
||
# Simulate that the slow-LLM postback button has fired.
|
||
rid = adapter._cache.register_pending("Uchat")
|
||
adapter._pending_buttons["Uchat"] = rid
|
||
result = asyncio.run(adapter.send("Uchat", "the answer"))
|
||
assert result.success
|
||
# Response must have been cached, not pushed/replied.
|
||
adapter._client.reply.assert_not_called()
|
||
adapter._client.push.assert_not_called()
|
||
assert adapter._cache.get(rid).state is State.READY
|
||
assert adapter._cache.get(rid).payload == "the answer"
|
||
|
||
def test_send_pending_button_autocompletes_to_final_email_after_quota_fallback(self, adapter, monkeypatch):
|
||
sent = {}
|
||
|
||
async def _fake_email_send(_cfg, chat_id, message, **kwargs):
|
||
sent["chat_id"] = chat_id
|
||
sent["message"] = message
|
||
sent["kwargs"] = kwargs
|
||
return {"success": True}
|
||
|
||
rid = adapter._cache.register_pending("Uchat")
|
||
adapter._pending_buttons["Uchat"] = rid
|
||
adapter._email_fallback_pending_notices_sent.add(rid)
|
||
monkeypatch.setattr(adapter, "_verified_email_for_chat", lambda _chat_id: "dk96@bremen.com.tw")
|
||
monkeypatch.setattr(_line, "_email_standalone_send", _fake_email_send)
|
||
|
||
result = asyncio.run(adapter.send("Uchat", "final answer"))
|
||
|
||
assert result.success
|
||
assert result.raw_response["kind"] == "email_fallback"
|
||
assert sent["chat_id"] == "dk96@bremen.com.tw"
|
||
assert "這次先改用 Email 交付結果" in sent["message"]
|
||
assert sent["kwargs"]["subject"] == "LINE 任務結果已改由 Email 交付"
|
||
assert sent["kwargs"]["from_name"] == "不來梅的艾瑪"
|
||
assert sent["message"].endswith("final answer")
|
||
assert adapter._cache.get(rid).state is State.DELIVERED
|
||
assert adapter._cache.get(rid).payload["kind"] == "email_fallback"
|
||
assert "Uchat" not in adapter._pending_buttons
|
||
adapter._client.reply.assert_not_called()
|
||
adapter._client.push.assert_not_called()
|
||
|
||
def test_send_system_bypass_skips_postback_cache(self, adapter):
|
||
# Even with a pending button, system busy-acks must surface visibly.
|
||
rid = adapter._cache.register_pending("Uchat")
|
||
adapter._pending_buttons["Uchat"] = rid
|
||
result = asyncio.run(adapter.send("Uchat", "⚡ Interrupting current run"))
|
||
assert result.success
|
||
# Bypass goes through push (no reply token stored)
|
||
adapter._client.push.assert_called_once()
|
||
# And the cache entry is unchanged (still PENDING for the eventual answer)
|
||
assert adapter._cache.get(rid).state is State.PENDING
|
||
|
||
def test_send_caps_messages_per_call_at_five(self, adapter):
|
||
# Build a payload that would naturally split into more than 5 LINE
|
||
# bubbles; the chunker should cap at 5 + truncate.
|
||
big = "\n\n".join(["x" * 4500 for _ in range(20)])
|
||
result = asyncio.run(adapter.send("Uchat", big))
|
||
assert result.success
|
||
call_kwargs = adapter._client.push.call_args
|
||
# call_args is (args, kwargs); for our send the messages are the 2nd positional
|
||
sent_messages = call_kwargs.args[1] if call_kwargs.args else call_kwargs.kwargs.get("messages")
|
||
# Without args, fall back to inspecting the call shape
|
||
if sent_messages is None:
|
||
# We invoked client.push(chat_id, messages) — check first batch
|
||
sent_messages = adapter._client.push.call_args.args[1]
|
||
assert len(sent_messages) <= 5
|
||
|
||
def test_format_message_strips_markdown(self, adapter):
|
||
out = adapter.format_message("**bold** [link](https://x.com)")
|
||
assert "**" not in out
|
||
assert "https://x.com" in out
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 8. Register() metadata + plugin entry points
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestRegister:
|
||
|
||
class _FakeCtx:
|
||
def __init__(self):
|
||
self.kwargs = None
|
||
|
||
def register_platform(self, **kw):
|
||
self.kwargs = kw
|
||
|
||
def test_register_calls_register_platform(self):
|
||
ctx = self._FakeCtx()
|
||
register(ctx)
|
||
assert ctx.kwargs is not None
|
||
assert ctx.kwargs["name"] == "line"
|
||
assert ctx.kwargs["label"] == "LINE"
|
||
|
||
def test_register_advertises_required_env(self):
|
||
ctx = self._FakeCtx()
|
||
register(ctx)
|
||
assert set(ctx.kwargs["required_env"]) == {
|
||
"LINE_CHANNEL_ACCESS_TOKEN",
|
||
"LINE_CHANNEL_SECRET",
|
||
}
|
||
|
||
def test_register_wires_allowlist_envs(self):
|
||
ctx = self._FakeCtx()
|
||
register(ctx)
|
||
assert ctx.kwargs["allowed_users_env"] == "LINE_ALLOWED_USERS"
|
||
assert ctx.kwargs["allow_all_env"] == "LINE_ALLOW_ALL_USERS"
|
||
|
||
def test_register_wires_cron_home_channel(self):
|
||
ctx = self._FakeCtx()
|
||
register(ctx)
|
||
assert ctx.kwargs["cron_deliver_env_var"] == "LINE_HOME_CHANNEL"
|
||
|
||
def test_register_provides_standalone_sender(self):
|
||
ctx = self._FakeCtx()
|
||
register(ctx)
|
||
assert callable(ctx.kwargs["standalone_sender_fn"])
|
||
|
||
def test_register_provides_env_enablement(self):
|
||
ctx = self._FakeCtx()
|
||
register(ctx)
|
||
assert callable(ctx.kwargs["env_enablement_fn"])
|
||
|
||
def test_register_factory_yields_line_adapter(self):
|
||
ctx = self._FakeCtx()
|
||
register(ctx)
|
||
from gateway.config import PlatformConfig
|
||
cfg = PlatformConfig(enabled=True, extra={
|
||
"channel_access_token": "tok",
|
||
"channel_secret": "sec",
|
||
})
|
||
ad = ctx.kwargs["adapter_factory"](cfg)
|
||
assert isinstance(ad, LineAdapter)
|
||
|
||
def test_max_message_length_below_line_per_bubble_limit(self):
|
||
ctx = self._FakeCtx()
|
||
register(ctx)
|
||
# LINE per-bubble limit is 5000; we register 4500 to leave headroom.
|
||
assert ctx.kwargs["max_message_length"] <= 5000
|
||
|
||
|
||
class TestEnvEnablement:
|
||
|
||
def test_returns_none_without_required_env(self, monkeypatch):
|
||
for key in ("LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET", "LINE_PUBLIC_URL", "LINE_HOME_CHANNEL", "LINE_PORT"):
|
||
monkeypatch.delenv(key, raising=False)
|
||
assert _env_enablement() is None
|
||
|
||
def test_returns_dict_with_credentials(self, monkeypatch):
|
||
for key in ("LINE_PUBLIC_URL", "LINE_HOME_CHANNEL", "LINE_PORT"):
|
||
monkeypatch.delenv(key, raising=False)
|
||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok")
|
||
monkeypatch.setenv("LINE_CHANNEL_SECRET", "sec")
|
||
assert _env_enablement() == {}
|
||
|
||
def test_seeds_port_from_env(self, monkeypatch):
|
||
for key in ("LINE_PUBLIC_URL", "LINE_HOME_CHANNEL"):
|
||
monkeypatch.delenv(key, raising=False)
|
||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok")
|
||
monkeypatch.setenv("LINE_CHANNEL_SECRET", "sec")
|
||
monkeypatch.setenv("LINE_PORT", "8080")
|
||
assert _env_enablement() == {"port": 8080}
|
||
|
||
def test_seeds_public_url(self, monkeypatch):
|
||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok")
|
||
monkeypatch.setenv("LINE_CHANNEL_SECRET", "sec")
|
||
monkeypatch.setenv("LINE_PUBLIC_URL", "https://my-tunnel.example.com")
|
||
result = _env_enablement()
|
||
assert result["public_url"] == "https://my-tunnel.example.com"
|
||
|
||
|
||
class TestStandaloneSend:
|
||
|
||
def test_missing_token_returns_error(self, monkeypatch):
|
||
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
|
||
from gateway.config import PlatformConfig
|
||
cfg = PlatformConfig(enabled=True, extra={})
|
||
result = asyncio.run(_standalone_send(cfg, "Uchat", "hi"))
|
||
assert "error" in result
|
||
|
||
def test_missing_chat_id_returns_error(self, monkeypatch):
|
||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok")
|
||
from gateway.config import PlatformConfig
|
||
cfg = PlatformConfig(enabled=True, extra={})
|
||
result = asyncio.run(_standalone_send(cfg, "", "hi"))
|
||
assert "error" in result
|
||
|
||
def test_pushes_via_client_when_credentials_present(self, monkeypatch):
|
||
from gateway.config import PlatformConfig
|
||
|
||
push_calls = []
|
||
|
||
class _FakeClient:
|
||
def __init__(self, *a, **kw):
|
||
pass
|
||
|
||
async def push(self, chat_id, messages):
|
||
push_calls.append((chat_id, messages))
|
||
|
||
monkeypatch.setattr(_line, "_LineClient", _FakeClient)
|
||
cfg = PlatformConfig(
|
||
enabled=True,
|
||
extra={"channel_access_token": "tok"},
|
||
)
|
||
result = asyncio.run(_standalone_send(cfg, "Uchat", "hello"))
|
||
assert result.get("success") is True
|
||
assert len(push_calls) == 1
|
||
assert push_calls[0][0] == "Uchat"
|
||
# Message wraps as text bubble
|
||
assert push_calls[0][1][0]["type"] == "text"
|
||
|
||
|
||
class TestPostbackButtonShape:
|
||
|
||
def test_template_buttons_structure(self):
|
||
msg = build_postback_button_message("hi", "Tap me", "rid-1")
|
||
assert msg["type"] == "template"
|
||
assert msg["template"]["type"] == "buttons"
|
||
assert msg["template"]["text"] == "hi"
|
||
actions = msg["template"]["actions"]
|
||
assert len(actions) == 1
|
||
assert actions[0]["type"] == "postback"
|
||
data = json.loads(actions[0]["data"])
|
||
assert data == {"action": "show_response", "request_id": "rid-1"}
|
||
assert "displayText" not in actions[0]
|
||
|
||
def test_text_truncated_to_160(self):
|
||
long = "x" * 200
|
||
msg = build_postback_button_message(long, "Tap", "rid")
|
||
assert len(msg["template"]["text"]) <= 160
|
||
|
||
def test_alt_text_truncated_to_400(self):
|
||
long = "x" * 500
|
||
msg = build_postback_button_message(long, "Tap", "rid")
|
||
assert len(msg["altText"]) <= 400
|
||
|
||
|
||
class TestQuotaFallbackPostback:
|
||
|
||
@pytest.fixture
|
||
def adapter(self):
|
||
from gateway.config import PlatformConfig
|
||
|
||
cfg = PlatformConfig(enabled=True, extra={
|
||
"channel_access_token": "tok",
|
||
"channel_secret": "sec",
|
||
})
|
||
ad = LineAdapter(cfg)
|
||
ad._client = MagicMock()
|
||
ad._client.reply = AsyncMock()
|
||
ad._client.push = AsyncMock()
|
||
ad._verified_email_target_if_bound = lambda _chat_id, email: email
|
||
return ad
|
||
|
||
def test_postback_push_quota_failure_marks_email_fallback_and_replays_notice(self, adapter, monkeypatch):
|
||
async def _fake_email_send(_cfg, chat_id, message, **kwargs):
|
||
return {"success": True}
|
||
|
||
rid = adapter._cache.register_pending("Uchat")
|
||
adapter._cache.set_ready(rid, "final answer")
|
||
adapter._pending_buttons["Uchat"] = rid
|
||
adapter._client.reply.side_effect = RuntimeError("reply token expired")
|
||
adapter._client.push.side_effect = RuntimeError(
|
||
'LINE push 429: {"message":"You have reached your monthly limit."}'
|
||
)
|
||
monkeypatch.setattr(adapter, "_verified_email_for_chat", lambda _chat_id: "dk96@bremen.com.tw")
|
||
monkeypatch.setattr(_line, "_email_standalone_send", _fake_email_send)
|
||
|
||
event = {
|
||
"replyToken": "rt-1",
|
||
"source": {"type": "user", "userId": "Uchat"},
|
||
"postback": {"data": json.dumps({"action": "show_response", "request_id": rid})},
|
||
}
|
||
asyncio.run(adapter._handle_postback_event(event))
|
||
|
||
entry = adapter._cache.get(rid)
|
||
assert entry is not None
|
||
assert entry.state is State.DELIVERED
|
||
assert entry.payload["kind"] == "email_fallback"
|
||
assert entry.payload["email"] == "dk96@bremen.com.tw"
|
||
assert entry.payload["notice"].startswith("LINE Push 額度已滿,這次完整回答已改寄到你的驗證 Email")
|
||
|
||
adapter._client.reply.reset_mock(side_effect=True)
|
||
adapter._client.reply.side_effect = None
|
||
followup = {
|
||
"replyToken": "rt-2",
|
||
"source": {"type": "user", "userId": "Uchat"},
|
||
"postback": {"data": json.dumps({"action": "show_response", "request_id": rid})},
|
||
}
|
||
asyncio.run(adapter._handle_postback_event(followup))
|
||
|
||
sent_messages = adapter._client.reply.call_args.args[1]
|
||
assert "LINE Push 額度已滿" in sent_messages[0]["text"]
|
||
assert "dk96@bremen.com.tw" in sent_messages[0]["text"]
|
||
|
||
def test_pending_email_notice_tells_user_to_continue_on_line(self, adapter, monkeypatch):
|
||
captured = {}
|
||
|
||
async def _fake_email_send(_cfg, chat_id, message, **kwargs):
|
||
captured["chat_id"] = chat_id
|
||
captured["message"] = message
|
||
captured["kwargs"] = kwargs
|
||
return {"success": True}
|
||
|
||
monkeypatch.setattr(adapter, "_verified_email_for_chat", lambda _chat_id: "dk96@bremen.com.tw")
|
||
monkeypatch.setattr(_line, "_email_standalone_send", _fake_email_send)
|
||
|
||
payload = asyncio.run(
|
||
adapter._send_email_quota_fallback(
|
||
"Uchat",
|
||
"幫我找 welcome kit 檔案",
|
||
mode="pending",
|
||
)
|
||
)
|
||
|
||
assert payload is not None
|
||
assert "直接回到 LINE 補充可能位置" in payload["notice"]
|
||
assert captured["kwargs"]["from_name"] == "不來梅的艾瑪"
|
||
assert captured["kwargs"]["subject"] == "LINE 檔案查找處理中,我先改寄 Email 跟你說明"
|
||
assert "不需要另外換平台除錯" in captured["message"]
|
||
|
||
def test_final_email_fallback_uses_human_sender_and_natural_subject(self, adapter, monkeypatch):
|
||
captured = {}
|
||
|
||
async def _fake_email_send(_cfg, chat_id, message, **kwargs):
|
||
captured["chat_id"] = chat_id
|
||
captured["message"] = message
|
||
captured["kwargs"] = kwargs
|
||
return {"success": True}
|
||
|
||
monkeypatch.setattr(adapter, "_verified_email_for_chat", lambda _chat_id: "dk96@bremen.com.tw")
|
||
monkeypatch.setattr(_line, "_email_standalone_send", _fake_email_send)
|
||
|
||
payload = asyncio.run(
|
||
adapter._send_email_quota_fallback(
|
||
"Uchat",
|
||
"這是 welcome kit 的整理結果",
|
||
mode="final",
|
||
)
|
||
)
|
||
|
||
assert payload is not None
|
||
assert captured["kwargs"]["from_name"] == "不來梅的艾瑪"
|
||
assert captured["kwargs"]["subject"] == "LINE 檔案查找結果已改由 Email 交付"
|
||
assert captured["message"].startswith("嗨,我是艾瑪。")
|
||
|
||
|
||
class TestCheckRequirements:
|
||
|
||
def test_rejects_without_token(self, monkeypatch):
|
||
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
|
||
monkeypatch.setenv("LINE_CHANNEL_SECRET", "s")
|
||
assert not check_requirements()
|
||
|
||
def test_rejects_without_secret(self, monkeypatch):
|
||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "t")
|
||
monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False)
|
||
assert not check_requirements()
|
||
|
||
|
||
class TestValidateConfig:
|
||
|
||
def test_validates_from_extra(self):
|
||
from gateway.config import PlatformConfig
|
||
cfg = PlatformConfig(
|
||
enabled=True,
|
||
extra={"channel_access_token": "t", "channel_secret": "s"},
|
||
)
|
||
assert validate_config(cfg)
|
||
|
||
def test_rejects_empty_config(self, monkeypatch):
|
||
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
|
||
monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False)
|
||
from gateway.config import PlatformConfig
|
||
cfg = PlatformConfig(enabled=True, extra={})
|
||
assert not validate_config(cfg)
|
||
|
||
|
||
class TestAdapterInit:
|
||
|
||
def test_init_from_config_extra(self, monkeypatch):
|
||
for k in ("LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET", "LINE_PORT", "LINE_PUBLIC_URL", "LINE_HOME_CHANNEL"):
|
||
monkeypatch.delenv(k, raising=False)
|
||
from gateway.config import PlatformConfig
|
||
cfg = PlatformConfig(
|
||
enabled=True,
|
||
extra={
|
||
"channel_access_token": "tok",
|
||
"channel_secret": "sec",
|
||
"port": 7777,
|
||
"public_url": "https://x.example.com",
|
||
"allowed_users": ["U1", "U2"],
|
||
},
|
||
)
|
||
ad = LineAdapter(cfg)
|
||
assert ad.channel_access_token == "tok"
|
||
assert ad.channel_secret == "sec"
|
||
assert ad.webhook_port == 7777
|
||
assert ad.public_base_url == "https://x.example.com"
|
||
assert ad.allowed_users == {"U1", "U2"}
|
||
|
||
def test_env_overrides_extra(self, monkeypatch):
|
||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "env-tok")
|
||
monkeypatch.setenv("LINE_PORT", "1234")
|
||
from gateway.config import PlatformConfig
|
||
cfg = PlatformConfig(
|
||
enabled=True,
|
||
extra={"channel_access_token": "extra-tok", "channel_secret": "s", "port": 5555},
|
||
)
|
||
ad = LineAdapter(cfg)
|
||
assert ad.channel_access_token == "env-tok"
|
||
assert ad.webhook_port == 1234
|
||
|
||
def test_csv_allowlist_parsed(self, monkeypatch):
|
||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "t")
|
||
monkeypatch.setenv("LINE_CHANNEL_SECRET", "s")
|
||
monkeypatch.setenv("LINE_ALLOWED_USERS", "U1, U2,U3")
|
||
monkeypatch.setenv("LINE_ALLOWED_GROUPS", "C1")
|
||
from gateway.config import PlatformConfig
|
||
ad = LineAdapter(PlatformConfig(enabled=True))
|
||
assert ad.allowed_users == {"U1", "U2", "U3"}
|
||
assert ad.allowed_groups == {"C1"}
|
||
|
||
def test_get_chat_info_infers_type_from_prefix(self, monkeypatch):
|
||
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "t")
|
||
monkeypatch.setenv("LINE_CHANNEL_SECRET", "s")
|
||
from gateway.config import PlatformConfig
|
||
ad = LineAdapter(PlatformConfig(enabled=True))
|
||
assert asyncio.run(ad.get_chat_info("U123"))["type"] == "dm"
|
||
assert asyncio.run(ad.get_chat_info("C123"))["type"] == "group"
|
||
assert asyncio.run(ad.get_chat_info("R123"))["type"] == "channel"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 9. Inbound message-type classification
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestMessageTypeMapping:
|
||
"""LINE webhook message types must map to the right normalized
|
||
MessageType so the gateway routes media correctly (e.g. voice → STT,
|
||
files → document handling). Regression guard for the old code that
|
||
referenced the non-existent ``MessageType.IMAGE`` and collapsed every
|
||
non-text message onto a single type."""
|
||
|
||
def test_image_event_not_attributeerror_regression(self):
|
||
# The bug: MessageType.IMAGE doesn't exist on the enum.
|
||
MessageType = _line.MessageType
|
||
assert not hasattr(MessageType, "IMAGE")
|
||
|
||
def test_every_line_type_maps_to_correct_enum(self):
|
||
MessageType = _line.MessageType
|
||
mapping = _line._LINE_MESSAGE_TYPES
|
||
assert mapping["text"] == MessageType.TEXT
|
||
assert mapping["image"] == MessageType.PHOTO
|
||
assert mapping["video"] == MessageType.VIDEO
|
||
# LINE has no separate voice type — audio clips are voice notes.
|
||
assert mapping["audio"] == MessageType.VOICE
|
||
assert mapping["file"] == MessageType.DOCUMENT
|
||
assert mapping["location"] == MessageType.LOCATION
|
||
assert mapping["sticker"] == MessageType.STICKER
|
||
|
||
def test_unknown_type_falls_back_to_text(self):
|
||
MessageType = _line.MessageType
|
||
assert _line._LINE_MESSAGE_TYPES.get("flex", MessageType.TEXT) == MessageType.TEXT
|
||
|
||
|
||
class TestInboundAuthorizationDeferral:
|
||
|
||
@pytest.fixture
|
||
def adapter(self, monkeypatch):
|
||
for key in (
|
||
"LINE_CHANNEL_ACCESS_TOKEN",
|
||
"LINE_CHANNEL_SECRET",
|
||
"LINE_PUBLIC_URL",
|
||
"LINE_HOME_CHANNEL",
|
||
"LINE_PORT",
|
||
"LINE_ALLOW_ALL_USERS",
|
||
"LINE_ALLOWED_USERS",
|
||
"LINE_ALLOWED_GROUPS",
|
||
"LINE_ALLOWED_ROOMS",
|
||
):
|
||
monkeypatch.delenv(key, raising=False)
|
||
from gateway.config import PlatformConfig
|
||
ad = LineAdapter(PlatformConfig(enabled=True, extra={
|
||
"channel_access_token": "tok",
|
||
"channel_secret": "sec",
|
||
}))
|
||
ad._client = MagicMock()
|
||
ad._handle_message_event = AsyncMock()
|
||
return ad
|
||
|
||
def test_unauthorized_user_dm_is_deferred_to_gateway_authz(self, adapter):
|
||
event = {
|
||
"type": "message",
|
||
"source": {"type": "user", "userId": "Uunauthorized"},
|
||
"message": {"id": "m1", "type": "text", "text": "hi"},
|
||
"replyToken": "rt-1",
|
||
}
|
||
|
||
asyncio.run(adapter._dispatch_event(event))
|
||
|
||
adapter._handle_message_event.assert_awaited_once_with(event)
|
||
|
||
def test_user_dm_still_deferred_when_only_group_room_allowlists_exist(self, adapter):
|
||
"""DM onboarding must survive even when only group/room allowlists are configured.
|
||
|
||
Regression guard for the easy mistake where a future refactor treats an
|
||
unlisted DM user the same as an unlisted group/room source and drops the
|
||
event at adapter intake before gateway verification can run.
|
||
"""
|
||
adapter.allowed_groups = {"C-ops"}
|
||
adapter.allowed_rooms = {"R-war-room"}
|
||
event = {
|
||
"type": "message",
|
||
"source": {"type": "user", "userId": "Unewcomer"},
|
||
"message": {"id": "m2", "type": "text", "text": "hi"},
|
||
"replyToken": "rt-2",
|
||
}
|
||
|
||
asyncio.run(adapter._dispatch_event(event))
|
||
|
||
adapter._handle_message_event.assert_awaited_once_with(event)
|
||
|
||
def test_unauthorized_group_still_rejected_at_adapter_gate(self, adapter):
|
||
event = {
|
||
"type": "message",
|
||
"source": {"type": "group", "groupId": "Cunauthorized", "userId": "Umember"},
|
||
"message": {"id": "m1", "type": "text", "text": "hi"},
|
||
"replyToken": "rt-1",
|
||
}
|
||
|
||
asyncio.run(adapter._dispatch_event(event))
|
||
|
||
adapter._handle_message_event.assert_not_awaited()
|