Spaces:
Running
Running
| """Coverage tests for backend/api/animetix/api/games/classic.py. | |
| These exercise the Classic mode endpoints (state / start / guess / reveal) | |
| against the real Django session (the session state port reads request.session), | |
| overriding the DI catalog/game services via the dependency_injector container. | |
| """ | |
| from unittest.mock import MagicMock | |
| import pytest | |
| from animetix.containers import container | |
| from dependency_injector import providers | |
| from django.contrib.auth.models import User | |
| from django.urls import reverse | |
| # NB: the per-test wiring guard lives in conftest.py (``_rewire_game_modules``). | |
| # We never call ``container.reset_override()`` -- on this dependency_injector | |
| # version it detaches the ``core -> persistence`` sub-container link and breaks the | |
| # views. Each test scopes its overrides with ``with`` blocks, which self-clean. | |
| def mock_catalog(): | |
| return { | |
| "title_to_full_data": { | |
| "Naruto": { | |
| "id": 1, | |
| "title": "Naruto", | |
| "description": "Ninja anime " * 30, | |
| "image": "http://img.jpg", | |
| "title_english": "Naruto EN", | |
| "title_native": "Naruto JP", | |
| "origin": "Japan", | |
| "year": 2002, | |
| "tags": ["action", "ninja", "shonen", "adventure", "battle", "extra"], | |
| "metadata": {"studio": "Pierrot"}, | |
| }, | |
| "One Piece": { | |
| "id": 2, | |
| "title": "One Piece", | |
| "description": "Pirate anime", | |
| "image": "http://img2.jpg", | |
| }, | |
| "Bleach": { | |
| "id": 3, | |
| "title": "Bleach", | |
| "description": "Soul reaper anime", | |
| "image": "http://img3.jpg", | |
| }, | |
| }, | |
| "titles": ["Naruto", "One Piece", "Bleach"], | |
| "title_to_index": {"Naruto": 1, "One Piece": 2, "Bleach": 3}, | |
| "lookup": [ | |
| {"id": 1, "title": "Naruto"}, | |
| {"id": 2, "title": "One Piece"}, | |
| {"id": 3, "title": "Bleach"}, | |
| ], | |
| } | |
| def _override(catalog=None, game=None): | |
| """Return the list of container override context managers for the test.""" | |
| overrides = [] | |
| if catalog is not None: | |
| overrides.append( | |
| container.core.catalog_service.override(providers.Object(catalog)) | |
| ) | |
| if game is not None: | |
| overrides.append(container.core.game_service.override(providers.Object(game))) | |
| return overrides | |
| # --------------------------------------------------------------------------- | |
| # ClassicGameStateView | |
| # --------------------------------------------------------------------------- | |
| def test_state_no_game_in_progress(api_client, mock_catalog): | |
| """GET state with empty session -> 400 No game in progress.""" | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = mock_catalog | |
| with container.core.catalog_service.override(providers.Object(cat)): | |
| resp = api_client.get(reverse("api_classic_state")) | |
| assert resp.status_code == 400 | |
| assert resp.json()["error"] == "No game in progress" | |
| def test_start_then_state_ok(api_client, mock_catalog): | |
| """Start (staff override) seeds the session, then state returns hints/guesses.""" | |
| user = User.objects.create_user("staff1", password="x", is_staff=True) | |
| api_client.force_authenticate(user=user) | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = mock_catalog | |
| game = MagicMock() | |
| game.select_secret.return_value = "Naruto" | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| start = api_client.post( | |
| reverse("api_classic_start"), | |
| {"media_type": "Anime", "override_secret": "Naruto"}, | |
| format="json", | |
| ) | |
| assert start.status_code == 200 | |
| assert start.json()["status"] == "started" | |
| resp = api_client.get(reverse("api_classic_state")) | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| assert data["media_type"] == "Anime" | |
| assert data["game_over"] is False | |
| # game not over -> secret hidden | |
| assert data["secret_title"] is None | |
| assert "origin" in data["hints"] | |
| def test_state_catalog_not_found(api_client): | |
| """State when catalog_service returns falsy -> 404 Catalog not found.""" | |
| user = User.objects.create_user("staff2", password="x", is_staff=True) | |
| api_client.force_authenticate(user=user) | |
| full_catalog = { | |
| "title_to_full_data": {"Naruto": {"title": "Naruto"}}, | |
| "titles": ["Naruto"], | |
| "title_to_index": {"Naruto": 1}, | |
| "lookup": [{"id": 1, "title": "Naruto"}], | |
| } | |
| cat = MagicMock() | |
| game = MagicMock() | |
| # Start succeeds with a real catalog, then state sees an empty catalog. | |
| cat.get_catalog.side_effect = [full_catalog, None] | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| api_client.post( | |
| reverse("api_classic_start"), | |
| {"media_type": "Anime", "override_secret": "Naruto"}, | |
| format="json", | |
| ) | |
| resp = api_client.get(reverse("api_classic_state")) | |
| assert resp.status_code == 404 | |
| assert resp.json()["error"] == "Catalog not found" | |
| def test_state_secret_data_not_found(api_client): | |
| """State when secret title missing from catalog data -> 404.""" | |
| user = User.objects.create_user("staff3", password="x", is_staff=True) | |
| api_client.force_authenticate(user=user) | |
| start_catalog = { | |
| "title_to_full_data": {"Naruto": {"title": "Naruto"}}, | |
| "titles": ["Naruto"], | |
| "title_to_index": {"Naruto": 1}, | |
| "lookup": [{"id": 1, "title": "Naruto"}], | |
| } | |
| state_catalog = { | |
| "title_to_full_data": {}, # secret no longer present | |
| "titles": [], | |
| "title_to_index": {}, | |
| "lookup": [], | |
| } | |
| cat = MagicMock() | |
| cat.get_catalog.side_effect = [start_catalog, state_catalog] | |
| game = MagicMock() | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| api_client.post( | |
| reverse("api_classic_start"), | |
| {"media_type": "Anime", "override_secret": "Naruto"}, | |
| format="json", | |
| ) | |
| resp = api_client.get(reverse("api_classic_state")) | |
| assert resp.status_code == 404 | |
| assert resp.json()["error"] == "Secret title data not found" | |
| # --------------------------------------------------------------------------- | |
| # ClassicGameStartView | |
| # --------------------------------------------------------------------------- | |
| def test_start_catalog_not_found(api_client, mock_catalog): | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = None | |
| game = MagicMock() | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| resp = api_client.post( | |
| reverse("api_classic_start"), {"media_type": "Anime"}, format="json" | |
| ) | |
| assert resp.status_code == 404 | |
| assert resp.json()["error"] == "Catalog not found" | |
| def test_start_select_secret_via_game_service(api_client, mock_catalog): | |
| """Non-staff start: secret chosen by game_service.select_secret.""" | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = mock_catalog | |
| game = MagicMock() | |
| game.select_secret.return_value = "Naruto" | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| resp = api_client.post( | |
| reverse("api_classic_start"), | |
| {"media_type": "Anime", "difficulty": "Hard"}, | |
| format="json", | |
| ) | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| assert data["status"] == "started" | |
| assert data["difficulty"] == "Hard" | |
| assert data["is_ranked"] is False | |
| game.select_secret.assert_called_once() | |
| def test_start_select_secret_failure(api_client, mock_catalog): | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = mock_catalog | |
| game = MagicMock() | |
| game.select_secret.return_value = None | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| resp = api_client.post( | |
| reverse("api_classic_start"), {"media_type": "Anime"}, format="json" | |
| ) | |
| assert resp.status_code == 500 | |
| assert resp.json()["error"] == "Failed to select secret title" | |
| def test_start_non_staff_override_ignored(api_client, mock_catalog): | |
| """A non-staff user passing override_secret is logged and ignored.""" | |
| user = User.objects.create_user("plain", password="x") | |
| api_client.force_authenticate(user=user) | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = mock_catalog | |
| game = MagicMock() | |
| game.select_secret.return_value = "Bleach" | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| resp = api_client.post( | |
| reverse("api_classic_start"), | |
| {"media_type": "Anime", "override_secret": "Naruto"}, | |
| format="json", | |
| ) | |
| assert resp.status_code == 200 | |
| # game_service was used because override was ignored | |
| game.select_secret.assert_called_once() | |
| # --------------------------------------------------------------------------- | |
| # ClassicGameGuessView | |
| # --------------------------------------------------------------------------- | |
| def _seed_started_game(api_client, cat, game, media_type="Anime"): | |
| """Start a game as staff with a fixed secret so the session has state.""" | |
| return api_client.post( | |
| reverse("api_classic_start"), | |
| {"media_type": media_type, "override_secret": "Naruto"}, | |
| format="json", | |
| ) | |
| def test_guess_no_game(api_client, mock_catalog): | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = mock_catalog | |
| game = MagicMock() | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| resp = api_client.post( | |
| reverse("api_classic_guess"), {"guess": "Naruto"}, format="json" | |
| ) | |
| assert resp.status_code == 400 | |
| assert resp.json()["error"] == "No game in progress" | |
| def test_guess_missing_guess_field(api_client, mock_catalog): | |
| user = User.objects.create_user("staffg1", password="x", is_staff=True) | |
| api_client.force_authenticate(user=user) | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = mock_catalog | |
| game = MagicMock() | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| _seed_started_game(api_client, cat, game) | |
| resp = api_client.post(reverse("api_classic_guess"), {}, format="json") | |
| assert resp.status_code == 400 | |
| assert resp.json()["error"] == "Guess is required" | |
| def test_guess_title_not_in_catalog(api_client, mock_catalog): | |
| user = User.objects.create_user("staffg2", password="x", is_staff=True) | |
| api_client.force_authenticate(user=user) | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = mock_catalog | |
| game = MagicMock() | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| _seed_started_game(api_client, cat, game) | |
| resp = api_client.post( | |
| reverse("api_classic_guess"), {"guess": "Unknown Title"}, format="json" | |
| ) | |
| assert resp.status_code == 400 | |
| assert "not in catalog" in resp.json()["error"] | |
| def test_guess_incorrect(api_client, mock_catalog): | |
| """A wrong guess returns a scored guess and keeps the game running.""" | |
| user = User.objects.create_user("staffg3", password="x", is_staff=True) | |
| api_client.force_authenticate(user=user) | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = mock_catalog | |
| game = MagicMock() | |
| game.check_title_match.return_value = False | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| _seed_started_game(api_client, cat, game) | |
| resp = api_client.post( | |
| reverse("api_classic_guess"), {"guess": "Bleach"}, format="json" | |
| ) | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| assert data["is_correct"] is False | |
| assert data["game_over"] is False | |
| assert data["latest_guess"]["title"] == "Bleach" | |
| assert data["guess_count"] == 1 | |
| def test_guess_correct_authenticated_creates_session(api_client, mock_catalog): | |
| """A correct guess by an authenticated user ends the game, awards a win, | |
| and persists a GameplaySession row.""" | |
| from animetix.models import GameplaySession | |
| user = User.objects.create_user("winner", password="x", is_staff=True) | |
| api_client.force_authenticate(user=user) | |
| # profile.add_win returns an achievement with attributes used by the view | |
| ach = MagicMock() | |
| ach.name = "First Win" | |
| ach.description = "You won" | |
| ach.xp_reward = 10 | |
| ach.badge_url = "http://badge.png" | |
| # The view accesses request.user.profile.add_win; ensure a profile exists. | |
| profile = getattr(user, "profile", None) | |
| if profile is None: | |
| pytest.skip("User has no profile relation configured") | |
| profile.add_win = MagicMock(return_value=[ach]) | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = mock_catalog | |
| game = MagicMock() | |
| game.check_title_match.return_value = True | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| _seed_started_game(api_client, cat, game) | |
| resp = api_client.post( | |
| reverse("api_classic_guess"), {"guess": "Naruto"}, format="json" | |
| ) | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| assert data["is_correct"] is True | |
| assert data["game_over"] is True | |
| assert data["secret_title"] == "Naruto" | |
| assert data["latest_guess"]["score"] == 100.0 | |
| assert GameplaySession.objects.filter(target_item="Naruto", was_won=True).exists() | |
| def test_guess_when_game_over(api_client, mock_catalog): | |
| """A second correct guess after game_over returns 400 Game already over.""" | |
| user = User.objects.create_user("staffg4", password="x", is_staff=True) | |
| api_client.force_authenticate(user=user) | |
| profile = getattr(user, "profile", None) | |
| if profile is not None: | |
| profile.add_win = MagicMock(return_value=[]) | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = mock_catalog | |
| game = MagicMock() | |
| game.check_title_match.return_value = True | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| _seed_started_game(api_client, cat, game) | |
| api_client.post( | |
| reverse("api_classic_guess"), {"guess": "Naruto"}, format="json" | |
| ) | |
| resp = api_client.post( | |
| reverse("api_classic_guess"), {"guess": "Naruto"}, format="json" | |
| ) | |
| assert resp.status_code == 400 | |
| assert resp.json()["error"] == "Game already over" | |
| # --------------------------------------------------------------------------- | |
| # ClassicGameRevealView | |
| # --------------------------------------------------------------------------- | |
| def test_reveal_no_game(api_client, mock_catalog): | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = mock_catalog | |
| with container.core.catalog_service.override(providers.Object(cat)): | |
| resp = api_client.post( | |
| reverse("api_classic_reveal"), {"hint_type": "origin"}, format="json" | |
| ) | |
| assert resp.status_code == 400 | |
| assert resp.json()["error"] == "No game in progress" | |
| def test_reveal_missing_hint_type(api_client, mock_catalog): | |
| user = User.objects.create_user("staffr1", password="x", is_staff=True) | |
| api_client.force_authenticate(user=user) | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = mock_catalog | |
| game = MagicMock() | |
| game.select_secret.return_value = "Naruto" | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| _seed_started_game(api_client, cat, game) | |
| resp = api_client.post(reverse("api_classic_reveal"), {}, format="json") | |
| assert resp.status_code == 400 | |
| # Missing and unknown hint types share one rejection message. | |
| assert resp.json()["error"] == "Invalid hint type" | |
| def test_reveal_ok(api_client, mock_catalog): | |
| user = User.objects.create_user("staffr2", password="x", is_staff=True) | |
| api_client.force_authenticate(user=user) | |
| cat = MagicMock() | |
| cat.get_catalog.return_value = mock_catalog | |
| game = MagicMock() | |
| with ( | |
| container.core.catalog_service.override(providers.Object(cat)), | |
| container.core.game_service.override(providers.Object(game)), | |
| ): | |
| _seed_started_game(api_client, cat, game) | |
| # Hints unlock progressively (n-th hint after n*step guesses, step=5); | |
| # "origin" is the 2nd hint, so seed 10 guesses to make it revealable. | |
| session = api_client.session | |
| session["guesses"] = [{"title": "x", "is_correct": False} for _ in range(10)] | |
| session.save() | |
| resp = api_client.post( | |
| reverse("api_classic_reveal"), {"hint_type": "origin"}, format="json" | |
| ) | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| assert "origin" in data["revealed_hints"] | |
| assert "hints" in data | |