CI / Build and push docker image (push) Successful in 6m2s
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.
140 lines
5.9 KiB
Python
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)
|