Files
weirsoe-party-protocol/lobby/tests.py
DEV-bot bb8109baf6
All checks were successful
CI / test-and-quality (push) Successful in 3m0s
CI / test-and-quality (pull_request) Successful in 3m0s
test(i18n): harden resolver logging and fallback coverage
2026-03-01 21:09:37 +00:00

1262 lines
58 KiB
Python

import json
import tempfile
from datetime import timedelta
from pathlib import Path
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from fupogfakta.models import (
Category,
GameSession,
Guess,
LieAnswer,
Player,
Question,
RoundConfig,
RoundQuestion,
)
from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message
User = get_user_model()
class LobbyFlowTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host", password="secret123")
def test_create_session_requires_login(self):
response = self.client.post(reverse("lobby:create_session"))
self.assertEqual(response.status_code, 302)
self.assertEqual(GameSession.objects.count(), 0)
def test_host_can_create_session(self):
self.client.login(username="host", password="secret123")
response = self.client.post(reverse("lobby:create_session"))
self.assertEqual(response.status_code, 201)
body = response.json()
self.assertEqual(body["session"]["status"], GameSession.Status.LOBBY)
self.assertEqual(len(body["session"]["code"]), 6)
session = GameSession.objects.get(code=body["session"]["code"])
self.assertEqual(session.host, self.host)
def test_player_can_join_with_code(self):
session = GameSession.objects.create(host=self.host, code="ABCD23")
response = self.client.post(
reverse("lobby:join_session"),
data={"code": "abcd23", "nickname": "Luna"},
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
body = response.json()
self.assertEqual(body["session"]["code"], "ABCD23")
self.assertEqual(body["player"]["nickname"], "Luna")
self.assertIn("session_token", body["player"])
self.assertTrue(body["player"]["session_token"])
self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists())
def test_player_can_join_with_trimmed_code(self):
session = GameSession.objects.create(host=self.host, code="ABCD23")
response = self.client.post(
reverse("lobby:join_session"),
data={"code": " abcd23 ", "nickname": "Luna"},
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists())
def test_join_rejects_code_empty_after_trim(self):
response = self.client.post(
reverse("lobby:join_session"),
data={"code": " ", "nickname": "Luna"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Session code is required")
def test_join_rejects_duplicate_nickname_case_insensitive(self):
session = GameSession.objects.create(host=self.host, code="QWER12")
Player.objects.create(session=session, nickname="Luna")
response = self.client.post(
reverse("lobby:join_session"),
data={"code": "QWER12", "nickname": "lUna"},
content_type="application/json",
)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"], "Nickname already taken")
def test_join_rejects_non_joinable_session(self):
GameSession.objects.create(host=self.host, code="ZXCV98", status=GameSession.Status.FINISHED)
response = self.client.post(
reverse("lobby:join_session"),
data={"code": "ZXCV98", "nickname": "Kai"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Session is not joinable")
def test_join_error_localizes_to_danish_with_accept_language_header(self):
response = self.client.post(
reverse("lobby:join_session"),
data={"code": " ", "nickname": "Luna"},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="da",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error_code"], "session_code_required")
self.assertEqual(response.json()["locale"], "da")
self.assertEqual(response.json()["error"], "Sessionskode er påkrævet")
def test_join_error_falls_back_to_english_for_unsupported_locale(self):
response = self.client.post(
reverse("lobby:join_session"),
data={"code": " ", "nickname": "Luna"},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="fr",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error_code"], "session_code_required")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Session code is required")
def test_session_detail_returns_players(self):
session = GameSession.objects.create(host=self.host, code="LMNO45")
Player.objects.create(session=session, nickname="Mia", score=7)
Player.objects.create(session=session, nickname="Bo", score=2)
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": "lmno45"}))
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["session"]["players_count"], 2)
self.assertEqual([p["nickname"] for p in payload["players"]], ["Bo", "Mia"])
class StartRoundTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host", password="secret123")
self.other_user = User.objects.create_user(username="other", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="ABCD23")
self.category = Category.objects.create(name="Historie", slug="historie", is_active=True)
Question.objects.create(
category=self.category,
prompt="Hvilket år faldt muren?",
correct_answer="1989",
is_active=True,
)
def test_host_can_start_round_with_selected_category(self):
self.client.login(username="host", 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",
)
self.assertEqual(response.status_code, 201)
body = response.json()
self.assertEqual(body["session"]["status"], GameSession.Status.LIE)
self.assertEqual(body["round"]["number"], 1)
self.assertEqual(body["round"]["category"]["slug"], self.category.slug)
self.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.LIE)
round_config = RoundConfig.objects.get(session=self.session, number=1)
self.assertEqual(round_config.category, self.category)
def test_host_start_round_uses_normalized_session_code_from_path(self):
self.client.login(username="host", password="secret123")
response = self.client.post(
reverse("lobby:start_round", kwargs={"code": " abcd23 "}),
data={"category_slug": self.category.slug},
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
self.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.LIE)
round_config = RoundConfig.objects.get(session=self.session, number=1)
self.assertEqual(round_config.category, self.category)
def test_start_round_requires_host(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",
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"], "Only host can start round")
def test_start_round_requires_existing_active_category_with_questions(self):
self.client.login(username="host", password="secret123")
response = self.client.post(
reverse("lobby:start_round", kwargs={"code": self.session.code}),
data={"category_slug": "ukendt"},
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
empty_category = Category.objects.create(name="Sport", slug="sport", is_active=True)
response = self.client.post(
reverse("lobby:start_round", kwargs={"code": self.session.code}),
data={"category_slug": empty_category.slug},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Category has no active questions")
def test_start_round_rejects_non_lobby_session(self):
self.client.login(username="host", password="secret123")
self.session.status = GameSession.Status.GUESS
self.session.save(update_fields=["status"])
response = self.client.post(
reverse("lobby:start_round", kwargs={"code": self.session.code}),
data={"category_slug": self.category.slug},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Round can only be started from lobby")
class LieSubmissionTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE)
self.category = Category.objects.create(name="Geografi", slug="geografi", is_active=True)
self.question = Question.objects.create(
category=self.category,
prompt="Hvad er hovedstaden i Australien?",
correct_answer="Canberra",
is_active=True,
)
RoundConfig.objects.create(
session=self.session,
number=1,
category=self.category,
lie_seconds=45,
)
self.player = Player.objects.create(session=self.session, nickname="Luna")
def test_host_can_show_question_and_get_lie_deadline(self):
self.client.login(username="host", password="secret123")
response = self.client.post(reverse("lobby:show_question", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 201)
payload = response.json()
self.assertEqual(payload["config"]["lie_seconds"], 45)
self.assertIn("lie_deadline_at", payload["round_question"])
self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists())
def test_player_can_submit_lie_before_deadline(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": self.player.session_token, "text": "Sydney"},
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(LieAnswer.objects.filter(round_question=round_question, player=self.player).exists())
def test_submit_lie_rejects_after_time_window(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
)
round_question.shown_at = timezone.now() - timedelta(seconds=46)
round_question.save(update_fields=["shown_at"])
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": self.player.session_token, "text": "Melbourne"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Lie submission window has closed")
def test_submit_lie_rejects_duplicate_submission(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
)
LieAnswer.objects.create(round_question=round_question, player=self.player, text="Perth")
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": self.player.session_token, "text": "Brisbane"},
content_type="application/json",
)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"], "Lie already submitted for this player")
def test_submit_lie_requires_session_token(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, "text": "Sydney"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "session_token is required")
def test_submit_lie_rejects_invalid_session_token(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",
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"], "Invalid player session token")
class MixAnswersTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host", password="secret123")
self.other_user = User.objects.create_user(username="other", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE)
self.category = Category.objects.create(name="Historie", slug="historie", is_active=True)
self.question = Question.objects.create(
category=self.category,
prompt="Hvilken by er Danmarks hovedstad?",
correct_answer="København",
is_active=True,
)
RoundConfig.objects.create(session=self.session, number=1, category=self.category)
self.round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer="København",
)
self.player_one = Player.objects.create(session=self.session, nickname="Luna")
self.player_two = Player.objects.create(session=self.session, nickname="Mads")
def test_host_can_mix_answers_and_transition_to_guess(self):
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus")
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense")
self.client.login(username="host", password="secret123")
response = self.client.post(
reverse(
"lobby:mix_answers",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
)
)
self.assertEqual(response.status_code, 200)
payload = response.json()
answer_texts = [entry["text"] for entry in payload["answers"]]
self.assertEqual(set(answer_texts), {"København", "Aarhus", "Odense"})
self.assertEqual(payload["session"]["status"], GameSession.Status.GUESS)
self.session.refresh_from_db()
self.round_question.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.GUESS)
self.assertEqual(self.round_question.mixed_answers, answer_texts)
def test_mix_answers_requires_host(self):
self.client.login(username="other", password="secret123")
response = self.client.post(
reverse(
"lobby:mix_answers",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
)
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"], "Only host can mix answers")
def test_mix_answers_deduplicates_case_insensitive_lies(self):
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="københavn")
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Aarhus")
self.client.login(username="host", password="secret123")
response = self.client.post(
reverse(
"lobby:mix_answers",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
)
)
self.assertEqual(response.status_code, 200)
answer_texts = [entry["text"] for entry in response.json()["answers"]]
self.assertEqual(set(answer_texts), {"København", "Aarhus"})
def test_mix_answers_is_idempotent_after_transition_to_guess(self):
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus")
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense")
self.client.login(username="host", password="secret123")
first = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}))
second = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}))
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertEqual([entry["text"] for entry in first.json()["answers"]], [entry["text"] for entry in second.json()["answers"]])
def test_session_detail_returns_persisted_mixed_answers_for_reconnect(self):
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus")
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense")
self.client.login(username="host", password="secret123")
mix_response = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}))
detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
self.assertEqual(mix_response.status_code, 200)
self.assertEqual(detail_response.status_code, 200)
self.assertEqual([entry["text"] for entry in mix_response.json()["answers"]], [entry["text"] for entry in detail_response.json()["round_question"]["answers"]])
class GuessSubmissionTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host_guess", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="GU3551", status=GameSession.Status.GUESS)
self.category = Category.objects.create(name="Videnskab", slug="videnskab", is_active=True)
self.question = Question.objects.create(
category=self.category,
prompt="Hvilken planet kaldes den røde planet?",
correct_answer="Mars",
is_active=True,
)
self.round_config = RoundConfig.objects.create(
session=self.session,
number=1,
category=self.category,
lie_seconds=45,
guess_seconds=30,
)
self.round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer="Mars",
)
self.player = Player.objects.create(session=self.session, nickname="Luna")
self.liar = Player.objects.create(session=self.session, nickname="Mads")
LieAnswer.objects.create(round_question=self.round_question, player=self.liar, text="Jupiter")
def test_player_can_submit_guess_in_guess_phase(self):
response = self.client.post(
reverse(
"lobby:submit_guess",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
),
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"},
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
payload = response.json()
self.assertTrue(payload["guess"]["is_correct"])
self.assertIsNone(payload["guess"]["fooled_player_id"])
self.assertIn("guess_deadline_at", payload["window"])
def test_submit_guess_rejects_when_not_in_guess_phase(self):
self.session.status = GameSession.Status.LIE
self.session.save(update_fields=["status"])
response = self.client.post(
reverse(
"lobby:submit_guess",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
),
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
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(
reverse(
"lobby:submit_guess",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
),
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Venus"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
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)
response = self.client.post(
reverse(
"lobby:submit_guess",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
),
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Jupiter"},
content_type="application/json",
)
self.assertEqual(response.status_code, 409)
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)
self.round_question.save(update_fields=["shown_at"])
response = self.client.post(
reverse(
"lobby:submit_guess",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
),
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Guess submission window has closed")
def test_submit_guess_requires_session_token(self):
response = self.client.post(
reverse(
"lobby:submit_guess",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
),
data={"player_id": self.player.id, "selected_text": "Mars"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "session_token is required")
def test_submit_guess_rejects_invalid_session_token(self):
response = self.client.post(
reverse(
"lobby:submit_guess",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
),
data={"player_id": self.player.id, "session_token": "wrong-token", "selected_text": "Mars"},
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"], "Invalid player session token")
class ScoreCalculationTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host_score", password="secret123")
self.other_user = User.objects.create_user(username="other_score", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="SC0RE1", status=GameSession.Status.GUESS)
self.category = Category.objects.create(name="Sport", slug="sport", is_active=True)
self.question = Question.objects.create(
category=self.category,
prompt="Hvilken sport spiller man i Wimbledon?",
correct_answer="Tennis",
is_active=True,
)
RoundConfig.objects.create(
session=self.session,
number=1,
category=self.category,
points_correct=5,
points_bluff=2,
)
self.round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer="Tennis",
)
self.player_one = Player.objects.create(session=self.session, nickname="Luna")
self.player_two = Player.objects.create(session=self.session, nickname="Mads")
self.player_three = Player.objects.create(session=self.session, nickname="Nora")
def test_host_can_calculate_scores_and_transition_to_reveal(self):
Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True)
Guess.objects.create(
round_question=self.round_question,
player=self.player_two,
selected_text="Padel",
is_correct=False,
fooled_player=self.player_three,
)
Guess.objects.create(
round_question=self.round_question,
player=self.player_three,
selected_text="Padel",
is_correct=False,
fooled_player=self.player_three,
)
self.client.login(username="host_score", password="secret123")
response = self.client.post(
reverse(
"lobby:calculate_scores",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
)
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL)
self.assertEqual(payload["events_created"], 2)
self.player_one.refresh_from_db()
self.player_three.refresh_from_db()
self.session.refresh_from_db()
self.assertEqual(self.player_one.score, 5)
self.assertEqual(self.player_three.score, 4)
self.assertEqual(self.session.status, GameSession.Status.REVEAL)
def test_calculate_scores_requires_host(self):
self.client.login(username="other_score", password="secret123")
response = self.client.post(
reverse(
"lobby:calculate_scores",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
)
)
self.assertEqual(response.status_code, 403)
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)
self.client.login(username="host_score", password="secret123")
first = self.client.post(
reverse(
"lobby:calculate_scores",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
)
)
second = self.client.post(
reverse(
"lobby:calculate_scores",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
)
)
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 409)
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.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):
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.REVEAL)
self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"])
def test_reveal_scoreboard_requires_host(self):
self.client.login(username="other_reveal", password="secret123")
response = self.client.get(
reverse(
"lobby:reveal_scoreboard",
kwargs={"code": self.session.code},
)
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"], "Only host can view scoreboard")
def test_host_can_finish_game_from_reveal(self):
self.client.login(username="host_reveal", password="secret123")
response = self.client.post(
reverse(
"lobby:finish_game",
kwargs={"code": self.session.code},
)
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["session"]["status"], GameSession.Status.FINISHED)
self.assertEqual(payload["winner"]["nickname"], "Luna")
self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"])
self.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
def test_finish_game_requires_host(self):
self.client.login(username="other_reveal", password="secret123")
response = self.client.post(
reverse(
"lobby:finish_game",
kwargs={"code": self.session.code},
)
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"], "Only host can finish game")
def test_finish_game_rejects_wrong_phase(self):
self.client.login(username="host_reveal", password="secret123")
self.session.status = GameSession.Status.GUESS
self.session.save(update_fields=["status"])
response = self.client.post(
reverse(
"lobby:finish_game",
kwargs={"code": self.session.code},
)
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Game can only be finished from reveal phase")
def test_host_can_start_next_round_from_reveal(self):
self.client.login(username="host_reveal", password="secret123")
response = self.client.post(
reverse(
"lobby:start_next_round",
kwargs={"code": self.session.code},
)
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["session"]["status"], GameSession.Status.LOBBY)
self.assertEqual(payload["session"]["current_round"], 2)
self.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.LOBBY)
self.assertEqual(self.session.current_round, 2)
def test_start_next_round_rejects_wrong_phase(self):
self.client.login(username="host_reveal", password="secret123")
self.session.status = GameSession.Status.GUESS
self.session.save(update_fields=["status"])
response = self.client.post(
reverse(
"lobby:start_next_round",
kwargs={"code": self.session.code},
)
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Next round can only start from reveal phase")
class UiScreenTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host_ui", password="secret123")
def test_host_screen_requires_login(self):
response = self.client.get(reverse("lobby:host_screen"))
self.assertEqual(response.status_code, 302)
def test_host_screen_renders_for_logged_in_user(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, "Host panel")
self.assertContains(response, "id=\"createSessionBtn\"")
self.assertContains(response, "id=\"createSessionHint\"")
self.assertContains(response, "saveHostContext")
self.assertContains(response, "restoreHostContext")
self.assertContains(response, "id=\"showQuestionBtn\"")
self.assertContains(response, "id=\"mixAnswersBtn\"")
self.assertContains(response, "id=\"hostActionHint\"")
self.assertContains(response, "id=\"categoryGuardHint\"")
self.assertContains(response, "id=\"phaseStatus\"")
self.assertContains(response, "updateHostActionState")
self.assertContains(response, "phaseLabel")
self.assertContains(response, "Opdatér session-status for fasebaserede host-actions.")
self.assertContains(response, "Angiv sessionkode for at aktivere host-actions.")
self.assertContains(response, "Kategori er kun redigérbar i lobby-fasen.")
self.assertContains(response, "Kræver 3-5 spillere i lobbyen.")
self.assertContains(response, "For mange spillere: maks 5 i MVP før runde-start.")
self.assertContains(response, "Round question-id kan kun redigeres i lie/guess/reveal-faser.")
self.assertContains(response, "roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion")
self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==\"lobby\"")
self.assertContains(response, "hostActionInFlight")
self.assertContains(response, "withHostActionLock")
self.assertContains(response, "updateCreateSessionState")
self.assertContains(response, "btn.disabled=hostActionInFlight||sessionDetailInFlight")
self.assertContains(response, "Opret session er låst mens en host-handling kører.")
self.assertContains(response, "Opret session er låst mens session-opdatering kører.")
self.assertContains(response, "Handling kører… afvent svar før næste klik.")
self.assertContains(response, "Session-data ikke opdateret endnu.")
self.assertContains(response, "Sidst opdateret:")
self.assertContains(response, "Session-data kan være forældet")
self.assertContains(response, "id=\"sessionDetailBtn\"")
self.assertContains(response, "id=\"sessionDetailHint\"")
self.assertContains(response, "updateSessionDetailState")
self.assertContains(response, "sessionDetailInFlight")
self.assertContains(response, "session_detail_in_flight")
self.assertContains(response, "codeInput.readOnly=sessionDetailInFlight||hostActionInFlight")
self.assertContains(response, "id=\"autoRefreshToggleBtn\"")
self.assertContains(response, "id=\"autoRefreshHint\"")
self.assertContains(response, "btn.disabled=hostActionInFlight||sessionDetailInFlight||!code()")
self.assertContains(response, "Auto-refresh-lås: afvent aktiv host-handling.")
self.assertContains(response, "Auto-refresh-lås: afvent aktiv session-opdatering.")
self.assertContains(response, "Opdaterer session-status…")
self.assertContains(response, "Session-opdatering er låst mens en host-handling kører.")
self.assertContains(response, "Angiv sessionkode for at opdatere session-status.")
self.assertContains(response, "markSessionRefresh")
self.assertContains(response, "updateLastRefreshStatus")
self.assertContains(response, "isSessionDetailRead")
self.assertContains(response, "showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==")
self.assertContains(response, "Host-actions er låst mens session-opdatering kører.")
self.assertContains(response, "Round question-id er låst mens session-opdatering kører.")
self.assertContains(response, "Kategori er låst mens session-opdatering kører.")
self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==")
self.assertContains(response, "hostShellRouteFromPath")
self.assertContains(response, "syncHostShellRoute")
self.assertContains(response, "Deep-link route guard: omdirigeret")
self.assertContains(response, "id=\"hostShellErrorBoundary\"")
self.assertContains(response, "recoverHostShell('retry')")
self.assertContains(response, "recoverHostShell('reload')")
self.assertContains(response, "setHostShellFatalError")
self.assertContains(response, "clearHostShellFatalError")
self.assertContains(response, "updateHostShellErrorBoundary")
self.assertContains(response, "host_shell_runtime_error")
self.assertContains(response, "window.addEventListener(\"unhandledrejection\"")
def test_host_screen_deeplink_requires_login(self):
response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"}))
self.assertEqual(response.status_code, 302)
def test_host_screen_deeplink_renders_for_logged_in_user(self):
self.client.login(username="host_ui", password="secret123")
response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"}))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Host panel")
def test_player_screen_is_public(self):
response = self.client.get(reverse("lobby:player_screen"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Player panel")
self.assertContains(response, "id=\"sessionToken\"")
self.assertContains(response, "session_token")
self.assertContains(response, "id=\"answerOptions\"")
self.assertContains(response, "renderAnswerOptions")
self.assertContains(response, "renderAnswerOptions(null)")
self.assertContains(response, "availableAnswers")
self.assertContains(response, "guessStorageKey")
self.assertContains(response, "persistGuessState")
self.assertContains(response, "savePlayerContext")
self.assertContains(response, "restorePlayerContext")
self.assertContains(response, "id=\"lieSubmitBtn\"")
self.assertContains(response, "id=\"lieStatus\"")
self.assertContains(response, "id=\"phaseStatus\"")
self.assertContains(response, "currentSessionStatus")
self.assertContains(response, "updatePhaseStatus")
self.assertContains(response, "Løgn-input er låst i fase")
self.assertContains(response, "Gæt er låst i fase")
self.assertContains(response, "Afvent aktivt spørgsmål fra host før du kan gætte.")
self.assertContains(response, "persistLieState")
self.assertContains(response, "updateLieSubmitState")
self.assertContains(response, "hasSubmitContext")
self.assertContains(response, "hasRoundQuestionContext")
self.assertContains(response, "canAttemptJoin")
self.assertContains(response, "missing_join_input")
self.assertContains(response, "Udfyld kode og nickname for at join.")
self.assertContains(response, "Afvent aktiv session-opdatering før join.")
self.assertContains(response, "btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin")
self.assertContains(response, "id=\"connectionBanner\"")
self.assertContains(response, "id=\"connectionRetryBtn\"")
self.assertContains(response, "retryConnection")
self.assertContains(response, "setConnectionLost")
self.assertContains(response, "connection_lost")
self.assertContains(response, "id=\"contextLockHint\"")
self.assertContains(response, "updateContextLockState")
self.assertContains(response, "Spillerkontekst er låst efter join.")
self.assertContains(response, "already_joined_client")
self.assertContains(response, "missing_submit_context")
self.assertContains(response, "invalid_client_guess")
self.assertContains(response, "lieSubmitInFlight")
self.assertContains(response, "guessSubmitInFlight")
self.assertContains(response, "Sender løgn…")
self.assertContains(response, "Sender gæt…")
self.assertContains(response, "Afvent aktiv session-opdatering før løgn-submit.")
self.assertContains(response, "Afvent aktiv session-opdatering før gæt-submit.")
self.assertContains(response, "sessionDetailInFlight||!hasValid||!hasContext||!hasRoundContext||!inGuessPhase")
self.assertContains(response, "btn.disabled=guessSubmitted||guessSubmitInFlight||sessionDetailInFlight")
self.assertContains(response, "lie_submit_in_flight")
self.assertContains(response, "guess_submit_in_flight")
self.assertContains(response, "guess_already_submitted_client")
self.assertContains(response, "id=\"playerAutoRefreshToggleBtn\"")
self.assertContains(response, "id=\"playerAutoRefreshHint\"")
self.assertContains(response, "id=\"playerLastRefreshStatus\"")
self.assertContains(response, "id=\"sessionDetailBtn\"")
self.assertContains(response, "id=\"sessionRefreshHint\"")
self.assertContains(response, "id=\"roundContextHint\"")
self.assertContains(response, "resetRoundContextForManualChange")
self.assertContains(response, "Runde-kontekst afventer session-opdatering.")
self.assertContains(response, "togglePlayerAutoRefresh")
self.assertContains(response, "btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code()")
self.assertContains(response, "Auto-refresh-lås: afvent aktiv session-opdatering.")
self.assertContains(response, "Auto-refresh-lås: afvent aktiv join.")
self.assertContains(response, "Auto-refresh kræver sessionkode.")
self.assertContains(response, "markPlayerSessionRefresh")
self.assertContains(response, "updatePlayerLastRefreshStatus")
self.assertContains(response, "updateSessionDetailState")
self.assertContains(response, "session_detail_in_flight")
self.assertContains(response, "Opdaterer session-status…")
self.assertContains(response, "Session-opdatering er låst mens join kører.")
self.assertContains(response, "Session-opdatering er låst mens submit kører.")
self.assertContains(response, "Session-data ikke opdateret endnu.")
self.assertContains(response, "Sidst opdateret:")
self.assertContains(response, "Session-data kan være forældet")
self.assertContains(response, "id=\"playerShellErrorBoundary\"")
self.assertContains(response, "recoverPlayerShell('retry')")
self.assertContains(response, "recoverPlayerShell('reload')")
self.assertContains(response, "setPlayerShellFatalError")
self.assertContains(response, "clearPlayerShellFatalError")
self.assertContains(response, "updatePlayerShellErrorBoundary")
self.assertContains(response, "player_shell_runtime_error")
self.assertContains(response, "window.addEventListener(\"error\"")
@override_settings(USE_SPA_UI=False)
def test_legacy_templates_are_used_when_spa_flag_is_off(self):
self.client.login(username="host_ui", password="secret123")
host_response = self.client.get(reverse("lobby:host_screen"))
player_response = self.client.get(reverse("lobby:player_screen"))
self.assertContains(host_response, "Host panel")
self.assertContains(player_response, "Player panel")
self.assertNotContains(host_response, "<app-root")
self.assertNotContains(player_response, "<app-root")
@override_settings(USE_SPA_UI=True)
def test_host_screen_can_render_angular_shell_when_feature_flag_enabled(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, "<app-root")
self.assertContains(response, "data-wpp-shell-route=\"/host\"")
self.assertContains(response, "data-wpp-shell-kind=\"host\"")
self.assertContains(response, "/static/frontend/angular/browser/main.js?v=dev")
self.assertContains(response, "/static/frontend/angular/browser/styles.css?v=dev")
@override_settings(USE_SPA_UI=True)
def test_host_screen_deeplink_preserves_spa_path_when_feature_flag_enabled(self):
self.client.login(username="host_ui", password="secret123")
response = self.client.get(
reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess/round-1"})
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<app-root")
self.assertContains(response, "data-wpp-shell-route=\"/host/guess/round-1\"")
self.assertContains(response, "data-wpp-shell-kind=\"host\"")
@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")
response = self.client.get("/lobby/ui/host//guess///round-1//")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<app-root")
self.assertContains(response, "data-wpp-shell-route=\"/host/guess/round-1\"")
self.assertContains(response, "data-wpp-shell-kind=\"host\"")
@override_settings(USE_SPA_UI=True)
def test_player_screen_can_render_angular_shell_when_feature_flag_enabled(self):
response = self.client.get(reverse("lobby:player_screen"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<app-root")
self.assertContains(response, "data-wpp-shell-route=\"/player\"")
self.assertContains(response, "data-wpp-shell-kind=\"player\"")
self.assertContains(response, "/static/frontend/angular/browser/main.js?v=dev")
@override_settings(USE_SPA_UI=True, WPP_SPA_ASSET_VERSION="release-2026-03-01")
def test_spa_shell_uses_configured_asset_version_for_cache_busting(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, "/static/frontend/angular/browser/styles.css?v=release-2026-03-01")
self.assertContains(response, "/static/frontend/angular/browser/main.js?v=release-2026-03-01")
class SessionDetailRoundQuestionTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host_detail", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="ABCDE1", status=GameSession.Status.LIE)
self.category = Category.objects.create(name="Historie", slug="historie-2", is_active=True)
self.question = Question.objects.create(
category=self.category,
prompt="Hvem opfandt pæren?",
correct_answer="Edison",
is_active=True,
)
def test_session_detail_includes_current_round_question_when_available(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
)
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["round_question"]["id"], round_question.id)
self.assertEqual(payload["round_question"]["prompt"], self.question.prompt)
class SessionDetailPhaseViewModelTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host_phase", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="PHASE1", status=GameSession.Status.LOBBY)
def test_session_detail_includes_shared_phase_view_model_contract(self):
Player.objects.create(session=self.session, nickname="P1")
Player.objects.create(session=self.session, nickname="P2")
Player.objects.create(session=self.session, nickname="P3")
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
phase = response.json()["phase_view_model"]
self.assertEqual(phase["status"], GameSession.Status.LOBBY)
self.assertEqual(phase["round_number"], 1)
self.assertEqual(phase["players_count"], 3)
self.assertEqual(phase["constraints"]["min_players_to_start"], 3)
self.assertEqual(phase["constraints"]["max_players_mvp"], 5)
self.assertTrue(phase["constraints"]["min_players_reached"])
self.assertTrue(phase["constraints"]["max_players_allowed"])
self.assertTrue(phase["host"]["can_start_round"])
self.assertFalse(phase["host"]["can_show_question"])
self.assertTrue(phase["player"]["can_join"])
self.assertFalse(phase["player"]["can_submit_lie"])
self.assertFalse(phase["player"]["can_submit_guess"])
def test_phase_view_model_flags_change_with_round_phase(self):
category = Category.objects.create(name="Kultur", slug="kultur", is_active=True)
question = Question.objects.create(
category=category,
prompt="Hvilket land kommer sushi fra?",
correct_answer="Japan",
is_active=True,
)
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=question,
correct_answer="Japan",
)
self.session.status = GameSession.Status.LIE
self.session.save(update_fields=["status"])
lie_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
lie_phase = lie_payload["phase_view_model"]
self.assertFalse(lie_phase["host"]["can_show_question"])
self.assertTrue(lie_phase["host"]["can_mix_answers"])
self.assertTrue(lie_phase["player"]["can_submit_lie"])
self.assertFalse(lie_phase["player"]["can_submit_guess"])
self.session.status = GameSession.Status.GUESS
self.session.save(update_fields=["status"])
guess_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
guess_phase = guess_payload["phase_view_model"]
self.assertTrue(guess_phase["host"]["can_mix_answers"])
self.assertTrue(guess_phase["host"]["can_calculate_scores"])
self.assertFalse(guess_phase["player"]["can_submit_lie"])
self.assertTrue(guess_phase["player"]["can_submit_guess"])
round_question.delete()
self.session.status = GameSession.Status.REVEAL
self.session.save(update_fields=["status"])
reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
reveal_phase = reveal_payload["phase_view_model"]
self.assertTrue(reveal_phase["host"]["can_reveal_scoreboard"])
self.assertTrue(reveal_phase["host"]["can_start_next_round"])
self.assertTrue(reveal_phase["host"]["can_finish_game"])
self.assertFalse(reveal_phase["player"]["can_view_final_result"])
self.session.status = GameSession.Status.FINISHED
self.session.save(update_fields=["status"])
finished_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
finished_phase = finished_payload["phase_view_model"]
self.assertFalse(finished_phase["player"]["can_join"])
self.assertTrue(finished_phase["player"]["can_view_final_result"])
class SmokeStagingCommandTests(TestCase):
def test_smoke_staging_command_runs_full_flow(self):
call_command("smoke_staging")
session = GameSession.objects.latest("created_at")
self.assertEqual(session.status, GameSession.Status.FINISHED)
self.assertEqual(Player.objects.filter(session=session).count(), 3)
def test_smoke_staging_writes_artifact_when_requested(self):
with tempfile.TemporaryDirectory() as tmp_dir:
artifact_path = Path(tmp_dir) / "smoke.json"
call_command("smoke_staging", artifact=str(artifact_path))
self.assertTrue(artifact_path.exists())
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
self.assertTrue(payload["ok"])
self.assertEqual(payload["command"], "smoke_staging")
self.assertEqual(payload["players"], ["P1", "P2", "P3"])
self.assertIn("generated_at", payload)
self.assertIn("session_code", payload)
self.assertEqual(
payload["steps"],
[
"create_session",
"join_players",
"start_round",
"show_question",
"submit_lies",
"mix_answers",
"submit_guesses",
"calculate_scores",
"reveal_scoreboard",
"finish_game",
],
)
class I18nResolverTests(TestCase):
def test_missing_backend_key_returns_key_deterministically(self):
self.assertEqual(resolve_error_message(key="missing_key", locale="da"), "missing_key")
def test_missing_backend_key_is_logged_with_context(self):
with self.assertLogs("lobby.i18n", level="WARNING") as logs:
result = resolve_error_message(key="missing_key", locale="da")
self.assertEqual(result, "missing_key")
self.assertTrue(any("i18n key 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",
return_value={"session_code_required": {"en": "Session code is required"}},
):
self.assertEqual(
resolve_error_message(key="session_code_required", locale="da"),
"Session code is required",
)
def test_shared_catalog_uses_en_default_and_da_en_matrix(self):
default_locale, supported_locales = i18n_locale_config()
catalog = lobby_i18n_catalog()
self.assertEqual(default_locale, "en")
self.assertIn("en", supported_locales)
self.assertIn("da", supported_locales)
for key, translations in catalog["backend"]["errors"].items():
self.assertTrue(translations.get("en"), f"backend key {key} missing en")
self.assertTrue(translations.get("da"), f"backend key {key} missing da")
for key, translations in catalog["frontend"]["errors"].items():
self.assertTrue(translations.get("en"), f"frontend key {key} missing en")
self.assertTrue(translations.get("da"), f"frontend key {key} missing da")
def test_backend_error_codes_map_to_same_named_translation_keys(self):
catalog = lobby_i18n_catalog()
backend_errors = catalog["backend"]["errors"]
for code, key in catalog["backend"]["error_codes"].items():
self.assertEqual(code, key)
self.assertIn(key, backend_errors)