Files
tavolo/server/tests/test_openapi.py
T
woggioni e98d43e59a
CI / Build and push docker image (push) Successful in 6m2s
Document every API response body in the OpenAPI spec
kaya-openapi 0.0.8 supports named component schemas, so the routes'
description-only responses become $refs: schemas for the core payloads
(auth providers, lobby, game view, ongoing games, match history,
leaderboard, ratings) live in tavolo.platform.openapi and are registered
through the mixin's components. Engine-defined parts (options echo, game
views, match results) stay open objects on purpose. Error responses now
carry the error envelope schema, and a new suite sweeps the document to
keep every /api response typed.
2026-10-14 22:07:38 +08:00

140 lines
5.9 KiB
Python

"""OpenAPI document tests: components, response schemas and Swagger UI."""
from __future__ import annotations
import unittest
from typing import Any, Dict, Tuple
from httpx import ASGITransport, AsyncClient
from kaya.openapi import schema_ref
from tavolo.app import app
from tavolo.platform.openapi import SCHEMAS
from tests.helpers import async_test
#: Methods documented for the application's own routes.
HTTP_METHODS = ("get", "post", "put", "patch", "delete", "options", "head")
class OpenApiDocumentTest(unittest.TestCase):
async def _get(self, path: str) -> Any:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get(path)
self.assertEqual(200, response.status_code)
return response
async def _spec(self) -> Dict[str, Any]:
response = await self._get("/api/openapi.json")
body: Dict[str, Any] = response.json()
return body
async def _schema(
self, spec: Dict[str, Any], path: str, method: str, status: str
) -> Dict[str, Any]:
schema: Dict[str, Any] = spec["paths"][path][method]["responses"][status][
"content"
]["application/json"]["schema"]
return schema
@async_test
async def test_components_registered(self) -> None:
spec = await self._spec()
self.assertEqual(SCHEMAS, spec["components"]["schemas"])
@async_test
async def test_every_api_response_documents_a_schema(self) -> None:
spec = await self._spec()
checked = 0
for path, item in spec["paths"].items():
if not path.startswith("/api/"):
continue
for method, operation in item.items():
if method not in HTTP_METHODS:
continue
for status, response in operation["responses"].items():
with self.subTest(path=path, method=method, status=status):
if status == "204":
# No content, so no schema either.
self.assertNotIn("content", response)
continue
self.assertIn("application/json", response["content"])
self.assertTrue(
response["content"]["application/json"]["schema"]
)
checked += 1
self.assertGreater(checked, 0)
@async_test
async def test_response_schemas_reference_components(self) -> None:
spec = await self._spec()
cases: Tuple[Tuple[str, str, str, Dict[str, Any]], ...] = (
("/api/health", "get", "200", schema_ref("Health")),
("/api/me", "get", "200", schema_ref("Me")),
("/api/me", "get", "401", schema_ref("Error")),
("/api/auth/providers", "get", "200", schema_ref("AuthProvidersPage")),
("/api/game-types", "get", "200", schema_ref("GameTypesPage")),
("/api/games", "post", "201", schema_ref("Lobby")),
("/api/games/preview", "get", "200", schema_ref("GamePreview")),
("/api/games/{game_id}", "get", "200", schema_ref("GameView")),
("/api/games/{game_id}", "get", "404", schema_ref("Error")),
("/api/me/games", "get", "200", schema_ref("OngoingGamesPage")),
("/api/me/matches", "get", "200", schema_ref("MatchesPage")),
("/api/leaderboard", "get", "200", schema_ref("LeaderboardPage")),
("/api/me/ratings", "get", "200", schema_ref("RatingsPage")),
)
for path, method, status, expected in cases:
with self.subTest(path=path, method=method, status=status):
self.assertEqual(
expected, await self._schema(spec, path, method, status)
)
@async_test
async def test_join_response_is_lobby_or_game_view(self) -> None:
spec = await self._spec()
schema = await self._schema(spec, "/api/games/join", "post", "200")
self.assertEqual(
{"anyOf": [schema_ref("Lobby"), schema_ref("GameView")]}, schema
)
@async_test
async def test_component_nesting_and_nullability(self) -> None:
spec = await self._spec()
schemas = spec["components"]["schemas"]
matches = schemas["MatchesPage"]["properties"]
self.assertEqual(schema_ref("Match"), matches["results"]["items"])
self.assertEqual(["string", "null"], matches["next_cursor"]["type"])
match = schemas["Match"]["properties"]
self.assertEqual(schema_ref("MatchPlayer"), match["players"]["items"])
self.assertEqual(["integer", "null"], match["your_elo_delta"]["type"])
lobby = schemas["Lobby"]
self.assertEqual(schema_ref("LobbyPlayer"), lobby["properties"]["players"]["items"])
self.assertIs(True, lobby["additionalProperties"])
self.assertEqual(
["string", "null"],
schemas["LobbyPlayer"]["properties"]["team"]["type"],
)
game_view = schemas["GameView"]
self.assertEqual("boolean", game_view["properties"]["spectator"]["type"])
self.assertIs(True, game_view["additionalProperties"])
# The join code is optional (players only), so it is not required.
self.assertNotIn("join_code", game_view["required"])
board = schemas["LeaderboardPage"]
self.assertEqual(["integer", "null"], board["properties"]["your_rank"]["type"])
self.assertEqual(
["integer", "null"], board["properties"]["your_page"]["type"]
)
self.assertEqual(schema_ref("LeaderboardEntry"),
board["properties"]["results"]["items"])
@async_test
async def test_docs_serve_swagger_ui(self) -> None:
response = await self._get("/api/docs")
self.assertIn("text/html", response.headers["content-type"])
self.assertIn("swagger-ui", response.text)