COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%

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 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-17 23:23:28 -04:00
parent 7c03bded8d
commit 758c9c5377
10 changed files with 903 additions and 2 deletions

View File

@@ -83,6 +83,18 @@ class BillboardViewTest(TestCase):
self.assertEqual(list(response.context["recent_events"]), []) 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): class ToggleBillboardAppletsTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="test@toggle.io") self.user = User.objects.create(email="test@toggle.io")

View File

@@ -150,3 +150,76 @@ class SkySaveTest(TestCase):
} }
data = self._post(payload).json() data = self._post(payload).json()
self.assertTrue(data["saved"]) 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)

View File

@@ -87,6 +87,65 @@ class GameEventModelTest(TestCase):
event = record(self.room, GameEvent.ROLES_REVEALED) event = record(self.room, GameEvent.ROLES_REVEALED)
self.assertIn("system", str(event)) 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): class ScrollPositionModelTest(TestCase):
def setUp(self): def setUp(self):

View File

@@ -166,6 +166,65 @@ class CursorMoveConsumerTest(TransactionTestCase):
await bc_comm.disconnect() 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') @tag('channels')
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS) @override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
class SigHoverConsumerTest(TransactionTestCase): class SigHoverConsumerTest(TransactionTestCase):

View File

@@ -8,7 +8,8 @@ from django.db import IntegrityError
from apps.lyric.models import Token, User from apps.lyric.models import Token, User
from apps.epic.models import ( 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, debit_token, select_token, sig_deck_cards, levity_sig_cards, gravity_sig_cards,
sig_seat_order, active_sig_seat, sig_seat_order, active_sig_seat,
) )
@@ -593,3 +594,112 @@ class RoomSkySelectStatusTest(TestCase):
self.room.save() self.room.save()
self.room.refresh_from_db() self.room.refresh_from_db()
self.assertIsNotNone(self.room.sig_select_started_at) 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)

View File

@@ -1612,3 +1612,278 @@ class PickSkyRenderingTest(TestCase):
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertContains(response, 'id="id_pick_sky_btn"') self.assertContains(response, 'id="id_pick_sky_btn"')
self.assertContains(response, 'style="display:none"') 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"])

View File

@@ -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"})

View File

@@ -4,6 +4,7 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from apps.applets.models import Applet, UserApplet from apps.applets.models import Applet, UserApplet
from apps.epic.models import DeckVariant
from apps.lyric.models import Token, User 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()) 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): class GameKitViewTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="gamer@test.io") self.user = User.objects.create(email="gamer@test.io")

View File

@@ -85,3 +85,25 @@ class LoginViewTest(TestCase):
mock_auth.authenticate.call_args, mock_auth.authenticate.call_args,
mock.call(uid="abc123") 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)

View File

@@ -1,7 +1,8 @@
from django.test import SimpleTestCase from django.test import SimpleTestCase
from django.utils import timezone
from unittest.mock import Mock 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): class TruncateEmailTest(SimpleTestCase):
@@ -34,3 +35,30 @@ class DisplayNameFilterTest(SimpleTestCase):
def test_returns_username_when_set(self): def test_returns_username_when_set(self):
user = Mock(username="earthman", email="sesquipedalian@abc.de") user = Mock(username="earthman", email="sesquipedalian@abc.de")
self.assertEqual(display_name(user), "earthman") 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}$')