From 13600981fcb6b344b349a6ba81ff262ced5069d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 14:07:21 +0000 Subject: [PATCH 1/7] chore(deps-dev): update tornado requirement from <7,>=6 to >=6.5.6,<7 Updates the requirements on [tornado](https://github.com/tornadoweb/tornado) to permit the latest version. - [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst) - [Commits](https://github.com/tornadoweb/tornado/compare/v6.0.0...v6.5.6) --- updated-dependencies: - dependency-name: tornado dependency-version: 6.5.5 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements/adapter_dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/adapter_dev.txt b/requirements/adapter_dev.txt index 7c479fd4b..1c3accd0d 100644 --- a/requirements/adapter_dev.txt +++ b/requirements/adapter_dev.txt @@ -26,7 +26,7 @@ sanic>=25.3.0,<26; python_version>"3.8" starlette>=0.19.1,<0.45; python_version<"3.9" starlette>=0.49.3,<1; python_version>="3.9" -tornado>=6,<7 +tornado>=6.5.6,<7 uvicorn<1 # The oldest version can vary among Python runtime versions gunicorn>=23.0.0,<24 websocket_client>=1.2.3,<2 # Socket Mode 3rd party implementation From 396ad67ffe1a92d3e8a92ba3b78fb344bbed3d12 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 29 May 2026 10:46:14 -0400 Subject: [PATCH 2/7] Update adapter_dev.txt --- requirements/adapter_dev.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements/adapter_dev.txt b/requirements/adapter_dev.txt index 1c3accd0d..ea924f5bb 100644 --- a/requirements/adapter_dev.txt +++ b/requirements/adapter_dev.txt @@ -26,7 +26,8 @@ sanic>=25.3.0,<26; python_version>"3.8" starlette>=0.19.1,<0.45; python_version<"3.9" starlette>=0.49.3,<1; python_version>="3.9" -tornado>=6.5.6,<7 +tornado>=6.2,<7; python_version<"3.9" +tornado>=6.5.6,<7; python_version>="3.9" uvicorn<1 # The oldest version can vary among Python runtime versions gunicorn>=23.0.0,<24 websocket_client>=1.2.3,<2 # Socket Mode 3rd party implementation From e1d8f7e8bf3ec30ca10dc72b05ab48d4ded1310c Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 29 May 2026 16:32:12 -0400 Subject: [PATCH 3/7] fix: align WSGI/ASGI test infrastructure and handler with spec --- requirements/adapter_dev.txt | 2 - requirements/test_adapter.txt | 1 - slack_bolt/adapter/wsgi/handler.py | 2 +- tests/mock_asgi_server.py | 78 +++++++++++----------- tests/mock_wsgi_server.py | 103 ++++++++++++++++++----------- 5 files changed, 102 insertions(+), 84 deletions(-) diff --git a/requirements/adapter_dev.txt b/requirements/adapter_dev.txt index ea924f5bb..ee2ad9b83 100644 --- a/requirements/adapter_dev.txt +++ b/requirements/adapter_dev.txt @@ -28,6 +28,4 @@ starlette>=0.19.1,<0.45; python_version<"3.9" starlette>=0.49.3,<1; python_version>="3.9" tornado>=6.2,<7; python_version<"3.9" tornado>=6.5.6,<7; python_version>="3.9" -uvicorn<1 # The oldest version can vary among Python runtime versions -gunicorn>=23.0.0,<24 websocket_client>=1.2.3,<2 # Socket Mode 3rd party implementation diff --git a/requirements/test_adapter.txt b/requirements/test_adapter.txt index cb81cd057..ecb3741f2 100644 --- a/requirements/test_adapter.txt +++ b/requirements/test_adapter.txt @@ -1,5 +1,4 @@ # pip install -r requirements/test_adapter.txt moto>=3,<6 # For AWS tests -docker>=5,<8 # Used by moto boddle>=0.2.9,<0.3 # For Bottle app tests sanic-testing>=0.7 diff --git a/slack_bolt/adapter/wsgi/handler.py b/slack_bolt/adapter/wsgi/handler.py index 2861b4425..a83f2c52d 100644 --- a/slack_bolt/adapter/wsgi/handler.py +++ b/slack_bolt/adapter/wsgi/handler.py @@ -70,7 +70,7 @@ def _get_http_response(self, request: WsgiHttpRequest) -> WsgiHttpResponse: def __call__( self, environ: Dict[str, Any], - start_response: Callable[[str, List[Tuple[str, str]]], None], + start_response: Callable[..., Any], ) -> Iterable[bytes]: request = WsgiHttpRequest(environ) if "HTTP" in request.protocol: diff --git a/tests/mock_asgi_server.py b/tests/mock_asgi_server.py index e71a0d3cb..68c210dbe 100644 --- a/tests/mock_asgi_server.py +++ b/tests/mock_asgi_server.py @@ -1,28 +1,36 @@ -from typing import Iterable, Tuple, Union +from typing import Iterable, Tuple + +from asgiref.testing import ApplicationCommunicator + from slack_bolt.adapter.asgi.base_handler import BaseSlackRequestHandler ENCODING = "utf-8" class AsgiTestServerResponse: - def __init__(self): - self.status_code: int = None - self._headers: Iterable[Tuple[bytes, bytes]] = [] - self._body: bytearray = bytearray(b"") + def __init__( + self, + status_code: int, + headers: Iterable[Tuple[bytes, bytes]] = (), + body: bytes = b"", + ): + self.status_code = status_code + self._headers = headers + self._body = body @property - def body(self): + def body(self) -> str: return self._body.decode(ENCODING) @property - def headers(self): + def headers(self) -> dict: return {header[0].decode(ENCODING): header[1].decode(ENCODING) for header in self._headers} class AsgiTestServerLifespanResponse: - def __init__(self): - self.type: str = None - self.message: str = "" + def __init__(self, type: str, message: str = ""): + self.type = type + self.message = message class AsgiTestServer: @@ -61,22 +69,17 @@ async def http( }, ) - async def receive(): - return {"type": "http.request", "body": bytes(body, ENCODING), "more_body": False} - - response = AsgiTestServerResponse() + communicator = ApplicationCommunicator(self.asgi_app, scope) + await communicator.send_input({"type": "http.request", "body": bytes(body, ENCODING), "more_body": False}) - async def send(event): - if event["type"] == "http.response.start": - response.status_code = event["status"] - response._headers = event["headers"] - elif event["type"] == "http.response.body": - response._body.extend(event["body"]) - else: - raise TypeError(f"Sent type {event['type']} in response {event} is not valid") + response_start = await communicator.receive_output(timeout=1) + response_body = await communicator.receive_output(timeout=1) - await self.asgi_app(scope, receive, send) - return response + return AsgiTestServerResponse( + status_code=response_start["status"], + headers=response_start.get("headers", []), + body=response_body.get("body", b""), + ) async def lifespan(self, event: str) -> AsgiTestServerLifespanResponse: """This implements the server side behavior of the lifespan event @@ -92,17 +95,14 @@ async def lifespan(self, event: str) -> AsgiTestServerLifespanResponse: }, ) - async def receive(): - return {"type": f"lifespan.{event}"} - - response = AsgiTestServerLifespanResponse() - - async def send(event: dict): - response.type = event["type"] - response.message = event.get("message", "") + communicator = ApplicationCommunicator(self.asgi_app, scope) + await communicator.send_input({"type": f"lifespan.{event}"}) - await self.asgi_app(scope, receive, send) - return response + result = await communicator.receive_output(timeout=1) + return AsgiTestServerLifespanResponse( + type=result["type"], + message=result.get("message", ""), + ) async def websocket(self) -> None: """This is not implemented""" @@ -113,10 +113,6 @@ async def websocket(self) -> None: }, ) - async def receive(): - return {} - - async def send(event: dict): - print(event) - - await self.asgi_app(scope, receive, send) + communicator = ApplicationCommunicator(self.asgi_app, scope) + await communicator.send_input({}) + await communicator.receive_output(timeout=1) diff --git a/tests/mock_wsgi_server.py b/tests/mock_wsgi_server.py index a389a898e..7e8bad2e8 100644 --- a/tests/mock_wsgi_server.py +++ b/tests/mock_wsgi_server.py @@ -1,4 +1,7 @@ -from typing import Dict, Iterable, Optional, Tuple +import io +from typing import Any, Callable, Dict, List, Optional, Tuple +from wsgiref.util import setup_testing_defaults +from wsgiref.validate import validator from slack_bolt.adapter.wsgi import SlackRequestHandler @@ -6,37 +9,49 @@ class WsgiTestServerResponse: - def __init__(self): + def __init__(self) -> None: self.status: Optional[str] = None - self._headers: Iterable[Tuple[str, str]] = [] - self._body: Iterable[bytes] = [] + self._headers: List[Tuple[str, str]] = [] + self._body: List[bytes] = [] @property def headers(self) -> Dict[str, str]: return {header[0]: header[1] for header in self._headers} @property - def body(self, length: int = 0) -> str: - return "".join([chunk.decode(ENCODING) for chunk in self._body[length:]]) + def body(self) -> str: + return "".join([chunk.decode(ENCODING) for chunk in self._body]) class MockReadable: + """PEP 3333 compliant input stream. + + Implements read, readline, readlines, and __iter__ as required + by the WSGI specification for wsgi.input. + """ + def __init__(self, body: str): self.body = body - self._body = bytes(body, ENCODING) + self._stream = io.BytesIO(bytes(body, ENCODING)) def get_content_length(self) -> int: - return len(self._body) + return len(self.body.encode(ENCODING)) + + def read(self, size: int = -1) -> bytes: + if size == -1: + return self._stream.read() + return self._stream.read(size) + + def readline(self, size: int = -1) -> bytes: + if size == -1: + return self._stream.readline() + return self._stream.readline(size) - def read(self, size: int) -> bytes: - if size < 0: - raise ValueError("Size must be positive.") - if size == 0: - return b"" - # The body can only be read once - _body = self._body[:size] - self._body = b"" - return _body + def readlines(self, hint: int = -1) -> List[bytes]: + return self._stream.readlines(hint) + + def __iter__(self): + return iter(self._stream) class WsgiTestServer: @@ -44,29 +59,26 @@ def __init__( self, wsgi_app: SlackRequestHandler, root_path: str = "", - version: Tuple[int, int] = (1, 0), - multithread: bool = False, - multiprocess: bool = False, - run_once: bool = False, input_terminated: bool = True, - server_software: bool = "mock/0.0.0", + server_software: str = "mock/0.0.0", url_scheme: str = "https", remote_addr: str = "127.0.0.1", remote_port: str = "63263", ): self.root_path = root_path - self.wsgi_app = wsgi_app - self.environ = { - "wsgi.version": version, - "wsgi.multithread": multithread, - "wsgi.multiprocess": multiprocess, - "wsgi.run_once": run_once, - "wsgi.input_terminated": input_terminated, - "SERVER_SOFTWARE": server_software, - "wsgi.url_scheme": url_scheme, - "REMOTE_ADDR": remote_addr, - "REMOTE_PORT": remote_port, - } + self.wsgi_app = validator(wsgi_app) + self.environ: Dict[str, Any] = {} + setup_testing_defaults(self.environ) + self.environ.update( + { + "wsgi.input_terminated": input_terminated, + "wsgi.errors": io.StringIO(), + "SERVER_SOFTWARE": server_software, + "wsgi.url_scheme": url_scheme, + "REMOTE_ADDR": remote_addr, + "REMOTE_PORT": remote_port, + } + ) def http( self, @@ -101,16 +113,29 @@ def http( environ[f"HTTP_{header_key}"] = value if body is not None: - environ["wsgi.input"] = MockReadable(body) + readable = MockReadable(body) + environ["wsgi.input"] = readable if "CONTENT_LENGTH" not in environ: - environ["CONTENT_LENGTH"] = str(environ["wsgi.input"].get_content_length()) + environ["CONTENT_LENGTH"] = str(readable.get_content_length()) + else: + environ["wsgi.input"] = MockReadable("") response = WsgiTestServerResponse() - def start_response(status, headers): + def start_response( + status: str, + headers: List[Tuple[str, str]], + exc_info: Optional[Any] = None, + ) -> Callable[[bytes], object]: response.status = status response._headers = headers - - response._body = self.wsgi_app(environ=environ, start_response=start_response) + return lambda s: None + + iterator = self.wsgi_app(environ, start_response) + try: + response._body = list(iterator) + finally: + if hasattr(iterator, "close"): + iterator.close() return response From 001d1de102c3de4c5814d366c16d2cd42055e3ff Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 29 May 2026 16:36:55 -0400 Subject: [PATCH 4/7] need asgiref --- requirements/test_async.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements/test_async.txt b/requirements/test_async.txt index a2533430e..e74af3982 100644 --- a/requirements/test_async.txt +++ b/requirements/test_async.txt @@ -1,4 +1,6 @@ # pip install -r requirements/test_async.txt -r test.txt -r async_dev.txt +asgiref>=3.7.2,<3.8; python_version<"3.9" +asgiref>=3.8,<4; python_version>="3.9" pytest-asyncio<2; From 3d36facde85ee2d766e62e993c58448063e8c2a5 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 29 May 2026 17:07:05 -0400 Subject: [PATCH 5/7] imrove wsgi adapter to match standard --- slack_bolt/adapter/wsgi/handler.py | 21 ++++++++++++++------- slack_bolt/adapter/wsgi/http_request.py | 9 ++++++--- slack_bolt/adapter/wsgi/http_response.py | 11 ++++++----- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/slack_bolt/adapter/wsgi/handler.py b/slack_bolt/adapter/wsgi/handler.py index a83f2c52d..fef54f73e 100644 --- a/slack_bolt/adapter/wsgi/handler.py +++ b/slack_bolt/adapter/wsgi/handler.py @@ -1,6 +1,10 @@ -from typing import Any, Callable, Dict, Iterable, List, Tuple +from typing import TYPE_CHECKING, Iterable from slack_bolt import App + +if TYPE_CHECKING: + from wsgiref.types import StartResponse, WSGIEnvironment + from slack_bolt.adapter.wsgi.http_request import WsgiHttpRequest from slack_bolt.adapter.wsgi.http_response import WsgiHttpResponse from slack_bolt.request import BoltRequest @@ -69,14 +73,17 @@ def _get_http_response(self, request: WsgiHttpRequest) -> WsgiHttpResponse: def __call__( self, - environ: Dict[str, Any], - start_response: Callable[..., Any], + environ: "WSGIEnvironment", + start_response: "StartResponse", ) -> Iterable[bytes]: request = WsgiHttpRequest(environ) - if "HTTP" in request.protocol: + if request.protocol.startswith("HTTP"): response: WsgiHttpResponse = self._get_http_response( request=request, ) - start_response(response.status, response.get_headers()) - return response.get_body() - raise TypeError(f"Unsupported SERVER_PROTOCOL: {request.protocol}") + else: + response = WsgiHttpResponse( + status=400, headers={"content-type": ["text/plain;charset=utf-8"]}, body="Bad Request" + ) + start_response(response.status, response.get_headers()) + return response.get_body() diff --git a/slack_bolt/adapter/wsgi/http_request.py b/slack_bolt/adapter/wsgi/http_request.py index 460d8f531..644d0e333 100644 --- a/slack_bolt/adapter/wsgi/http_request.py +++ b/slack_bolt/adapter/wsgi/http_request.py @@ -1,4 +1,7 @@ -from typing import Any, Dict, Sequence, Union +from typing import TYPE_CHECKING, Dict, Sequence, Union + +if TYPE_CHECKING: + from wsgiref.types import WSGIEnvironment from .internals import ENCODING @@ -12,7 +15,7 @@ class WsgiHttpRequest: __slots__ = ("method", "path", "query_string", "protocol", "environ") - def __init__(self, environ: Dict[str, Any]): + def __init__(self, environ: "WSGIEnvironment"): self.method: str = environ.get("REQUEST_METHOD", "GET") self.path: str = environ.get("PATH_INFO", "") self.query_string: str = environ.get("QUERY_STRING", "") @@ -33,5 +36,5 @@ def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]: def get_body(self) -> str: if "wsgi.input" not in self.environ: return "" - content_length = int(self.environ.get("CONTENT_LENGTH", 0)) + content_length = int(self.environ.get("CONTENT_LENGTH") or 0) return self.environ["wsgi.input"].read(content_length).decode(ENCODING) diff --git a/slack_bolt/adapter/wsgi/http_response.py b/slack_bolt/adapter/wsgi/http_response.py index 1ad32e672..32956d276 100644 --- a/slack_bolt/adapter/wsgi/http_response.py +++ b/slack_bolt/adapter/wsgi/http_response.py @@ -1,5 +1,5 @@ from http import HTTPStatus -from typing import Dict, Iterable, List, Sequence, Tuple +from typing import Dict, Iterable, List, Optional, Sequence, Tuple from .internals import ENCODING @@ -13,18 +13,19 @@ class WsgiHttpResponse: __slots__ = ("status", "_headers", "_body") - def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""): + def __init__(self, status: int, headers: Optional[Dict[str, Sequence[str]]] = None, body: str = ""): _status = HTTPStatus(status) self.status = f"{_status.value} {_status.phrase}" - self._headers = headers + self._headers = headers or {} self._body = bytes(body, ENCODING) def get_headers(self) -> List[Tuple[str, str]]: headers: List[Tuple[str, str]] = [] - for key, value in self._headers.items(): + for key, values in self._headers.items(): if key.lower() == "content-length": continue - headers.append((key, value[0])) + for v in values: + headers.append((key, v)) headers.append(("content-length", str(len(self._body)))) return headers From 16a467f096fba6996a73853f9e5d0fa3daeb7275 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 29 May 2026 17:56:22 -0400 Subject: [PATCH 6/7] Improve asgi adapter behavior --- slack_bolt/adapter/asgi/base_handler.py | 20 ++++--- slack_bolt/adapter/asgi/http_response.py | 9 ++-- tests/adapter_tests/asgi/test_asgi_http.py | 53 +++++++++++++++++++ .../adapter_tests/asgi/test_asgi_lifespan.py | 19 +++++++ tests/mock_asgi_server.py | 16 +++++- 5 files changed, 102 insertions(+), 15 deletions(-) diff --git a/slack_bolt/adapter/asgi/base_handler.py b/slack_bolt/adapter/asgi/base_handler.py index 5e68c51f4..adfa060c1 100644 --- a/slack_bolt/adapter/asgi/base_handler.py +++ b/slack_bolt/adapter/asgi/base_handler.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, Union +from typing import Callable, Union from .http_request import AsgiHttpRequest from .http_response import AsgiHttpResponse @@ -47,15 +47,13 @@ async def _get_http_response(self, method: str, path: str, request: AsgiHttpRequ return AsgiHttpResponse(status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body) return AsgiHttpResponse(status=404, headers={"content-type": ["text/plain;charset=utf-8"]}, body="Not Found") - async def _handle_lifespan(self, receive: Callable) -> Dict[str, str]: - while True: - lifespan = await receive() - if lifespan["type"] == "lifespan.startup": - """Do something before startup""" - return {"type": "lifespan.startup.complete"} - if lifespan["type"] == "lifespan.shutdown": - """Do something before shutdown""" - return {"type": "lifespan.shutdown.complete"} + async def _handle_lifespan(self, receive: Callable, send: Callable) -> None: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + message = await receive() + if message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) async def __call__(self, scope: scope_type, receive: Callable, send: Callable) -> None: if scope["type"] == "http": @@ -66,6 +64,6 @@ async def __call__(self, scope: scope_type, receive: Callable, send: Callable) - await send(response.get_response_body()) return if scope["type"] == "lifespan": - await send(await self._handle_lifespan(receive)) + await self._handle_lifespan(receive, send) return raise TypeError(f"Unsupported scope type: {scope['type']!r}") diff --git a/slack_bolt/adapter/asgi/http_response.py b/slack_bolt/adapter/asgi/http_response.py index c8178b8f5..233b6191d 100644 --- a/slack_bolt/adapter/asgi/http_response.py +++ b/slack_bolt/adapter/asgi/http_response.py @@ -8,11 +8,14 @@ class AsgiHttpResponse: def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""): self.status: int = status + self.body: bytes = bytes(body, ENCODING) self.raw_headers: List[Tuple[bytes, bytes]] = [ - (bytes(key, ENCODING), bytes(value[0], ENCODING)) for key, value in headers.items() + (bytes(key, ENCODING), bytes(v, ENCODING)) + for key, values in headers.items() + if key.lower() != "content-length" + for v in values ] - self.raw_headers.append((b"content-length", bytes(str(len(body)), ENCODING))) - self.body: bytes = bytes(body, ENCODING) + self.raw_headers.append((b"content-length", bytes(str(len(self.body)), ENCODING))) def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]: return { diff --git a/tests/adapter_tests/asgi/test_asgi_http.py b/tests/adapter_tests/asgi/test_asgi_http.py index 72b6434bf..f9f106461 100644 --- a/tests/adapter_tests/asgi/test_asgi_http.py +++ b/tests/adapter_tests/asgi/test_asgi_http.py @@ -223,6 +223,59 @@ async def test_url_verification(self): assert response.headers.get("content-type") == "application/json;charset=utf-8" assert_auth_test_count(self, 1) + @pytest.mark.asyncio + async def test_content_length_multibyte_body(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack(text="Hello ☃") # snowman is 3 bytes in UTF-8 + + app.command("/hello-world")(command_handler) + + body = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + + headers = self.build_raw_headers(str(int(time())), body) + + asgi_server = AsgiTestServer(SlackRequestHandler(app)) + response = await asgi_server.http("POST", headers, body) + + assert response.status_code == 200 + content_length = int(response.headers.get("content-length")) + actual_bytes = len(response.body.encode("utf-8")) + assert content_length == actual_bytes + + @pytest.mark.asyncio + async def test_multi_value_headers(self): + from slack_bolt.adapter.asgi.http_response import AsgiHttpResponse + + headers = { + "set-cookie": ["cookie1=value1; Path=/", "cookie2=value2; Path=/"], + "content-type": ["text/html; charset=utf-8"], + } + response = AsgiHttpResponse(status=200, headers=headers, body="OK") + + set_cookie_headers = [(name, value) for name, value in response.raw_headers if name == b"set-cookie"] + assert len(set_cookie_headers) == 2 + assert set_cookie_headers[0] == (b"set-cookie", b"cookie1=value1; Path=/") + assert set_cookie_headers[1] == (b"set-cookie", b"cookie2=value2; Path=/") + @pytest.mark.asyncio async def test_unsupported_method(self): app = App( diff --git a/tests/adapter_tests/asgi/test_asgi_lifespan.py b/tests/adapter_tests/asgi/test_asgi_lifespan.py index 488a990f1..5b48f6b8a 100644 --- a/tests/adapter_tests/asgi/test_asgi_lifespan.py +++ b/tests/adapter_tests/asgi/test_asgi_lifespan.py @@ -1,5 +1,6 @@ import pytest +from asgiref.testing import ApplicationCommunicator from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient @@ -59,6 +60,24 @@ async def test_shutdown(self): assert response.type == "lifespan.shutdown.complete" assert response.message == "" + @pytest.mark.asyncio + async def test_full_lifespan_cycle(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + scope = {"type": "lifespan", "asgi": {"version": "3.0", "spec_version": "2.3"}} + communicator = ApplicationCommunicator(SlackRequestHandler(app), scope) + + await communicator.send_input({"type": "lifespan.startup"}) + startup_response = await communicator.receive_output(timeout=1) + assert startup_response["type"] == "lifespan.startup.complete" + + await communicator.send_input({"type": "lifespan.shutdown"}) + shutdown_response = await communicator.receive_output(timeout=1) + assert shutdown_response["type"] == "lifespan.shutdown.complete" + @pytest.mark.asyncio async def test_failed_event(self): app = App( diff --git a/tests/mock_asgi_server.py b/tests/mock_asgi_server.py index 68c210dbe..0b29a3e5a 100644 --- a/tests/mock_asgi_server.py +++ b/tests/mock_asgi_server.py @@ -24,7 +24,15 @@ def body(self) -> str: @property def headers(self) -> dict: - return {header[0].decode(ENCODING): header[1].decode(ENCODING) for header in self._headers} + result = {} + for header in self._headers: + key = header[0].decode(ENCODING) + if key not in result: + result[key] = header[1].decode(ENCODING) + return result + + def get_headers_list(self, name: str) -> list: + return [header[1].decode(ENCODING) for header in self._headers if header[0].decode(ENCODING) == name] class AsgiTestServerLifespanResponse: @@ -99,6 +107,12 @@ async def lifespan(self, event: str) -> AsgiTestServerLifespanResponse: await communicator.send_input({"type": f"lifespan.{event}"}) result = await communicator.receive_output(timeout=1) + + # Send shutdown so the handler exits cleanly + if event == "startup": + await communicator.send_input({"type": "lifespan.shutdown"}) + await communicator.receive_output(timeout=1) + return AsgiTestServerLifespanResponse( type=result["type"], message=result.get("message", ""), From b94bc21209b5a59eee7db5bef99fa717a274faca Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 29 May 2026 18:08:59 -0400 Subject: [PATCH 7/7] improve readability --- slack_bolt/adapter/asgi/http_response.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/slack_bolt/adapter/asgi/http_response.py b/slack_bolt/adapter/asgi/http_response.py index 233b6191d..58969f137 100644 --- a/slack_bolt/adapter/asgi/http_response.py +++ b/slack_bolt/adapter/asgi/http_response.py @@ -9,12 +9,12 @@ class AsgiHttpResponse: def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""): self.status: int = status self.body: bytes = bytes(body, ENCODING) - self.raw_headers: List[Tuple[bytes, bytes]] = [ - (bytes(key, ENCODING), bytes(v, ENCODING)) - for key, values in headers.items() - if key.lower() != "content-length" - for v in values - ] + self.raw_headers: List[Tuple[bytes, bytes]] = [] + for key, values in headers.items(): + if key.lower() == "content-length": + continue + for v in values: + self.raw_headers.append((bytes(key, ENCODING), bytes(v, ENCODING))) self.raw_headers.append((b"content-length", bytes(str(len(self.body)), ENCODING))) def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]: