From 758c9c537716db2ac39be4582be0a7d9049fec2d Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 17 Apr 2026 23:23:28 -0400 Subject: [PATCH] =?UTF-8?q?COVERAGE:=20patch=2091%=20=E2=86=92=2096%+=20?= =?UTF-8?q?=E2=80=94=20603=20tests,=20tasks.py=20at=20100%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New/extended tests across billboard, dashboard, drama, epic, gameboard, and lyric to cover previously untested branches: dev_login view, scroll position endpoints, sky preview error paths, drama to_prose/to_activity branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology model __str__, character model, sig reserve/ready/confirm views, natus preview/save views, and the full tasks.py countdown scheduler. Co-Authored-By: Claude Sonnet 4.6 --- .../billboard/tests/integrated/test_views.py | 12 + .../tests/integrated/test_sky_views.py | 73 +++++ .../drama/tests/integrated/test_models.py | 59 ++++ .../epic/tests/integrated/test_consumers.py | 59 ++++ src/apps/epic/tests/integrated/test_models.py | 112 ++++++- src/apps/epic/tests/integrated/test_views.py | 275 ++++++++++++++++++ src/apps/epic/tests/unit/test_tasks.py | 204 +++++++++++++ .../gameboard/tests/integrated/test_views.py | 59 ++++ src/apps/lyric/tests/integrated/test_views.py | 22 ++ .../lyric/tests/unit/test_templatetags.py | 30 +- 10 files changed, 903 insertions(+), 2 deletions(-) create mode 100644 src/apps/epic/tests/unit/test_tasks.py diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index d16e363..f93ae60 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -83,6 +83,18 @@ class BillboardViewTest(TestCase): self.assertEqual(list(response.context["recent_events"]), []) +class SaveScrollPositionViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="reader@test.io") + self.client.force_login(self.user) + self.room = Room.objects.create(name="Test Room", owner=self.user) + self.url = f"/billboard/room/{self.room.id}/scroll-position/" + + def test_get_returns_405(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 405) + + class ToggleBillboardAppletsTest(TestCase): def setUp(self): self.user = User.objects.create(email="test@toggle.io") diff --git a/src/apps/dashboard/tests/integrated/test_sky_views.py b/src/apps/dashboard/tests/integrated/test_sky_views.py index d907a86..9aecbee 100644 --- a/src/apps/dashboard/tests/integrated/test_sky_views.py +++ b/src/apps/dashboard/tests/integrated/test_sky_views.py @@ -150,3 +150,76 @@ class SkySaveTest(TestCase): } data = self._post(payload).json() self.assertTrue(data["saved"]) + + def test_invalid_birth_dt_string_sets_sky_birth_dt_to_none(self): + payload = { + "birth_dt": "not-a-date", + "birth_lat": 51.5, + "birth_lon": -0.1, + "birth_place": "", + "house_system": "O", + "chart_data": {}, + } + response = self._post(payload) + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertIsNone(self.user.sky_birth_dt) + + +class SkyPreviewErrorPathTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="star2@test.io") + self.client.force_login(self.user) + self.url = reverse("sky_preview") + + def test_non_numeric_lat_returns_400(self): + response = self.client.get(self.url, {"date": "1990-06-15", "lat": "abc", "lon": "0"}) + self.assertEqual(response.status_code, 400) + + def test_invalid_tz_string_returns_400(self): + response = self.client.get( + self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1", "tz": "Not/ATimezone"} + ) + self.assertEqual(response.status_code, 400) + + def test_bad_date_format_returns_400(self): + response = self.client.get( + self.url, + {"date": "not-a-date", "time": "09:00", "lat": "51.5", "lon": "-0.1", "tz": "UTC"}, + ) + self.assertEqual(response.status_code, 400) + + @patch("apps.dashboard.views.http_requests") + def test_pyswiss_tz_failure_falls_back_to_utc_and_continues(self, mock_requests): + chart_payload = { + "planets": {"Sun": {"degree": 84.5, "sign": "Gemini", "retrograde": False}}, + "houses": {"cusps": [0] * 12}, + "elements": {}, + "house_system": "O", + } + tz_response = MagicMock() + tz_response.raise_for_status.side_effect = Exception("tz timeout") + + chart_response = MagicMock() + chart_response.json.return_value = chart_payload + chart_response.raise_for_status = MagicMock() + + mock_requests.get.side_effect = [tz_response, chart_response] + + response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["timezone"], "UTC") + + @patch("apps.dashboard.views.http_requests") + def test_pyswiss_chart_failure_returns_502(self, mock_requests): + tz_response = MagicMock() + tz_response.json.return_value = {"timezone": "UTC"} + tz_response.raise_for_status = MagicMock() + + chart_response = MagicMock() + chart_response.raise_for_status.side_effect = Exception("chart timeout") + + mock_requests.get.side_effect = [tz_response, chart_response] + + response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"}) + self.assertEqual(response.status_code, 502) diff --git a/src/apps/drama/tests/integrated/test_models.py b/src/apps/drama/tests/integrated/test_models.py index b45d7ae..a2a4224 100644 --- a/src/apps/drama/tests/integrated/test_models.py +++ b/src/apps/drama/tests/integrated/test_models.py @@ -87,6 +87,65 @@ class GameEventModelTest(TestCase): event = record(self.room, GameEvent.ROLES_REVEALED) self.assertIn("system", str(event)) + # ── to_prose — remaining verb branches ─────────────────────────────── + + def test_slot_reserved_prose(self): + event = record(self.room, GameEvent.SLOT_RESERVED, actor=self.user) + self.assertEqual(event.to_prose(), "reserves a seat") + + def test_slot_returned_prose(self): + event = record(self.room, GameEvent.SLOT_RETURNED, actor=self.user) + self.assertEqual(event.to_prose(), "withdraws from the gate") + + def test_slot_released_prose_includes_slot_number(self): + event = record(self.room, GameEvent.SLOT_RELEASED, actor=self.user, slot_number=3) + self.assertIn("slot 3", event.to_prose()) + + def test_invite_sent_prose(self): + event = record(self.room, GameEvent.INVITE_SENT, actor=self.user) + self.assertEqual(event.to_prose(), "sends an invitation") + + def test_role_select_started_prose(self): + event = record(self.room, GameEvent.ROLE_SELECT_STARTED) + self.assertEqual(event.to_prose(), "Role selection begins") + + def test_roles_revealed_prose(self): + event = record(self.room, GameEvent.ROLES_REVEALED) + self.assertEqual(event.to_prose(), "All roles assigned") + + def test_role_selected_prose_unknown_role_code_uses_question_mark_ordinal(self): + event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user, + role="XX", role_display="Unknown") + self.assertIn("?", event.to_prose()) + + def test_sig_unready_prose(self): + event = record(self.room, GameEvent.SIG_UNREADY, actor=self.user) + self.assertIn("disembodies", event.to_prose()) + self.assertIn("Significator", event.to_prose()) + + def test_unknown_verb_falls_back_to_verb_string(self): + event = record(self.room, "custom_event", actor=self.user) + self.assertEqual(event.to_prose(), "custom_event") + + def test_to_activity_returns_none_when_actor_has_no_username(self): + actor = User.objects.create(email="noname@test.io") + event = record(self.room, GameEvent.SLOT_FILLED, actor=actor, slot_number=1) + self.assertIsNone(event.to_activity("https://example.com")) + + +class ScrollPositionStrTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="reader@test.io") + self.room = Room.objects.create(name="Test Room", owner=self.user) + + def test_str_includes_email_room_and_position(self): + from apps.drama.models import ScrollPosition + sp = ScrollPosition.objects.create(user=self.user, room=self.room, position=42) + s = str(sp) + self.assertIn("reader@test.io", s) + self.assertIn("Test Room", s) + self.assertIn("42", s) + class ScrollPositionModelTest(TestCase): def setUp(self): diff --git a/src/apps/epic/tests/integrated/test_consumers.py b/src/apps/epic/tests/integrated/test_consumers.py index 0133d8e..e84142b 100644 --- a/src/apps/epic/tests/integrated/test_consumers.py +++ b/src/apps/epic/tests/integrated/test_consumers.py @@ -166,6 +166,65 @@ class CursorMoveConsumerTest(TransactionTestCase): await bc_comm.disconnect() +@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS) +class MissingConsumerHandlersTest(SimpleTestCase): + """Covers the simple pass-through handlers not exercised by other tests.""" + + async def _send_and_receive(self, room_path, group_name, msg): + communicator = WebsocketCommunicator(application, room_path) + await communicator.connect() + channel_layer = get_channel_layer() + await channel_layer.group_send(group_name, msg) + response = await communicator.receive_json_from() + await communicator.disconnect() + return response + + async def test_receives_sig_selected_broadcast(self): + room_id = "00000000-0000-0000-0000-000000000002" + response = await self._send_and_receive( + f"/ws/room/{room_id}/", + f"room_{room_id}", + {"type": "sig_selected", "card_id": "abc"}, + ) + self.assertEqual(response["type"], "sig_selected") + + async def test_receives_countdown_start_broadcast(self): + room_id = "00000000-0000-0000-0000-000000000002" + response = await self._send_and_receive( + f"/ws/room/{room_id}/", + f"room_{room_id}", + {"type": "countdown_start", "polarity": "levity", "seconds": 12}, + ) + self.assertEqual(response["type"], "countdown_start") + + async def test_receives_countdown_cancel_broadcast(self): + room_id = "00000000-0000-0000-0000-000000000002" + response = await self._send_and_receive( + f"/ws/room/{room_id}/", + f"room_{room_id}", + {"type": "countdown_cancel", "polarity": "levity", "seconds_remaining": 7}, + ) + self.assertEqual(response["type"], "countdown_cancel") + + async def test_receives_polarity_room_done_broadcast(self): + room_id = "00000000-0000-0000-0000-000000000002" + response = await self._send_and_receive( + f"/ws/room/{room_id}/", + f"room_{room_id}", + {"type": "polarity_room_done", "polarity": "levity"}, + ) + self.assertEqual(response["type"], "polarity_room_done") + + async def test_receives_pick_sky_available_broadcast(self): + room_id = "00000000-0000-0000-0000-000000000002" + response = await self._send_and_receive( + f"/ws/room/{room_id}/", + f"room_{room_id}", + {"type": "pick_sky_available"}, + ) + self.assertEqual(response["type"], "pick_sky_available") + + @tag('channels') @override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS) class SigHoverConsumerTest(TransactionTestCase): diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index 7191462..78fd41c 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -8,7 +8,8 @@ from django.db import IntegrityError from apps.lyric.models import Token, User from apps.epic.models import ( - DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard, + AspectType, Character, DeckVariant, GateSlot, HouseLabel, Planet, Room, RoomInvite, + SigReservation, Sign, TableSeat, TarotCard, debit_token, select_token, sig_deck_cards, levity_sig_cards, gravity_sig_cards, sig_seat_order, active_sig_seat, ) @@ -593,3 +594,112 @@ class RoomSkySelectStatusTest(TestCase): self.room.save() self.room.refresh_from_db() self.assertIsNotNone(self.room.sig_select_started_at) + + +# ── TarotDeck.draw / shuffle ────────────────────────────────────────────────── + +class TarotDeckDrawTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="dealer@test.io") + self.room = Room.objects.create(name="R", owner=self.user) + + def test_draw_raises_value_error_when_too_few_cards_remain(self): + from apps.epic.models import TarotDeck + deck_variant = DeckVariant.objects.first() + all_ids = list(TarotCard.objects.filter(deck_variant=deck_variant).values_list('id', flat=True)) + td = TarotDeck.objects.create( + room=self.room, + deck_variant=deck_variant, + drawn_card_ids=all_ids, + ) + with self.assertRaises(ValueError): + td.draw(1) + + def test_shuffle_resets_drawn_card_ids(self): + from apps.epic.models import TarotDeck + deck_variant = DeckVariant.objects.first() + some_ids = list(TarotCard.objects.filter(deck_variant=deck_variant).values_list('id', flat=True)[:3]) + td = TarotDeck.objects.create( + room=self.room, + deck_variant=deck_variant, + drawn_card_ids=some_ids, + ) + td.shuffle() + td.refresh_from_db() + self.assertEqual(td.drawn_card_ids, []) + + +# ── sig_deck_cards with no equipped deck ───────────────────────────────────── + +class SigDeckCardsNoEquippedDeckTest(TestCase): + def test_returns_empty_list_when_owner_has_no_equipped_deck(self): + user = User.objects.create(email="nodeck@test.io") + user.equipped_deck = None + user.save(update_fields=["equipped_deck"]) + room = Room.objects.create(name="R", owner=user) + self.assertEqual(sig_deck_cards(room), []) + + +# ── Astrology model __str__ methods ────────────────────────────────────────── + +class AstrologyModelStrTest(TestCase): + def test_zodiac_sign_str(self): + sign = Sign.objects.first() + if sign is None: + self.skipTest("No Sign rows") + self.assertEqual(str(sign), sign.name) + + def test_planet_str(self): + planet = Planet.objects.first() + if planet is None: + self.skipTest("No Planet rows") + self.assertEqual(str(planet), planet.name) + + def test_aspect_type_str(self): + aspect = AspectType.objects.first() + if aspect is None: + self.skipTest("No AspectType rows") + self.assertEqual(str(aspect), aspect.name) + + def test_house_label_str(self): + label = HouseLabel.objects.first() + if label is None: + self.skipTest("No HouseLabel rows") + self.assertIn(str(label.number), str(label)) + + +# ── Character model ─────────────────────────────────────────────────────────── + +class CharacterModelTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="char@test.io") + self.room = Room.objects.create(name="R", owner=self.user) + self.seat = TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=1, role="PC") + + def test_draft_str(self): + char = Character.objects.create(seat=self.seat) + self.assertIn("draft", str(char)) + + def test_confirmed_str(self): + char = Character.objects.create(seat=self.seat, confirmed_at=timezone.now()) + self.assertIn("confirmed", str(char)) + + def test_is_confirmed_false_for_draft(self): + char = Character.objects.create(seat=self.seat) + self.assertFalse(char.is_confirmed) + + def test_is_confirmed_true_when_confirmed_at_set(self): + char = Character.objects.create(seat=self.seat, confirmed_at=timezone.now()) + self.assertTrue(char.is_confirmed) + + def test_is_active_true_when_confirmed_and_not_retired(self): + char = Character.objects.create(seat=self.seat, confirmed_at=timezone.now()) + self.assertTrue(char.is_active) + + def test_is_active_false_when_retired(self): + char = Character.objects.create( + seat=self.seat, + confirmed_at=timezone.now(), + retired_at=timezone.now(), + ) + self.assertFalse(char.is_active) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 675c708..a35c95c 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -1612,3 +1612,278 @@ class PickSkyRenderingTest(TestCase): response = self.client.get(self.url) self.assertContains(response, 'id="id_pick_sky_btn"') self.assertContains(response, 'style="display:none"') + + +# ── select_role GET redirect ────────────────────────────────────────────────── + +class SelectRoleGetRedirectTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="gamer@sr.io") + self.client.force_login(self.user) + self.room = Room.objects.create(name="R", owner=self.user) + self.room.table_status = Room.ROLE_SELECT + self.room.save() + + def test_get_redirects_to_room(self): + response = self.client.get(reverse("epic:select_role", kwargs={"room_id": self.room.id})) + self.assertRedirects(response, reverse("epic:room", kwargs={"room_id": self.room.id}), + fetch_redirect_response=False) + + +# ── sig_reserve / sig_ready / sig_confirm / select_sig helpers ──────────────── + +def _make_sig_room(owner, *extra_gamers): + room = Room.objects.create(name="SR", owner=owner) + seat_map = {} + gamers = [owner] + list(extra_gamers) + roles = ["PC", "NC", "EC", "SC", "AC", "BC"] + for i, (gamer, role) in enumerate(zip(gamers, roles), start=1): + seat = TableSeat.objects.create(room=room, gamer=gamer, slot_number=i, role=role) + seat_map[role] = seat + room.table_status = Room.SIG_SELECT + room.save() + return room, seat_map + + +class SigReserveViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="pc@sig.io") + self.client.force_login(self.user) + self.room, self.seats = _make_sig_room(self.user) + self.card = TarotCard.objects.first() + + def test_non_post_returns_405(self): + response = self.client.get(reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})) + self.assertEqual(response.status_code, 405) + + def test_reserve_action_succeeds(self): + response = self.client.post( + reverse("epic:sig_reserve", kwargs={"room_id": self.room.id}), + {"action": "reserve", "card_id": str(self.card.pk)}, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(SigReservation.objects.filter(room=self.room, gamer=self.user).exists()) + + def test_release_action_removes_reservation(self): + SigReservation.objects.create( + room=self.room, gamer=self.user, card=self.card, + polarity=SigReservation.LEVITY, seat=self.seats["PC"], + ) + response = self.client.post( + reverse("epic:sig_reserve", kwargs={"room_id": self.room.id}), + {"action": "release"}, + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(SigReservation.objects.filter(room=self.room, gamer=self.user).exists()) + + +class SigReadyViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="pc@ready.io") + self.client.force_login(self.user) + self.room, self.seats = _make_sig_room(self.user) + self.card = ( + TarotCard.objects.filter(arcana=TarotCard.MIDDLE).first() + or TarotCard.objects.first() + ) + self.res = SigReservation.objects.create( + room=self.room, gamer=self.user, card=self.card, + polarity=SigReservation.LEVITY, seat=self.seats["PC"], + ) + + def test_non_post_returns_405(self): + response = self.client.get(reverse("epic:sig_ready", kwargs={"room_id": self.room.id})) + self.assertEqual(response.status_code, 405) + + def test_ready_action_sets_ready_flag(self): + response = self.client.post( + reverse("epic:sig_ready", kwargs={"room_id": self.room.id}), + {"action": "ready"}, + ) + self.assertEqual(response.status_code, 200) + self.res.refresh_from_db() + self.assertTrue(self.res.ready) + + def test_ready_action_idempotent_when_already_ready(self): + self.res.ready = True + self.res.save() + response = self.client.post( + reverse("epic:sig_ready", kwargs={"room_id": self.room.id}), + {"action": "ready"}, + ) + self.assertEqual(response.status_code, 200) + + def test_unready_action_saves_seconds_remaining(self): + self.res.ready = True + self.res.save() + response = self.client.post( + reverse("epic:sig_ready", kwargs={"room_id": self.room.id}), + {"action": "unready", "seconds_remaining": "7"}, + ) + self.assertEqual(response.status_code, 200) + self.res.refresh_from_db() + self.assertEqual(self.res.countdown_remaining, 7) + + def test_unready_action_handles_invalid_seconds_remaining(self): + self.res.ready = True + self.res.save() + response = self.client.post( + reverse("epic:sig_ready", kwargs={"room_id": self.room.id}), + {"action": "unready", "seconds_remaining": "abc"}, + ) + self.assertEqual(response.status_code, 200) + self.res.refresh_from_db() + self.assertEqual(self.res.countdown_remaining, 12) + + +class SigConfirmViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="pc@confirm.io") + self.client.force_login(self.user) + self.room, self.seats = _make_sig_room(self.user) + + def test_non_post_returns_405(self): + response = self.client.get(reverse("epic:sig_confirm", kwargs={"room_id": self.room.id})) + self.assertEqual(response.status_code, 405) + + +class SelectSigViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="pc@selsig.io") + self.client.force_login(self.user) + self.room, self.seats = _make_sig_room(self.user) + + def test_non_post_redirects(self): + response = self.client.get(reverse("epic:select_sig", kwargs={"room_id": self.room.id})) + self.assertEqual(response.status_code, 302) + + def test_nonexistent_card_returns_400(self): + response = self.client.post( + reverse("epic:select_sig", kwargs={"room_id": self.room.id}), + {"card_id": "99999999"}, + ) + self.assertEqual(response.status_code, 400) + + +# ── natus_preview (epic) ────────────────────────────────────────────────────── + +class NatusPreviewViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="pc@natus.io") + self.client.force_login(self.user) + self.room, _ = _make_sig_room(self.user) + self.room.table_status = Room.SKY_SELECT + self.room.save() + self.url = reverse("epic:natus_preview", kwargs={"room_id": self.room.id}) + + def test_missing_params_returns_400(self): + response = self.client.get(self.url, {"date": "1990-06-15"}) + self.assertEqual(response.status_code, 400) + + def test_non_numeric_lat_returns_400(self): + response = self.client.get(self.url, {"date": "1990-06-15", "lat": "abc", "lon": "0"}) + self.assertEqual(response.status_code, 400) + + def test_out_of_range_lat_returns_400(self): + response = self.client.get(self.url, {"date": "1990-06-15", "lat": "999", "lon": "0"}) + self.assertEqual(response.status_code, 400) + + def test_invalid_tz_string_returns_400(self): + response = self.client.get( + self.url, + {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1", "tz": "Not/Real"}, + ) + self.assertEqual(response.status_code, 400) + + def test_bad_date_format_returns_400(self): + response = self.client.get( + self.url, + {"date": "baddate", "time": "09:00", "lat": "51.5", "lon": "-0.1", "tz": "UTC"}, + ) + self.assertEqual(response.status_code, 400) + + @patch("apps.epic.views.http_requests") + def test_pyswiss_failure_returns_502(self, mock_requests): + from unittest.mock import MagicMock + tz_r = MagicMock() + tz_r.json.return_value = {"timezone": "UTC"} + tz_r.raise_for_status = MagicMock() + chart_r = MagicMock() + chart_r.raise_for_status.side_effect = Exception("timeout") + mock_requests.get.side_effect = [tz_r, chart_r] + response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"}) + self.assertEqual(response.status_code, 502) + + @patch("apps.epic.views.http_requests") + def test_success_returns_chart_distinctions_timezone(self, mock_requests): + from unittest.mock import MagicMock + payload = { + "planets": {"Sun": {"degree": 84.5}}, + "houses": {"cusps": [0] * 12}, + "elements": {"Earth": 1}, + "house_system": "O", + } + tz_r = MagicMock() + tz_r.json.return_value = {"timezone": "Europe/London"} + tz_r.raise_for_status = MagicMock() + ch_r = MagicMock() + ch_r.json.return_value = payload + ch_r.raise_for_status = MagicMock() + mock_requests.get.side_effect = [tz_r, ch_r] + response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"}) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("distinctions", data) + self.assertIn("Stone", data["elements"]) + self.assertNotIn("Earth", data["elements"]) + + +# ── natus_save (epic) ───────────────────────────────────────────────────────── + +class NatusSaveViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="pc@natussave.io") + self.client.force_login(self.user) + self.room, _ = _make_sig_room(self.user) + self.room.table_status = Room.SKY_SELECT + self.room.save() + self.url = reverse("epic:natus_save", kwargs={"room_id": self.room.id}) + + def _post(self, payload): + import json as _json + return self.client.post(self.url, data=_json.dumps(payload), content_type="application/json") + + def test_get_returns_405(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 405) + + def test_invalid_json_returns_400(self): + response = self.client.post(self.url, data="not json", content_type="application/json") + self.assertEqual(response.status_code, 400) + + def test_save_draft_returns_id_and_not_confirmed(self): + response = self._post({ + "birth_dt": "1990-06-15T09:00:00Z", + "birth_lat": 51.5, + "birth_lon": -0.1, + "birth_place": "London", + "house_system": "O", + "chart_data": {}, + }) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("id", data) + self.assertFalse(data["confirmed"]) + + def test_confirm_action_locks_character(self): + response = self._post({ + "birth_dt": "1990-06-15T09:00:00Z", + "birth_lat": 51.5, + "birth_lon": -0.1, + "birth_place": "", + "house_system": "O", + "chart_data": {}, + "action": "confirm", + }) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["confirmed"]) diff --git a/src/apps/epic/tests/unit/test_tasks.py b/src/apps/epic/tests/unit/test_tasks.py new file mode 100644 index 0000000..44e8c0d --- /dev/null +++ b/src/apps/epic/tests/unit/test_tasks.py @@ -0,0 +1,204 @@ +from unittest.mock import patch, MagicMock + +from django.test import TestCase + +from apps.epic.models import Room, SigReservation, TableSeat, TarotCard +from apps.lyric.models import User +from apps.epic.tasks import ( + _cache_key, cancel_polarity_confirm, schedule_polarity_confirm, +) + + +class CacheKeyTest(TestCase): + def test_cache_key_format(self): + self.assertEqual(_cache_key("room-1", "levity"), "sig_countdown_room-1_levity") + + +class CancelPolarityConfirmTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="owner@tasks.io") + self.room = Room.objects.create(name="R", owner=self.user) + + def test_cancel_with_no_timer_is_a_noop(self): + cancel_polarity_confirm(str(self.room.id), SigReservation.LEVITY) + + def test_cancel_clears_cache_entry(self): + from django.core.cache import cache + key = _cache_key(str(self.room.id), SigReservation.LEVITY) + cache.set(key, "sometoken", timeout=60) + cancel_polarity_confirm(str(self.room.id), SigReservation.LEVITY) + self.assertIsNone(cache.get(key)) + + @patch("apps.epic.tasks._timers") + def test_cancel_calls_timer_cancel_when_present(self, mock_timers): + mock_timer = MagicMock() + key = f"{self.room.id}_levity" + mock_timers.pop.return_value = mock_timer + cancel_polarity_confirm(str(self.room.id), SigReservation.LEVITY) + mock_timer.cancel.assert_called_once() + + +class FireFunctionTest(TestCase): + """Tests for the _fire() callback executed by threading.Timer.""" + + def setUp(self): + self.owner = User.objects.create(email="owner@fire.io") + self.room = Room.objects.create(name="R", owner=self.owner) + self.room.table_status = Room.SIG_SELECT + self.room.save() + roles = ["PC", "NC", "SC"] + self.gamers = [self.owner] + for i, role in enumerate(roles): + if i == 0: + TableSeat.objects.create(room=self.room, gamer=self.owner, slot_number=1, role=role) + else: + g = User.objects.create(email=f"g{i}@fire.io") + self.gamers.append(g) + TableSeat.objects.create(room=self.room, gamer=g, slot_number=i+1, role=role) + # Gravity seats (no significators needed for levity test) + grav_roles = ["BC", "EC", "AC"] + for i, role in enumerate(grav_roles, start=4): + g = User.objects.create(email=f"grav{i}@fire.io") + TableSeat.objects.create(room=self.room, gamer=g, slot_number=i, role=role) + + def _set_token(self): + from django.core.cache import cache + import uuid + token = str(uuid.uuid4()) + cache.set(_cache_key(str(self.room.id), SigReservation.LEVITY), token, 120) + return token + + @patch("apps.epic.tasks._group_send") + def test_fire_does_nothing_if_token_mismatch(self, mock_send): + from apps.epic.tasks import _fire + self._set_token() + _fire(str(self.room.id), SigReservation.LEVITY, "wrong-token") + mock_send.assert_not_called() + + @patch("apps.epic.tasks._group_send") + def test_fire_does_nothing_if_room_not_sig_select(self, mock_send): + from apps.epic.tasks import _fire + token = self._set_token() + self.room.table_status = Room.ROLE_SELECT + self.room.save() + _fire(str(self.room.id), SigReservation.LEVITY, token) + mock_send.assert_not_called() + + @patch("apps.epic.tasks._group_send") + def test_fire_does_nothing_if_fewer_than_3_ready(self, mock_send): + from apps.epic.tasks import _fire + token = self._set_token() + cards = list(TarotCard.objects.all()[:2]) + seats = list(TableSeat.objects.filter(room=self.room, role__in=["PC", "NC"])) + for i, seat in enumerate(seats): + SigReservation.objects.create( + room=self.room, gamer=seat.gamer, card=cards[i], + polarity=SigReservation.LEVITY, seat=seat, ready=True, + ) + _fire(str(self.room.id), SigReservation.LEVITY, token) + mock_send.assert_not_called() + + @patch("apps.epic.tasks._group_send") + def test_fire_assigns_significators_and_broadcasts_when_all_ready(self, mock_send): + from apps.epic.tasks import _fire + token = self._set_token() + cards = list(TarotCard.objects.all()[:3]) + seats = list(TableSeat.objects.filter(room=self.room, role__in=["PC", "NC", "SC"])) + for i, seat in enumerate(seats): + SigReservation.objects.create( + room=self.room, gamer=seat.gamer, card=cards[i], + polarity=SigReservation.LEVITY, seat=seat, ready=True, + ) + _fire(str(self.room.id), SigReservation.LEVITY, token) + self.assertTrue(mock_send.called) + levity_seats = TableSeat.objects.filter(room=self.room, role__in=["PC", "NC", "SC"]) + for i, seat in enumerate(levity_seats): + seat.refresh_from_db() + self.assertEqual(seat.significator, cards[i]) + + def test_fire_does_nothing_for_nonexistent_room(self): + from apps.epic.tasks import _fire + from django.core.cache import cache + fake_id = "00000000-0000-0000-0000-000000000000" + token = "known-token" + cache.set(_cache_key(fake_id, SigReservation.LEVITY), token, 60) + _fire(fake_id, SigReservation.LEVITY, token) + + @patch("apps.epic.tasks._group_send") + def test_fire_does_nothing_if_all_sigs_already_assigned(self, mock_send): + from apps.epic.tasks import _fire + token = self._set_token() + cards = list(TarotCard.objects.all()[:3]) + seats = list(TableSeat.objects.filter(room=self.room, role__in=["PC", "NC", "SC"])) + for i, seat in enumerate(seats): + seat.significator = cards[i] + seat.save(update_fields=["significator"]) + _fire(str(self.room.id), SigReservation.LEVITY, token) + mock_send.assert_not_called() + + @patch("apps.epic.tasks._group_send") + def test_fire_broadcasts_pick_sky_when_all_polarity_sigs_assigned(self, mock_send): + """When both levity AND gravity seats all have significators, fire() triggers SKY_SELECT.""" + from apps.epic.tasks import _fire + token = self._set_token() + cards = list(TarotCard.objects.all()[:6]) + # Give gravity seats significators so the all-assigned check passes + gravity_seats = list(TableSeat.objects.filter(room=self.room, role__in=["BC", "EC", "AC"])) + for i, seat in enumerate(gravity_seats): + seat.significator = cards[i] + seat.save(update_fields=["significator"]) + # Create ready levity reservations (different cards from gravity) + levity_seats = list(TableSeat.objects.filter(room=self.room, role__in=["PC", "NC", "SC"])) + levity_cards = cards[3:6] + for i, seat in enumerate(levity_seats): + SigReservation.objects.create( + room=self.room, gamer=seat.gamer, card=levity_cards[i], + polarity=SigReservation.LEVITY, seat=seat, ready=True, + ) + _fire(str(self.room.id), SigReservation.LEVITY, token) + call_types = [c.args[1]["type"] for c in mock_send.call_args_list] + self.assertIn("polarity_room_done", call_types) + self.assertIn("pick_sky_available", call_types) + self.room.refresh_from_db() + self.assertEqual(self.room.table_status, "SKY_SELECT") + + +class SchedulePolarityConfirmTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="owner@schedule.io") + self.room = Room.objects.create(name="R", owner=self.user) + + def test_schedule_sets_cache_token(self): + from django.core.cache import cache + schedule_polarity_confirm(str(self.room.id), SigReservation.LEVITY, 60) + token = cache.get(_cache_key(str(self.room.id), SigReservation.LEVITY)) + self.assertIsNotNone(token) + + def test_schedule_registers_timer(self): + from apps.epic.tasks import _timers + key = f"{self.room.id}_{SigReservation.LEVITY}" + schedule_polarity_confirm(str(self.room.id), SigReservation.LEVITY, 60) + self.assertIn(key, _timers) + _timers[key].cancel() # clean up + + def test_schedule_cancels_prior_timer_before_scheduling(self): + from apps.epic.tasks import _timers + schedule_polarity_confirm(str(self.room.id), SigReservation.LEVITY, 60) + key = f"{self.room.id}_{SigReservation.LEVITY}" + first_timer = _timers.get(key) + schedule_polarity_confirm(str(self.room.id), SigReservation.LEVITY, 60) + second_timer = _timers.get(key) + self.assertIsNotNone(second_timer) + self.assertIsNot(first_timer, second_timer) + second_timer.cancel() + + +class GroupSendTest(TestCase): + @patch("apps.epic.tasks.async_to_sync") + def test_group_send_calls_async_to_sync(self, mock_a2s): + from apps.epic.tasks import _group_send + mock_fn = MagicMock() + mock_a2s.return_value = mock_fn + _group_send("room-abc", {"type": "test"}) + mock_a2s.assert_called_once() + mock_fn.assert_called_once_with("room_room-abc", {"type": "test"}) diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 1bd40b0..961cb99 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -4,6 +4,7 @@ from django.test import TestCase from django.urls import reverse from apps.applets.models import Applet, UserApplet +from apps.epic.models import DeckVariant from apps.lyric.models import Token, User @@ -106,6 +107,64 @@ class ToggleGameAppletsViewTest(TestCase): self.assertFalse(UserApplet.objects.filter(user=self.user, applet=dash_applet).exists()) +class EquipDeckViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="gamer@test.io") + self.client.force_login(self.user) + self.deck = DeckVariant.objects.first() + + def test_get_returns_405(self): + response = self.client.get(reverse("equip_deck", kwargs={"deck_id": self.deck.pk})) + self.assertEqual(response.status_code, 405) + + def test_post_equips_deck(self): + response = self.client.post(reverse("equip_deck", kwargs={"deck_id": self.deck.pk})) + self.assertEqual(response.status_code, 204) + self.user.refresh_from_db() + self.assertEqual(self.user.equipped_deck, self.deck) + + +class UnequipDeckViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="gamer@test.io") + self.deck = DeckVariant.objects.first() + self.user.equipped_deck = self.deck + self.user.save(update_fields=["equipped_deck"]) + self.client.force_login(self.user) + + def test_get_returns_405(self): + response = self.client.get(reverse("unequip_deck", kwargs={"deck_id": self.deck.pk})) + self.assertEqual(response.status_code, 405) + + def test_post_clears_equipped_deck_when_matches(self): + response = self.client.post(reverse("unequip_deck", kwargs={"deck_id": self.deck.pk})) + self.assertEqual(response.status_code, 204) + self.user.refresh_from_db() + self.assertIsNone(self.user.equipped_deck) + + def test_post_ignores_non_matching_deck(self): + other_deck = DeckVariant.objects.exclude(pk=self.deck.pk).first() + if other_deck is None: + self.skipTest("Only one deck variant in DB") + self.client.post(reverse("unequip_deck", kwargs={"deck_id": other_deck.pk})) + self.user.refresh_from_db() + self.assertEqual(self.user.equipped_deck, self.deck) + + +class UnequipTrinketViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="gamer@test.io") + self.client.force_login(self.user) + + def test_get_returns_405(self): + from apps.lyric.models import Token + token = Token.objects.filter(user=self.user).first() + if token is None: + self.skipTest("No token for user") + response = self.client.get(reverse("unequip_trinket", kwargs={"token_id": token.pk})) + self.assertEqual(response.status_code, 405) + + class GameKitViewTest(TestCase): def setUp(self): self.user = User.objects.create(email="gamer@test.io") diff --git a/src/apps/lyric/tests/integrated/test_views.py b/src/apps/lyric/tests/integrated/test_views.py index 9746002..aae10f1 100644 --- a/src/apps/lyric/tests/integrated/test_views.py +++ b/src/apps/lyric/tests/integrated/test_views.py @@ -85,3 +85,25 @@ class LoginViewTest(TestCase): mock_auth.authenticate.call_args, mock.call(uid="abc123") ) + + +class DevLoginViewTest(TestCase): + def test_happy_path_sets_session_cookie_and_redirects(self): + from django.test import override_settings + with override_settings(DEBUG=True): + response = self.client.get("/lyric/dev-login/testsessionkey/") + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "/") + self.assertIn("sessionid", response.cookies) + + def test_next_param_sets_redirect_target(self): + from django.test import override_settings + with override_settings(DEBUG=True): + response = self.client.get("/lyric/dev-login/key/?next=/gameboard/") + self.assertEqual(response["Location"], "/gameboard/") + + def test_returns_404_when_debug_is_false(self): + from django.test import override_settings + with override_settings(DEBUG=False): + response = self.client.get("/lyric/dev-login/key/") + self.assertEqual(response.status_code, 404) diff --git a/src/apps/lyric/tests/unit/test_templatetags.py b/src/apps/lyric/tests/unit/test_templatetags.py index 67ef906..36ae66b 100644 --- a/src/apps/lyric/tests/unit/test_templatetags.py +++ b/src/apps/lyric/tests/unit/test_templatetags.py @@ -1,7 +1,8 @@ from django.test import SimpleTestCase +from django.utils import timezone from unittest.mock import Mock -from apps.lyric.templatetags.lyric_extras import display_name, truncate_email +from apps.lyric.templatetags.lyric_extras import display_name, relative_ts, truncate_email class TruncateEmailTest(SimpleTestCase): @@ -34,3 +35,30 @@ class DisplayNameFilterTest(SimpleTestCase): def test_returns_username_when_set(self): user = Mock(username="earthman", email="sesquipedalian@abc.de") self.assertEqual(display_name(user), "earthman") + + +class RelativeTsFilterTest(SimpleTestCase): + def test_returns_empty_string_for_none(self): + self.assertEqual(relative_ts(None), "") + + def test_returns_time_for_recent_dt(self): + dt = timezone.now() - timezone.timedelta(hours=1) + result = relative_ts(dt) + self.assertRegex(result, r'\d+:\d+') + + def test_returns_weekday_for_2_day_old_dt(self): + dt = timezone.now() - timezone.timedelta(days=2) + result = relative_ts(dt) + self.assertIn(result, ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]) + + def test_returns_month_day_for_30_day_old_dt(self): + dt = timezone.now() - timezone.timedelta(days=30) + result = relative_ts(dt) + import re + self.assertRegex(result, r'^\d{2} \w{3}$') + + def test_returns_month_day_year_for_over_1_year_old_dt(self): + dt = timezone.now() - timezone.timedelta(days=400) + result = relative_ts(dt) + import re + self.assertRegex(result, r'^\d{2} \w{3} \d{4}$')