Add Sycamore/WASM frontend and restructure into server/ + web/
Repo is now a monorepo:
- server/: the kaya backend, unchanged in behaviour, plus:
- GET /api/me for SPA session detection
- last_move recorded on every play and broadcast in the game state, so
clients can show who played which card the moment they play it
- legal_moves per hand card for the player on turn (rules stay
server-side)
- static catch-all route serving the compiled SPA with index.html
fallback; Tortoise context now bound only for /api/* requests
- configurable OIDC post-login/logout redirects for dev against trunk
- web/: Sycamore 0.9 + WASM frontend (trunk): login via the OIDC flow,
lobby (create match / join by code), live game page over websocket with
card images (CC0 woodcut napoletane deck), capture picker, move banner,
game-over overlay, match history and leaderboard pages
- server/Dockerfile gains a rust+trunk stage building web/dist; the single
app image serves the SPA; compose builds from the repo root with
overridable ports/OIDC env
Verified end-to-end against the compose stack: four OIDC logins, game
creation, three joins by code, websocket play with broadcasts to all
players and out-of-turn rejection. 51 backend tests, mypy and cargo tests
all green.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
"""Test package init.
|
||||
|
||||
Sets environment overrides BEFORE any test module imports
|
||||
:mod:`scopa.app` (which evaluates :data:`scopa.config.settings`
|
||||
at import time). Works under both ``python -m unittest discover`` and
|
||||
``pytest``; conftest.py mirrors this for pytest-only collection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
||||
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/scopa")
|
||||
os.environ.setdefault("OIDC_CLIENT_ID", "scopa")
|
||||
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
|
||||
# Unset REDIS_URL: sessions and live games use the in-memory fallbacks.
|
||||
os.environ.pop("REDIS_URL", None)
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Test helpers package."""
|
||||
from .oidc import make_user, oidc_user, ws_users
|
||||
|
||||
__all__ = ["make_user", "oidc_user", "ws_users"]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Helpers for faking the OIDC authenticated user during tests.
|
||||
|
||||
HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers
|
||||
through :func:`scopa.auth.get_ws_user`. Patching those two entry points
|
||||
lets route and websocket tests run entirely in-process with no IdP.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import unittest.mock as _mock
|
||||
from typing import Iterator, Optional, Sequence
|
||||
|
||||
from kaya.oidc import OIDCUser
|
||||
|
||||
# Import the app first: it pulls in the route modules, which import
|
||||
# ``scopa.auth`` themselves. Importing ``auth`` before ``app`` would hit a
|
||||
# partially initialized module (same constraint as reimpasto).
|
||||
from scopa.app import oidc_mixin
|
||||
from scopa import auth
|
||||
|
||||
|
||||
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
||||
return OIDCUser({"sub": sub, "preferred_username": name or sub})
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def oidc_user(sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
|
||||
"""Context manager: patch ``oidc_mixin.get_user`` to return this user."""
|
||||
user = make_user(sub, name)
|
||||
patcher = _mock.patch.object(oidc_mixin, "get_user", return_value=user)
|
||||
patcher.start()
|
||||
try:
|
||||
yield user
|
||||
finally:
|
||||
patcher.stop()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]:
|
||||
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
|
||||
one per websocket connection, in order. Once exhausted it keeps
|
||||
returning the last user."""
|
||||
remaining = list(users)
|
||||
last = remaining[-1] if remaining else None
|
||||
|
||||
def _next(_ws):
|
||||
if remaining:
|
||||
return remaining.pop(0)
|
||||
return last
|
||||
|
||||
patcher = _mock.patch.object(auth, "get_ws_user", side_effect=_next)
|
||||
patcher.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
patcher.stop()
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Rule engine tests: captures, scope, scoring and full-match simulation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from scopa.game import engine
|
||||
from scopa.game.errors import (
|
||||
CardNotInHand,
|
||||
GameFinished,
|
||||
IllegalMove,
|
||||
NotYourTurn,
|
||||
)
|
||||
from scopa.game.state import (
|
||||
PHASE_FINISHED,
|
||||
PHASE_PLAYING,
|
||||
Card,
|
||||
GameState,
|
||||
PlayerState,
|
||||
)
|
||||
|
||||
|
||||
def card(code: str) -> Card:
|
||||
return Card.parse(code)
|
||||
|
||||
|
||||
def make_state(
|
||||
hands,
|
||||
table,
|
||||
turn: int = 0,
|
||||
*,
|
||||
captured=None,
|
||||
scope=None,
|
||||
target: int = 11,
|
||||
last_taker=None,
|
||||
) -> GameState:
|
||||
"""Build a controlled game state directly (bypassing the deal)."""
|
||||
state = GameState(
|
||||
id="game-1",
|
||||
join_code="ABC123",
|
||||
creator_sub="p0",
|
||||
target_score=target,
|
||||
phase=PHASE_PLAYING,
|
||||
turn=turn,
|
||||
last_taker=last_taker,
|
||||
)
|
||||
for seat, hand in enumerate(hands):
|
||||
state.players.append(
|
||||
PlayerState(sub=f"p{seat}", name=f"p{seat}", seat=seat,
|
||||
hand=[card(c) for c in hand])
|
||||
)
|
||||
if captured is not None:
|
||||
for player, codes in zip(state.players, captured):
|
||||
player.captured = [card(c) for c in codes]
|
||||
if scope is not None:
|
||||
for player, value in zip(state.players, scope):
|
||||
player.scope = value
|
||||
state.table = [card(c) for c in table]
|
||||
return state
|
||||
|
||||
|
||||
class DeckTest(unittest.TestCase):
|
||||
def test_full_deck_has_40_unique_cards(self) -> None:
|
||||
deck = engine.full_deck()
|
||||
self.assertEqual(40, len(deck))
|
||||
self.assertEqual(40, len({c.code for c in deck}))
|
||||
self.assertEqual(4, len({c.suit for c in deck}))
|
||||
self.assertEqual(4, sum(1 for c in deck if c.rank == 7))
|
||||
|
||||
def test_shuffled_deck_is_permutation(self) -> None:
|
||||
deck = engine.shuffled_deck()
|
||||
self.assertEqual(
|
||||
sorted(c.code for c in engine.full_deck()),
|
||||
sorted(c.code for c in deck),
|
||||
)
|
||||
|
||||
|
||||
class CaptureTest(unittest.TestCase):
|
||||
def test_equal_card_is_mandatory(self) -> None:
|
||||
table = [card("05C"), card("02D"), card("03S")]
|
||||
options = engine.legal_captures(table, card("05D"))
|
||||
self.assertEqual([["05C"]], [[c.code for c in o] for o in options])
|
||||
|
||||
def test_sum_combination(self) -> None:
|
||||
table = [card("01C"), card("03C"), card("02S")]
|
||||
options = engine.legal_captures(table, card("04D"))
|
||||
self.assertEqual([["01C", "03C"]], [[c.code for c in o] for o in options])
|
||||
|
||||
def test_multiple_equal_cards_each_a_separate_option(self) -> None:
|
||||
table = [card("05C"), card("05S")]
|
||||
options = engine.legal_captures(table, card("05D"))
|
||||
self.assertEqual(
|
||||
[["05C"], ["05S"]], sorted([[c.code for c in o] for o in options])
|
||||
)
|
||||
|
||||
def test_no_capture(self) -> None:
|
||||
table = [card("09C"), card("08S")]
|
||||
self.assertEqual([], engine.legal_captures(table, card("02D")))
|
||||
|
||||
def test_play_without_capture_places_card_on_table(self) -> None:
|
||||
state = make_state([["02D", "03D"], ["01C"], ["01S"], ["01B"]],
|
||||
table=["09C"])
|
||||
engine.play(state, "p0", "02D")
|
||||
self.assertIn("02D", [c.code for c in state.table])
|
||||
self.assertNotIn("02D", [c.code for c in state.players[0].hand])
|
||||
self.assertEqual(1, state.turn)
|
||||
|
||||
def test_play_capture_and_scopa(self) -> None:
|
||||
state = make_state([["02D", "09C"], ["01C"], ["01S"], ["01B"]],
|
||||
table=["02C"])
|
||||
engine.play(state, "p0", "02D", ["02C"])
|
||||
self.assertEqual(1, state.players[0].scope)
|
||||
self.assertEqual([], state.table)
|
||||
self.assertEqual(
|
||||
["02C", "02D"], [c.code for c in state.players[0].captured]
|
||||
)
|
||||
# The move is recorded for the "who played what" announcement.
|
||||
assert state.last_move is not None
|
||||
self.assertEqual(0, state.last_move.seat)
|
||||
self.assertEqual("p0", state.last_move.name)
|
||||
self.assertEqual("02D", state.last_move.card)
|
||||
self.assertEqual(["02C"], state.last_move.captured)
|
||||
self.assertTrue(state.last_move.scopa)
|
||||
|
||||
def test_play_without_capture_records_move(self) -> None:
|
||||
state = make_state([["02D", "03D"], ["01C"], ["01S"], ["01B"]],
|
||||
table=["09C"])
|
||||
engine.play(state, "p0", "02D")
|
||||
assert state.last_move is not None
|
||||
self.assertEqual("02D", state.last_move.card)
|
||||
self.assertEqual([], state.last_move.captured)
|
||||
self.assertFalse(state.last_move.scopa)
|
||||
|
||||
def test_illegal_combination_when_equal_card_present(self) -> None:
|
||||
state = make_state([["05D"], ["01C"], ["01S"], ["01B"]],
|
||||
table=["05C", "02D", "03S"])
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.play(state, "p0", "05D", ["02D", "03S"])
|
||||
|
||||
def test_illegal_capture_rejected(self) -> None:
|
||||
state = make_state([["04D"], ["01C"], ["01S"], ["01B"]],
|
||||
table=["02C", "03S"])
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.play(state, "p0", "04D", ["02C"])
|
||||
|
||||
def test_no_capture_requested_when_capture_possible(self) -> None:
|
||||
state = make_state([["05D"], ["01C"], ["01S"], ["01B"]],
|
||||
table=["05C"])
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.play(state, "p0", "05D")
|
||||
|
||||
def test_not_your_turn(self) -> None:
|
||||
state = make_state([["02D"], ["03D"], ["04D"], ["05D"]],
|
||||
table=[], turn=1)
|
||||
with self.assertRaises(NotYourTurn):
|
||||
engine.play(state, "p0", "02D")
|
||||
|
||||
def test_card_not_in_hand(self) -> None:
|
||||
state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[])
|
||||
with self.assertRaises(CardNotInHand):
|
||||
engine.play(state, "p0", "07D")
|
||||
|
||||
def test_finished_game_rejects_moves(self) -> None:
|
||||
state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[])
|
||||
state.phase = PHASE_FINISHED
|
||||
with self.assertRaises(GameFinished):
|
||||
engine.play(state, "p0", "02D")
|
||||
|
||||
|
||||
class LastPlayTest(unittest.TestCase):
|
||||
def test_no_scopa_on_last_play_of_hand(self) -> None:
|
||||
# p0 plays the last card of the hand (everyone else is already
|
||||
# empty): the capture empties the table but must NOT count as a
|
||||
# scopa. Team A still reaches the target of 2 with carte + denara.
|
||||
state = make_state([["02D"], [], [], []],
|
||||
table=["02C"], target=2)
|
||||
engine.play(state, "p0", "02D", ["02C"])
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(0, state.winner)
|
||||
self.assertEqual(0, state.hand_scores[-1]["scope"]["A"])
|
||||
|
||||
def test_table_swept_to_last_taker(self) -> None:
|
||||
# target 2 so the game ends on this hand and the capture piles are
|
||||
# not reset by the next deal.
|
||||
state = make_state([["02D"], [], [], []],
|
||||
table=["05C", "04D"], last_taker=1, target=2)
|
||||
engine.play(state, "p0", "02D")
|
||||
captured = {c.code for c in state.players[1].captured}
|
||||
self.assertEqual({"05C", "04D", "02D"}, captured)
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(1, state.winner)
|
||||
|
||||
|
||||
class ScoringTest(unittest.TestCase):
|
||||
def test_primiera_values_and_all_suits_requirement(self) -> None:
|
||||
self.assertEqual(70, engine.primiera_score(
|
||||
[card(c) for c in ["07D", "06C", "01S", "05B"]]))
|
||||
self.assertEqual(0, engine.primiera_score(
|
||||
[card(c) for c in ["07D", "06C", "01S"]]))
|
||||
self.assertEqual(40, engine.primiera_score(
|
||||
[card(c) for c in ["08D", "09C", "10S", "10B"]]))
|
||||
|
||||
def test_hand_points_carte_denara_settebello_primiera_scope(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], []],
|
||||
table=[],
|
||||
captured=[
|
||||
["07D", "06C", "01S", "05B"], # seat 0, team A
|
||||
["03D", "04C", "07S", "02B"], # seat 1, team B
|
||||
["02D"], # seat 2, team A
|
||||
["10D", "10C", "10S", "10B"], # seat 3, team B
|
||||
],
|
||||
scope=[1, 0, 0, 2],
|
||||
)
|
||||
points, details = engine.hand_points(state)
|
||||
self.assertEqual([3, 3], points)
|
||||
self.assertEqual({"A": 5, "B": 8}, details["cards"])
|
||||
self.assertEqual({"A": 2, "B": 2}, details["denara"])
|
||||
self.assertEqual({"A": True, "B": False}, details["settebello"])
|
||||
self.assertEqual({"A": 70, "B": 60}, details["primiera"])
|
||||
self.assertEqual({"A": 1, "B": 2}, details["scope"])
|
||||
|
||||
def test_ties_award_nothing(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], []],
|
||||
table=[],
|
||||
captured=[
|
||||
["06C", "01S", "05B", "02D"],
|
||||
["06S", "01B", "05D", "02C"],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
scope=[0, 0, 0, 0],
|
||||
)
|
||||
points, _ = engine.hand_points(state)
|
||||
# Equal cards, equal denara, equal primiera and no settebello:
|
||||
# everything ties, so no points at all.
|
||||
self.assertEqual([0, 0], points)
|
||||
|
||||
|
||||
class MatchFlowTest(unittest.TestCase):
|
||||
def test_join_starts_when_full(self) -> None:
|
||||
state = engine.create_game("g", "CODE42", "p0", "p0", target_score=11)
|
||||
self.assertEqual(1, len(state.players))
|
||||
engine.join_game(state, "p1", "p1")
|
||||
engine.join_game(state, "p2", "p2")
|
||||
self.assertEqual("lobby", state.phase)
|
||||
engine.join_game(state, "p3", "p3")
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
self.assertEqual(4, len(state.players))
|
||||
for player in state.players:
|
||||
self.assertEqual(10, len(player.hand))
|
||||
self.assertEqual([], state.table)
|
||||
self.assertEqual(1, state.turn) # dealer is seat 0
|
||||
|
||||
def test_match_ends_when_target_reached(self) -> None:
|
||||
state = make_state([["02D"], [], [], []],
|
||||
table=["02C"], target=1)
|
||||
engine.play(state, "p0", "02D", ["02C"])
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(0, state.winner)
|
||||
self.assertGreaterEqual(state.scores[0], 1)
|
||||
|
||||
def test_state_for_player_hides_other_hands(self) -> None:
|
||||
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["07C"])
|
||||
view = engine.state_for_player(state, "p0")
|
||||
players = {p["seat"]: p for p in view["players"]}
|
||||
self.assertEqual(["02D", "03C"], players[0]["hand"])
|
||||
self.assertNotIn("hand", players[1])
|
||||
self.assertEqual(1, players[1]["cards_left"])
|
||||
self.assertEqual(["07C"], view["table"])
|
||||
self.assertTrue(view.get("your_turn"))
|
||||
|
||||
def test_legal_moves_only_for_player_on_turn(self) -> None:
|
||||
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["07C"])
|
||||
view = engine.state_for_player(state, "p0")
|
||||
legal = view["legal_moves"]
|
||||
# 02D can capture nothing; 03C has no combination either (only 07C
|
||||
# on the table).
|
||||
self.assertEqual({}, legal)
|
||||
|
||||
state = make_state([["09D"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["07C", "02S"])
|
||||
view = engine.state_for_player(state, "p0")
|
||||
self.assertEqual({"09D": [["07C", "02S"]]}, view["legal_moves"])
|
||||
|
||||
# A player who is not on turn gets no legal_moves key.
|
||||
other = engine.state_for_player(state, "p1")
|
||||
self.assertNotIn("legal_moves", other)
|
||||
self.assertNotIn("your_turn", other)
|
||||
|
||||
def test_full_random_match_reaches_completion(self) -> None:
|
||||
state = engine.create_game("g", "CODE99", "p0", "p0", target_score=11)
|
||||
for i in range(1, 4):
|
||||
engine.join_game(state, f"p{i}", f"p{i}")
|
||||
|
||||
moves = 0
|
||||
while state.phase != PHASE_FINISHED and moves < 200000:
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
played = player.hand[0]
|
||||
options = engine.legal_captures(state.table, played)
|
||||
capture = [c.code for c in options[0]] if options else None
|
||||
engine.play(state, player.sub, played.code, capture)
|
||||
moves += 1
|
||||
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertIn(state.winner, (0, 1))
|
||||
# At the end all 40 cards are captured and no hand is left.
|
||||
self.assertEqual([], state.table)
|
||||
self.assertTrue(all(not p.hand for p in state.players))
|
||||
self.assertEqual(40, sum(len(p.captured) for p in state.players))
|
||||
self.assertTrue(state.finished_at)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Game lobby route tests via kaya's ASGI transport."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pwo import async_test
|
||||
|
||||
from scopa.app import app
|
||||
from tests.helpers import oidc_user
|
||||
|
||||
|
||||
class GamesRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_create_requires_auth(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.post("/api/games", json={})
|
||||
self.assertEqual(401, response.status_code)
|
||||
self.assertEqual({"error": "unauthenticated"}, response.json())
|
||||
|
||||
@async_test
|
||||
async def test_create_and_read_lobby(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post("/api/games", json={"target_score": 16})
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(3, body["seats_open"])
|
||||
self.assertEqual(16, body["target_score"])
|
||||
self.assertEqual(6, len(body["join_code"]))
|
||||
game_id = body["id"]
|
||||
|
||||
with oidc_user("alice"):
|
||||
snapshot = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual(200, snapshot.status_code)
|
||||
self.assertEqual("alice", snapshot.json()["players"][0]["sub"])
|
||||
|
||||
with oidc_user("mallory"):
|
||||
forbidden = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual(403, forbidden.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_join_fills_seats_and_starts_game(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post("/api/games", json={})
|
||||
code = created.json()["join_code"]
|
||||
|
||||
for player in ("bob", "carol"):
|
||||
with oidc_user(player):
|
||||
joined = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual(200, joined.status_code)
|
||||
self.assertEqual("lobby", joined.json()["phase"])
|
||||
|
||||
with oidc_user("dave"):
|
||||
started = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual(200, started.status_code)
|
||||
state = started.json()
|
||||
self.assertEqual("playing", state["phase"])
|
||||
self.assertEqual(4, len(state["players"]))
|
||||
self.assertEqual([], state["table"])
|
||||
for participant in state["players"]:
|
||||
self.assertEqual(10, participant["cards_left"])
|
||||
# The view is personalized to Dave: he sees his own hand in
|
||||
# seat 3 but not Alice's in seat 0.
|
||||
self.assertIn("hand", state["players"][3])
|
||||
self.assertNotIn("hand", state["players"][0])
|
||||
self.assertEqual(1, state["turn"])
|
||||
|
||||
@async_test
|
||||
async def test_join_errors(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post("/api/games", json={})
|
||||
code = created.json()["join_code"]
|
||||
|
||||
with oidc_user("bob"):
|
||||
unknown = await client.post("/api/games/join", json={"code": "ZZZZZZ"})
|
||||
self.assertEqual(404, unknown.status_code)
|
||||
|
||||
with oidc_user("alice"):
|
||||
duplicate = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual(409, duplicate.status_code)
|
||||
|
||||
with oidc_user("bob"):
|
||||
missing = await client.post("/api/games/join", json={})
|
||||
self.assertEqual(400, missing.status_code)
|
||||
|
||||
for player in ("bob", "carol", "dave"):
|
||||
with oidc_user(player):
|
||||
await client.post("/api/games/join", json={"code": code})
|
||||
with oidc_user("erin"):
|
||||
late = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual(409, late.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_create_rejects_bad_target_score(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
zero = await client.post("/api/games", json={"target_score": 0})
|
||||
text = await client.post("/api/games", json={"target_score": "eleven"})
|
||||
huge = await client.post("/api/games", json={"target_score": 1000})
|
||||
self.assertEqual(400, zero.status_code)
|
||||
self.assertEqual(400, text.status_code)
|
||||
self.assertEqual(400, huge.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_get_unknown_game(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
response = await client.get("/api/games/does-not-exist")
|
||||
self.assertEqual(404, response.status_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for the whoami endpoint and the static SPA host."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pwo import async_test
|
||||
|
||||
from scopa.app import app
|
||||
from scopa.config import settings
|
||||
from tests.helpers import oidc_user
|
||||
|
||||
|
||||
class MeRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_me_authenticated(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
response = await client.get("/api/me")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
|
||||
|
||||
@async_test
|
||||
async def test_me_unauthenticated(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/me")
|
||||
self.assertEqual(401, response.status_code)
|
||||
|
||||
|
||||
class StaticRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_serves_files_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/>")
|
||||
|
||||
patched = dataclasses.replace(settings, static_dir=dist)
|
||||
with mock.patch("scopa.routes.static.settings", patched):
|
||||
transport = ASGITransport(app=app)
|
||||
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.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")
|
||||
with mock.patch("scopa.routes.static.settings", patched):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/")
|
||||
self.assertEqual(404, response.status_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Match-statistics tests: Postgres persistence and the stats endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pwo import async_test
|
||||
|
||||
from scopa.app import app, tortoise_mixin
|
||||
from scopa.game import engine
|
||||
from scopa.game.state import GameState
|
||||
from scopa.models import Match, MatchPlayer
|
||||
from scopa.stats import save_match_result
|
||||
from tests.helpers import oidc_user
|
||||
|
||||
|
||||
async def _use_app_db():
|
||||
"""Bind the same Tortoise context the app uses for this event loop and
|
||||
return it, so tests can seed rows the route handlers will see."""
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
return ctx
|
||||
|
||||
|
||||
def _finished_state() -> GameState:
|
||||
# Team A sweeps the (single-card) table with carte + denara and reaches
|
||||
# a target of 2, ending the match.
|
||||
state = GameState(
|
||||
id="stats-game",
|
||||
join_code="STATS1",
|
||||
creator_sub="alice",
|
||||
target_score=2,
|
||||
phase=engine.PHASE_PLAYING,
|
||||
turn=0,
|
||||
table=[engine.parse_card("02C")],
|
||||
)
|
||||
from scopa.game.state import PlayerState, Card
|
||||
|
||||
state.players = [
|
||||
PlayerState(sub="alice", name="alice", seat=0, hand=[Card.parse("02D")]),
|
||||
PlayerState(sub="bob", name="bob", seat=1),
|
||||
PlayerState(sub="carol", name="carol", seat=2),
|
||||
PlayerState(sub="dave", name="dave", seat=3),
|
||||
]
|
||||
return state
|
||||
|
||||
|
||||
class SaveMatchResultTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_finished_match_is_persisted_once(self) -> None:
|
||||
ctx = await _use_app_db()
|
||||
state = _finished_state()
|
||||
engine.play(state, "alice", "02D", ["02C"])
|
||||
self.assertEqual(engine.PHASE_FINISHED, state.phase)
|
||||
|
||||
with ctx:
|
||||
await save_match_result(state)
|
||||
await save_match_result(state) # idempotent
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
self.assertEqual(4, await MatchPlayer.all().count())
|
||||
|
||||
match = await Match.all().first()
|
||||
assert match is not None
|
||||
self.assertEqual(state.scores[0], match.team_a_score)
|
||||
self.assertEqual("A", match.winner_team)
|
||||
winners = await MatchPlayer.filter(won=True)
|
||||
self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners})
|
||||
|
||||
|
||||
async def _seed_two_matches() -> None:
|
||||
ctx = await _use_app_db()
|
||||
with ctx:
|
||||
for index, (a_score, b_score, winner, finished) in enumerate(
|
||||
[
|
||||
(11, 5, "A", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
|
||||
(8, 11, "B", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)),
|
||||
]
|
||||
):
|
||||
match = await Match.create(
|
||||
id=uuid.uuid4(),
|
||||
team_a_score=a_score,
|
||||
team_b_score=b_score,
|
||||
winner_team=winner,
|
||||
target_score=11,
|
||||
hands_played=2 + index,
|
||||
started_at=datetime(2025, 12, 31, tzinfo=timezone.utc),
|
||||
finished_at=finished,
|
||||
)
|
||||
seats = [
|
||||
("alice", 0, "A"),
|
||||
("bob", 1, "B"),
|
||||
("carol", 2, "A"),
|
||||
("dave", 3, "B"),
|
||||
]
|
||||
for sub, seat, team in seats:
|
||||
await MatchPlayer.create(
|
||||
id=uuid.uuid4(),
|
||||
match=match,
|
||||
user_sub=sub,
|
||||
display_name=sub,
|
||||
seat=seat,
|
||||
team=team,
|
||||
won=(team == winner),
|
||||
)
|
||||
|
||||
|
||||
class StatsRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_my_matches_newest_first(self) -> None:
|
||||
await _seed_two_matches()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
response = await client.get("/api/me/matches")
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
self.assertEqual(2, len(results))
|
||||
self.assertEqual("B", results[0]["winner_team"]) # newest first
|
||||
self.assertFalse(results[0]["you_won"])
|
||||
self.assertTrue(results[1]["you_won"])
|
||||
self.assertEqual(4, len(results[0]["players"]))
|
||||
self.assertIn("next_cursor", response.json())
|
||||
|
||||
@async_test
|
||||
async def test_my_matches_pagination(self) -> None:
|
||||
await _seed_two_matches()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
first = await client.get("/api/me/matches?limit=1")
|
||||
cursor = first.json()["next_cursor"]
|
||||
self.assertIsNotNone(cursor)
|
||||
second = await client.get(f"/api/me/matches?limit=1&cursor={cursor}")
|
||||
self.assertEqual(1, len(first.json()["results"]))
|
||||
self.assertEqual(1, len(second.json()["results"]))
|
||||
self.assertNotEqual(
|
||||
first.json()["results"][0]["id"],
|
||||
second.json()["results"][0]["id"],
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_my_matches_requires_auth(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/me/matches")
|
||||
self.assertEqual(401, response.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_aggregates(self) -> None:
|
||||
await _seed_two_matches()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/leaderboard")
|
||||
self.assertEqual(200, response.status_code)
|
||||
by_sub = {row["user_sub"]: row for row in response.json()["results"]}
|
||||
self.assertEqual(2, by_sub["alice"]["matches"])
|
||||
self.assertEqual(1, by_sub["alice"]["wins"]) # team A won match 1
|
||||
self.assertEqual(19, by_sub["alice"]["points"])
|
||||
self.assertEqual(1, by_sub["bob"]["wins"]) # team B won match 2
|
||||
self.assertEqual(16, by_sub["bob"]["points"])
|
||||
# Alice leads on points after tying Bob on wins.
|
||||
self.assertEqual("alice", response.json()["results"][0]["user_sub"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""In-memory game store behaviour (the Redis store shares this interface)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from pwo import async_test
|
||||
|
||||
from scopa.game import engine
|
||||
from scopa.store import InMemoryGameStore
|
||||
|
||||
|
||||
class InMemoryGameStoreTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_save_load_roundtrip(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
state = engine.create_game("g1", "CODE01", "alice", "alice", target_score=16)
|
||||
engine.join_game(state, "bob", "bob")
|
||||
await store.save(state)
|
||||
|
||||
loaded = await store.load("g1")
|
||||
self.assertIsNotNone(loaded)
|
||||
assert loaded is not None
|
||||
self.assertEqual("CODE01", loaded.join_code)
|
||||
self.assertEqual(16, loaded.target_score)
|
||||
self.assertEqual(["alice", "bob"], [p.sub for p in loaded.players])
|
||||
|
||||
@async_test
|
||||
async def test_load_missing_returns_none(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
self.assertIsNone(await store.load("nope"))
|
||||
self.assertIsNone(await store.find_by_code("NOPE01"))
|
||||
|
||||
@async_test
|
||||
async def test_find_by_code(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
state = engine.create_game("g2", "CODE02", "alice", "alice")
|
||||
await store.save(state)
|
||||
found = await store.find_by_code("code02") # case-insensitive
|
||||
self.assertIsNotNone(found)
|
||||
assert found is not None
|
||||
self.assertEqual("g2", found.id)
|
||||
|
||||
@async_test
|
||||
async def test_load_returns_a_copy(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
state = engine.create_game("g3", "CODE03", "alice", "alice")
|
||||
await store.save(state)
|
||||
first = await store.load("g3")
|
||||
assert first is not None
|
||||
first.phase = "tampered"
|
||||
second = await store.load("g3")
|
||||
assert second is not None
|
||||
self.assertEqual("lobby", second.phase)
|
||||
|
||||
@async_test
|
||||
async def test_publish_reaches_subscriber(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
state = engine.create_game("g4", "CODE04", "alice", "alice")
|
||||
await store.save(state)
|
||||
|
||||
received = []
|
||||
|
||||
async with store.subscribe("g4") as events:
|
||||
await store.publish("g4")
|
||||
async for _ in events:
|
||||
received.append(True)
|
||||
break
|
||||
|
||||
self.assertEqual([True], received)
|
||||
|
||||
@async_test
|
||||
async def test_lock_serializes_concurrent_mutations(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
order = []
|
||||
|
||||
async def holder() -> None:
|
||||
async with store.lock("g5"):
|
||||
order.append("holder-enter")
|
||||
await asyncio.sleep(0.05)
|
||||
order.append("holder-exit")
|
||||
|
||||
async def contender() -> None:
|
||||
await asyncio.sleep(0.01)
|
||||
async with store.lock("g5"):
|
||||
order.append("contender")
|
||||
|
||||
await asyncio.gather(holder(), contender())
|
||||
self.assertEqual(
|
||||
["holder-enter", "holder-exit", "contender"], order
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""WebSocket live-play tests via kaya's ASGI websocket transport."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from httpx_ws import WebSocketDisconnect, aconnect_ws
|
||||
from httpx_ws.transport import ASGIWebSocketTransport
|
||||
from pwo import async_test
|
||||
|
||||
from scopa.app import app
|
||||
from tests.helpers import make_user, oidc_user, ws_users
|
||||
|
||||
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||
|
||||
|
||||
class WebSocketTest(unittest.TestCase):
|
||||
async def _started_game(self, client: AsyncClient) -> dict:
|
||||
"""Create a game and seat four players; return the playing state."""
|
||||
with oidc_user("alice"):
|
||||
created = await client.post("/api/games", json={})
|
||||
code = created.json()["join_code"]
|
||||
response = created
|
||||
for player in PLAYERS[1:]:
|
||||
with oidc_user(player):
|
||||
response = await client.post("/api/games/join", json={"code": code})
|
||||
return response.json()
|
||||
|
||||
async def _bob_view(self, client: AsyncClient, game_id: str) -> dict:
|
||||
with oidc_user("bob"):
|
||||
return (await client.get(f"/api/games/{game_id}")).json()
|
||||
|
||||
@async_test
|
||||
async def test_move_updates_all_connections(self) -> None:
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await self._started_game(client)
|
||||
game_id = state["id"]
|
||||
bob_hand = (await self._bob_view(client, game_id))["players"][1]["hand"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("bob"), make_user("alice")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||
first = await bob_ws.receive_json()
|
||||
self.assertEqual("state", first["type"])
|
||||
self.assertEqual(1, first["game"]["turn"])
|
||||
self.assertTrue(first["game"].get("your_turn"))
|
||||
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||
alice_first = await alice_ws.receive_json()
|
||||
self.assertEqual("state", alice_first["type"])
|
||||
self.assertEqual(
|
||||
"alice", alice_first["game"]["players"][0]["sub"]
|
||||
)
|
||||
self.assertNotIn("hand", alice_first["game"]["players"][1])
|
||||
|
||||
await bob_ws.send_json(
|
||||
{"action": "play", "card": bob_hand[0]}
|
||||
)
|
||||
bob_update = await bob_ws.receive_json()
|
||||
alice_update = await alice_ws.receive_json()
|
||||
|
||||
for update in (bob_update, alice_update):
|
||||
self.assertEqual("state", update["type"])
|
||||
self.assertEqual(2, update["game"]["turn"])
|
||||
self.assertEqual(1, len(update["game"]["table"]))
|
||||
|
||||
@async_test
|
||||
async def test_illegal_move_returns_error(self) -> None:
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await self._started_game(client)
|
||||
game_id = state["id"]
|
||||
bob_hand = (await self._bob_view(client, game_id))["players"][1]["hand"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("bob")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||
await bob_ws.receive_json()
|
||||
await bob_ws.send_json(
|
||||
{"action": "play", "card": bob_hand[0]}
|
||||
)
|
||||
await bob_ws.receive_json() # the resulting state
|
||||
# Bob cannot play twice in a row.
|
||||
await bob_ws.send_json(
|
||||
{"action": "play", "card": bob_hand[1]}
|
||||
)
|
||||
error = await bob_ws.receive_json()
|
||||
self.assertEqual("error", error["type"])
|
||||
self.assertEqual("illegal_move", error["code"])
|
||||
|
||||
@async_test
|
||||
async def test_unknown_game_is_closed(self) -> None:
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("alice")]):
|
||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||
async with aconnect_ws("/ws/games/no-such-game", ws_client):
|
||||
pass
|
||||
self.assertEqual(4404, caught.exception.code)
|
||||
|
||||
@async_test
|
||||
async def test_non_player_is_closed(self) -> None:
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await self._started_game(client)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("mallory")]):
|
||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
||||
pass
|
||||
self.assertEqual(4403, caught.exception.code)
|
||||
|
||||
@async_test
|
||||
async def test_unauthenticated_is_closed(self) -> None:
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([]):
|
||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||
async with aconnect_ws("/ws/games/whatever", ws_client):
|
||||
pass
|
||||
self.assertEqual(4401, caught.exception.code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user