Serve static assets with Granian and add YAML-configurable logging
Granian serves the compiled SPA assets directly in Rust: hashed js/wasm/css under /static (the release build uses --public-url /static/) and the card images under /assets, configured with the GRANIAN_STATIC_PATH_ROUTE/MOUNT/ DIR_TO_FILE env vars in the Dockerfile. The Python catch-all now only serves the SPA shell (index.html) at / and for client-side routes. Every module logs through getLogger(__name__): lifecycle and business events at INFO, per-move and store detail at DEBUG. The built-in default writes DEBUG to the console; LOGGING_CONFIG points at a YAML file in the logging.config.dictConfig schema to take over the configuration. PyYAML becomes a direct dependency.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
"""Tests for the logging configuration entry point."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from tavolo.logging_config import configure_logging
|
||||
|
||||
|
||||
class LoggingConfigTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
# configure_logging mutates the global logging state; snapshot and
|
||||
# restore it so the rest of the suite is unaffected.
|
||||
root = logging.getLogger()
|
||||
self._root_handlers = root.handlers[:]
|
||||
self._root_level = root.level
|
||||
tavolo = logging.getLogger("tavolo")
|
||||
self._tavolo_level = tavolo.level
|
||||
|
||||
def tearDown(self) -> None:
|
||||
root = logging.getLogger()
|
||||
root.handlers = self._root_handlers
|
||||
root.level = self._root_level
|
||||
logging.getLogger("tavolo").level = self._tavolo_level
|
||||
|
||||
def test_default_config_when_unset(self) -> None:
|
||||
configure_logging(None)
|
||||
root = logging.getLogger()
|
||||
self.assertEqual(logging.DEBUG, root.level)
|
||||
self.assertTrue(
|
||||
any(isinstance(h, logging.StreamHandler) for h in root.handlers),
|
||||
"default config installs a console stream handler",
|
||||
)
|
||||
|
||||
def test_yaml_config_is_applied(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = Path(tmp) / "logging.yaml"
|
||||
config.write_text(
|
||||
"version: 1\n"
|
||||
"disable_existing_loggers: false\n"
|
||||
"root:\n"
|
||||
" level: WARNING\n"
|
||||
"loggers:\n"
|
||||
" tavolo:\n"
|
||||
" level: DEBUG\n"
|
||||
)
|
||||
configure_logging(str(config))
|
||||
self.assertEqual(logging.DEBUG, logging.getLogger("tavolo").getEffectiveLevel())
|
||||
self.assertEqual(logging.WARNING, logging.getLogger().getEffectiveLevel())
|
||||
|
||||
def test_missing_file_raises(self) -> None:
|
||||
with self.assertRaises(RuntimeError):
|
||||
configure_logging("/nonexistent/logging.yaml")
|
||||
|
||||
def test_non_mapping_yaml_raises(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = Path(tmp) / "logging.yaml"
|
||||
config.write_text("- just\n- a\n- list\n")
|
||||
with self.assertRaises(RuntimeError):
|
||||
configure_logging(str(config))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -34,15 +34,13 @@ class MeRouteTest(unittest.TestCase):
|
||||
|
||||
|
||||
class StaticRouteTest(unittest.TestCase):
|
||||
"""The app only serves the SPA shell; asset files under /static are
|
||||
served by Granian and are not reachable through the ASGI transport."""
|
||||
|
||||
@async_test
|
||||
async def test_serves_files_and_spa_fallback(self) -> None:
|
||||
async def test_serves_shell_and_spa_fallback(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as dist:
|
||||
root = Path(dist)
|
||||
(root / "index.html").write_text("<html>spa</html>")
|
||||
(root / "app.js").write_text("console.log(1)")
|
||||
cards = root / "assets" / "cards"
|
||||
cards.mkdir(parents=True)
|
||||
(cards / "07D.svg").write_text("<svg/>")
|
||||
(Path(dist) / "index.html").write_text("<html>spa</html>")
|
||||
|
||||
patched = dataclasses.replace(settings, static_dir=dist)
|
||||
with mock.patch("tavolo.routes.static.settings", patched):
|
||||
@@ -50,27 +48,14 @@ class StaticRouteTest(unittest.TestCase):
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
index = await client.get("/")
|
||||
self.assertEqual(200, index.status_code)
|
||||
self.assertEqual("text/html; charset=utf-8", index.headers["content-type"])
|
||||
self.assertIn(b"spa", index.content)
|
||||
|
||||
js = await client.get("/app.js")
|
||||
self.assertEqual(200, js.status_code)
|
||||
self.assertEqual("text/javascript; charset=utf-8", js.headers["content-type"])
|
||||
|
||||
svg = await client.get("/assets/cards/07D.svg")
|
||||
self.assertEqual(200, svg.status_code)
|
||||
self.assertEqual("image/svg+xml", svg.headers["content-type"])
|
||||
|
||||
# Unknown client-side route falls back to the app shell.
|
||||
fallback = await client.get("/game/some-id")
|
||||
self.assertEqual(200, fallback.status_code)
|
||||
self.assertIn(b"spa", fallback.content)
|
||||
|
||||
# Traversal attempts never escape the dist directory.
|
||||
traversal = await client.get("/..%2F..%2Fetc%2Fpasswd")
|
||||
self.assertIn(traversal.status_code, (200, 404))
|
||||
if traversal.status_code == 200:
|
||||
self.assertIn(b"spa", traversal.content)
|
||||
|
||||
@async_test
|
||||
async def test_missing_dist_returns_404(self) -> None:
|
||||
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
|
||||
|
||||
Reference in New Issue
Block a user