From ff466ef4918f59b5e54ea96c2df330add40b9815 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 00:21:44 +0000 Subject: [PATCH 1/3] test: increase test coverage for tubes, opal, and hanabi views - Add tests for tubes/views.py tube_join function - Fix bug in tubes/views.py logging.critical() call - Add JoinRecordFactory for tubes tests - Add tests for opal/views.py leaderboard, person_log, and finish views - Add tests for hanabi/views.py HanabiReplayList and hanabi_upload views Coverage improved from 92% to 93% overall, with specific improvements: - opal/views.py: 64% -> 94% - hanabi/views.py: 76% -> 97% - tubes/views.py: added coverage for tube_join --- hanabi/tests.py | 91 +++++++++++++++++++++++++ opal/tests.py | 164 +++++++++++++++++++++++++++++++++++++++++++++ tubes/factories.py | 13 +++- tubes/tests.py | 93 +++++++++++++++++++------ tubes/views.py | 1 - 5 files changed, 341 insertions(+), 21 deletions(-) diff --git a/hanabi/tests.py b/hanabi/tests.py index a4318d78e..fd1cbac71 100644 --- a/hanabi/tests.py +++ b/hanabi/tests.py @@ -92,3 +92,94 @@ def test_register(otis): "hanabi-register", data={"hanab_username": "alice"}, follow=True ) otis.assert_has(resp, "You already registered") + + +@pytest.mark.django_db +def test_replay_list(otis): + """Test viewing the replay list for a contest.""" + contest = HanabiContestFactory.create( + pk=42, + variant_name="Rainbow (5 Suits)", + variant_id=15, + start_date=datetime.datetime(2024, 1, 1, tzinfo=UTC), + end_date=datetime.datetime(2024, 1, 14, tzinfo=UTC), + processed=True, # Contest results are processed + ) + + # Create some replays + HanabiReplayFactory.create(contest=contest, game_score=25, turn_count=40) + HanabiReplayFactory.create(contest=contest, game_score=24, turn_count=42) + HanabiReplayFactory.create(contest=contest, game_score=20, turn_count=35) + + # View the replays list + resp = otis.get_20x("hanabi-replays", contest.pk) + assert len(resp.context["replays"]) == 3 + assert resp.context["contest"] == contest + + +@pytest.mark.django_db +def test_replay_list_unprocessed(otis): + """Test that unprocessed contest results are not visible to non-staff.""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + admin = UserFactory.create(username="admin", is_staff=True, is_superuser=True) + + contest = HanabiContestFactory.create( + pk=99, + start_date=datetime.datetime(2024, 1, 1, tzinfo=UTC), + end_date=datetime.datetime(2024, 1, 14, tzinfo=UTC), + processed=False, # Contest not processed yet + ) + + # Non-staff user cannot view unprocessed results + otis.login(alice) + otis.get_40x("hanabi-replays", contest.pk) + + # Staff can view unprocessed results + otis.login(admin) + resp = otis.get_20x("hanabi-replays", contest.pk) + assert resp.context["contest"] == contest + + +@pytest.mark.django_db +def test_replay_list_with_participation(otis): + """Test replay list shows own_replay when user participated.""" + from hanabi.factories import HanabiParticipationFactory, HanabiPlayerFactory + + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + + contest = HanabiContestFactory.create( + pk=77, + start_date=datetime.datetime(2024, 1, 1, tzinfo=UTC), + end_date=datetime.datetime(2024, 1, 14, tzinfo=UTC), + processed=True, + ) + + # Create a replay with Alice's participation + player = HanabiPlayerFactory.create(user=alice) + replay = HanabiReplayFactory.create(contest=contest, game_score=22) + HanabiParticipationFactory.create(player=player, replay=replay) + + otis.login(alice) + resp = otis.get_20x("hanabi-replays", contest.pk) + assert resp.context["own_replay"] == replay + + +@pytest.mark.django_db +def test_hanabi_upload(otis): + """Test the hanabi_upload admin view.""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + admin = UserFactory.create(username="admin", is_staff=True, is_superuser=True) + + contest = HanabiContestFactory.create(pk=55) + + # Non-admin cannot access + otis.login(alice) + otis.get_40x("hanabi-upload", contest.pk) + + # Admin can access (even though it's not implemented) + otis.login(admin) + resp = otis.get_20x("hanabi-upload", contest.pk) + otis.assert_has(resp, "Not implemented") diff --git a/opal/tests.py b/opal/tests.py index 2092e15ef..27e4ef494 100644 --- a/opal/tests.py +++ b/opal/tests.py @@ -393,3 +393,167 @@ def test_hint_visibility(otis): resp = otis.get_20x("opal-show-puzzle", puzzle.hunt.slug, puzzle.slug) assert "will release" not in resp.content.decode() assert "use your brain" in resp.content.decode() + + +@pytest.mark.django_db +def test_leaderboard(otis): + """Test the leaderboard view (admin only).""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create( + username="alice", first_name="Alice", last_name="Aardvark", groups=(verified_group,) + ) + bob = UserFactory.create( + username="bob", first_name="Bob", last_name="Beta", groups=(verified_group,) + ) + admin = UserFactory.create(username="admin", is_staff=True, is_superuser=True) + + hunt = OpalHuntFactory.create( + slug="hunt", + start_date=datetime.datetime(2024, 8, 1, tzinfo=UTC), + ) + puzzle1 = OpalPuzzleFactory.create( + hunt=hunt, answer="one", order=1, is_metapuzzle=False + ) + puzzle2 = OpalPuzzleFactory.create( + hunt=hunt, answer="two", order=2, is_metapuzzle=False + ) + puzzle3 = OpalPuzzleFactory.create( + hunt=hunt, answer="three", order=3, is_metapuzzle=True + ) + + # Alice solves all puzzles (including meta) after hunt start + with freeze_time("2024-08-15"): + OpalAttemptFactory.create(user=alice, puzzle=puzzle1, guess="one") + OpalAttemptFactory.create(user=alice, puzzle=puzzle2, guess="two") + OpalAttemptFactory.create(user=alice, puzzle=puzzle3, guess="three") + + # Bob solves first puzzle only + with freeze_time("2024-08-20"): + OpalAttemptFactory.create(user=bob, puzzle=puzzle1, guess="one") + + # Test access control + otis.login(alice) + otis.get_40x("opal-leaderboard", "hunt") + + otis.login(admin) + resp = otis.get_20x("opal-leaderboard", "hunt") + otis.assert_has(resp, "Alice Aardvark") + otis.assert_has(resp, "Bob Beta") + assert resp.context["hunt"] == hunt + assert len(resp.context["rows"]) == 2 + + +@pytest.mark.django_db +def test_leaderboard_early_access(otis): + """Test the leaderboard with early access users (testsolvers).""" + testsolver_group = GroupFactory(name="Testsolver") + testsolver = UserFactory.create( + username="testsolver", first_name="Test", last_name="Solver", groups=(testsolver_group,) + ) + admin = UserFactory.create(username="admin", is_staff=True, is_superuser=True) + + hunt = OpalHuntFactory.create( + slug="hunt", + start_date=datetime.datetime(2024, 8, 10, tzinfo=UTC), + ) + puzzle1 = OpalPuzzleFactory.create(hunt=hunt, answer="one", order=1) + + # Testsolver solves before hunt starts (early access) + with freeze_time("2024-08-05"): + OpalAttemptFactory.create(user=testsolver, puzzle=puzzle1, guess="one") + + otis.login(admin) + resp = otis.get_20x("opal-leaderboard", "hunt") + rows = resp.context["rows"] + assert len(rows) == 1 + assert rows[0]["has_early_access"] is True + + +@pytest.mark.django_db +def test_person_log(otis): + """Test the person_log view (admin only).""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + admin = UserFactory.create(username="admin", is_staff=True, is_superuser=True) + + hunt = OpalHuntFactory.create(slug="hunt") + puzzle = OpalPuzzleFactory.create(hunt=hunt, answer="answer") + + # Alice makes some attempts + OpalAttemptFactory.create(user=alice, puzzle=puzzle, guess="wrong1") + OpalAttemptFactory.create(user=alice, puzzle=puzzle, guess="wrong2") + OpalAttemptFactory.create(user=alice, puzzle=puzzle, guess="answer") + + # Test access control + otis.login(alice) + otis.get_40x("opal-person-log", "hunt", alice.pk) + + otis.login(admin) + resp = otis.get_20x("opal-person-log", "hunt", alice.pk) + assert resp.context["hunt"] == hunt + assert resp.context["hunter"] == alice + assert len(resp.context["attempts"]) == 3 + + +@pytest.mark.django_db +def test_finish_page(otis): + """Test the finish page (after solving a puzzle with achievement).""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + + ach = AchievementFactory.create(diamonds=5) + hunt = OpalHuntFactory.create(slug="hunt") + puzzle = OpalPuzzleFactory.create( + hunt=hunt, slug="final", answer="answer", achievement=ach + ) + + otis.login(alice) + + # Cannot access finish page without solving + otis.get_40x("opal-finish", "hunt", "final") + + # Solve the puzzle + OpalAttemptFactory.create(user=alice, puzzle=puzzle, guess="answer") + + # Now can access finish page + resp = otis.get_20x("opal-finish", "hunt", "final") + assert resp.context["puzzle"] == puzzle + assert resp.context["achievement"] == ach + + +@pytest.mark.django_db +def test_finish_page_no_achievement(otis): + """Test finish page for puzzle without achievement - should be forbidden.""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + + hunt = OpalHuntFactory.create(slug="hunt") + puzzle = OpalPuzzleFactory.create( + hunt=hunt, slug="noach", answer="answer", achievement=None + ) + + otis.login(alice) + OpalAttemptFactory.create(user=alice, puzzle=puzzle, guess="answer") + + # Should be forbidden since puzzle has no achievement + otis.get_40x("opal-finish", "hunt", "noach") + + +@pytest.mark.django_db +def test_close_answer(otis): + """Test submitting a close answer.""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + + hunt = OpalHuntFactory.create(slug="hunt") + puzzle = OpalPuzzleFactory.create( + hunt=hunt, slug="puzzle", answer="CORRECT", partial_answers="CORRELATION\nALMOSTCORRECT" + ) + + otis.login(alice) + resp = otis.post_20x( + "opal-show-puzzle", "hunt", "puzzle", + data={"guess": "CORRELATION"}, + follow=True, + ) + otis.assert_has(resp, "Keep going") diff --git a/tubes/factories.py b/tubes/factories.py index 57de518a1..09ea2724c 100644 --- a/tubes/factories.py +++ b/tubes/factories.py @@ -1,7 +1,8 @@ +import factory from factory.django import DjangoModelFactory from factory.faker import Faker -from .models import Tube +from .models import JoinRecord, Tube class TubeFactory(DjangoModelFactory): @@ -12,3 +13,13 @@ class Meta: description = Faker("paragraph") status = "TB_ACTIVE" main_url = Faker("url") + + +class JoinRecordFactory(DjangoModelFactory): + class Meta: + model = JoinRecord + + tube = factory.SubFactory(TubeFactory) + user = None + activation_time = None + invite_url = Faker("url") diff --git a/tubes/tests.py b/tubes/tests.py index 19e26a1c5..0a2e93186 100644 --- a/tubes/tests.py +++ b/tubes/tests.py @@ -2,7 +2,7 @@ from core.factories import GroupFactory, UserFactory -from .factories import TubeFactory +from .factories import JoinRecordFactory, TubeFactory from .models import JoinRecord @@ -19,24 +19,6 @@ def test_active_tubes(otis): otis.get_40x("tube-join", tube.pk) assert not JoinRecord.objects.exists() - # TODO update this to work with the new system - - # - # otis.login("alice") - # resp = otis.get_20x("tube-list") - # assert tube.display_name.encode() in resp.content - # assert tube.main_url.encode() not in resp.content - # - # resp = otis.get_40x("tube-join", tube.pk) # redirect to join URL - # assert len(JoinRecord.objects.all()) == 1 - # - # resp = otis.get_20x("tube-list") - # assert tube.display_name.encode() in resp.content - # assert tube.main_url.encode() in resp.content - # - # otis.get_40x("tube-join", tube.pk) - # assert len(JoinRecord.objects.all()) == 1 - @pytest.mark.django_db def test_inactive_tubes(otis): @@ -48,3 +30,76 @@ def test_inactive_tubes(otis): resp = otis.get_20x("tube-list") otis.assert_not_has(resp, tube.display_name) otis.get_40x("tube-join", tube.pk) + + +@pytest.mark.django_db +def test_tube_join_existing_record(otis): + """Test when user already has a JoinRecord for the tube.""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + tube = TubeFactory.create(accepting_signups=True) + jr = JoinRecordFactory.create(tube=tube, user=alice, invite_url="https://example.com/invite") + + otis.login(alice) + resp = otis.get("tube-join", tube.pk) + # Should redirect to the invite URL + otis.assert_30x(resp) + assert resp.url == jr.invite_url + + +@pytest.mark.django_db +def test_tube_join_existing_record_no_invite_url(otis): + """Test when user has JoinRecord but no invite_url, redirects to main_url.""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + tube = TubeFactory.create(accepting_signups=True, main_url="https://example.com/main") + JoinRecordFactory.create(tube=tube, user=alice, invite_url="") + + otis.login(alice) + resp = otis.get("tube-join", tube.pk) + otis.assert_30x(resp) + assert resp.url == tube.main_url + + +@pytest.mark.django_db +def test_tube_join_assigns_available_record(otis): + """Test when an unclaimed JoinRecord exists, it gets assigned to the user.""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + tube = TubeFactory.create(accepting_signups=True) + jr = JoinRecordFactory.create(tube=tube, user=None, invite_url="https://example.com/join") + + otis.login(alice) + resp = otis.get("tube-join", tube.pk) + otis.assert_30x(resp) + + # Verify the JoinRecord was assigned to alice + jr.refresh_from_db() + assert jr.user == alice + assert jr.activation_time is not None + + +@pytest.mark.django_db +def test_tube_join_no_available_records(otis): + """Test when no JoinRecords are available - shows error message.""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + tube = TubeFactory.create(accepting_signups=True) + # No JoinRecords created for this tube + + otis.login(alice) + resp = otis.get("tube-join", tube.pk, follow=True) + otis.assert_20x(resp) + # Should redirect to tube-list with error message + otis.assert_has(resp, "Ran out of one-time invite codes") + + +@pytest.mark.django_db +def test_tube_join_not_accepting_signups(otis): + """Test joining a tube that's not accepting signups.""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + tube = TubeFactory.create(accepting_signups=False) + + otis.login(alice) + otis.get_40x("tube-join", tube.pk) diff --git a/tubes/views.py b/tubes/views.py index 1221b840f..446c6bcf5 100644 --- a/tubes/views.py +++ b/tubes/views.py @@ -40,7 +40,6 @@ def tube_join(request: HttpRequest, pk: int) -> HttpResponse: request, "Ran out of one-time invite codes, please contact staff." ) logging.critical( - request, f"{tube} somehow ran out of one-time codes when {request.user} tried to join", ) From 37f2038d05efc33b09fcbedc5f4107541525ba25 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 00:34:57 +0000 Subject: [PATCH 2/3] style: apply ruff formatting --- opal/tests.py | 21 ++++++++++++++++----- tubes/tests.py | 12 +++++++++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/opal/tests.py b/opal/tests.py index 27e4ef494..834bc11dc 100644 --- a/opal/tests.py +++ b/opal/tests.py @@ -400,7 +400,10 @@ def test_leaderboard(otis): """Test the leaderboard view (admin only).""" verified_group = GroupFactory(name="Verified") alice = UserFactory.create( - username="alice", first_name="Alice", last_name="Aardvark", groups=(verified_group,) + username="alice", + first_name="Alice", + last_name="Aardvark", + groups=(verified_group,), ) bob = UserFactory.create( username="bob", first_name="Bob", last_name="Beta", groups=(verified_group,) @@ -448,7 +451,10 @@ def test_leaderboard_early_access(otis): """Test the leaderboard with early access users (testsolvers).""" testsolver_group = GroupFactory(name="Testsolver") testsolver = UserFactory.create( - username="testsolver", first_name="Test", last_name="Solver", groups=(testsolver_group,) + username="testsolver", + first_name="Test", + last_name="Solver", + groups=(testsolver_group,), ) admin = UserFactory.create(username="admin", is_staff=True, is_superuser=True) @@ -546,13 +552,18 @@ def test_close_answer(otis): alice = UserFactory.create(username="alice", groups=(verified_group,)) hunt = OpalHuntFactory.create(slug="hunt") - puzzle = OpalPuzzleFactory.create( - hunt=hunt, slug="puzzle", answer="CORRECT", partial_answers="CORRELATION\nALMOSTCORRECT" + OpalPuzzleFactory.create( + hunt=hunt, + slug="puzzle", + answer="CORRECT", + partial_answers="CORRELATION\nALMOSTCORRECT", ) otis.login(alice) resp = otis.post_20x( - "opal-show-puzzle", "hunt", "puzzle", + "opal-show-puzzle", + "hunt", + "puzzle", data={"guess": "CORRELATION"}, follow=True, ) diff --git a/tubes/tests.py b/tubes/tests.py index 0a2e93186..006c8387d 100644 --- a/tubes/tests.py +++ b/tubes/tests.py @@ -38,7 +38,9 @@ def test_tube_join_existing_record(otis): verified_group = GroupFactory(name="Verified") alice = UserFactory.create(username="alice", groups=(verified_group,)) tube = TubeFactory.create(accepting_signups=True) - jr = JoinRecordFactory.create(tube=tube, user=alice, invite_url="https://example.com/invite") + jr = JoinRecordFactory.create( + tube=tube, user=alice, invite_url="https://example.com/invite" + ) otis.login(alice) resp = otis.get("tube-join", tube.pk) @@ -52,7 +54,9 @@ def test_tube_join_existing_record_no_invite_url(otis): """Test when user has JoinRecord but no invite_url, redirects to main_url.""" verified_group = GroupFactory(name="Verified") alice = UserFactory.create(username="alice", groups=(verified_group,)) - tube = TubeFactory.create(accepting_signups=True, main_url="https://example.com/main") + tube = TubeFactory.create( + accepting_signups=True, main_url="https://example.com/main" + ) JoinRecordFactory.create(tube=tube, user=alice, invite_url="") otis.login(alice) @@ -67,7 +71,9 @@ def test_tube_join_assigns_available_record(otis): verified_group = GroupFactory(name="Verified") alice = UserFactory.create(username="alice", groups=(verified_group,)) tube = TubeFactory.create(accepting_signups=True) - jr = JoinRecordFactory.create(tube=tube, user=None, invite_url="https://example.com/join") + jr = JoinRecordFactory.create( + tube=tube, user=None, invite_url="https://example.com/join" + ) otis.login(alice) resp = otis.get("tube-join", tube.pk) From 8ede692d8d0f6489a5d15193a8075113948989bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 00:39:01 +0000 Subject: [PATCH 3/3] fix: use correct SubFactory import from factory.declarations --- tubes/factories.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tubes/factories.py b/tubes/factories.py index 09ea2724c..3ecada979 100644 --- a/tubes/factories.py +++ b/tubes/factories.py @@ -1,4 +1,4 @@ -import factory +from factory.declarations import SubFactory from factory.django import DjangoModelFactory from factory.faker import Faker @@ -19,7 +19,7 @@ class JoinRecordFactory(DjangoModelFactory): class Meta: model = JoinRecord - tube = factory.SubFactory(TubeFactory) + tube = SubFactory(TubeFactory) user = None activation_time = None invite_url = Faker("url")