Skip to content

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