From 80520bad5197dc47b0c5c505aaed611f9d78a08f Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Fri, 13 Mar 2026 09:16:23 +0000 Subject: [PATCH 1/3] feat(i18n): unify django api error resolution --- lobby/i18n.py | 14 +++- lobby/views.py | 136 ++++++++++++++++---------------- shared/i18n/key-manifest.json | 51 +++++++++++- shared/i18n/lobby.json | 142 +++++++++++++++++++++++++++++++++- 4 files changed, 270 insertions(+), 73 deletions(-) diff --git a/lobby/i18n.py b/lobby/i18n.py index b5808c8..a40d8dd 100644 --- a/lobby/i18n.py +++ b/lobby/i18n.py @@ -24,6 +24,15 @@ def lobby_i18n_error_messages() -> dict: return shared_i18n_catalog().get("backend", {}).get("errors", {}) +def resolve_error_key(code: str) -> str: + resolved = lobby_i18n_errors().get(code) + if isinstance(resolved, str) and resolved: + return resolved + + LOGGER.warning("i18n error code missing in shared catalog", extra={"code": code}) + return code + + def _quality_value(language_candidate: str) -> float | None: for parameter in language_candidate.split(";")[1:]: key, separator, value = parameter.partition("=") @@ -78,12 +87,13 @@ def resolve_error_message(*, key: str, locale: str) -> str: return key -def api_error(request: HttpRequest, *, key: str, status: int) -> JsonResponse: +def api_error(request: HttpRequest, *, code: str, status: int) -> JsonResponse: locale = resolve_locale(request) + key = resolve_error_key(code) return JsonResponse( { "error": resolve_error_message(key=key, locale=locale), - "error_code": key, + "error_code": code, "locale": locale, }, status=status, diff --git a/lobby/views.py b/lobby/views.py index 0282fb2..21c8fbe 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -20,7 +20,7 @@ from fupogfakta.models import ( ScoreEvent, ) -from .i18n import api_error, lobby_i18n_errors +from .i18n import api_error SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" SESSION_CODE_LENGTH = 6 @@ -31,7 +31,7 @@ JOINABLE_STATUSES = { GameSession.Status.GUESS, GameSession.Status.REVEAL, } -ERROR_CODES = lobby_i18n_errors() + def _json_body(request: HttpRequest) -> dict: @@ -129,14 +129,14 @@ def join_session(request: HttpRequest) -> JsonResponse: if not code: return api_error( request, - key=ERROR_CODES.get("session_code_required", "session_code_required"), + code="session_code_required", status=400, ) if len(nickname) < 2 or len(nickname) > 40: return api_error( request, - key=ERROR_CODES.get("nickname_invalid", "nickname_invalid"), + code="nickname_invalid", status=400, ) @@ -145,21 +145,21 @@ def join_session(request: HttpRequest) -> JsonResponse: except GameSession.DoesNotExist: return api_error( request, - key=ERROR_CODES.get("session_not_found", "session_not_found"), + code="session_not_found", status=404, ) if session.status not in JOINABLE_STATUSES: return api_error( request, - key=ERROR_CODES.get("session_not_joinable", "session_not_joinable"), + code="session_not_joinable", status=400, ) if Player.objects.filter(session=session, nickname__iexact=nickname).exists(): return api_error( request, - key=ERROR_CODES.get("nickname_taken", "nickname_taken"), + code="nickname_taken", status=409, ) @@ -191,7 +191,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: except GameSession.DoesNotExist: return api_error( request, - key=ERROR_CODES.get("session_not_found", "session_not_found"), + code="session_not_found", status=404, ) @@ -252,7 +252,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: if not category_slug: return api_error( request, - key=ERROR_CODES.get("category_slug_required", "category_slug_required"), + code="category_slug_required", status=400, ) @@ -263,21 +263,21 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: except GameSession.DoesNotExist: return api_error( request, - key=ERROR_CODES.get("session_not_found", "session_not_found"), + code="session_not_found", status=404, ) if session.host_id != request.user.id: return api_error( request, - key=ERROR_CODES.get("host_only_start_round", "host_only_start_round"), + code="host_only_start_round", status=403, ) if session.status != GameSession.Status.LOBBY: return api_error( request, - key=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"), + code="round_start_invalid_phase", status=400, ) @@ -286,14 +286,14 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: except Category.DoesNotExist: return api_error( request, - key=ERROR_CODES.get("category_not_found", "category_not_found"), + code="category_not_found", status=404, ) if not Question.objects.filter(category=category, is_active=True).exists(): return api_error( request, - key=ERROR_CODES.get("category_has_no_questions", "category_has_no_questions"), + code="category_has_no_questions", status=400, ) @@ -302,7 +302,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: if session.status != GameSession.Status.LOBBY: return api_error( request, - key=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"), + code="round_start_invalid_phase", status=400, ) @@ -314,7 +314,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: if not created: return api_error( request, - key=ERROR_CODES.get("round_already_configured", "round_already_configured"), + code="round_already_configured", status=409, ) @@ -350,21 +350,21 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: except GameSession.DoesNotExist: return api_error( request, - key=ERROR_CODES.get("session_not_found", "session_not_found"), + code="session_not_found", status=404, ) if session.host_id != request.user.id: return api_error( request, - key=ERROR_CODES.get("host_only_show_question", "host_only_show_question"), + code="host_only_show_question", status=403, ) if session.status != GameSession.Status.LIE: return api_error( request, - key=ERROR_CODES.get("show_question_invalid_phase", "show_question_invalid_phase"), + code="show_question_invalid_phase", status=400, ) @@ -373,14 +373,14 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: except RoundConfig.DoesNotExist: return api_error( request, - key=ERROR_CODES.get("round_config_missing", "round_config_missing"), + code="round_config_missing", status=400, ) if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists(): return api_error( request, - key=ERROR_CODES.get("question_already_shown", "question_already_shown"), + code="question_already_shown", status=409, ) @@ -393,7 +393,7 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: if not available_questions.exists(): return api_error( request, - key=ERROR_CODES.get("no_available_questions", "no_available_questions"), + code="no_available_questions", status=400, ) @@ -434,29 +434,29 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR lie_text = str(payload.get("text", "")).strip() if not player_id: - return JsonResponse({"error": "player_id is required"}, status=400) + return api_error(request, code="player_id_required", status=400) if not session_token: - return JsonResponse({"error": "session_token is required"}, status=400) + return api_error(request, code="session_token_required", status=400) if not lie_text or len(lie_text) > 255: - return JsonResponse({"error": "text must be between 1 and 255 characters"}, status=400) + return api_error(request, code="lie_text_invalid", status=400) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error(request, code="session_not_found", status=404) if session.status != GameSession.Status.LIE: - return JsonResponse({"error": "Lie submission is only allowed in lie phase"}, status=400) + return api_error(request, code="lie_submission_invalid_phase", status=400) try: player = Player.objects.get(pk=player_id, session=session) except Player.DoesNotExist: - return JsonResponse({"error": "Player not found in session"}, status=404) + return api_error(request, code="player_not_found_in_session", status=404) if player.session_token != session_token: - return JsonResponse({"error": "Invalid player session token"}, status=403) + return api_error(request, code="invalid_player_session_token", status=403) try: round_question = RoundQuestion.objects.get( @@ -465,21 +465,21 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR round_number=session.current_round, ) except RoundQuestion.DoesNotExist: - return JsonResponse({"error": "Round question not found"}, status=404) + return api_error(request, code="round_question_not_found", status=404) try: round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) except RoundConfig.DoesNotExist: - return JsonResponse({"error": "Round config missing"}, status=400) + return api_error(request, code="round_config_missing", status=400) lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) if timezone.now() > lie_deadline_at: - return JsonResponse({"error": "Lie submission window has closed"}, status=400) + return api_error(request, code="lie_submission_closed", status=400) try: lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text) except IntegrityError: - return JsonResponse({"error": "Lie already submitted for this player"}, status=409) + return api_error(request, code="lie_already_submitted", status=409) return JsonResponse( { @@ -507,21 +507,21 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json except GameSession.DoesNotExist: return api_error( request, - key=ERROR_CODES.get("session_not_found", "session_not_found"), + code="session_not_found", status=404, ) if session.host_id != request.user.id: return api_error( request, - key=ERROR_CODES.get("host_only_mix_answers", "host_only_mix_answers"), + code="host_only_mix_answers", status=403, ) if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: return api_error( request, - key=ERROR_CODES.get("mix_answers_invalid_phase", "mix_answers_invalid_phase"), + code="mix_answers_invalid_phase", status=400, ) @@ -534,7 +534,7 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json except RoundQuestion.DoesNotExist: return api_error( request, - key=ERROR_CODES.get("round_question_not_found", "round_question_not_found"), + code="round_question_not_found", status=404, ) @@ -543,7 +543,7 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: return api_error( request, - key=ERROR_CODES.get("mix_answers_invalid_phase", "mix_answers_invalid_phase"), + code="mix_answers_invalid_phase", status=400, ) @@ -563,7 +563,7 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json if len(deduped_answers) < 2: return api_error( request, - key=ERROR_CODES.get("not_enough_answers_to_mix", "not_enough_answers_to_mix"), + code="not_enough_answers_to_mix", status=400, ) @@ -601,29 +601,29 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso selected_text = str(payload.get("selected_text", "")).strip() if not player_id: - return JsonResponse({"error": "player_id is required"}, status=400) + return api_error(request, code="player_id_required", status=400) if not session_token: - return JsonResponse({"error": "session_token is required"}, status=400) + return api_error(request, code="session_token_required", status=400) if not selected_text or len(selected_text) > 255: - return JsonResponse({"error": "selected_text must be between 1 and 255 characters"}, status=400) + return api_error(request, code="selected_text_invalid", status=400) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error(request, code="session_not_found", status=404) if session.status != GameSession.Status.GUESS: - return JsonResponse({"error": "Guess submission is only allowed in guess phase"}, status=400) + return api_error(request, code="guess_submission_invalid_phase", status=400) try: player = Player.objects.get(pk=player_id, session=session) except Player.DoesNotExist: - return JsonResponse({"error": "Player not found in session"}, status=404) + return api_error(request, code="player_not_found_in_session", status=404) if player.session_token != session_token: - return JsonResponse({"error": "Invalid player session token"}, status=403) + return api_error(request, code="invalid_player_session_token", status=403) try: round_question = RoundQuestion.objects.get( @@ -632,18 +632,18 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso round_number=session.current_round, ) except RoundQuestion.DoesNotExist: - return JsonResponse({"error": "Round question not found"}, status=404) + return api_error(request, code="round_question_not_found", status=404) try: round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) except RoundConfig.DoesNotExist: - return JsonResponse({"error": "Round config missing"}, status=400) + return api_error(request, code="round_config_missing", status=400) guess_deadline_at = round_question.shown_at + timedelta( seconds=round_config.lie_seconds + round_config.guess_seconds ) if timezone.now() > guess_deadline_at: - return JsonResponse({"error": "Guess submission window has closed"}, status=400) + return api_error(request, code="guess_submission_closed", status=400) allowed_answers = { round_question.correct_answer.strip().casefold(), @@ -656,7 +656,7 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso selected_normalized = selected_text.casefold() if selected_normalized not in allowed_answers: - return JsonResponse({"error": "Selected answer is not part of this round"}, status=400) + return api_error(request, code="selected_answer_invalid", status=400) correct_normalized = round_question.correct_answer.strip().casefold() fooled_player_id = None @@ -674,7 +674,7 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso fooled_player_id=fooled_player_id, ) except IntegrityError: - return JsonResponse({"error": "Guess already submitted for this player"}, status=409) + return api_error(request, code="guess_already_submitted", status=409) return JsonResponse( { @@ -705,13 +705,13 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error(request, code="session_not_found", status=404) if session.host_id != request.user.id: - return JsonResponse({"error": "Only host can view scoreboard"}, status=403) + return api_error(request, code="host_only_view_scoreboard", status=403) if session.status != GameSession.Status.REVEAL: - return JsonResponse({"error": "Scoreboard is only available in reveal phase"}, status=400) + return api_error(request, code="scoreboard_invalid_phase", status=400) leaderboard = list( Player.objects.filter(session=session) @@ -739,15 +739,15 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error(request, code="session_not_found", status=404) if session.host_id != request.user.id: - return JsonResponse({"error": "Only host can start next round"}, status=403) + return api_error(request, code="host_only_start_next_round", status=403) with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status != GameSession.Status.REVEAL: - return JsonResponse({"error": "Next round can only start from reveal phase"}, status=400) + return api_error(request, code="next_round_invalid_phase", status=400) locked_session.current_round += 1 locked_session.status = GameSession.Status.LOBBY @@ -771,15 +771,15 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse: try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error(request, code="session_not_found", status=404) if session.host_id != request.user.id: - return JsonResponse({"error": "Only host can finish game"}, status=403) + return api_error(request, code="host_only_finish_game", status=403) with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status != GameSession.Status.REVEAL: - return JsonResponse({"error": "Game can only be finished from reveal phase"}, status=400) + return api_error(request, code="finish_game_invalid_phase", status=400) locked_session.status = GameSession.Status.FINISHED locked_session.save(update_fields=["status"]) @@ -813,20 +813,20 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error(request, code="session_not_found", status=404) if session.host_id != request.user.id: - return JsonResponse({"error": "Only host can calculate scores"}, status=403) + return api_error(request, code="host_only_calculate_scores", status=403) already_calculated = ScoreEvent.objects.filter( session=session, meta__round_question_id=round_question_id, ).exists() if already_calculated: - return JsonResponse({"error": "Scores already calculated for this round question"}, status=409) + return api_error(request, code="scores_already_calculated", status=409) if session.status != GameSession.Status.GUESS: - return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400) + return api_error(request, code="calculate_scores_invalid_phase", status=400) try: round_question = RoundQuestion.objects.get( @@ -835,16 +835,16 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> round_number=session.current_round, ) except RoundQuestion.DoesNotExist: - return JsonResponse({"error": "Round question not found"}, status=404) + return api_error(request, code="round_question_not_found", status=404) try: round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) except RoundConfig.DoesNotExist: - return JsonResponse({"error": "Round config missing"}, status=400) + return api_error(request, code="round_config_missing", status=400) guesses = list(round_question.guesses.select_related("player")) if not guesses: - return JsonResponse({"error": "No guesses submitted for this round question"}, status=400) + return api_error(request, code="no_guesses_submitted", status=400) bluff_counts = {} for guess in guesses: @@ -854,7 +854,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status != GameSession.Status.GUESS: - return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400) + return api_error(request, code="calculate_scores_invalid_phase", status=400) score_events = [] diff --git a/shared/i18n/key-manifest.json b/shared/i18n/key-manifest.json index 2240fb8..e3b8ab3 100644 --- a/shared/i18n/key-manifest.json +++ b/shared/i18n/key-manifest.json @@ -1,5 +1,8 @@ { - "locales": ["en", "da"], + "locales": [ + "en", + "da" + ], "frontend_error_keys": [ "join_failed", "nickname_invalid", @@ -11,50 +14,96 @@ "unknown" ], "backend_error_codes": [ + "calculate_scores_invalid_phase", "category_has_no_questions", "category_not_found", "category_slug_required", + "finish_game_invalid_phase", + "guess_already_submitted", + "guess_submission_closed", + "guess_submission_invalid_phase", + "host_only_calculate_scores", + "host_only_finish_game", "host_only_mix_answers", "host_only_show_question", + "host_only_start_next_round", "host_only_start_round", + "host_only_view_scoreboard", + "invalid_player_session_token", + "lie_already_submitted", + "lie_submission_closed", + "lie_submission_invalid_phase", + "lie_text_invalid", "mix_answers_invalid_phase", + "next_round_invalid_phase", "nickname_invalid", "nickname_taken", "no_available_questions", + "no_guesses_submitted", "not_enough_answers_to_mix", + "player_id_required", + "player_not_found_in_session", "question_already_shown", "round_already_configured", "round_config_missing", "round_question_not_found", "round_start_invalid_phase", + "scoreboard_invalid_phase", + "scores_already_calculated", + "selected_answer_invalid", + "selected_text_invalid", "session_code_required", "session_not_found", "session_not_joinable", + "session_token_required", "show_question_invalid_phase" ], "allowed_contract_only_backend_codes": [ "host_only_action" ], "backend_error_keys": [ + "calculate_scores_invalid_phase", "category_has_no_questions", "category_not_found", "category_slug_required", + "finish_game_invalid_phase", + "guess_already_submitted", + "guess_submission_closed", + "guess_submission_invalid_phase", + "host_only_calculate_scores", + "host_only_finish_game", "host_only_mix_answers", "host_only_show_question", + "host_only_start_next_round", "host_only_start_round", + "host_only_view_scoreboard", + "invalid_player_session_token", + "lie_already_submitted", + "lie_submission_closed", + "lie_submission_invalid_phase", + "lie_text_invalid", "mix_answers_invalid_phase", + "next_round_invalid_phase", "nickname_invalid", "nickname_taken", "no_available_questions", + "no_guesses_submitted", "not_enough_answers_to_mix", + "player_id_required", + "player_not_found_in_session", "question_already_shown", "round_already_configured", "round_config_missing", "round_question_not_found", "round_start_invalid_phase", + "scoreboard_invalid_phase", + "scores_already_calculated", + "selected_answer_invalid", + "selected_text_invalid", "session_code_required", "session_not_found", "session_not_joinable", + "session_token_required", "show_question_invalid_phase" ] } diff --git a/shared/i18n/lobby.json b/shared/i18n/lobby.json index c7cf10f..19bf059 100644 --- a/shared/i18n/lobby.json +++ b/shared/i18n/lobby.json @@ -296,7 +296,30 @@ "not_enough_answers_to_mix": "not_enough_answers_to_mix", "host_only_start_round": "host_only_start_round", "host_only_show_question": "host_only_show_question", - "host_only_mix_answers": "host_only_mix_answers" + "host_only_mix_answers": "host_only_mix_answers", + "player_id_required": "player_id_required", + "session_token_required": "session_token_required", + "lie_text_invalid": "lie_text_invalid", + "player_not_found_in_session": "player_not_found_in_session", + "invalid_player_session_token": "invalid_player_session_token", + "lie_submission_closed": "lie_submission_closed", + "lie_already_submitted": "lie_already_submitted", + "host_only_view_scoreboard": "host_only_view_scoreboard", + "scoreboard_invalid_phase": "scoreboard_invalid_phase", + "host_only_start_next_round": "host_only_start_next_round", + "next_round_invalid_phase": "next_round_invalid_phase", + "host_only_finish_game": "host_only_finish_game", + "finish_game_invalid_phase": "finish_game_invalid_phase", + "host_only_calculate_scores": "host_only_calculate_scores", + "scores_already_calculated": "scores_already_calculated", + "calculate_scores_invalid_phase": "calculate_scores_invalid_phase", + "no_guesses_submitted": "no_guesses_submitted", + "guess_submission_closed": "guess_submission_closed", + "selected_answer_invalid": "selected_answer_invalid", + "guess_already_submitted": "guess_already_submitted", + "lie_submission_invalid_phase": "lie_submission_invalid_phase", + "selected_text_invalid": "selected_text_invalid", + "guess_submission_invalid_phase": "guess_submission_invalid_phase" }, "errors": { "session_code_required": { @@ -378,6 +401,98 @@ "host_only_mix_answers": { "en": "Only host can mix answers", "da": "Kun værten kan blande svar" + }, + "player_id_required": { + "en": "player_id is required", + "da": "player_id er påkrævet" + }, + "session_token_required": { + "en": "session_token is required", + "da": "session_token er påkrævet" + }, + "lie_text_invalid": { + "en": "text must be between 1 and 255 characters", + "da": "text skal være mellem 1 og 255 tegn" + }, + "player_not_found_in_session": { + "en": "Player not found in session", + "da": "Spiller blev ikke fundet i sessionen" + }, + "invalid_player_session_token": { + "en": "Invalid player session token", + "da": "Ugyldigt spiller-session-token" + }, + "lie_submission_closed": { + "en": "Lie submission window has closed", + "da": "Vinduet for løgn-indsendelse er lukket" + }, + "lie_already_submitted": { + "en": "Lie already submitted for this player", + "da": "Løgnen er allerede indsendt for denne spiller" + }, + "host_only_view_scoreboard": { + "en": "Only host can view scoreboard", + "da": "Kun værten kan se scoreboard" + }, + "scoreboard_invalid_phase": { + "en": "Scoreboard is only available in reveal phase", + "da": "Scoreboard er kun tilgængeligt i reveal-fasen" + }, + "host_only_start_next_round": { + "en": "Only host can start next round", + "da": "Kun værten kan starte næste runde" + }, + "next_round_invalid_phase": { + "en": "Next round can only start from reveal phase", + "da": "Næste runde kan kun startes fra reveal-fasen" + }, + "host_only_finish_game": { + "en": "Only host can finish game", + "da": "Kun værten kan afslutte spillet" + }, + "finish_game_invalid_phase": { + "en": "Game can only be finished from reveal phase", + "da": "Spillet kan kun afsluttes fra reveal-fasen" + }, + "host_only_calculate_scores": { + "en": "Only host can calculate scores", + "da": "Kun værten kan udregne score" + }, + "scores_already_calculated": { + "en": "Scores already calculated for this round question", + "da": "Score er allerede udregnet for dette rundespørgsmål" + }, + "calculate_scores_invalid_phase": { + "en": "Scores can only be calculated in guess phase", + "da": "Score kan kun udregnes i gættefasen" + }, + "no_guesses_submitted": { + "en": "No guesses submitted for this round question", + "da": "Ingen gæt er indsendt for dette rundespørgsmål" + }, + "guess_submission_closed": { + "en": "Guess submission window has closed", + "da": "Vinduet for gæt-indsendelse er lukket" + }, + "selected_answer_invalid": { + "en": "Selected answer is not part of this round", + "da": "Det valgte svar er ikke en del af denne runde" + }, + "guess_already_submitted": { + "en": "Guess already submitted for this player", + "da": "Gættet er allerede indsendt for denne spiller" + }, + "lie_submission_invalid_phase": { + "en": "Lie submission is only allowed in lie phase", + "da": "Løgn kan kun indsendes i løgnefasen" + }, + "selected_text_invalid": { + "en": "selected_text must be between 1 and 255 characters", + "da": "selected_text skal være mellem 1 og 255 tegn" + }, + "guess_submission_invalid_phase": { + "en": "Guess submission is only allowed in guess phase", + "da": "Gæt kan kun indsendes i gættefasen" } } }, @@ -416,7 +531,30 @@ "no_available_questions": "start_round_failed", "mix_answers_invalid_phase": "start_round_failed", "round_question_not_found": "start_round_failed", - "not_enough_answers_to_mix": "start_round_failed" + "not_enough_answers_to_mix": "start_round_failed", + "player_id_required": "unknown", + "session_token_required": "unknown", + "lie_text_invalid": "unknown", + "lie_submission_invalid_phase": "unknown", + "player_not_found_in_session": "unknown", + "invalid_player_session_token": "unknown", + "lie_submission_closed": "unknown", + "lie_already_submitted": "unknown", + "selected_text_invalid": "unknown", + "guess_submission_invalid_phase": "unknown", + "guess_submission_closed": "unknown", + "selected_answer_invalid": "unknown", + "guess_already_submitted": "unknown", + "host_only_view_scoreboard": "unknown", + "scoreboard_invalid_phase": "unknown", + "host_only_start_next_round": "unknown", + "next_round_invalid_phase": "unknown", + "host_only_finish_game": "unknown", + "finish_game_invalid_phase": "unknown", + "host_only_calculate_scores": "unknown", + "scores_already_calculated": "unknown", + "calculate_scores_invalid_phase": "unknown", + "no_guesses_submitted": "unknown" } } } From db7be0dfc66bf102a8bbd8f4ca4ddccc4680e9e4 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Fri, 13 Mar 2026 09:16:23 +0000 Subject: [PATCH 2/3] test(i18n): cover locale fallback and backend error payloads --- lobby/tests.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/lobby/tests.py b/lobby/tests.py index 6b156f3..1aefac6 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -360,6 +360,8 @@ class LieSubmissionTests(TestCase): ) 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): @@ -381,6 +383,8 @@ 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 already submitted for this player") def test_submit_lie_requires_session_token(self): @@ -401,6 +405,8 @@ 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") def test_submit_lie_rejects_invalid_session_token(self): @@ -421,8 +427,33 @@ 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"], "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") @@ -582,6 +613,8 @@ 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") def test_submit_guess_rejects_unknown_answer(self): @@ -595,6 +628,8 @@ class GuessSubmissionTests(TestCase): ) 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): @@ -610,6 +645,8 @@ class GuessSubmissionTests(TestCase): ) 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): @@ -626,6 +663,8 @@ class GuessSubmissionTests(TestCase): ) 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") @@ -641,6 +680,8 @@ class GuessSubmissionTests(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") def test_submit_guess_rejects_invalid_session_token(self): @@ -654,6 +695,8 @@ class GuessSubmissionTests(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"], "Invalid player session token") @@ -735,6 +778,8 @@ class ScoreCalculationTests(TestCase): ) 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): @@ -756,6 +801,8 @@ class ScoreCalculationTests(TestCase): 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") @@ -793,6 +840,8 @@ class RevealRoundFlowTests(TestCase): ) 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_host_can_finish_game_from_reveal(self): @@ -825,6 +874,8 @@ class RevealRoundFlowTests(TestCase): ) self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error_code"], "host_only_finish_game") + self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Only host can finish game") def test_finish_game_rejects_wrong_phase(self): @@ -840,6 +891,8 @@ class RevealRoundFlowTests(TestCase): ) 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 reveal phase") def test_host_can_start_next_round_from_reveal(self): @@ -874,8 +927,26 @@ class RevealRoundFlowTests(TestCase): ) self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error_code"], "next_round_invalid_phase") + self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Next round can only start from reveal phase") + 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") @@ -1342,6 +1413,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", From 864984273af5cbcce0ba21714756809e47dab4db Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Fri, 13 Mar 2026 10:38:07 +0000 Subject: [PATCH 3/3] fix(ci): drop unused lobby i18n import --- lobby/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lobby/views.py b/lobby/views.py index 0e8214c..aaf2658 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -22,7 +22,7 @@ from fupogfakta.models import ( from realtime.broadcast import sync_broadcast_phase_event -from .i18n import api_error, lobby_i18n_errors +from .i18n import api_error SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" SESSION_CODE_LENGTH = 6