From 257732e2abf9ef7c13f9ce5eb531b76159e3148e Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 1 Mar 2026 22:32:33 +0000 Subject: [PATCH] feat(issue-225): extend backend i18n error contract to flow endpoints --- lobby/tests.py | 36 ++++++++++++++++++ lobby/views.py | 84 +++++++++++++++++++++++++++++++++++------- shared/i18n/lobby.json | 70 ++++++++++++++++++++++++++++++++++- 3 files changed, 174 insertions(+), 16 deletions(-) diff --git a/lobby/tests.py b/lobby/tests.py index 15ba351..84e631e 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -209,6 +209,8 @@ class StartRoundTests(TestCase): ) 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): @@ -228,6 +230,8 @@ class StartRoundTests(TestCase): 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): @@ -244,6 +248,36 @@ class StartRoundTests(TestCase): 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") + class LieSubmissionTests(TestCase): def setUp(self): @@ -434,6 +468,8 @@ class MixAnswersTests(TestCase): ) 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): diff --git a/lobby/views.py b/lobby/views.py index 3509eb9..0282fb2 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -268,7 +268,11 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: ) if session.host_id != request.user.id: - return JsonResponse({"error": "Only host can start round"}, status=403) + return api_error( + request, + key=ERROR_CODES.get("host_only_start_round", "host_only_start_round"), + status=403, + ) if session.status != GameSession.Status.LOBBY: return api_error( @@ -287,7 +291,11 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: ) if not Question.objects.filter(category=category, is_active=True).exists(): - return JsonResponse({"error": "Category has no active questions"}, status=400) + return api_error( + request, + key=ERROR_CODES.get("category_has_no_questions", "category_has_no_questions"), + status=400, + ) with transaction.atomic(): session = GameSession.objects.select_for_update().get(pk=session.pk) @@ -340,21 +348,41 @@ def show_question(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, + key=ERROR_CODES.get("session_not_found", "session_not_found"), + status=404, + ) if session.host_id != request.user.id: - return JsonResponse({"error": "Only host can show question"}, status=403) + return api_error( + request, + key=ERROR_CODES.get("host_only_show_question", "host_only_show_question"), + status=403, + ) if session.status != GameSession.Status.LIE: - return JsonResponse({"error": "Question can only be shown in lie phase"}, status=400) + return api_error( + request, + key=ERROR_CODES.get("show_question_invalid_phase", "show_question_invalid_phase"), + status=400, + ) try: round_config = RoundConfig.objects.get(session=session, number=session.current_round) except RoundConfig.DoesNotExist: - return JsonResponse({"error": "Round config missing"}, status=400) + return api_error( + request, + key=ERROR_CODES.get("round_config_missing", "round_config_missing"), + status=400, + ) if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists(): - return JsonResponse({"error": "Question already shown for this round"}, status=409) + return api_error( + request, + key=ERROR_CODES.get("question_already_shown", "question_already_shown"), + status=409, + ) used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True) available_questions = Question.objects.filter( @@ -363,7 +391,11 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: ).exclude(pk__in=used_question_ids) if not available_questions.exists(): - return JsonResponse({"error": "No available questions in category"}, status=400) + return api_error( + request, + key=ERROR_CODES.get("no_available_questions", "no_available_questions"), + status=400, + ) question = random.choice(list(available_questions)) round_question = RoundQuestion.objects.create( @@ -473,13 +505,25 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error( + request, + key=ERROR_CODES.get("session_not_found", "session_not_found"), + status=404, + ) if session.host_id != request.user.id: - return JsonResponse({"error": "Only host can mix answers"}, status=403) + return api_error( + request, + key=ERROR_CODES.get("host_only_mix_answers", "host_only_mix_answers"), + status=403, + ) if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: - return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400) + return api_error( + request, + key=ERROR_CODES.get("mix_answers_invalid_phase", "mix_answers_invalid_phase"), + status=400, + ) try: round_question = RoundQuestion.objects.get( @@ -488,12 +532,20 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json round_number=session.current_round, ) except RoundQuestion.DoesNotExist: - return JsonResponse({"error": "Round question not found"}, status=404) + return api_error( + request, + key=ERROR_CODES.get("round_question_not_found", "round_question_not_found"), + status=404, + ) with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: - return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400) + return api_error( + request, + key=ERROR_CODES.get("mix_answers_invalid_phase", "mix_answers_invalid_phase"), + status=400, + ) locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk) @@ -509,7 +561,11 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json deduped_answers.append(text.strip()) if len(deduped_answers) < 2: - return JsonResponse({"error": "Not enough answers to mix"}, status=400) + return api_error( + request, + key=ERROR_CODES.get("not_enough_answers_to_mix", "not_enough_answers_to_mix"), + status=400, + ) random.shuffle(deduped_answers) locked_round_question.mixed_answers = deduped_answers diff --git a/shared/i18n/lobby.json b/shared/i18n/lobby.json index 9081cff..b9a9ddb 100644 --- a/shared/i18n/lobby.json +++ b/shared/i18n/lobby.json @@ -281,7 +281,18 @@ "category_slug_required": "category_slug_required", "category_not_found": "category_not_found", "round_start_invalid_phase": "round_start_invalid_phase", - "round_already_configured": "round_already_configured" + "round_already_configured": "round_already_configured", + "category_has_no_questions": "category_has_no_questions", + "show_question_invalid_phase": "show_question_invalid_phase", + "round_config_missing": "round_config_missing", + "question_already_shown": "question_already_shown", + "no_available_questions": "no_available_questions", + "mix_answers_invalid_phase": "mix_answers_invalid_phase", + "round_question_not_found": "round_question_not_found", + "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" }, "errors": { "session_code_required": { @@ -319,6 +330,50 @@ "round_already_configured": { "en": "Round already configured", "da": "Runden er allerede konfigureret" + }, + "category_has_no_questions": { + "en": "Category has no active questions", + "da": "Kategorien har ingen aktive spørgsmål" + }, + "show_question_invalid_phase": { + "en": "Question can only be shown in lie phase", + "da": "Spørgsmålet kan kun vises i løgnefasen" + }, + "round_config_missing": { + "en": "Round config missing", + "da": "Rundekonfiguration mangler" + }, + "question_already_shown": { + "en": "Question already shown for this round", + "da": "Spørgsmålet er allerede vist for denne runde" + }, + "no_available_questions": { + "en": "No available questions in category", + "da": "Ingen tilgængelige spørgsmål i kategorien" + }, + "mix_answers_invalid_phase": { + "en": "Answers can only be mixed in lie or guess phase", + "da": "Svar kan kun blandes i løgne- eller gættefasen" + }, + "round_question_not_found": { + "en": "Round question not found", + "da": "Rundespørgsmål blev ikke fundet" + }, + "not_enough_answers_to_mix": { + "en": "Not enough answers to mix", + "da": "Ikke nok svar at blande" + }, + "host_only_start_round": { + "en": "Only host can start round", + "da": "Kun værten kan starte runden" + }, + "host_only_show_question": { + "en": "Only host can show question", + "da": "Kun værten kan vise spørgsmålet" + }, + "host_only_mix_answers": { + "en": "Only host can mix answers", + "da": "Kun værten kan blande svar" } } }, @@ -345,7 +400,18 @@ "category_slug_required": "start_round_failed", "category_not_found": "start_round_failed", "round_start_invalid_phase": "start_round_failed", - "round_already_configured": "start_round_failed" + "round_already_configured": "start_round_failed", + "host_only_start_round": "start_round_failed", + "host_only_show_question": "start_round_failed", + "host_only_mix_answers": "start_round_failed", + "category_has_no_questions": "start_round_failed", + "show_question_invalid_phase": "start_round_failed", + "round_config_missing": "start_round_failed", + "question_already_shown": "start_round_failed", + "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" } } } -- 2.39.5