import json import tempfile from datetime import timedelta from pathlib import Path from unittest.mock import patch from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone from fupogfakta import payloads as gameplay_payloads, services as gameplay_services from fupogfakta.models import ( Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent, ) from lobby import views as lobby_views from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale User = get_user_model() class LobbyGameplayExtractionTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="extract_host", password="secret123") self.client.login(username="extract_host", password="secret123") self.session = GameSession.objects.create( host=self.host, code="EXTR42", status=GameSession.Status.SCOREBOARD, ) self.category = Category.objects.create(name="Historie", slug="historie-extract", is_active=True) self.round_config = RoundConfig.objects.create( session=self.session, number=1, category=self.category, ) self.question = Question.objects.create( category=self.category, prompt="Hvornår faldt muren?", correct_answer="1989", is_active=True, ) def test_lobby_views_use_extracted_gameplay_helpers(self): self.assertIs(lobby_views._get_current_round_question, gameplay_services.get_current_round_question) self.assertIs(lobby_views._select_round_question, gameplay_services.select_round_question) 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_next_round, gameplay_services.start_next_round) self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) self.assertIs(lobby_views._build_start_next_round_response, gameplay_payloads.build_start_next_round_response) self.assertIs(lobby_views._build_finish_game_response, gameplay_payloads.build_finish_game_response) @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._build_start_next_round_response", return_value={"ok": True}) @patch("lobby.views._start_next_round") def test_start_next_round_view_delegates_transition_to_service( self, mock_start_next_round, mock_build_response, mock_sync_broadcast_phase_event, ): next_round_config = RoundConfig.objects.create( session=self.session, number=2, category=self.category, started_from_scoreboard=True, ) round_question = RoundQuestion.objects.create( session=self.session, round_number=2, question=self.question, correct_answer=self.question.correct_answer, ) transition = gameplay_services.RoundTransitionResult( session=self.session, round_config=next_round_config, round_question=round_question, should_broadcast=True, phase_event_name="phase.lie_started", phase_event_payload={"round_question_id": round_question.id}, ) mock_start_next_round.return_value = transition response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"ok": True}) mock_start_next_round.assert_called_once_with(self.session) mock_build_response.assert_called_once_with(self.session, next_round_config, round_question) mock_sync_broadcast_phase_event.assert_called_once_with( self.session.code, "phase.lie_started", {"round_question_id": round_question.id}, ) @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._build_finish_game_response", return_value={"ok": True}) @patch("lobby.views._finish_game") def test_finish_game_view_delegates_transition_to_service( self, mock_finish_game, mock_build_response, mock_sync_broadcast_phase_event, ): finished_session = GameSession.objects.get(pk=self.session.pk) finished_session.status = GameSession.Status.FINISHED transition = gameplay_services.FinishGameResult( session=finished_session, should_broadcast=True, phase_event_name="phase.game_over", phase_event_payload={"winner": None, "leaderboard": []}, ) mock_finish_game.return_value = transition response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"ok": True}) mock_finish_game.assert_called_once_with(self.session) mock_build_response.assert_called_once_with(finished_session) mock_sync_broadcast_phase_event.assert_called_once_with( self.session.code, "phase.game_over", {"winner": None, "leaderboard": []}, ) @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._build_start_next_round_response", return_value={"ok": True}) @patch("lobby.views._start_next_round") def test_start_next_round_view_skips_broadcast_on_service_replay( self, mock_start_next_round, mock_build_response, mock_sync_broadcast_phase_event, ): replay_round_config = RoundConfig.objects.create( session=self.session, number=2, category=self.category, started_from_scoreboard=True, ) round_question = RoundQuestion.objects.create( session=self.session, round_number=2, question=self.question, correct_answer=self.question.correct_answer, ) replay_session = GameSession.objects.get(pk=self.session.pk) replay_session.status = GameSession.Status.LIE replay_session.current_round = 2 transition = gameplay_services.RoundTransitionResult( session=replay_session, round_config=replay_round_config, round_question=round_question, should_broadcast=False, ) mock_start_next_round.return_value = transition response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"ok": True}) mock_start_next_round.assert_called_once_with(self.session) mock_build_response.assert_called_once_with(replay_session, replay_round_config, round_question) mock_sync_broadcast_phase_event.assert_not_called() @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._build_finish_game_response", return_value={"ok": True}) @patch("lobby.views._finish_game") def test_finish_game_view_skips_broadcast_on_service_replay( self, mock_finish_game, mock_build_response, mock_sync_broadcast_phase_event, ): finished_session = GameSession.objects.get(pk=self.session.pk) finished_session.status = GameSession.Status.FINISHED transition = gameplay_services.FinishGameResult(session=finished_session, should_broadcast=False) mock_finish_game.return_value = transition response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"ok": True}) mock_finish_game.assert_called_once_with(self.session) mock_build_response.assert_called_once_with(finished_session) mock_sync_broadcast_phase_event.assert_not_called() class LobbyFlowTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host", password="secret123") def test_create_session_requires_login(self): response = self.client.post(reverse("lobby:create_session")) self.assertEqual(response.status_code, 302) self.assertEqual(GameSession.objects.count(), 0) def test_host_can_create_session(self): self.client.login(username="host", password="secret123") response = self.client.post(reverse("lobby:create_session")) self.assertEqual(response.status_code, 201) body = response.json() self.assertEqual(body["session"]["status"], GameSession.Status.LOBBY) self.assertEqual(len(body["session"]["code"]), 6) session = GameSession.objects.get(code=body["session"]["code"]) self.assertEqual(session.host, self.host) def test_player_can_join_with_code(self): session = GameSession.objects.create(host=self.host, code="ABCD23") response = self.client.post( reverse("lobby:join_session"), data={"code": "abcd23", "nickname": "Luna"}, content_type="application/json", ) self.assertEqual(response.status_code, 201) body = response.json() self.assertEqual(body["session"]["code"], "ABCD23") self.assertEqual(body["player"]["nickname"], "Luna") self.assertIn("session_token", body["player"]) self.assertTrue(body["player"]["session_token"]) self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists()) def test_player_can_join_with_trimmed_code(self): session = GameSession.objects.create(host=self.host, code="ABCD23") response = self.client.post( reverse("lobby:join_session"), data={"code": " abcd23 ", "nickname": "Luna"}, content_type="application/json", ) self.assertEqual(response.status_code, 201) self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists()) def test_join_rejects_code_empty_after_trim(self): response = self.client.post( reverse("lobby:join_session"), data={"code": " ", "nickname": "Luna"}, content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error"], "Session code is required") def test_join_rejects_duplicate_nickname_case_insensitive(self): session = GameSession.objects.create(host=self.host, code="QWER12") Player.objects.create(session=session, nickname="Luna") response = self.client.post( reverse("lobby:join_session"), data={"code": "QWER12", "nickname": "lUna"}, content_type="application/json", ) self.assertEqual(response.status_code, 409) self.assertEqual(response.json()["error"], "Nickname already taken") def test_player_can_join_during_scoreboard_phase(self): session = GameSession.objects.create(host=self.host, code="ZXCV97", status=GameSession.Status.SCOREBOARD) response = self.client.post( reverse("lobby:join_session"), data={"code": "ZXCV97", "nickname": "Kai"}, content_type="application/json", ) self.assertEqual(response.status_code, 201) body = response.json() self.assertEqual(body["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual(body["player"]["nickname"], "Kai") self.assertTrue(Player.objects.filter(session=session, nickname="Kai").exists()) def test_join_rejects_non_joinable_session(self): GameSession.objects.create(host=self.host, code="ZXCV98", status=GameSession.Status.FINISHED) response = self.client.post( reverse("lobby:join_session"), data={"code": "ZXCV98", "nickname": "Kai"}, content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error"], "Session is not joinable") def test_join_error_localizes_to_danish_with_accept_language_header(self): response = self.client.post( reverse("lobby:join_session"), data={"code": " ", "nickname": "Luna"}, content_type="application/json", HTTP_ACCEPT_LANGUAGE="da", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error_code"], "session_code_required") self.assertEqual(response.json()["locale"], "da") self.assertEqual(response.json()["error"], "Sessionskode er påkrævet") def test_join_error_falls_back_to_english_for_unsupported_locale(self): response = self.client.post( reverse("lobby:join_session"), data={"code": " ", "nickname": "Luna"}, content_type="application/json", HTTP_ACCEPT_LANGUAGE="fr", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error_code"], "session_code_required") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Session code is required") def test_join_error_payload_uses_stable_i18n_contract_keys(self): response = self.client.post( reverse("lobby:join_session"), data={"code": " ", "nickname": "Luna"}, content_type="application/json", HTTP_ACCEPT_LANGUAGE="da", ) self.assertEqual(response.status_code, 400) self.assertEqual(sorted(response.json().keys()), ["error", "error_code", "locale"]) def test_session_detail_returns_players(self): session = GameSession.objects.create(host=self.host, code="LMNO45") Player.objects.create(session=session, nickname="Mia", score=7) Player.objects.create(session=session, nickname="Bo", score=2) response = self.client.get(reverse("lobby:session_detail", kwargs={"code": "lmno45"})) self.assertEqual(response.status_code, 200) payload = response.json() self.assertEqual(payload["session"]["players_count"], 2) self.assertEqual([p["nickname"] for p in payload["players"]], ["Bo", "Mia"]) class StartRoundTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host", password="secret123") self.other_user = User.objects.create_user(username="other", password="secret123") self.session = GameSession.objects.create(host=self.host, code="ABCD23") self.category = Category.objects.create(name="Historie", slug="historie", is_active=True) Question.objects.create( category=self.category, prompt="Hvilket år faldt muren?", correct_answer="1989", is_active=True, ) def test_host_can_start_round_with_selected_category(self): self.client.login(username="host", password="secret123") response = self.client.post( reverse("lobby:start_round", kwargs={"code": self.session.code}), data={"category_slug": self.category.slug}, content_type="application/json", ) self.assertEqual(response.status_code, 201) body = response.json() self.assertEqual(body["session"]["status"], GameSession.Status.LIE) self.assertEqual(body["round"]["number"], 1) self.assertEqual(body["round"]["category"]["slug"], self.category.slug) self.assertEqual(body["round_question"]["prompt"], "Hvilket år faldt muren?") self.assertIn("lie_deadline_at", body["round_question"]) self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.LIE) round_config = RoundConfig.objects.get(session=self.session, number=1) self.assertEqual(round_config.category, self.category) self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists()) def test_host_start_round_uses_normalized_session_code_from_path(self): self.client.login(username="host", password="secret123") response = self.client.post( reverse("lobby:start_round", kwargs={"code": " abcd23 "}), data={"category_slug": self.category.slug}, content_type="application/json", ) self.assertEqual(response.status_code, 201) self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.LIE) round_config = RoundConfig.objects.get(session=self.session, number=1) self.assertEqual(round_config.category, self.category) def test_start_round_requires_host(self): self.client.login(username="other", password="secret123") response = self.client.post( reverse("lobby:start_round", kwargs={"code": self.session.code}), data={"category_slug": self.category.slug}, content_type="application/json", ) self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error_code"], "host_only_start_round") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Only host can start round") def test_start_round_requires_existing_active_category_with_questions(self): self.client.login(username="host", password="secret123") response = self.client.post( reverse("lobby:start_round", kwargs={"code": self.session.code}), data={"category_slug": "ukendt"}, content_type="application/json", ) self.assertEqual(response.status_code, 404) empty_category = Category.objects.create(name="Sport", slug="sport", is_active=True) response = self.client.post( reverse("lobby:start_round", kwargs={"code": self.session.code}), data={"category_slug": empty_category.slug}, content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error_code"], "category_has_no_questions") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Category has no active questions") def test_start_round_rejects_non_lobby_session(self): self.client.login(username="host", password="secret123") self.session.status = GameSession.Status.GUESS self.session.save(update_fields=["status"]) response = self.client.post( reverse("lobby:start_round", kwargs={"code": self.session.code}), data={"category_slug": self.category.slug}, content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error"], "Round can only be started from lobby") def test_start_round_error_localizes_to_danish(self): self.client.login(username="other", password="secret123") response = self.client.post( reverse("lobby:start_round", kwargs={"code": self.session.code}), data={"category_slug": self.category.slug}, content_type="application/json", HTTP_ACCEPT_LANGUAGE="da", ) self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error_code"], "host_only_start_round") self.assertEqual(response.json()["locale"], "da") self.assertEqual(response.json()["error"], "Kun værten kan starte runden") def test_start_round_error_falls_back_to_english_for_unsupported_locale(self): self.client.login(username="other", password="secret123") response = self.client.post( reverse("lobby:start_round", kwargs={"code": self.session.code}), data={"category_slug": self.category.slug}, content_type="application/json", HTTP_ACCEPT_LANGUAGE="fr", ) self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error_code"], "host_only_start_round") 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")) 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") response = self.client.post( reverse("lobby:start_round", kwargs={"code": self.session.code}), data={"category_slug": self.category.slug}, content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error_code"], "no_available_questions") self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.LOBBY) self.assertFalse(RoundConfig.objects.filter(session=self.session, number=1).exists()) self.assertFalse(RoundQuestion.objects.filter(session=self.session, round_number=1).exists()) class LieSubmissionTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host", password="secret123") self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE) self.category = Category.objects.create(name="Geografi", slug="geografi", is_active=True) self.question = Question.objects.create( category=self.category, prompt="Hvad er hovedstaden i Australien?", correct_answer="Canberra", is_active=True, ) RoundConfig.objects.create( session=self.session, number=1, category=self.category, lie_seconds=45, ) self.player = Player.objects.create(session=self.session, nickname="Luna") def test_host_can_show_question_and_get_lie_deadline(self): self.client.login(username="host", password="secret123") response = self.client.post(reverse("lobby:show_question", kwargs={"code": self.session.code})) self.assertEqual(response.status_code, 201) payload = response.json() self.assertEqual(payload["config"]["lie_seconds"], 45) self.assertIn("lie_deadline_at", payload["round_question"]) self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists()) def test_player_can_submit_lie_before_deadline(self): round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question, correct_answer=self.question.correct_answer, ) response = self.client.post( reverse( "lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": round_question.id}, ), data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Sydney"}, content_type="application/json", ) self.assertEqual(response.status_code, 201) self.assertTrue(LieAnswer.objects.filter(round_question=round_question, player=self.player).exists()) def test_submit_lie_rejects_after_time_window(self): round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question, correct_answer=self.question.correct_answer, ) round_question.shown_at = timezone.now() - timedelta(seconds=46) round_question.save(update_fields=["shown_at"]) response = self.client.post( reverse( "lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": round_question.id}, ), data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Melbourne"}, content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error_code"], "lie_submission_closed") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Lie submission window has closed") def test_submit_lie_rejects_duplicate_submission(self): round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question, correct_answer=self.question.correct_answer, ) LieAnswer.objects.create(round_question=round_question, player=self.player, text="Perth") response = self.client.post( reverse( "lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": round_question.id}, ), data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Brisbane"}, content_type="application/json", ) self.assertEqual(response.status_code, 409) self.assertEqual(response.json()["error_code"], "lie_already_submitted") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Lie already submitted for this player") def test_submit_lie_requires_session_token(self): round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question, correct_answer=self.question.correct_answer, ) response = self.client.post( reverse( "lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": round_question.id}, ), data={"player_id": self.player.id, "text": "Sydney"}, content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error_code"], "session_token_required") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "session_token is required") def test_submit_lie_rejects_invalid_session_token(self): round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question, correct_answer=self.question.correct_answer, ) response = self.client.post( reverse( "lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": round_question.id}, ), data={"player_id": self.player.id, "session_token": "invalid-token", "text": "Sydney"}, content_type="application/json", ) self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error_code"], "invalid_player_session_token") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Invalid player session token") def test_submit_lie_uses_danish_locale_payload_from_accept_language(self): round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question, correct_answer=self.question.correct_answer, ) response = self.client.post( reverse( "lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": round_question.id}, ), data={"player_id": self.player.id, "session_token": "invalid-token", "text": "Sydney"}, content_type="application/json", HTTP_ACCEPT_LANGUAGE="da-DK,da;q=0.9,en;q=0.1", ) self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error_code"], "invalid_player_session_token") self.assertEqual(response.json()["locale"], "da") self.assertEqual(response.json()["error"], "Ugyldigt spiller-session-token") class MixAnswersTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host", password="secret123") self.other_user = User.objects.create_user(username="other", password="secret123") self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE) self.category = Category.objects.create(name="Historie", slug="historie", is_active=True) self.question = Question.objects.create( category=self.category, prompt="Hvilken by er Danmarks hovedstad?", correct_answer="København", is_active=True, ) RoundConfig.objects.create(session=self.session, number=1, category=self.category) self.round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question, correct_answer="København", ) self.player_one = Player.objects.create(session=self.session, nickname="Luna") self.player_two = Player.objects.create(session=self.session, nickname="Mads") def test_host_can_mix_answers_and_transition_to_guess(self): LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus") LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense") self.client.login(username="host", password="secret123") response = self.client.post( reverse( "lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, ) ) self.assertEqual(response.status_code, 200) payload = response.json() answer_texts = [entry["text"] for entry in payload["answers"]] self.assertEqual(set(answer_texts), {"København", "Aarhus", "Odense"}) self.assertEqual(payload["session"]["status"], GameSession.Status.GUESS) self.session.refresh_from_db() self.round_question.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.GUESS) self.assertEqual(self.round_question.mixed_answers, answer_texts) def test_mix_answers_requires_host(self): self.client.login(username="other", password="secret123") response = self.client.post( reverse( "lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, ) ) self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error_code"], "host_only_mix_answers") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Only host can mix answers") def test_mix_answers_deduplicates_case_insensitive_lies(self): LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="københavn") LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Aarhus") self.client.login(username="host", password="secret123") response = self.client.post( reverse( "lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, ) ) self.assertEqual(response.status_code, 200) answer_texts = [entry["text"] for entry in response.json()["answers"]] self.assertEqual(set(answer_texts), {"København", "Aarhus"}) def test_mix_answers_is_idempotent_after_transition_to_guess(self): LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus") LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense") self.client.login(username="host", password="secret123") first = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id})) second = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id})) self.assertEqual(first.status_code, 200) self.assertEqual(second.status_code, 200) self.assertEqual([entry["text"] for entry in first.json()["answers"]], [entry["text"] for entry in second.json()["answers"]]) def test_session_detail_returns_persisted_mixed_answers_for_reconnect(self): LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus") LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense") self.client.login(username="host", password="secret123") mix_response = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id})) detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})) self.assertEqual(mix_response.status_code, 200) self.assertEqual(detail_response.status_code, 200) self.assertEqual([entry["text"] for entry in mix_response.json()["answers"]], [entry["text"] for entry in detail_response.json()["round_question"]["answers"]]) class GuessSubmissionTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host_guess", password="secret123") self.session = GameSession.objects.create(host=self.host, code="GU3551", status=GameSession.Status.GUESS) self.category = Category.objects.create(name="Videnskab", slug="videnskab", is_active=True) self.question = Question.objects.create( category=self.category, prompt="Hvilken planet kaldes den røde planet?", correct_answer="Mars", is_active=True, ) self.round_config = RoundConfig.objects.create( session=self.session, number=1, category=self.category, lie_seconds=45, guess_seconds=30, ) self.round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question, correct_answer="Mars", ) self.player = Player.objects.create(session=self.session, nickname="Luna") self.liar = Player.objects.create(session=self.session, nickname="Mads") LieAnswer.objects.create(round_question=self.round_question, player=self.liar, text="Jupiter") def test_player_can_submit_guess_in_guess_phase(self): response = self.client.post( reverse( "lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, ), data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"}, content_type="application/json", ) self.assertEqual(response.status_code, 201) payload = response.json() self.assertTrue(payload["guess"]["is_correct"]) self.assertIsNone(payload["guess"]["fooled_player_id"]) self.assertIn("guess_deadline_at", payload["window"]) def test_submit_guess_rejects_when_not_in_guess_phase(self): self.session.status = GameSession.Status.LIE self.session.save(update_fields=["status"]) response = self.client.post( reverse( "lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, ), data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"}, content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error_code"], "guess_submission_invalid_phase") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Guess submission is only allowed in guess phase") def test_submit_guess_rejects_unknown_answer(self): response = self.client.post( reverse( "lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, ), data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Venus"}, content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error_code"], "selected_answer_invalid") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Selected answer is not part of this round") def test_submit_guess_rejects_duplicate_submission(self): Guess.objects.create(round_question=self.round_question, player=self.player, selected_text="Mars", is_correct=True) response = self.client.post( reverse( "lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, ), data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Jupiter"}, content_type="application/json", ) self.assertEqual(response.status_code, 409) self.assertEqual(response.json()["error_code"], "guess_already_submitted") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Guess already submitted for this player") def test_submit_guess_rejects_after_deadline(self): self.round_question.shown_at = timezone.now() - timedelta(seconds=76) self.round_question.save(update_fields=["shown_at"]) response = self.client.post( reverse( "lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, ), data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"}, content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error_code"], "guess_submission_closed") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Guess submission window has closed") def test_submit_guess_requires_session_token(self): response = self.client.post( reverse( "lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, ), data={"player_id": self.player.id, "selected_text": "Mars"}, content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error_code"], "session_token_required") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "session_token is required") def test_submit_guess_rejects_invalid_session_token(self): response = self.client.post( reverse( "lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, ), data={"player_id": self.player.id, "session_token": "wrong-token", "selected_text": "Mars"}, content_type="application/json", ) self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error_code"], "invalid_player_session_token") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Invalid player session token") class CanonicalRoundFlowTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host_canonical", password="secret123") self.session = GameSession.objects.create(host=self.host, code="CN2871") self.category = Category.objects.create(name="Kanon", slug="kanon", is_active=True) self.question = Question.objects.create( category=self.category, prompt="Hvem skrev Hamlet?", correct_answer="Shakespeare", is_active=True, ) self.players = [ Player.objects.create(session=self.session, nickname="Luna"), Player.objects.create(session=self.session, nickname="Mads"), Player.objects.create(session=self.session, nickname="Nora"), ] def test_canonical_round_flow_auto_advances_from_start_to_scoreboard(self): self.client.login(username="host_canonical", password="secret123") start_response = self.client.post( reverse("lobby:start_round", kwargs={"code": self.session.code}), data={"category_slug": self.category.slug}, content_type="application/json", ) self.assertEqual(start_response.status_code, 201) round_question_id = start_response.json()["round_question"]["id"] self.assertEqual(start_response.json()["session"]["status"], GameSession.Status.LIE) lie_responses = [] for index, player in enumerate(self.players, start=1): lie_responses.append( self.client.post( reverse("lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": round_question_id}), data={"player_id": player.id, "session_token": player.session_token, "text": f"Løgn {index}"}, content_type="application/json", ) ) self.assertTrue(all(response.status_code == 201 for response in lie_responses)) self.assertEqual(lie_responses[-1].json()["session"]["status"], GameSession.Status.GUESS) self.assertTrue(lie_responses[-1].json()["phase_transition"]["auto_advanced"]) self.assertGreaterEqual(len(lie_responses[-1].json()["answers"]), 2) guess_targets = ["Shakespeare", "Løgn 1", "Shakespeare"] guess_responses = [] for player, selected_text in zip(self.players, guess_targets, strict=True): guess_responses.append( self.client.post( reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question_id}), data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text}, content_type="application/json", ) ) self.assertTrue(all(response.status_code == 201 for response in guess_responses)) self.assertEqual(guess_responses[-1].json()["session"]["status"], GameSession.Status.REVEAL) self.assertTrue(guess_responses[-1].json()["phase_transition"]["auto_advanced"]) self.assertIsNotNone(guess_responses[-1].json()["reveal"]) detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})) self.assertEqual(detail_response.status_code, 200) payload = detail_response.json() self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual(payload["phase_view_model"]["current_phase"], GameSession.Status.SCOREBOARD) self.assertTrue(payload["phase_view_model"]["readiness"]["scoreboard_ready"]) self.assertEqual([entry["nickname"] for entry in payload["scoreboard"]], ["Luna", "Nora", "Mads"]) self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare") @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._resolve_scores") def test_session_detail_promotes_zero_score_event_reveal_to_scoreboard(self, mock_resolve_scores, mock_sync_broadcast): self.client.login(username="host_canonical", password="secret123") self.session.status = GameSession.Status.GUESS self.session.save(update_fields=["status"]) round_config = RoundConfig.objects.create( session=self.session, number=1, category=self.category, points_correct=5, points_bluff=2, ) round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question, correct_answer="Shakespeare", ) LieAnswer.objects.create(round_question=round_question, player=self.players[0], text="Marlowe") LieAnswer.objects.create(round_question=round_question, player=self.players[1], text="Chaucer") LieAnswer.objects.create(round_question=round_question, player=self.players[2], text="Austen") mock_resolve_scores.return_value = ([], [ {"id": player.id, "nickname": player.nickname, "score": player.score} for player in sorted(self.players, key=lambda player: player.nickname) ]) guess_targets = ["Shakespeare", "Shakespeare", "Shakespeare"] guess_responses = [] for player, selected_text in zip(self.players, guess_targets, strict=True): guess_responses.append( self.client.post( reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question.id}), data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text}, content_type="application/json", ) ) self.assertTrue(all(response.status_code == 201 for response in guess_responses)) self.assertEqual(guess_responses[-1].json()["session"]["status"], GameSession.Status.REVEAL) self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 0) detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})) self.assertEqual(detail_response.status_code, 200) payload = detail_response.json() self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual(payload["phase_view_model"]["current_phase"], GameSession.Status.SCOREBOARD) self.assertTrue(payload["phase_view_model"]["readiness"]["scoreboard_ready"]) self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare") mock_resolve_scores.assert_called_once_with(self.session, round_question, round_config) mock_sync_broadcast.assert_any_call( self.session.code, "phase.scoreboard", { "leaderboard": payload["scoreboard"], "current_round": self.session.current_round, }, ) def test_canonical_round_flow_bootstraps_second_round_without_first_round_carry_over(self): self.client.login(username="host_canonical", password="secret123") extra_question = Question.objects.create( category=self.category, prompt="Hvem malede Mona Lisa?", correct_answer="Da Vinci", is_active=True, ) start_response = self.client.post( reverse("lobby:start_round", kwargs={"code": self.session.code}), data={"category_slug": self.category.slug}, content_type="application/json", ) self.assertEqual(start_response.status_code, 201) first_round_question_id = start_response.json()["round_question"]["id"] first_round_prompt = start_response.json()["round_question"]["prompt"] first_round_correct_answer = RoundQuestion.objects.get(pk=first_round_question_id).correct_answer second_question = extra_question if first_round_prompt == self.question.prompt else self.question final_lie_response = None for index, player in enumerate(self.players, start=1): lie_response = self.client.post( reverse("lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": first_round_question_id}), data={"player_id": player.id, "session_token": player.session_token, "text": f"Første løgn {index}"}, content_type="application/json", ) self.assertEqual(lie_response.status_code, 201) final_lie_response = lie_response self.assertIsNotNone(final_lie_response) for player, selected_text in zip( self.players, [first_round_correct_answer, first_round_correct_answer, first_round_correct_answer], strict=True, ): guess_response = self.client.post( reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": first_round_question_id}), data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text}, content_type="application/json", ) self.assertEqual(guess_response.status_code, 201) scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() self.assertEqual(scoreboard_payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual(scoreboard_payload["round_question"]["id"], first_round_question_id) self.assertIsNotNone(scoreboard_payload["reveal"]) self.assertIsNotNone(scoreboard_payload["scoreboard"]) self.assertGreaterEqual(len(scoreboard_payload["reveal"]["guesses"]), 1) next_round_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) self.assertEqual(next_round_response.status_code, 200) self.assertEqual(next_round_response.json()["session"]["status"], GameSession.Status.LIE) self.assertEqual(next_round_response.json()["session"]["current_round"], 2) detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE) self.assertEqual(detail_payload["session"]["current_round"], 2) self.assertEqual(detail_payload["phase_view_model"]["current_phase"], GameSession.Status.LIE) self.assertIsNone(detail_payload["reveal"]) self.assertIsNone(detail_payload["scoreboard"]) self.assertEqual(detail_payload["round_question"]["round_number"], 2) self.assertNotEqual(detail_payload["round_question"]["id"], first_round_question_id) self.assertEqual(detail_payload["round_question"]["prompt"], second_question.prompt) self.assertEqual(detail_payload["round_question"]["answers"], []) round_two_question = RoundQuestion.objects.get(session=self.session, round_number=2) self.assertEqual(round_two_question.question, second_question) self.assertEqual(round_two_question.lies.count(), 0) self.assertEqual(round_two_question.guesses.count(), 0) self.assertEqual(round_two_question.mixed_answers, []) @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._resolve_scores") @patch("lobby.views.GameSession.objects.get") def test_submit_guess_skips_rescore_when_locked_session_is_already_revealing( self, mock_session_get, mock_resolve_scores, mock_sync_broadcast, ): round_config = RoundConfig.objects.create( session=self.session, number=1, category=self.category, points_correct=5, points_bluff=2, ) round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question, correct_answer="Shakespeare", ) LieAnswer.objects.create(round_question=round_question, player=self.players[0], text="Marlowe") Guess.objects.create( round_question=round_question, player=self.players[0], selected_text="Shakespeare", is_correct=True, ) Guess.objects.create( round_question=round_question, player=self.players[1], selected_text="Marlowe", is_correct=False, fooled_player=self.players[0], ) self.players[0].score = round_config.points_correct + round_config.points_bluff self.players[0].save(update_fields=["score"]) ScoreEvent.objects.create( session=self.session, player=self.players[0], delta=round_config.points_correct, reason="guess_correct", meta={"round_question_id": round_question.id, "guess_id": 1}, ) ScoreEvent.objects.create( session=self.session, player=self.players[0], delta=round_config.points_bluff, reason="bluff_success", meta={"round_question_id": round_question.id, "fooled_count": 1}, ) self.session.status = GameSession.Status.REVEAL self.session.save(update_fields=["status"]) stale_session = GameSession( pk=self.session.pk, host=self.host, code=self.session.code, status=GameSession.Status.GUESS, current_round=self.session.current_round, ) mock_session_get.return_value = stale_session response = self.client.post( reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question.id}), data={ "player_id": self.players[2].id, "session_token": self.players[2].session_token, "selected_text": "Shakespeare", }, content_type="application/json", ) self.assertEqual(response.status_code, 201) self.assertEqual(response.json()["session"]["status"], GameSession.Status.REVEAL) self.assertTrue(response.json()["phase_transition"]["auto_advanced"]) self.assertIsNotNone(response.json()["reveal"]) mock_resolve_scores.assert_not_called() mock_sync_broadcast.assert_not_called() self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 2) class ScoreCalculationTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host_score", password="secret123") self.other_user = User.objects.create_user(username="other_score", password="secret123") self.session = GameSession.objects.create(host=self.host, code="SC0RE1", status=GameSession.Status.GUESS) self.category = Category.objects.create(name="Sport", slug="sport", is_active=True) self.question = Question.objects.create( category=self.category, prompt="Hvilken sport spiller man i Wimbledon?", correct_answer="Tennis", is_active=True, ) RoundConfig.objects.create( session=self.session, number=1, category=self.category, points_correct=5, points_bluff=2, ) self.round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question, correct_answer="Tennis", ) self.player_one = Player.objects.create(session=self.session, nickname="Luna") self.player_two = Player.objects.create(session=self.session, nickname="Mads") self.player_three = Player.objects.create(session=self.session, nickname="Nora") def test_host_can_calculate_scores_and_transition_to_reveal(self): LieAnswer.objects.create(round_question=self.round_question, player=self.player_three, text="Padel") Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True) Guess.objects.create( round_question=self.round_question, player=self.player_two, selected_text="Padel", is_correct=False, fooled_player=self.player_three, ) Guess.objects.create( round_question=self.round_question, player=self.player_three, selected_text="Padel", is_correct=False, fooled_player=self.player_three, ) self.client.login(username="host_score", password="secret123") response = self.client.post( reverse( "lobby:calculate_scores", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, ) ) self.assertEqual(response.status_code, 200) payload = response.json() self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL) self.assertEqual(payload["events_created"], 2) self.assertEqual(payload["reveal"]["round_question_id"], self.round_question.id) self.assertEqual(payload["reveal"]["correct_answer"], "Tennis") self.assertEqual( payload["reveal"]["lies"], [ { "player_id": self.player_three.id, "nickname": "Nora", "text": "Padel", "created_at": payload["reveal"]["lies"][0]["created_at"], } ], ) self.assertEqual( payload["reveal"]["guesses"], [ { "player_id": self.player_one.id, "nickname": "Luna", "selected_text": "Tennis", "is_correct": True, "created_at": payload["reveal"]["guesses"][0]["created_at"], "fooled_player_id": None, }, { "player_id": self.player_two.id, "nickname": "Mads", "selected_text": "Padel", "is_correct": False, "created_at": payload["reveal"]["guesses"][1]["created_at"], "fooled_player_id": self.player_three.id, "fooled_player_nickname": "Nora", }, { "player_id": self.player_three.id, "nickname": "Nora", "selected_text": "Padel", "is_correct": False, "created_at": payload["reveal"]["guesses"][2]["created_at"], "fooled_player_id": self.player_three.id, "fooled_player_nickname": "Nora", }, ], ) self.player_one.refresh_from_db() self.player_three.refresh_from_db() self.session.refresh_from_db() self.assertEqual(self.player_one.score, 5) self.assertEqual(self.player_three.score, 4) self.assertEqual(self.session.status, GameSession.Status.REVEAL) def test_calculate_scores_requires_host(self): self.client.login(username="other_score", password="secret123") response = self.client.post( reverse( "lobby:calculate_scores", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, ) ) self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error_code"], "host_only_calculate_scores") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Only host can calculate scores") def test_calculate_scores_rejects_duplicate_calculation(self): Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True) self.client.login(username="host_score", password="secret123") first = self.client.post( reverse( "lobby:calculate_scores", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, ) ) second = self.client.post( reverse( "lobby:calculate_scores", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, ) ) self.assertEqual(first.status_code, 200) self.assertEqual(second.status_code, 409) self.assertEqual(second.json()["error_code"], "scores_already_calculated") self.assertEqual(second.json()["locale"], "en") self.assertEqual(second.json()["error"], "Scores already calculated for this round question") class RevealRoundFlowTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host_reveal", password="secret123") self.other_user = User.objects.create_user(username="other_reveal", password="secret123") self.session = GameSession.objects.create(host=self.host, code="RVL123", status=GameSession.Status.REVEAL) self.player_one = Player.objects.create(session=self.session, nickname="Luna", score=9) self.player_two = Player.objects.create(session=self.session, nickname="Mads", score=3) self.category = Category.objects.create(name="Reveal", slug="reveal", is_active=True) self.question = Question.objects.create( category=self.category, prompt="Hvad er Danmarks hovedstad?", correct_answer="København", is_active=True, ) self.next_question = Question.objects.create( category=self.category, prompt="Hvad er Sveriges hovedstad?", correct_answer="Stockholm", is_active=True, ) self.round_config = RoundConfig.objects.create(session=self.session, number=1, category=self.category) self.round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question, correct_answer=self.question.correct_answer, ) ScoreEvent.objects.create( session=self.session, player=self.player_one, delta=5, reason="guess_correct", meta={"round_question_id": self.round_question.id}, ) @patch("lobby.views.sync_broadcast_phase_event") def test_host_can_get_reveal_scoreboard(self, mock_sync_broadcast_phase_event): self.client.login(username="host_reveal", password="secret123") response = self.client.get( reverse( "lobby:reveal_scoreboard", kwargs={"code": self.session.code}, ) ) self.assertEqual(response.status_code, 200) payload = response.json() self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) mock_sync_broadcast_phase_event.assert_called_once_with( self.session.code, "phase.scoreboard", { "leaderboard": [ {"id": self.player_one.id, "nickname": "Luna", "score": 9}, {"id": self.player_two.id, "nickname": "Mads", "score": 3}, ], "current_round": 1, }, ) self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD) def test_reveal_scoreboard_requires_host(self): self.client.login(username="other_reveal", password="secret123") response = self.client.get( reverse( "lobby:reveal_scoreboard", kwargs={"code": self.session.code}, ), HTTP_ACCEPT_LANGUAGE="fr", ) self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error_code"], "host_only_view_scoreboard") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Only host can view scoreboard") def test_reveal_scoreboard_is_idempotent_in_scoreboard_phase(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) self.client.login(username="host_reveal", password="secret123") response = self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) self.assertEqual(response.status_code, 200) payload = response.json() self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) @patch("lobby.views.sync_broadcast_phase_event") def test_host_can_finish_game_from_scoreboard(self, _mock_sync_broadcast_phase_event): self.client.login(username="host_reveal", password="secret123") self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) response = self.client.post( reverse( "lobby:finish_game", kwargs={"code": self.session.code}, ) ) self.assertEqual(response.status_code, 200) payload = response.json() self.assertEqual(payload["session"]["status"], GameSession.Status.FINISHED) self.assertEqual(payload["winner"]["nickname"], "Luna") self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.FINISHED) @patch("lobby.views.sync_broadcast_phase_event") def test_finish_game_is_idempotent_after_transition_to_finished(self, mock_sync_broadcast_phase_event): self.client.login(username="host_reveal", password="secret123") self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) first_response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) second_response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) self.assertEqual(first_response.status_code, 200) self.assertEqual(second_response.status_code, 200) self.assertEqual(first_response.json(), second_response.json()) self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.FINISHED) self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.FINISHED) self.assertEqual(mock_sync_broadcast_phase_event.call_count, 2) self.assertEqual(mock_sync_broadcast_phase_event.call_args_list[0].args[1], "phase.scoreboard") self.assertEqual(mock_sync_broadcast_phase_event.call_args_list[1].args[1], "phase.game_over") def test_finish_game_requires_host(self): self.client.login(username="other_reveal", password="secret123") response = self.client.post( reverse( "lobby:finish_game", kwargs={"code": self.session.code}, ), HTTP_ACCEPT_LANGUAGE="da", ) self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error_code"], "host_only_finish_game") self.assertEqual(response.json()["locale"], "da") self.assertEqual(response.json()["error"], "Kun værten kan afslutte spillet") def test_finish_game_rejects_wrong_phase(self): self.client.login(username="host_reveal", password="secret123") self.session.status = GameSession.Status.GUESS self.session.save(update_fields=["status"]) response = self.client.post( reverse( "lobby:finish_game", kwargs={"code": self.session.code}, ), HTTP_ACCEPT_LANGUAGE="fr", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error_code"], "finish_game_invalid_phase") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase") @patch("lobby.views.sync_broadcast_phase_event") def test_host_can_start_next_round_from_scoreboard(self, mock_sync_broadcast_phase_event): self.client.login(username="host_reveal", password="secret123") self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) mock_sync_broadcast_phase_event.reset_mock() response = self.client.post( reverse( "lobby:start_next_round", kwargs={"code": self.session.code}, ) ) self.assertEqual(response.status_code, 200) payload = response.json() self.assertEqual(payload["session"]["status"], GameSession.Status.LIE) self.assertEqual(payload["session"]["current_round"], 2) self.assertEqual(payload["round"]["category"]["slug"], self.category.slug) self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt) self.assertEqual(payload["config"]["lie_seconds"], self.round_config.lie_seconds) self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.LIE) self.assertEqual(self.session.current_round, 2) self.assertTrue( RoundConfig.objects.filter( session=self.session, number=2, category=self.category, started_from_scoreboard=True, ).exists() ) self.assertTrue( RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists() ) mock_sync_broadcast_phase_event.assert_called_once() self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[0], self.session.code) self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") @patch("lobby.views.sync_broadcast_phase_event") def test_start_next_round_is_idempotent_after_transition_to_lie(self, mock_sync_broadcast_phase_event): self.client.login(username="host_reveal", password="secret123") self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) mock_sync_broadcast_phase_event.reset_mock() first_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) second_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) self.assertEqual(first_response.status_code, 200) self.assertEqual(second_response.status_code, 200) self.assertEqual(first_response.json(), second_response.json()) self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.LIE) self.assertEqual(second_response.json()["session"]["current_round"], 2) self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.LIE) self.assertEqual(self.session.current_round, 2) self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=2).count(), 1) mock_sync_broadcast_phase_event.assert_called_once() self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") def test_start_next_round_rejects_plain_lie_phase_without_prior_scoreboard_transition(self): self.client.login(username="host_reveal", password="secret123") ScoreEvent.objects.filter(session=self.session).delete() self.session.status = GameSession.Status.LIE self.session.current_round = 2 self.session.save(update_fields=["status", "current_round"]) RoundConfig.objects.create(session=self.session, number=2, category=self.category, started_from_scoreboard=False) RoundQuestion.objects.create( session=self.session, round_number=2, question=self.next_question, correct_answer=self.next_question.correct_answer, ) response = self.client.post( reverse( "lobby:start_next_round", kwargs={"code": self.session.code}, ), HTTP_ACCEPT_LANGUAGE="en", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error_code"], "next_round_invalid_phase") self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.LIE) self.assertEqual(self.session.current_round, 2) self.assertEqual(RoundConfig.objects.filter(session=self.session, number=1).count(), 1) self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=1).count(), 1) self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=2).count(), 1) def test_start_next_round_clears_existing_next_round_bootstrap_state(self): self.client.login(username="host_reveal", password="secret123") self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) stale_round_question = RoundQuestion.objects.create( session=self.session, round_number=2, question=self.next_question, correct_answer=self.next_question.correct_answer, mixed_answers=["Stale truth", "Stale lie"], ) LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Stale lie") Guess.objects.create( round_question=stale_round_question, player=self.player_two, selected_text="Stale truth", is_correct=True, ) response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) self.assertEqual(response.status_code, 200) self.session.refresh_from_db() stale_round_question.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.LIE) self.assertEqual(self.session.current_round, 2) self.assertEqual(response.json()["round_question"]["id"], stale_round_question.id) self.assertEqual(stale_round_question.mixed_answers, []) self.assertEqual(stale_round_question.lies.count(), 0) self.assertEqual(stale_round_question.guesses.count(), 0) detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE) self.assertEqual(detail_payload["session"]["current_round"], 2) self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id) self.assertEqual(detail_payload["round_question"]["answers"], []) self.assertIsNone(detail_payload["reveal"]) self.assertIsNone(detail_payload["scoreboard"]) def test_start_next_round_requires_host(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) self.client.login(username="other_reveal", password="secret123") response = self.client.post( reverse( "lobby:start_next_round", kwargs={"code": self.session.code}, ), HTTP_ACCEPT_LANGUAGE="fr", ) self.assertEqual(response.status_code, 403) self.assertEqual(response.json(), { "error": "Only host can start next round", "error_code": "host_only_start_next_round", "locale": "en", }) @patch("lobby.views.sync_broadcast_phase_event") def test_reveal_scoreboard_allows_repeated_reads_after_promotion(self, mock_sync_broadcast_phase_event): self.client.login(username="host_reveal", password="secret123") first_response = self.client.get( reverse( "lobby:reveal_scoreboard", kwargs={"code": self.session.code}, ) ) second_response = self.client.get( reverse( "lobby:reveal_scoreboard", kwargs={"code": self.session.code}, ) ) self.assertEqual(first_response.status_code, 200) self.assertEqual(second_response.status_code, 200) self.assertEqual(first_response.json()["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual([item["nickname"] for item in second_response.json()["leaderboard"]], ["Luna", "Mads"]) self.assertEqual(mock_sync_broadcast_phase_event.call_count, 1) def test_start_next_round_rejects_wrong_phase(self): self.client.login(username="host_reveal", password="secret123") self.session.status = GameSession.Status.GUESS self.session.save(update_fields=["status"]) response = self.client.post( reverse( "lobby:start_next_round", kwargs={"code": self.session.code}, ), HTTP_ACCEPT_LANGUAGE="da", ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error_code"], "next_round_invalid_phase") self.assertEqual(response.json()["locale"], "da") self.assertEqual(response.json()["error"], "Næste runde kan kun starte fra scoreboard-fasen") def test_start_next_round_does_not_persist_round_config_when_question_selection_fails(self): self.client.login(username="host_reveal", password="secret123") self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) self.next_question.delete() response = self.client.post( reverse( "lobby:start_next_round", kwargs={"code": self.session.code}, ) ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error_code"], "no_available_questions") self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD) self.assertEqual(self.session.current_round, 1) self.assertFalse(RoundConfig.objects.filter(session=self.session, number=2).exists()) self.assertFalse(RoundQuestion.objects.filter(session=self.session, round_number=2).exists()) def test_reveal_scoreboard_unsupported_locale_falls_back_to_en_deterministically(self): self.client.login(username="other_reveal", password="secret123") response = self.client.get( reverse( "lobby:reveal_scoreboard", kwargs={"code": self.session.code}, ), HTTP_ACCEPT_LANGUAGE="fr-FR,fr;q=0.9", ) self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error_code"], "host_only_view_scoreboard") self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Only host can view scoreboard") class UiScreenTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host_ui", password="secret123") def test_host_screen_requires_login(self): response = self.client.get(reverse("lobby:host_screen")) self.assertEqual(response.status_code, 302) def test_host_screen_renders_for_logged_in_user(self): self.client.login(username="host_ui", password="secret123") response = self.client.get(reverse("lobby:host_screen")) self.assertEqual(response.status_code, 200) self.assertContains(response, "Host panel") self.assertContains(response, "id=\"createSessionBtn\"") self.assertContains(response, "id=\"createSessionHint\"") self.assertContains(response, "saveHostContext") self.assertContains(response, "restoreHostContext") self.assertContains(response, "id=\"roundQuestionStatus\"") self.assertContains(response, "id=\"hostActionHint\"") self.assertContains(response, "id=\"categoryGuardHint\"") self.assertContains(response, "id=\"phaseStatus\"") self.assertContains(response, "updateHostActionState") self.assertContains(response, "phaseLabel") self.assertContains(response, "Opdatér session-status for fasebaserede host-actions.") self.assertContains(response, "Angiv sessionkode for at aktivere host-actions.") self.assertContains(response, "Kategori er kun redigérbar i lobby-fasen.") self.assertContains(response, "Kræver 3-5 spillere i lobbyen.") self.assertContains(response, "For mange spillere: maks 5 i MVP før runde-start.") self.assertContains(response, "Round question-id styres server-side i canonical flow og er kun read-only kontekst for host.") self.assertContains(response, "Round question-id styres server-side i canonical flow og er read-only i fase:") self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==\"lobby\"") self.assertContains(response, "hostActionInFlight") self.assertContains(response, "withHostActionLock") self.assertContains(response, "updateCreateSessionState") self.assertContains(response, "btn.disabled=hostActionInFlight||sessionDetailInFlight") self.assertContains(response, "Opret session er låst mens en host-handling kører.") self.assertContains(response, "Opret session er låst mens session-opdatering kører.") self.assertContains(response, "Handling kører… afvent svar før næste klik.") self.assertContains(response, "Session-data ikke opdateret endnu.") self.assertContains(response, "Sidst opdateret:") self.assertContains(response, "Session-data kan være forældet") self.assertContains(response, "id=\"sessionDetailBtn\"") self.assertContains(response, "id=\"sessionDetailHint\"") self.assertContains(response, "updateSessionDetailState") self.assertContains(response, "sessionDetailInFlight") self.assertContains(response, "session_detail_in_flight") self.assertContains(response, "codeInput.readOnly=sessionDetailInFlight||hostActionInFlight") self.assertContains(response, "id=\"autoRefreshToggleBtn\"") self.assertContains(response, "id=\"autoRefreshHint\"") self.assertContains(response, "btn.disabled=hostActionInFlight||sessionDetailInFlight||!code()") self.assertContains(response, "Auto-refresh-lås: afvent aktiv host-handling.") self.assertContains(response, "Auto-refresh-lås: afvent aktiv session-opdatering.") self.assertContains(response, "Opdaterer session-status…") self.assertContains(response, "Session-opdatering er låst mens en host-handling kører.") self.assertContains(response, "Angiv sessionkode for at opdatere session-status.") self.assertContains(response, "markSessionRefresh") self.assertContains(response, "updateLastRefreshStatus") self.assertContains(response, "isSessionDetailRead") self.assertContains(response, "Mid-round faseskift er server-styrede i canonical flow. Host monitorerer kun fremdrift i fase:") self.assertContains(response, "Host-actions er klar: vælg næste runde eller afslut spillet.") self.assertContains(response, 'if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}') self.assertContains(response, "Host-actions er låst mens session-opdatering kører.") self.assertContains(response, "Round question-id er låst mens session-opdatering kører.") self.assertContains(response, "Kategori er låst mens session-opdatering kører.") self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==") self.assertContains(response, "hostShellRouteFromPath") self.assertContains(response, "syncHostShellRoute") self.assertContains(response, "Deep-link route guard: omdirigeret") self.assertContains(response, "id=\"hostShellErrorBoundary\"") self.assertContains(response, "recoverHostShell('retry')") self.assertContains(response, "recoverHostShell('reload')") self.assertContains(response, "setHostShellFatalError") self.assertContains(response, "clearHostShellFatalError") self.assertContains(response, "updateHostShellErrorBoundary") self.assertContains(response, "host_shell_runtime_error") self.assertContains(response, "window.addEventListener(\"unhandledrejection\"") def test_host_screen_deeplink_requires_login(self): response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"})) self.assertEqual(response.status_code, 302) def test_host_screen_deeplink_renders_for_logged_in_user(self): self.client.login(username="host_ui", password="secret123") response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"})) self.assertEqual(response.status_code, 200) self.assertContains(response, "Host panel") def test_player_screen_is_public(self): response = self.client.get(reverse("lobby:player_screen")) self.assertEqual(response.status_code, 200) self.assertContains(response, "Player panel") self.assertContains(response, "id=\"sessionToken\"") self.assertContains(response, "session_token") self.assertContains(response, "id=\"answerOptions\"") self.assertContains(response, "renderAnswerOptions") self.assertContains(response, "renderAnswerOptions(null)") self.assertContains(response, "availableAnswers") self.assertContains(response, "guessStorageKey") self.assertContains(response, "persistGuessState") self.assertContains(response, "savePlayerContext") self.assertContains(response, "restorePlayerContext") self.assertContains(response, "id=\"lieSubmitBtn\"") self.assertContains(response, "id=\"lieStatus\"") self.assertContains(response, "id=\"phaseStatus\"") self.assertContains(response, "currentSessionStatus") self.assertContains(response, "updatePhaseStatus") self.assertContains(response, "Løgn-input er låst i fase") self.assertContains(response, "Gæt er låst i fase") self.assertContains(response, "Afvent aktivt spørgsmål fra host før du kan gætte.") self.assertContains(response, "persistLieState") self.assertContains(response, "updateLieSubmitState") self.assertContains(response, "hasSubmitContext") self.assertContains(response, "hasRoundQuestionContext") self.assertContains(response, "canAttemptJoin") self.assertContains(response, "missing_join_input") self.assertContains(response, "Udfyld kode og nickname for at join.") self.assertContains(response, "Afvent aktiv session-opdatering før join.") self.assertContains(response, "btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin") self.assertContains(response, "id=\"connectionBanner\"") self.assertContains(response, "id=\"connectionRetryBtn\"") self.assertContains(response, "retryConnection") self.assertContains(response, "setConnectionLost") self.assertContains(response, "connection_lost") self.assertContains(response, "id=\"contextLockHint\"") self.assertContains(response, "updateContextLockState") self.assertContains(response, "Spillerkontekst er låst efter join.") self.assertContains(response, "already_joined_client") self.assertContains(response, "missing_submit_context") self.assertContains(response, "invalid_client_guess") self.assertContains(response, "lieSubmitInFlight") self.assertContains(response, "guessSubmitInFlight") self.assertContains(response, "Sender løgn…") self.assertContains(response, "Sender gæt…") self.assertContains(response, "Afvent aktiv session-opdatering før løgn-submit.") self.assertContains(response, "Afvent aktiv session-opdatering før gæt-submit.") self.assertContains(response, "sessionDetailInFlight||!hasValid||!hasContext||!hasRoundContext||!inGuessPhase") self.assertContains(response, "btn.disabled=guessSubmitted||guessSubmitInFlight||sessionDetailInFlight") self.assertContains(response, "lie_submit_in_flight") self.assertContains(response, "guess_submit_in_flight") self.assertContains(response, "guess_already_submitted_client") self.assertContains(response, "id=\"playerAutoRefreshToggleBtn\"") self.assertContains(response, "id=\"playerAutoRefreshHint\"") self.assertContains(response, "id=\"playerLastRefreshStatus\"") self.assertContains(response, "id=\"sessionDetailBtn\"") self.assertContains(response, "id=\"sessionRefreshHint\"") self.assertContains(response, "id=\"roundContextHint\"") self.assertContains(response, "resetRoundContextForManualChange") self.assertContains(response, "Runde-kontekst afventer session-opdatering.") self.assertContains(response, "togglePlayerAutoRefresh") self.assertContains(response, "btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code()") self.assertContains(response, "Auto-refresh-lås: afvent aktiv session-opdatering.") self.assertContains(response, "Auto-refresh-lås: afvent aktiv join.") self.assertContains(response, "Auto-refresh kræver sessionkode.") self.assertContains(response, "markPlayerSessionRefresh") self.assertContains(response, "updatePlayerLastRefreshStatus") self.assertContains(response, "updateSessionDetailState") self.assertContains(response, "session_detail_in_flight") self.assertContains(response, "Opdaterer session-status…") self.assertContains(response, "Session-opdatering er låst mens join kører.") self.assertContains(response, "Session-opdatering er låst mens submit kører.") self.assertContains(response, "Session-data ikke opdateret endnu.") self.assertContains(response, "Sidst opdateret:") self.assertContains(response, "Session-data kan være forældet") self.assertContains(response, "id=\"playerShellErrorBoundary\"") self.assertContains(response, "recoverPlayerShell('retry')") self.assertContains(response, "recoverPlayerShell('reload')") self.assertContains(response, "setPlayerShellFatalError") self.assertContains(response, "clearPlayerShellFatalError") self.assertContains(response, "updatePlayerShellErrorBoundary") self.assertContains(response, "player_shell_runtime_error") self.assertContains(response, "installSecondaryDeviceAudioGuard") self.assertContains(response, "silencePlayerMediaElements") self.assertContains(response, "querySelectorAll(\"audio,video\")") self.assertNotContains(response, "") self.assertEqual(payload["players"], ["P1", "P2", "P3"]) self.assertIn("generated_at", payload) self.assertIn("session_code", payload) self.assertEqual(payload["question"]["correct_answer"], "Correct") self.assertEqual(payload["guess_plan"]["P2"], "Correct") step_names = [step["step"] for step in payload["steps"]] self.assertEqual( step_names, [ "create_session", "join_players", "start_round", "auto_guess_transition", "submit_guesses", "auto_reveal_transition", "auto_scoreboard_transition", "finish_game", ], ) reveal_step = payload["steps"][5] self.assertEqual(reveal_step["session_status"], GameSession.Status.REVEAL) self.assertEqual(reveal_step["reveal"]["correct_answer"], "Correct") self.assertEqual(reveal_step["reveal"]["lies_count"], 3) self.assertEqual(reveal_step["reveal"]["guesses_count"], 3) self.assertEqual(len(reveal_step["reveal"]["fooled_player_ids"]), 2) self.assertEqual(len(reveal_step["reveal"]["correct_guess_player_ids"]), 1) scoreboard_step = payload["steps"][6] self.assertEqual(scoreboard_step["session_status"], GameSession.Status.SCOREBOARD) self.assertEqual(len(scoreboard_step["leaderboard"]), 3) class I18nResolverTests(TestCase): def test_resolve_locale_accepts_language_tags_and_normalizes_to_supported_base_locale(self): response = self.client.post( reverse("lobby:join_session"), data={"code": "", "nickname": "Luna"}, content_type="application/json", HTTP_ACCEPT_LANGUAGE="da-DK,da;q=0.9,en;q=0.8", ) self.assertEqual(response.status_code, 400) self.assertEqual(resolve_locale(response.wsgi_request), "da") def test_resolve_locale_accepts_underscore_language_tags(self): response = self.client.post( reverse("lobby:join_session"), data={"code": "", "nickname": "Luna"}, content_type="application/json", HTTP_ACCEPT_LANGUAGE="da_DK", ) self.assertEqual(response.status_code, 400) self.assertEqual(resolve_locale(response.wsgi_request), "da") def test_resolve_locale_uses_next_supported_language_when_primary_is_unsupported(self): response = self.client.post( reverse("lobby:join_session"), data={"code": "", "nickname": "Luna"}, content_type="application/json", HTTP_ACCEPT_LANGUAGE="fr-FR, da;q=0.9, en;q=0.8", ) self.assertEqual(response.status_code, 400) self.assertEqual(resolve_locale(response.wsgi_request), "da") def test_resolve_locale_skips_q0_languages_and_falls_back_to_next_supported(self): response = self.client.post( reverse("lobby:join_session"), data={"code": "", "nickname": "Luna"}, content_type="application/json", HTTP_ACCEPT_LANGUAGE="da;q=0, en;q=0.8", ) self.assertEqual(response.status_code, 400) self.assertEqual(resolve_locale(response.wsgi_request), "en") def test_resolve_locale_prefers_highest_quality_supported_language(self): response = self.client.post( reverse("lobby:join_session"), data={"code": "", "nickname": "Luna"}, content_type="application/json", HTTP_ACCEPT_LANGUAGE="da;q=0.8, en;q=0.9", ) self.assertEqual(response.status_code, 400) self.assertEqual(resolve_locale(response.wsgi_request), "en") def test_resolve_locale_defaults_to_en_when_header_missing(self): response = self.client.post( reverse("lobby:join_session"), data={"code": "", "nickname": "Luna"}, content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual(resolve_locale(response.wsgi_request), "en") def test_missing_backend_key_returns_key_deterministically(self): self.assertEqual(resolve_error_message(key="missing_key", locale="da"), "missing_key") def test_missing_backend_key_is_logged_with_context(self): with self.assertLogs("lobby.i18n", level="WARNING") as logs: result = resolve_error_message(key="missing_key", locale="da") self.assertEqual(result, "missing_key") self.assertTrue(any("i18n key missing in shared catalog" in entry for entry in logs.output)) def test_missing_backend_error_code_is_logged_with_context(self): from lobby.i18n import resolve_error_key with self.assertLogs("lobby.i18n", level="WARNING") as logs: result = resolve_error_key("missing_code") self.assertEqual(result, "missing_code") self.assertTrue(any("i18n error code missing in shared catalog" in entry for entry in logs.output)) def test_missing_locale_translation_falls_back_to_default_locale(self): with patch( "lobby.i18n.lobby_i18n_error_messages", return_value={"session_code_required": {"en": "Session code is required"}}, ): self.assertEqual( resolve_error_message(key="session_code_required", locale="da"), "Session code is required", ) def test_shared_catalog_uses_en_default_and_da_en_matrix(self): default_locale, supported_locales = i18n_locale_config() catalog = lobby_i18n_catalog() self.assertEqual(default_locale, "en") self.assertIn("en", supported_locales) self.assertIn("da", supported_locales) for key, translations in catalog["backend"]["errors"].items(): self.assertTrue(translations.get("en"), f"backend key {key} missing en") self.assertTrue(translations.get("da"), f"backend key {key} missing da") for key, translations in catalog["frontend"]["errors"].items(): self.assertTrue(translations.get("en"), f"frontend key {key} missing en") self.assertTrue(translations.get("da"), f"frontend key {key} missing da") def test_backend_error_codes_map_via_shared_backend_frontend_key_map(self): catalog = lobby_i18n_catalog() backend_errors = catalog["backend"]["errors"] frontend_errors = catalog["frontend"]["errors"] shared_map = catalog["contract"]["backend_to_frontend_error_keys"] for code, backend_key in catalog["backend"]["error_codes"].items(): frontend_key = shared_map.get(code) self.assertIn(backend_key, backend_errors) self.assertTrue(frontend_key, f"missing frontend mapping for backend code: {code}") self.assertIn(frontend_key, frontend_errors)