test_webhook_adapter.py
1 """Unit tests for the generic webhook platform adapter. 2 3 Covers: 4 - HMAC signature validation (GitHub, GitLab, generic) 5 - Prompt rendering with dot-notation template variables 6 - Event type filtering 7 - HTTP handler behaviour (404, 202, health) 8 - Idempotency cache (duplicate delivery IDs) 9 - Rate limiting (fixed-window, per route) 10 - Body size limits 11 - INSECURE_NO_AUTH bypass 12 - Session isolation for concurrent webhooks 13 - Delivery info cleanup after send() 14 - connect / disconnect lifecycle 15 """ 16 17 import asyncio 18 import hashlib 19 import hmac 20 import json 21 import time 22 from unittest.mock import AsyncMock, MagicMock, patch 23 24 import pytest 25 from aiohttp import web 26 from aiohttp.test_utils import TestClient, TestServer 27 28 from gateway.config import Platform, PlatformConfig 29 from gateway.platforms.base import MessageEvent, MessageType, SendResult 30 from gateway.platforms.webhook import ( 31 WebhookAdapter, 32 _INSECURE_NO_AUTH, 33 check_webhook_requirements, 34 ) 35 36 37 # --------------------------------------------------------------------------- 38 # Helpers 39 # --------------------------------------------------------------------------- 40 41 def _make_config( 42 routes=None, 43 secret="", 44 rate_limit=30, 45 max_body_bytes=1_048_576, 46 host="0.0.0.0", 47 port=0, # let OS pick a free port in tests 48 ): 49 """Build a PlatformConfig suitable for WebhookAdapter.""" 50 extra = { 51 "host": host, 52 "port": port, 53 "routes": routes or {}, 54 "rate_limit": rate_limit, 55 "max_body_bytes": max_body_bytes, 56 } 57 if secret: 58 extra["secret"] = secret 59 return PlatformConfig(enabled=True, extra=extra) 60 61 62 def _make_adapter(routes=None, **kwargs): 63 """Create a WebhookAdapter with sensible defaults for testing.""" 64 config = _make_config(routes=routes, **kwargs) 65 return WebhookAdapter(config) 66 67 68 def _create_app(adapter: WebhookAdapter) -> web.Application: 69 """Build the aiohttp Application from the adapter (without starting a full server).""" 70 app = web.Application() 71 app.router.add_get("/health", adapter._handle_health) 72 app.router.add_post("/webhooks/{route_name}", adapter._handle_webhook) 73 return app 74 75 76 def _mock_request(headers=None, body=b"", content_length=None, match_info=None): 77 """Build a lightweight mock aiohttp request for non-HTTP tests.""" 78 req = MagicMock() 79 req.headers = headers or {} 80 req.content_length = content_length if content_length is not None else len(body) 81 req.match_info = match_info or {} 82 req.method = "POST" 83 84 async def _read(): 85 return body 86 87 req.read = _read 88 return req 89 90 91 def _github_signature(body: bytes, secret: str) -> str: 92 """Compute X-Hub-Signature-256 for *body* using *secret*.""" 93 return "sha256=" + hmac.new( 94 secret.encode(), body, hashlib.sha256 95 ).hexdigest() 96 97 98 def _generic_signature(body: bytes, secret: str) -> str: 99 """Compute X-Webhook-Signature (plain HMAC-SHA256 hex) for *body*.""" 100 return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() 101 102 103 # =================================================================== 104 # Signature validation 105 # =================================================================== 106 107 108 class TestValidateSignature: 109 """Tests for WebhookAdapter._validate_signature.""" 110 111 def test_validate_github_signature_valid(self): 112 """Valid X-Hub-Signature-256 is accepted.""" 113 adapter = _make_adapter() 114 body = b'{"action": "opened"}' 115 secret = "webhook-secret-42" 116 sig = _github_signature(body, secret) 117 req = _mock_request(headers={"X-Hub-Signature-256": sig}) 118 assert adapter._validate_signature(req, body, secret) is True 119 120 def test_validate_github_signature_invalid(self): 121 """Wrong X-Hub-Signature-256 is rejected.""" 122 adapter = _make_adapter() 123 body = b'{"action": "opened"}' 124 secret = "webhook-secret-42" 125 req = _mock_request(headers={"X-Hub-Signature-256": "sha256=deadbeef"}) 126 assert adapter._validate_signature(req, body, secret) is False 127 128 def test_validate_gitlab_token(self): 129 """GitLab plain-token match via X-Gitlab-Token.""" 130 adapter = _make_adapter() 131 secret = "gl-token-value" 132 req = _mock_request(headers={"X-Gitlab-Token": secret}) 133 assert adapter._validate_signature(req, b"{}", secret) is True 134 135 def test_validate_gitlab_token_wrong(self): 136 """Wrong X-Gitlab-Token is rejected.""" 137 adapter = _make_adapter() 138 req = _mock_request(headers={"X-Gitlab-Token": "wrong"}) 139 assert adapter._validate_signature(req, b"{}", "correct") is False 140 141 def test_validate_no_signature_with_secret_rejects(self): 142 """Secret configured but no recognised signature header → reject.""" 143 adapter = _make_adapter() 144 req = _mock_request(headers={}) # no sig headers at all 145 assert adapter._validate_signature(req, b"{}", "my-secret") is False 146 147 def test_validate_no_secret_allows_all(self): 148 """When the secret is empty/falsy, the validator is never even called 149 by the handler (secret check is 'if secret and secret != _INSECURE...'). 150 Verify that an empty secret isn't accidentally passed to the validator.""" 151 # This tests the semantics: empty secret means skip validation entirely. 152 # The handler code does: if secret and secret != _INSECURE_NO_AUTH: validate 153 # So with an empty secret, _validate_signature is never reached. 154 # We just verify the code path is correct by constructing an adapter 155 # with no secret and confirming the route config resolves to "". 156 adapter = _make_adapter( 157 routes={"test": {"prompt": "hello"}}, 158 secret="", 159 ) 160 # The route has no secret, global secret is empty 161 route_secret = adapter._routes["test"].get("secret", adapter._global_secret) 162 assert not route_secret # empty → validation is skipped in handler 163 164 def test_validate_generic_signature_valid(self): 165 """Valid X-Webhook-Signature (generic HMAC-SHA256 hex) is accepted.""" 166 adapter = _make_adapter() 167 body = b'{"event": "push"}' 168 secret = "generic-secret" 169 sig = _generic_signature(body, secret) 170 req = _mock_request(headers={"X-Webhook-Signature": sig}) 171 assert adapter._validate_signature(req, body, secret) is True 172 173 174 # =================================================================== 175 # Prompt rendering 176 # =================================================================== 177 178 179 class TestRenderPrompt: 180 """Tests for WebhookAdapter._render_prompt.""" 181 182 def test_render_prompt_dot_notation(self): 183 """Dot-notation {pull_request.title} resolves nested keys.""" 184 adapter = _make_adapter() 185 payload = {"pull_request": {"title": "Fix bug", "number": 42}} 186 result = adapter._render_prompt( 187 "PR #{pull_request.number}: {pull_request.title}", 188 payload, 189 "pull_request", 190 "github", 191 ) 192 assert result == "PR #42: Fix bug" 193 194 def test_render_prompt_missing_key_preserved(self): 195 """{nonexistent} is left as-is when key doesn't exist in payload.""" 196 adapter = _make_adapter() 197 result = adapter._render_prompt( 198 "Hello {nonexistent}!", 199 {"action": "opened"}, 200 "push", 201 "test", 202 ) 203 assert "{nonexistent}" in result 204 205 def test_render_prompt_no_template_dumps_json(self): 206 """Empty template → JSON dump fallback with event/route context.""" 207 adapter = _make_adapter() 208 payload = {"key": "value"} 209 result = adapter._render_prompt("", payload, "push", "my-route") 210 assert "push" in result 211 assert "my-route" in result 212 assert "key" in result 213 214 215 # =================================================================== 216 # Delivery extra rendering 217 # =================================================================== 218 219 220 class TestRenderDeliveryExtra: 221 def test_render_delivery_extra_templates(self): 222 """String values in deliver_extra are rendered with payload data.""" 223 adapter = _make_adapter() 224 extra = {"repo": "{repository.full_name}", "pr_number": "{number}", "static": 42} 225 payload = {"repository": {"full_name": "org/repo"}, "number": 7} 226 result = adapter._render_delivery_extra(extra, payload) 227 assert result["repo"] == "org/repo" 228 assert result["pr_number"] == "7" 229 assert result["static"] == 42 # non-string left as-is 230 231 232 # =================================================================== 233 # Event filtering 234 # =================================================================== 235 236 237 class TestEventFilter: 238 """Tests for event type filtering in _handle_webhook.""" 239 240 @pytest.mark.asyncio 241 async def test_event_filter_accepts_matching(self): 242 """Matching event type passes through.""" 243 routes = { 244 "gh": { 245 "secret": _INSECURE_NO_AUTH, 246 "events": ["pull_request"], 247 "prompt": "PR: {action}", 248 } 249 } 250 adapter = _make_adapter(routes=routes) 251 # Stub handle_message to avoid running the agent 252 adapter.handle_message = AsyncMock() 253 254 app = _create_app(adapter) 255 async with TestClient(TestServer(app)) as cli: 256 resp = await cli.post( 257 "/webhooks/gh", 258 json={"action": "opened"}, 259 headers={"X-GitHub-Event": "pull_request"}, 260 ) 261 assert resp.status == 202 262 263 @pytest.mark.asyncio 264 async def test_event_filter_rejects_non_matching(self): 265 """Non-matching event type returns 200 with status=ignored.""" 266 routes = { 267 "gh": { 268 "secret": _INSECURE_NO_AUTH, 269 "events": ["pull_request"], 270 "prompt": "test", 271 } 272 } 273 adapter = _make_adapter(routes=routes) 274 275 app = _create_app(adapter) 276 async with TestClient(TestServer(app)) as cli: 277 resp = await cli.post( 278 "/webhooks/gh", 279 json={"action": "opened"}, 280 headers={"X-GitHub-Event": "push"}, 281 ) 282 assert resp.status == 200 283 data = await resp.json() 284 assert data["status"] == "ignored" 285 286 @pytest.mark.asyncio 287 async def test_event_filter_empty_allows_all(self): 288 """No events list → accept any event type.""" 289 routes = { 290 "all": { 291 "secret": _INSECURE_NO_AUTH, 292 "prompt": "got it", 293 } 294 } 295 adapter = _make_adapter(routes=routes) 296 adapter.handle_message = AsyncMock() 297 298 app = _create_app(adapter) 299 async with TestClient(TestServer(app)) as cli: 300 resp = await cli.post( 301 "/webhooks/all", 302 json={"action": "any"}, 303 headers={"X-GitHub-Event": "whatever"}, 304 ) 305 assert resp.status == 202 306 307 308 # =================================================================== 309 # HTTP handling 310 # =================================================================== 311 312 313 class TestHTTPHandling: 314 315 @pytest.mark.asyncio 316 async def test_unknown_route_returns_404(self): 317 """POST to an unknown route returns 404.""" 318 adapter = _make_adapter(routes={"real": {"secret": _INSECURE_NO_AUTH, "prompt": "x"}}) 319 app = _create_app(adapter) 320 async with TestClient(TestServer(app)) as cli: 321 resp = await cli.post("/webhooks/nonexistent", json={"a": 1}) 322 assert resp.status == 404 323 324 @pytest.mark.asyncio 325 async def test_webhook_handler_returns_202(self): 326 """Valid request returns 202 Accepted.""" 327 routes = {"test": {"secret": _INSECURE_NO_AUTH, "prompt": "hi"}} 328 adapter = _make_adapter(routes=routes) 329 adapter.handle_message = AsyncMock() 330 331 app = _create_app(adapter) 332 async with TestClient(TestServer(app)) as cli: 333 resp = await cli.post("/webhooks/test", json={"data": "value"}) 334 assert resp.status == 202 335 data = await resp.json() 336 assert data["status"] == "accepted" 337 assert data["route"] == "test" 338 339 @pytest.mark.asyncio 340 async def test_health_endpoint(self): 341 """GET /health returns 200 with status=ok.""" 342 adapter = _make_adapter() 343 app = _create_app(adapter) 344 async with TestClient(TestServer(app)) as cli: 345 resp = await cli.get("/health") 346 assert resp.status == 200 347 data = await resp.json() 348 assert data["status"] == "ok" 349 assert data["platform"] == "webhook" 350 351 @pytest.mark.asyncio 352 async def test_connect_starts_server(self): 353 """connect() starts the HTTP listener and marks adapter as connected.""" 354 routes = {"r1": {"secret": _INSECURE_NO_AUTH, "prompt": "x"}} 355 adapter = _make_adapter(routes=routes, port=0) 356 # Use port 0 — the OS picks a free port, but aiohttp requires a real bind. 357 # We just test that the method completes and marks connected. 358 # Need to mock TCPSite to avoid actual binding. 359 with patch("gateway.platforms.webhook.web.AppRunner") as MockRunner, \ 360 patch("gateway.platforms.webhook.web.TCPSite") as MockSite: 361 mock_runner_inst = AsyncMock() 362 MockRunner.return_value = mock_runner_inst 363 mock_site_inst = AsyncMock() 364 MockSite.return_value = mock_site_inst 365 366 result = await adapter.connect() 367 assert result is True 368 assert adapter.is_connected 369 mock_runner_inst.setup.assert_awaited_once() 370 mock_site_inst.start.assert_awaited_once() 371 372 await adapter.disconnect() 373 374 @pytest.mark.asyncio 375 async def test_disconnect_cleans_up(self): 376 """disconnect() stops the server and marks adapter disconnected.""" 377 adapter = _make_adapter() 378 # Simulate a runner that was previously set up 379 mock_runner = AsyncMock() 380 adapter._runner = mock_runner 381 adapter._running = True 382 383 await adapter.disconnect() 384 mock_runner.cleanup.assert_awaited_once() 385 assert adapter._runner is None 386 assert not adapter.is_connected 387 388 389 # =================================================================== 390 # Idempotency 391 # =================================================================== 392 393 394 class TestIdempotency: 395 396 @pytest.mark.asyncio 397 async def test_duplicate_delivery_id_returns_200(self): 398 """Second request with same delivery ID returns 200 duplicate.""" 399 routes = {"idem": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}} 400 adapter = _make_adapter(routes=routes) 401 adapter.handle_message = AsyncMock() 402 403 app = _create_app(adapter) 404 async with TestClient(TestServer(app)) as cli: 405 headers = {"X-GitHub-Delivery": "delivery-123"} 406 resp1 = await cli.post("/webhooks/idem", json={"a": 1}, headers=headers) 407 assert resp1.status == 202 408 409 resp2 = await cli.post("/webhooks/idem", json={"a": 1}, headers=headers) 410 assert resp2.status == 200 411 data = await resp2.json() 412 assert data["status"] == "duplicate" 413 414 @pytest.mark.asyncio 415 async def test_expired_delivery_id_allows_reprocess(self): 416 """After TTL expires, the same delivery ID is accepted again.""" 417 routes = {"idem": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}} 418 adapter = _make_adapter(routes=routes) 419 adapter._idempotency_ttl = 1 # 1 second TTL for test speed 420 adapter.handle_message = AsyncMock() 421 422 app = _create_app(adapter) 423 async with TestClient(TestServer(app)) as cli: 424 headers = {"X-GitHub-Delivery": "delivery-456"} 425 426 resp1 = await cli.post("/webhooks/idem", json={"x": 1}, headers=headers) 427 assert resp1.status == 202 428 429 # Backdate the cache entry so it appears expired 430 adapter._seen_deliveries["delivery-456"] = time.time() - 3700 431 432 resp2 = await cli.post("/webhooks/idem", json={"x": 1}, headers=headers) 433 assert resp2.status == 202 # re-accepted 434 435 436 # =================================================================== 437 # Rate limiting 438 # =================================================================== 439 440 441 class TestRateLimiting: 442 443 @pytest.mark.asyncio 444 async def test_rate_limit_rejects_excess(self): 445 """Exceeding the rate limit returns 429.""" 446 routes = {"limited": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}} 447 adapter = _make_adapter(routes=routes, rate_limit=2) 448 adapter.handle_message = AsyncMock() 449 450 app = _create_app(adapter) 451 async with TestClient(TestServer(app)) as cli: 452 # Two requests within limit 453 for i in range(2): 454 resp = await cli.post( 455 "/webhooks/limited", 456 json={"n": i}, 457 headers={"X-GitHub-Delivery": f"d-{i}"}, 458 ) 459 assert resp.status == 202, f"Request {i} should be accepted" 460 461 # Third request should be rate-limited 462 resp = await cli.post( 463 "/webhooks/limited", 464 json={"n": 99}, 465 headers={"X-GitHub-Delivery": "d-99"}, 466 ) 467 assert resp.status == 429 468 469 @pytest.mark.asyncio 470 async def test_rate_limit_window_resets(self): 471 """After the 60-second window passes, requests are allowed again.""" 472 routes = {"limited": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}} 473 adapter = _make_adapter(routes=routes, rate_limit=1) 474 adapter.handle_message = AsyncMock() 475 476 app = _create_app(adapter) 477 async with TestClient(TestServer(app)) as cli: 478 resp = await cli.post( 479 "/webhooks/limited", 480 json={"n": 1}, 481 headers={"X-GitHub-Delivery": "d-a"}, 482 ) 483 assert resp.status == 202 484 485 # Backdate all rate-limit timestamps to > 60 seconds ago 486 adapter._rate_counts["limited"] = [time.time() - 120] 487 488 resp = await cli.post( 489 "/webhooks/limited", 490 json={"n": 2}, 491 headers={"X-GitHub-Delivery": "d-b"}, 492 ) 493 assert resp.status == 202 # allowed again 494 495 496 # =================================================================== 497 # Body size limit 498 # =================================================================== 499 500 501 class TestBodySize: 502 503 @pytest.mark.asyncio 504 async def test_oversized_payload_rejected(self): 505 """Content-Length > max_body_bytes returns 413.""" 506 routes = {"big": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}} 507 adapter = _make_adapter(routes=routes, max_body_bytes=100) 508 509 app = _create_app(adapter) 510 async with TestClient(TestServer(app)) as cli: 511 large_payload = {"data": "x" * 200} 512 resp = await cli.post( 513 "/webhooks/big", 514 json=large_payload, 515 headers={"Content-Length": "999999"}, 516 ) 517 assert resp.status == 413 518 519 520 # =================================================================== 521 # INSECURE_NO_AUTH 522 # =================================================================== 523 524 525 class TestInsecureNoAuth: 526 527 @pytest.mark.asyncio 528 async def test_insecure_no_auth_skips_validation(self): 529 """Setting secret to _INSECURE_NO_AUTH bypasses signature check.""" 530 routes = {"open": {"secret": _INSECURE_NO_AUTH, "prompt": "hello"}} 531 adapter = _make_adapter(routes=routes) 532 adapter.handle_message = AsyncMock() 533 534 app = _create_app(adapter) 535 async with TestClient(TestServer(app)) as cli: 536 # No signature header at all — should still be accepted 537 resp = await cli.post("/webhooks/open", json={"test": True}) 538 assert resp.status == 202 539 540 541 # =================================================================== 542 # Session isolation 543 # =================================================================== 544 545 546 class TestSessionIsolation: 547 548 @pytest.mark.asyncio 549 async def test_concurrent_webhooks_get_independent_sessions(self): 550 """Two events on the same route produce different session keys.""" 551 routes = {"ci": {"secret": _INSECURE_NO_AUTH, "prompt": "build"}} 552 adapter = _make_adapter(routes=routes) 553 554 captured_events = [] 555 556 async def _capture(event): 557 captured_events.append(event) 558 559 adapter.handle_message = _capture 560 561 app = _create_app(adapter) 562 async with TestClient(TestServer(app)) as cli: 563 resp1 = await cli.post( 564 "/webhooks/ci", 565 json={"ref": "main"}, 566 headers={"X-GitHub-Delivery": "aaa-111"}, 567 ) 568 assert resp1.status == 202 569 570 resp2 = await cli.post( 571 "/webhooks/ci", 572 json={"ref": "dev"}, 573 headers={"X-GitHub-Delivery": "bbb-222"}, 574 ) 575 assert resp2.status == 202 576 577 # Wait for the async tasks to be created 578 await asyncio.sleep(0.05) 579 580 assert len(captured_events) == 2 581 ids = {ev.source.chat_id for ev in captured_events} 582 assert len(ids) == 2, "Each delivery must have a unique session chat_id" 583 584 585 # =================================================================== 586 # Delivery info cleanup 587 # =================================================================== 588 589 590 class TestDeliveryCleanup: 591 592 @pytest.mark.asyncio 593 async def test_delivery_info_survives_multiple_sends(self): 594 """send() must NOT pop delivery_info. 595 596 Interim status messages (fallback notifications, context-pressure 597 warnings, etc.) flow through the same send() path as the final 598 response. If the entry were popped on the first send, the final 599 response would silently downgrade to the ``log`` deliver type. 600 Regression test for that bug. 601 """ 602 adapter = _make_adapter() 603 chat_id = "webhook:test:d-xyz" 604 adapter._delivery_info[chat_id] = { 605 "deliver": "log", 606 "deliver_extra": {}, 607 "payload": {"x": 1}, 608 } 609 adapter._delivery_info_created[chat_id] = time.time() 610 611 # First send (e.g. an interim status message) 612 result1 = await adapter.send(chat_id, "Status: switching to fallback") 613 assert result1.success is True 614 # Entry must still be present so the final send can read it 615 assert chat_id in adapter._delivery_info 616 617 # Second send (the final agent response) 618 result2 = await adapter.send(chat_id, "Final agent response") 619 assert result2.success is True 620 assert chat_id in adapter._delivery_info 621 622 @pytest.mark.asyncio 623 async def test_delivery_info_pruned_via_ttl(self): 624 """Stale delivery_info entries are dropped on the next POST.""" 625 adapter = _make_adapter() 626 adapter._idempotency_ttl = 60 # short TTL for the test 627 now = time.time() 628 629 # Stale entry — older than TTL 630 adapter._delivery_info["webhook:test:old"] = {"deliver": "log"} 631 adapter._delivery_info_created["webhook:test:old"] = now - 120 632 633 # Fresh entry — should survive 634 adapter._delivery_info["webhook:test:new"] = {"deliver": "log"} 635 adapter._delivery_info_created["webhook:test:new"] = now - 5 636 637 adapter._prune_delivery_info(now) 638 639 assert "webhook:test:old" not in adapter._delivery_info 640 assert "webhook:test:old" not in adapter._delivery_info_created 641 assert "webhook:test:new" in adapter._delivery_info 642 assert "webhook:test:new" in adapter._delivery_info_created 643 644 645 # =================================================================== 646 # check_webhook_requirements 647 # =================================================================== 648 649 650 class TestCheckRequirements: 651 def test_returns_true_when_aiohttp_available(self): 652 assert check_webhook_requirements() is True 653 654 @patch("gateway.platforms.webhook.AIOHTTP_AVAILABLE", False) 655 def test_returns_false_without_aiohttp(self): 656 assert check_webhook_requirements() is False 657 658 659 # =================================================================== 660 # __raw__ template token 661 # =================================================================== 662 663 664 class TestRawTemplateToken: 665 """Tests for the {__raw__} special token in _render_prompt.""" 666 667 def test_raw_resolves_to_full_json_payload(self): 668 """{__raw__} in a template dumps the entire payload as JSON.""" 669 adapter = _make_adapter() 670 payload = {"action": "opened", "number": 42} 671 result = adapter._render_prompt( 672 "Payload: {__raw__}", payload, "push", "test" 673 ) 674 expected_json = json.dumps(payload, indent=2) 675 assert result == f"Payload: {expected_json}" 676 677 def test_raw_truncated_at_4000_chars(self): 678 """{__raw__} output is truncated at 4000 characters for large payloads.""" 679 adapter = _make_adapter() 680 # Build a payload whose JSON repr exceeds 4000 chars 681 payload = {"data": "x" * 5000} 682 result = adapter._render_prompt("{__raw__}", payload, "push", "test") 683 assert len(result) <= 4000 684 685 def test_raw_mixed_with_other_variables(self): 686 """{__raw__} can be mixed with regular template variables.""" 687 adapter = _make_adapter() 688 payload = {"action": "closed", "number": 7} 689 result = adapter._render_prompt( 690 "Action={action} Raw={__raw__}", payload, "push", "test" 691 ) 692 assert result.startswith("Action=closed Raw=") 693 assert '"action": "closed"' in result 694 assert '"number": 7' in result 695 696 697 # =================================================================== 698 # Cross-platform delivery thread_id passthrough 699 # =================================================================== 700 701 702 class TestDeliverCrossPlatformThreadId: 703 """Tests for thread_id passthrough in _deliver_cross_platform.""" 704 705 def _setup_adapter_with_mock_target(self): 706 """Set up a webhook adapter with a mocked gateway_runner and target adapter.""" 707 adapter = _make_adapter() 708 mock_target = AsyncMock() 709 mock_target.send = AsyncMock(return_value=SendResult(success=True)) 710 711 mock_runner = MagicMock() 712 mock_runner.adapters = {Platform("telegram"): mock_target} 713 mock_runner.config.get_home_channel.return_value = None 714 715 adapter.gateway_runner = mock_runner 716 return adapter, mock_target 717 718 @pytest.mark.asyncio 719 async def test_thread_id_passed_as_metadata(self): 720 """thread_id from deliver_extra is passed as metadata to adapter.send().""" 721 adapter, mock_target = self._setup_adapter_with_mock_target() 722 delivery = { 723 "deliver_extra": { 724 "chat_id": "12345", 725 "thread_id": "999", 726 } 727 } 728 await adapter._deliver_cross_platform("telegram", "hello", delivery) 729 mock_target.send.assert_awaited_once_with( 730 "12345", "hello", metadata={"thread_id": "999"} 731 ) 732 733 @pytest.mark.asyncio 734 async def test_message_thread_id_passed_as_thread_id(self): 735 """message_thread_id from deliver_extra is mapped to thread_id in metadata.""" 736 adapter, mock_target = self._setup_adapter_with_mock_target() 737 delivery = { 738 "deliver_extra": { 739 "chat_id": "12345", 740 "message_thread_id": "888", 741 } 742 } 743 await adapter._deliver_cross_platform("telegram", "hello", delivery) 744 mock_target.send.assert_awaited_once_with( 745 "12345", "hello", metadata={"thread_id": "888"} 746 ) 747 748 @pytest.mark.asyncio 749 async def test_no_thread_id_sends_no_metadata(self): 750 """When no thread_id is present, metadata is None.""" 751 adapter, mock_target = self._setup_adapter_with_mock_target() 752 delivery = { 753 "deliver_extra": { 754 "chat_id": "12345", 755 } 756 } 757 await adapter._deliver_cross_platform("telegram", "hello", delivery) 758 mock_target.send.assert_awaited_once_with( 759 "12345", "hello", metadata=None 760 )