feat(issue-225): extend backend i18n error contract to flow endpoints
All checks were successful
CI / test-and-quality (push) Successful in 3m40s
CI / test-and-quality (pull_request) Successful in 3m43s

This commit is contained in:
2026-03-01 22:32:33 +00:00
parent 64fe273691
commit 257732e2ab
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.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):

View File

@@ -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