diff --git a/fupogfakta/services.py b/fupogfakta/services.py index 467e261..46584b4 100644 --- a/fupogfakta/services.py +++ b/fupogfakta/services.py @@ -1,18 +1,23 @@ import random +from datetime import timedelta from dataclasses import dataclass from typing import Any from django.db import transaction from django.utils import timezone -from .models import GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent +from .models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent from .payloads import ( build_finish_game_phase_event, build_finish_game_response, + build_lie_started_payload, + build_question_shown_payload, + build_question_shown_response, build_reveal_scoreboard_response, build_scoreboard_phase_event, build_start_next_round_phase_event, build_start_next_round_response, + build_start_round_response, ) @@ -121,6 +126,81 @@ def prepare_mixed_answers(round_question: RoundQuestion) -> list[str]: + + +def start_round(session: GameSession, category_slug: str) -> RoundTransitionResult: + try: + category = Category.objects.get(slug=category_slug, is_active=True) + except Category.DoesNotExist: + raise ValueError("category_not_found") + + if not Question.objects.filter(category=category, is_active=True).exists(): + raise ValueError("category_has_no_questions") + + with transaction.atomic(): + locked_session = GameSession.objects.select_for_update().get(pk=session.pk) + if locked_session.status != GameSession.Status.LOBBY: + raise ValueError("round_start_invalid_phase") + + if RoundConfig.objects.filter(session=locked_session, number=locked_session.current_round).exists(): + raise ValueError("round_already_configured") + + round_config = RoundConfig( + session=locked_session, + number=locked_session.current_round, + category=category, + ) + + round_question = select_round_question(locked_session, round_config) + + round_config.save() + locked_session.status = GameSession.Status.LIE + locked_session.save(update_fields=["status"]) + + phase_event = { + "name": "phase.lie_started", + "payload": build_lie_started_payload(locked_session, round_config, round_question), + } + return RoundTransitionResult( + session=locked_session, + round_config=round_config, + round_question=round_question, + should_broadcast=True, + response_payload=build_start_round_response(locked_session, round_config, round_question), + phase_event_name=phase_event["name"], + phase_event_payload=phase_event["payload"], + ) + + +def show_question(session: GameSession) -> RoundTransitionResult: + if session.status != GameSession.Status.LIE: + raise ValueError("show_question_invalid_phase") + + try: + round_config = RoundConfig.objects.get(session=session, number=session.current_round) + except RoundConfig.DoesNotExist: + raise ValueError("round_config_missing") + + round_question = get_current_round_question(session) + if round_question is None: + round_question = select_round_question(session, round_config) + + lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) + lie_deadline_iso = lie_deadline_at.isoformat() + phase_event = { + "name": "phase.question_shown", + "payload": build_question_shown_payload(round_question, lie_deadline_iso, round_config.lie_seconds), + } + return RoundTransitionResult( + session=session, + round_config=round_config, + round_question=round_question, + should_broadcast=True, + response_payload=build_question_shown_response(round_question, lie_deadline_iso, round_config.lie_seconds), + phase_event_name=phase_event["name"], + phase_event_payload=phase_event["payload"], + ) + def start_next_round(session: GameSession) -> RoundTransitionResult: with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) diff --git a/lobby/tests.py b/lobby/tests.py index f4cbe6e..84a0bc0 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -57,29 +57,29 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._prepare_mixed_answers, gameplay_services.prepare_mixed_answers) self.assertIs(lobby_views._resolve_scores, gameplay_services.resolve_scores) self.assertIs(lobby_views._promote_reveal_to_scoreboard, gameplay_services.promote_reveal_to_scoreboard) + self.assertIs(lobby_views._start_round, gameplay_services.start_round) + self.assertIs(lobby_views._show_question, gameplay_services.show_question) self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round) self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) self.assertIs(lobby_views._build_phase_view_model, gameplay_payloads.build_phase_view_model) self.assertIs(lobby_views._build_round_question_payload, gameplay_payloads.build_round_question_payload) self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) - self.assertIs(lobby_views._build_start_round_response, gameplay_payloads.build_start_round_response) - self.assertIs(lobby_views._build_question_shown_payload, gameplay_payloads.build_question_shown_payload) - self.assertIs(lobby_views._build_question_shown_response, gameplay_payloads.build_question_shown_response) def test_start_round_view_source_stays_http_thin(self): source = inspect.getsource(inspect.unwrap(lobby_views.start_round)) - self.assertIn("lie_started_payload = _build_lie_started_payload(session, round_config, round_question)", source) - self.assertIn("_build_start_round_response(session, round_config, round_question)", source) - self.assertNotIn('"round_question": {', source) + self.assertIn("transition = _start_round(session, category_slug)", source) + self.assertNotIn("RoundConfig", source) + self.assertNotIn("RoundQuestion", source) + self.assertNotIn("build_start_round_response", source) def test_show_question_view_source_stays_http_thin(self): source = inspect.getsource(inspect.unwrap(lobby_views.show_question)) - self.assertIn("_build_question_shown_payload(round_question, lie_deadline_iso, round_config.lie_seconds)", source) - self.assertIn("_build_question_shown_response(round_question, lie_deadline_iso, round_config.lie_seconds)", source) - self.assertNotIn('"round_question": {', source) - self.assertNotIn('"round_question_id": round_question.id', source) + self.assertIn("transition = _show_question(session)", source) + self.assertNotIn("RoundConfig", source) + self.assertNotIn("RoundQuestion", source) + self.assertNotIn("build_question_shown_response", source) def test_start_next_round_view_source_stays_http_thin(self): source = inspect.getsource(inspect.unwrap(lobby_views.start_next_round)) @@ -99,6 +99,81 @@ class LobbyGameplayExtractionTests(TestCase): self.assertNotIn("build_finish_game_response", source) self.assertNotIn("build_finish_game_phase_event", source) + + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._start_round") + def test_start_round_view_delegates_transition_to_service( + self, + mock_start_round, + mock_sync_broadcast_phase_event, + ): + lobby_session = GameSession.objects.create(host=self.host, code="LOBBY1", status=GameSession.Status.LOBBY) + transition = gameplay_services.RoundTransitionResult( + session=lobby_session, + round_config=self.round_config, + round_question=RoundQuestion.objects.create( + session=lobby_session, + round_number=1, + question=self.question, + correct_answer=self.question.correct_answer, + ), + should_broadcast=True, + response_payload={"ok": True}, + phase_event_name="phase.lie_started", + phase_event_payload={"round_question_id": 123}, + ) + mock_start_round.return_value = transition + + response = self.client.post( + reverse("lobby:start_round", kwargs={"code": lobby_session.code}), + data=json.dumps({"category_slug": self.category.slug}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json(), {"ok": True}) + mock_start_round.assert_called_once_with(lobby_session, self.category.slug) + mock_sync_broadcast_phase_event.assert_called_once_with( + lobby_session.code, + "phase.lie_started", + {"round_question_id": 123}, + ) + + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._show_question") + def test_show_question_view_delegates_transition_to_service( + self, + mock_show_question, + mock_sync_broadcast_phase_event, + ): + lie_session = GameSession.objects.create(host=self.host, code="LIE123", status=GameSession.Status.LIE) + transition = gameplay_services.RoundTransitionResult( + session=lie_session, + round_config=self.round_config, + round_question=RoundQuestion.objects.create( + session=lie_session, + round_number=1, + question=self.question, + correct_answer=self.question.correct_answer, + ), + should_broadcast=True, + response_payload={"ok": True}, + phase_event_name="phase.question_shown", + phase_event_payload={"round_question_id": 456}, + ) + mock_show_question.return_value = transition + + response = self.client.post(reverse("lobby:show_question", kwargs={"code": lie_session.code})) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json(), {"ok": True}) + mock_show_question.assert_called_once_with(lie_session) + mock_sync_broadcast_phase_event.assert_called_once_with( + lie_session.code, + "phase.question_shown", + {"round_question_id": 456}, + ) + @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._start_next_round") def test_start_next_round_view_delegates_transition_to_service( @@ -512,7 +587,7 @@ class StartRoundTests(TestCase): self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Only host can start round") - @patch("lobby.views._select_round_question", side_effect=ValueError("no_available_questions")) + @patch("fupogfakta.services.select_round_question", side_effect=ValueError("no_available_questions")) def test_start_round_does_not_persist_round_config_when_question_selection_fails(self, _mock_select_round_question): self.client.login(username="host", password="secret123") diff --git a/lobby/views.py b/lobby/views.py index 9641bc3..c6eb56a 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -1,7 +1,7 @@ -import json -import random from datetime import timedelta +import json +import random from django.contrib.auth.decorators import login_required from django.db import IntegrityError, transaction from django.http import HttpRequest, JsonResponse @@ -11,14 +11,10 @@ from django.views.decorators.http import require_GET, require_POST from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import ( build_leaderboard as _build_leaderboard, - build_lie_started_payload as _build_lie_started_payload, build_phase_view_model as _build_phase_view_model, - build_question_shown_payload as _build_question_shown_payload, - build_question_shown_response as _build_question_shown_response, build_reveal_payload as _build_reveal_payload, build_round_question_payload as _build_round_question_payload, build_scoreboard_phase_event as _build_scoreboard_phase_event, - build_start_round_response as _build_start_round_response, ) from fupogfakta.services import ( finish_game as _finish_game, @@ -27,7 +23,9 @@ from fupogfakta.services import ( promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard, resolve_scores as _resolve_scores, select_round_question as _select_round_question, + show_question as _show_question, start_next_round as _start_next_round, + start_round as _start_round, ) from realtime.broadcast import sync_broadcast_phase_event @@ -255,72 +253,23 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: status=403, ) - if session.status != GameSession.Status.LOBBY: - return api_error( - request, - code="round_start_invalid_phase", - status=400, - ) - try: - category = Category.objects.get(slug=category_slug, is_active=True) - except Category.DoesNotExist: - return api_error( - request, - code="category_not_found", - status=404, - ) - - if not Question.objects.filter(category=category, is_active=True).exists(): - return api_error( - request, - code="category_has_no_questions", - status=400, - ) - - with transaction.atomic(): - session = GameSession.objects.select_for_update().get(pk=session.pk) - if session.status != GameSession.Status.LOBBY: - return api_error( - request, - code="round_start_invalid_phase", - status=400, - ) - - if RoundConfig.objects.filter(session=session, number=session.current_round).exists(): - return api_error( - request, - code="round_already_configured", - status=409, - ) - - round_config = RoundConfig( - session=session, - number=session.current_round, - category=category, - ) - - try: - round_question = _select_round_question(session, round_config) - except ValueError as exc: - return api_error(request, code=str(exc), status=400) - - round_config.save() - session.status = GameSession.Status.LIE - session.save(update_fields=["status"]) - - lie_started_payload = _build_lie_started_payload(session, round_config, round_question) + transition = _start_round(session, category_slug) + except ValueError as exc: + error_code = str(exc) + error_status = { + "category_not_found": 404, + "round_already_configured": 409, + }.get(error_code, 400) + return api_error(request, code=error_code, status=error_status) sync_broadcast_phase_event( - session.code, - "phase.lie_started", - lie_started_payload, + transition.session.code, + transition.phase_event_name, + transition.phase_event_payload, ) - return JsonResponse( - _build_start_round_response(session, round_config, round_question), - status=201, - ) + return JsonResponse(transition.response_payload, status=201) @require_POST @@ -344,45 +293,18 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: status=403, ) - if session.status != GameSession.Status.LIE: - return api_error( - request, - code="show_question_invalid_phase", - status=400, - ) - try: - round_config = RoundConfig.objects.get(session=session, number=session.current_round) - except RoundConfig.DoesNotExist: - return api_error( - request, - code="round_config_missing", - status=400, - ) - - existing_round_question = _get_current_round_question(session) - if existing_round_question is not None: - round_question = existing_round_question - else: - try: - round_question = _select_round_question(session, round_config) - except ValueError as exc: - return api_error(request, code=str(exc), status=400) - - lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) - - lie_deadline_iso = lie_deadline_at.isoformat() + transition = _show_question(session) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) sync_broadcast_phase_event( - session.code, - "phase.question_shown", - _build_question_shown_payload(round_question, lie_deadline_iso, round_config.lie_seconds), + transition.session.code, + transition.phase_event_name, + transition.phase_event_payload, ) - return JsonResponse( - _build_question_shown_response(round_question, lie_deadline_iso, round_config.lie_seconds), - status=201, - ) + return JsonResponse(transition.response_payload, status=201) @require_POST