tests/spa/test_spa_application.py¶
Source from this local checkout, regenerated when the reader rebuilds.
Line links use #L<number>; a GitHub line range opens its first line.
1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 # https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """The new front, from the outside: what a browser gets, and what the recipe builds.16 17 Everything here goes through the doors production goes through — the recipe builds18 the server, the lifespan builds the pool, an ASGI call gets an answer — because19 this front's whole job is to be that seam. The pool below is a SUBCLASS whose20 ``serve_request`` is scripted: what the chain does with a request has its own21 tests one folder over, and what belongs here is only what the front does with the22 answer, or with the refusal.23 """24 25 from __future__ import annotations26 27 import base6428 from typing import Any29 30 import pytest31 32 from tests.spa.orchestration.frame_helpers import read_http_request, http_reply33 from genro_routes import route34 35 from genro_asgi import AsgiServer36 from genro_asgi.config.builder import AsgiConfigBuilder37 from genro_asgi_multiworker_spa.spa_app import (38 CONNECTION_COOKIE_MAX_AGE,39 ERR_503_TEXT,40 SPA_CONNECTION_ID_COOKIE,41 ERR_502_TEXT,42 SpaApplication,43 )44 from genro_asgi.server import QUITTING, REFUSED_RETRY_AFTER_SECONDS, STOPPING45 from genro_asgi_multiworker_spa.orchestration import AssignmentRefused, SiteFailedRequest, SpaCommander46 47 from ..conftest import LifespanRunner, ask_app, get_answer_header48 49 50 class ScriptedCommander(SpaCommander):51 """A pool that answers from a script: no processes, no wire, no beat."""52 53 #: What the next request gets: a reply, or an exception raised instead.54 reply: dict[str, Any] = {"result": {"status": 200, "headers": [], "body": ""}}55 failure: Exception | None = None56 57 def __init__(self, *args: Any, **kwargs: Any) -> None:58 super().__init__(*args, **kwargs)59 #: One entry per request, as (cid, http).60 self.calls: list[tuple[str, dict[str, Any]]] = []61 self.started = False62 63 async def start(self) -> None:64 self.started = True65 66 async def stop(self) -> None:67 self.started = False68 69 async def serve_request(70 self, cid: str, http: dict[str, Any], *, hold_timeout: float71 ) -> dict[str, Any]:72 self.calls.append((cid, read_http_request(http)))73 if self.failure is not None:74 raise self.failure75 return http_reply(http, self.reply)76 77 78 class ScriptedFront(SpaApplication):79 """The front under test: its own route, and a pool that answers from a script."""80 81 commander_class = ScriptedCommander82 83 @route()84 def ping(self) -> dict[str, bool]:85 return {"ping": True}86 87 88 def recipe_for(root, fronts: int = 1) -> type[AsgiConfigBuilder]:89 """A recipe with the pool section and as many fronts as asked for."""90 91 class FrontConfig(AsgiConfigBuilder):92 def main(self, configuration_root: Any) -> None:93 cfg = configuration_root.configuration()94 applications = cfg.applications()95 for index in range(fronts):96 front = applications.application(97 code=f"site{index}",98 mount="" if not index else f"other{index}",99 app_class=ScriptedFront,100 )101 # The pool belongs to the front that owns it: its whole102 # orchestration hangs here, under its own node.103 commander = front.orchestration().commander(104 frozen_users_path=str(root / f"frozen_users{index}"),105 instance_dir=str(root / f"i{index}"),106 )107 groups = commander.groups(default="standard")108 groups.group(name="standard", entry_module="never.launched")109 110 return FrontConfig111 112 113 @pytest.fixture114 def server(tmp_path):115 """A server built from a recipe, with its front mounted and not yet started."""116 return AsgiServer(config=recipe_for(tmp_path))117 118 119 @pytest.fixture120 async def started(server):121 """The same server, through its own lifespan: the pool exists and is up."""122 runner = LifespanRunner(server)123 await runner.startup()124 yield server125 await runner.shutdown()126 127 128 # -- the pool is born with the server --129 130 131 async def test_the_pool_is_built_from_the_recipe_when_the_server_starts(server):132 front = server.applications["site0"]133 runner = LifespanRunner(server)134 135 with pytest.raises(RuntimeError):136 front.commander137 138 await runner.startup()139 140 assert isinstance(front.commander, ScriptedCommander)141 assert front.commander.started is True142 # What the recipe said, where it had to arrive.143 assert front.commander.default_group == "standard"144 assert set(front.commander.group_map) == {"standard"}145 146 pool = front.commander147 await runner.shutdown()148 # The pool was taken down AND let go: the front holds no vertex out of the149 # lifespan, so a later startup builds a new one instead of finding a dead one.150 assert pool.started is False151 assert front._commander is None152 with pytest.raises(RuntimeError):153 front.commander154 155 156 async def test_two_fronts_each_own_their_pool(tmp_path):157 """A pool belongs to the front that owns it: two fronts are two pools."""158 server = AsgiServer(config=recipe_for(tmp_path, fronts=2))159 runner = LifespanRunner(server)160 161 await runner.startup()162 163 first = server.applications["site0"].commander164 second = server.applications["site1"].commander165 assert first is not second166 assert first.freeze_handler.root_path != second.freeze_handler.root_path167 168 await runner.shutdown()169 170 171 # -- the demux --172 173 174 async def test_its_own_route_answers_natively(started):175 front = started.applications["site0"]176 177 answer = await ask_app(front, "/ping")178 179 assert answer["status"] == 200180 assert front.commander.calls == []181 182 183 async def test_a_path_of_the_site_is_forwarded(started):184 front = started.applications["site0"]185 front.commander.reply = {186 "result": {187 "status": 201,188 "headers": [["content-type", "text/html"]],189 "body": base64.b64encode(b"<h1>the site</h1>").decode(),190 }191 }192 193 answer = await ask_app(front, "/invoices/42")194 195 assert answer["status"] == 201196 assert answer["body"] == b"<h1>the site</h1>"197 assert get_answer_header(answer, "content-type") == "text/html"198 cid, http = front.commander.calls[0]199 assert http["path"] == "/invoices/42"200 assert http["cid"] == cid201 202 203 # -- the cookie --204 205 206 async def test_a_request_with_no_cookie_travels_with_none_and_mints_nothing(started):207 front = started.applications["site0"]208 209 await ask_app(front, "/invoices")210 211 cid, http = front.commander.calls[0]212 assert cid is None213 assert http["cid"] is None214 # Nothing of ours is added to what the browser sent.215 assert all(SPA_CONNECTION_ID_COOKIE not in value for _, value in http["headers"])216 217 218 async def test_the_connection_the_site_named_becomes_the_cookie(started):219 front = started.applications["site0"]220 front.commander.reply = {"result": {"status": 200, "connection_id": "site-1"}}221 222 answer = await ask_app(front, "/invoices")223 224 cookie = get_answer_header(answer, "set-cookie")225 assert f"{SPA_CONNECTION_ID_COOKIE}=site-1" in cookie226 # The same life the hosted site gives its own connection cookie.227 assert f"Max-Age={CONNECTION_COOKIE_MAX_AGE}" in cookie228 229 230 async def test_a_request_that_reused_its_connection_is_answered_without_a_cookie(started):231 front = started.applications["site0"]232 front.commander.reply = {"result": {"status": 200, "connection_id": "site-1"}}233 234 answer = await ask_app(front, "/invoices", cookies={SPA_CONNECTION_ID_COOKIE: "site-1"})235 236 assert get_answer_header(answer, "set-cookie") is None237 assert front.commander.calls[0][0] == "site-1"238 239 240 async def test_a_connection_the_site_replaced_overwrites_the_cookie(started):241 """The site validates its own cookie and creates a fresh connection when it242 does not: the browser must be told, or it would route on a dead id forever."""243 front = started.applications["site0"]244 front.commander.reply = {"result": {"status": 200, "connection_id": "site-2"}}245 246 answer = await ask_app(front, "/invoices", cookies={SPA_CONNECTION_ID_COOKIE: "site-1"})247 248 assert f"{SPA_CONNECTION_ID_COOKIE}=site-2" in get_answer_header(answer, "set-cookie")249 250 251 # -- the two refusals --252 253 254 async def test_a_pool_that_takes_nobody_is_a_polite_503(started):255 front = started.applications["site0"]256 front.commander.failure = AssignmentRefused("mario", "no worker admits him", retry_after=30.0)257 258 answer = await ask_app(front, "/invoices")259 260 assert answer["status"] == 503261 assert get_answer_header(answer, "retry-after") == "30"262 assert answer["body"] == ERR_503_TEXT.encode()263 264 265 async def test_the_inside_of_the_house_never_reaches_the_browser(started):266 front = started.applications["site0"]267 front.commander.failure = SiteFailedRequest(268 "mario", "ProgrammingError: relation invoices_2024 does not exist"269 )270 271 answer = await ask_app(front, "/invoices")272 273 assert answer["status"] == 502274 assert answer["body"] == ERR_502_TEXT.encode()275 assert b"invoices_2024" not in answer["body"]276 277 278 async def test_a_wire_that_is_gone_while_the_server_quits_is_a_503(started, monkeypatch):279 """The wire died because the server is leaving: a refusal, not a breakage."""280 front = started.applications["site0"]281 monkeypatch.setattr(front.server, "state", QUITTING)282 front.commander.failure = ConnectionError("no child on the wire")283 284 answer = await ask_app(front, "/invoices")285 286 assert answer["status"] == 503287 assert get_answer_header(answer, "retry-after") == str(REFUSED_RETRY_AFTER_SECONDS)288 289 290 async def test_a_wire_that_is_gone_is_the_same_502(started):291 front = started.applications["site0"]292 front.commander.failure = ConnectionError("no child on the wire")293 294 answer = await ask_app(front, "/invoices")295 296 assert answer["status"] == 502297 298 299 async def test_a_refusal_names_no_connection_and_writes_no_cookie(started):300 """The site never served it, so there is nothing to name — and a refusal must301 not overwrite the connection the browser already holds."""302 front = started.applications["site0"]303 front.commander.failure = AssignmentRefused("mario", "no worker admits him", retry_after=30.0)304 305 answer = await ask_app(front, "/invoices", cookies={SPA_CONNECTION_ID_COOKIE: "site-1"})306 307 assert get_answer_header(answer, "set-cookie") is None308 309 310 async def test_the_front_takes_the_photo_only_when_the_server_is_quitting(started, monkeypatch):311 """QUITTING gets the soft quit; any other way out is dry."""312 front = started.applications["site0"]313 called = []314 315 async def note_quit(self):316 called.append("quit")317 318 async def note_stop(self):319 called.append("stop")320 321 monkeypatch.setattr(ScriptedCommander, "quit", note_quit)322 monkeypatch.setattr(ScriptedCommander, "stop", note_stop)323 324 # One way out per pool: the front lets go of its vertex on the way down, so325 # the second exit is a second pool, built by its own startup.326 monkeypatch.setattr(front.server, "state", STOPPING)327 await front.on_shutdown()328 await front.on_startup()329 monkeypatch.setattr(front.server, "state", QUITTING)330 await front.on_shutdown()331 332 assert called == ["stop", "quit"]333 assert front._commander is None