merge: rebase canonical reveal flow onto main
All checks were successful
CI / test-and-quality (push) Successful in 2m55s
CI / test-and-quality (pull_request) Successful in 3m2s

This commit is contained in:
root
2026-03-15 12:57:15 +00:00
34 changed files with 4040 additions and 199 deletions

View File

@@ -100,6 +100,21 @@ class LobbyFlowTests(TestCase):
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)
@@ -360,9 +375,9 @@ class LieSubmissionTests(TestCase):
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error_code"], "lie_submission_window_closed")
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.")
self.assertEqual(response.json()["error"], "Lie submission window has closed")
def test_submit_lie_rejects_duplicate_submission(self):
round_question = RoundQuestion.objects.create(
@@ -385,7 +400,7 @@ class LieSubmissionTests(TestCase):
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 has already been submitted for this player.")
self.assertEqual(response.json()["error"], "Lie already submitted for this player")
def test_submit_lie_requires_session_token(self):
round_question = RoundQuestion.objects.create(
@@ -407,7 +422,7 @@ class LieSubmissionTests(TestCase):
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.")
self.assertEqual(response.json()["error"], "session_token is required")
def test_submit_lie_rejects_invalid_session_token(self):
round_question = RoundQuestion.objects.create(
@@ -429,7 +444,30 @@ class LieSubmissionTests(TestCase):
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"], "Player session token is invalid.")
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):
@@ -592,7 +630,7 @@ class GuessSubmissionTests(TestCase):
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.")
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(
@@ -606,7 +644,8 @@ class GuessSubmissionTests(TestCase):
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error_code"], "selected_answer_invalid")
self.assertEqual(response.json()["error"], "Selected answer is not part of this round.")
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)
@@ -622,7 +661,8 @@ class GuessSubmissionTests(TestCase):
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error_code"], "guess_already_submitted")
self.assertEqual(response.json()["error"], "Guess has already been submitted for this player.")
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)
@@ -638,8 +678,9 @@ class GuessSubmissionTests(TestCase):
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error_code"], "guess_submission_window_closed")
self.assertEqual(response.json()["error"], "Guess submission window has closed.")
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")
@@ -654,7 +695,9 @@ class GuessSubmissionTests(TestCase):
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Session token is required.")
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(
@@ -668,7 +711,8 @@ class GuessSubmissionTests(TestCase):
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error_code"], "invalid_player_session_token")
self.assertEqual(response.json()["error"], "Player session token is invalid.")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Invalid player session token")
class ScoreCalculationTests(TestCase):
@@ -795,7 +839,9 @@ class ScoreCalculationTests(TestCase):
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"], "Only the host can calculate scores.")
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)
@@ -816,18 +862,21 @@ class ScoreCalculationTests(TestCase):
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 409)
self.assertEqual(second.json()["error"], "Scores have already been calculated for this round question.")
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.SCOREBOARD)
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)
def test_host_can_get_reveal_scoreboard(self):
@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(
@@ -841,6 +890,20 @@ class RevealRoundFlowTests(TestCase):
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")
@@ -849,15 +912,32 @@ class RevealRoundFlowTests(TestCase):
reverse(
"lobby:reveal_scoreboard",
kwargs={"code": self.session.code},
)
),
HTTP_ACCEPT_LANGUAGE="fr",
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"], "Only the host can view the scoreboard.")
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_host_can_finish_game_from_scoreboard(self):
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",
@@ -881,11 +961,14 @@ class RevealRoundFlowTests(TestCase):
reverse(
"lobby:finish_game",
kwargs={"code": self.session.code},
)
),
HTTP_ACCEPT_LANGUAGE="da",
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"], "Only the host can finish the game.")
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")
@@ -896,14 +979,19 @@ class RevealRoundFlowTests(TestCase):
reverse(
"lobby:finish_game",
kwargs={"code": self.session.code},
)
),
HTTP_ACCEPT_LANGUAGE="fr",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase.")
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")
def test_host_can_start_next_round_from_scoreboard(self):
@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}))
response = self.client.post(
reverse(
@@ -921,6 +1009,50 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(self.session.status, GameSession.Status.LOBBY)
self.assertEqual(self.session.current_round, 2)
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
@@ -930,11 +1062,30 @@ class RevealRoundFlowTests(TestCase):
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"], "Next round can only start from scoreboard phase.")
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_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):
@@ -1150,6 +1301,17 @@ class UiScreenTests(TestCase):
self.assertContains(response, "data-wpp-shell-route=\"/host/guess/round-1\"")
self.assertContains(response, "data-wpp-shell-kind=\"host\"")
def test_host_screen_template_registers_scoreboard_shell_route(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,
'var HOST_SHELL_ROUTES={lobby:"lobby",lie:"lie",guess:"guess",reveal:"reveal",scoreboard:"scoreboard",finished:"finished"};',
)
@override_settings(USE_SPA_UI=True)
def test_host_screen_deeplink_normalizes_redundant_slashes_when_feature_flag_enabled(self):
self.client.login(username="host_ui", password="secret123")
@@ -1496,6 +1658,15 @@ class I18nResolverTests(TestCase):
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",