Merge pull request '[MVP][READY] #225 Backend i18n baseline (resolver + fallback)' (#232) from feat/issue-225-backend-i18n-baseline into main
All checks were successful
CI / test-and-quality (push) Successful in 3m59s

This commit was merged in pull request #232.
This commit is contained in:
2026-03-01 23:44:22 +01:00
3 changed files with 174 additions and 16 deletions

View File

@@ -209,6 +209,8 @@ class StartRoundTests(TestCase):
) )
self.assertEqual(response.status_code, 403) 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") self.assertEqual(response.json()["error"], "Only host can start round")
def test_start_round_requires_existing_active_category_with_questions(self): def test_start_round_requires_existing_active_category_with_questions(self):
@@ -228,6 +230,8 @@ class StartRoundTests(TestCase):
content_type="application/json", content_type="application/json",
) )
self.assertEqual(response.status_code, 400) 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") self.assertEqual(response.json()["error"], "Category has no active questions")
def test_start_round_rejects_non_lobby_session(self): 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.status_code, 400)
self.assertEqual(response.json()["error"], "Round can only be started from lobby") 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): class LieSubmissionTests(TestCase):
def setUp(self): def setUp(self):
@@ -434,6 +468,8 @@ class MixAnswersTests(TestCase):
) )
self.assertEqual(response.status_code, 403) 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") self.assertEqual(response.json()["error"], "Only host can mix answers")
def test_mix_answers_deduplicates_case_insensitive_lies(self): def test_mix_answers_deduplicates_case_insensitive_lies(self):

View File

@@ -268,7 +268,11 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
) )
if session.host_id != request.user.id: 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: if session.status != GameSession.Status.LOBBY:
return api_error( 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(): 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(): with transaction.atomic():
session = GameSession.objects.select_for_update().get(pk=session.pk) session = GameSession.objects.select_for_update().get(pk=session.pk)
@@ -340,21 +348,41 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: 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: 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: 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: try:
round_config = RoundConfig.objects.get(session=session, number=session.current_round) round_config = RoundConfig.objects.get(session=session, number=session.current_round)
except RoundConfig.DoesNotExist: 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(): 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) used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
available_questions = Question.objects.filter( available_questions = Question.objects.filter(
@@ -363,7 +391,11 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
).exclude(pk__in=used_question_ids) ).exclude(pk__in=used_question_ids)
if not available_questions.exists(): 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)) question = random.choice(list(available_questions))
round_question = RoundQuestion.objects.create( round_question = RoundQuestion.objects.create(
@@ -473,13 +505,25 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: 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: 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}: 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: try:
round_question = RoundQuestion.objects.get( 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, round_number=session.current_round,
) )
except RoundQuestion.DoesNotExist: 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(): with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk) locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: 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) 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()) deduped_answers.append(text.strip())
if len(deduped_answers) < 2: 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) random.shuffle(deduped_answers)
locked_round_question.mixed_answers = deduped_answers locked_round_question.mixed_answers = deduped_answers

View File

@@ -281,7 +281,18 @@
"category_slug_required": "category_slug_required", "category_slug_required": "category_slug_required",
"category_not_found": "category_not_found", "category_not_found": "category_not_found",
"round_start_invalid_phase": "round_start_invalid_phase", "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": { "errors": {
"session_code_required": { "session_code_required": {
@@ -319,6 +330,50 @@
"round_already_configured": { "round_already_configured": {
"en": "Round already configured", "en": "Round already configured",
"da": "Runden er allerede konfigureret" "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_slug_required": "start_round_failed",
"category_not_found": "start_round_failed", "category_not_found": "start_round_failed",
"round_start_invalid_phase": "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"
} }
} }
} }