From b9c4d7a61d73f2a0d99cf2b82cf9bb613f1d9df0 Mon Sep 17 00:00:00 2001 From: badbl0cks <4161747+badbl0cks@users.noreply.github.com> Date: Thu, 27 Mar 2025 17:26:07 -0700 Subject: [PATCH] Fix friend_code max length issues in tests, and fix in_game_name length issues, also update tests to fit more scenarios --- accounts/models.py | 12 +- accounts/tests.py | 7 +- cards/models.py | 14 +- cards/tests.py | 11 +- home/tests.py | 5 +- tests/utils/rarity.py | 10 + trades/mixins.py | 16 +- trades/models.py | 8 +- trades/tests.py | 499 ++++++++++++++++++++++++++++++++++++++++-- trades/views.py | 42 ++-- 10 files changed, 558 insertions(+), 66 deletions(-) create mode 100644 tests/utils/rarity.py diff --git a/accounts/models.py b/accounts/models.py index 9efd024..f0bb6f9 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,6 +1,14 @@ from django.contrib.auth.models import AbstractUser from django.db import models from django.core.exceptions import ValidationError +import re + +def validate_friend_code(value): + """Validate that friend code follows the format XXXX-XXXX-XXXX-XXXX where X is a digit.""" + if not re.match(r'^\d{4}-\d{4}-\d{4}-\d{4}$', value): + raise ValidationError( + 'Friend code must be in format XXXX-XXXX-XXXX-XXXX where X is a digit.' + ) class CustomUser(AbstractUser): default_friend_code = models.ForeignKey("FriendCode", on_delete=models.SET_NULL, null=True, blank=True) @@ -34,8 +42,8 @@ class CustomUser(AbstractUser): self.save(update_fields=["default_friend_code"]) class FriendCode(models.Model): - friend_code = models.CharField(max_length=19) - in_game_name = models.CharField(max_length=16, null=False, blank=False) + friend_code = models.CharField(max_length=19, validators=[validate_friend_code]) + in_game_name = models.CharField(max_length=14, null=False, blank=False) user = models.ForeignKey(CustomUser, on_delete=models.PROTECT, related_name='friend_codes') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/accounts/tests.py b/accounts/tests.py index 0233194..392f3e7 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -12,6 +12,7 @@ from accounts.models import FriendCode from accounts.forms import FriendCodeForm, CustomUserCreationForm, UserSettingsForm from accounts.templatetags import gravatar from trades.models import TradeOffer +from tests.utils.rarity import RARITY_MAPPING # Create your tests here. @@ -415,11 +416,11 @@ class FriendCodeViewsTests(TestCase): response = self.client.get(url) self.assertEqual(response.status_code, 200) # POST request. - new_data = {"in_game_name": "UpdatedViewGame"} + new_data = {"in_game_name": "UpdatedGame"} response = self.client.post(url, new_data) self.assertEqual(response.status_code, 302) self.friend_code2.refresh_from_db() - self.assertEqual(self.friend_code2.in_game_name, "UpdatedViewGame") + self.assertEqual(self.friend_code2.in_game_name, "UpdatedGame") def test_edit_friend_code_view_wrong_user(self): """A user should not be able to edit a friend code that does not belong to them.""" @@ -490,7 +491,7 @@ class FriendCodeViewsTests(TestCase): self.trade_offer = TradeOffer.objects.create( initiated_by=self.friend_code2, is_closed=False, - rarity_icon="⭐️", + rarity_icon=RARITY_MAPPING[5], rarity_level=5 ) url = reverse("delete_friend_code", kwargs={"pk": self.friend_code2.pk}) diff --git a/cards/models.py b/cards/models.py index a007463..168f6ed 100644 --- a/cards/models.py +++ b/cards/models.py @@ -36,16 +36,18 @@ class Deck(models.Model): class Card(models.Model): id = models.AutoField(primary_key=True) - name = models.CharField(max_length=128) + name = models.CharField(max_length=64) decks = models.ManyToManyField("Deck") - cardset = models.CharField(max_length=8) + cardset = models.CharField(max_length=32) cardnum = models.IntegerField() - style = models.CharField(max_length=255, null=False) - rarity_icon = models.CharField(max_length=8) + style = models.CharField(max_length=16) + rarity_icon = models.CharField(max_length=12) rarity_level = models.IntegerField() created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + class Meta: + unique_together = ('cardset', 'cardnum') + def __str__(self): - # For display, we show the original rarity icons. - return f"{self.name} {self.rarity_icon} {self.cardset}" \ No newline at end of file + return f"{self.name} ({self.cardset} #{self.cardnum})" \ No newline at end of file diff --git a/cards/tests.py b/cards/tests.py index c2aa441..13c55a7 100644 --- a/cards/tests.py +++ b/cards/tests.py @@ -9,6 +9,7 @@ from accounts.models import CustomUser, FriendCode from cards.models import Card, Deck, DeckNameTranslation, CardNameTranslation from trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard from cards.templatetags import card_badge, card_multiselect +from tests.utils.rarity import RARITY_MAPPING class CardsModelsTests(TestCase): def setUp(self): @@ -19,15 +20,15 @@ class CardsModelsTests(TestCase): name="Test Card", cardset="A", cardnum=1, - style="color: blue;", - rarity_icon="★", + style="default", + rarity_icon=RARITY_MAPPING[1], rarity_level=1 ) # Establish many-to-many relationship. self.card.decks.add(self.deck) def test_card_str(self): - expected = f"{self.card.name} {self.card.rarity_icon} {self.card.cardset}" + expected = f"{self.card.name} ({self.card.cardset} #{self.card.cardnum})" self.assertEqual(str(self.card), expected) def test_deck_str(self): @@ -158,8 +159,8 @@ class CardsViewsTests(TestCase): name="Test Card", cardset="A", cardnum=1, - style="background: red;", - rarity_icon="★", + style="default", + rarity_icon=RARITY_MAPPING[1], rarity_level=1 ) diff --git a/home/tests.py b/home/tests.py index bb3adc2..6083493 100644 --- a/home/tests.py +++ b/home/tests.py @@ -10,6 +10,7 @@ from collections import OrderedDict from unittest.mock import patch, MagicMock from django.core.exceptions import ObjectDoesNotExist import importlib +from tests.utils.rarity import RARITY_MAPPING User = get_user_model() @@ -74,13 +75,13 @@ class HomePageViewTests(TestCase): # Create trade offers with consistent rarities cls.common_trade = TradeOffer.objects.create( initiated_by=cls.friend_code, - rarity_icon='★', + rarity_icon=RARITY_MAPPING[1], rarity_level=1 ) cls.rare_trade = TradeOffer.objects.create( initiated_by=cls.friend_code, - rarity_icon='★★★', + rarity_icon=RARITY_MAPPING[3], rarity_level=3 ) diff --git a/tests/utils/rarity.py b/tests/utils/rarity.py new file mode 100644 index 0000000..24bf7dd --- /dev/null +++ b/tests/utils/rarity.py @@ -0,0 +1,10 @@ +RARITY_MAPPING = { + 1: "🔷", + 2: "🔷🔷", + 3: "🔷🔷🔷", + 4: "🔷🔷🔷🔷", + 5: "⭐️", + 6: "⭐️⭐️", + 7: "⭐️⭐️⭐️", + 8: "👑" +} \ No newline at end of file diff --git a/trades/mixins.py b/trades/mixins.py index 9f01854..afd4fc0 100644 --- a/trades/mixins.py +++ b/trades/mixins.py @@ -1,4 +1,5 @@ from cards.models import Card +from django.core.exceptions import PermissionDenied class TradeOfferContextMixin: def get_context_data(self, **kwargs): @@ -20,4 +21,17 @@ class TradeOfferContextMixin: selected_friend_code = self.request.user.default_friend_code or friend_codes.first() context["selected_friend_code"] = selected_friend_code - return context \ No newline at end of file + return context + +class FriendCodeRequiredMixin: + """ + Mixin to ensure the authenticated user has at least one friend code. + This mixin must be placed after LoginRequiredMixin in the view's inheritance order. + """ + def dispatch(self, request, *args, **kwargs): + # Since LoginRequiredMixin guarantees that request.user is authenticated, + # we assume request.user has the attribute `friend_codes`. If no friend code exists, + # raise a PermissionDenied error. + if not getattr(request.user, 'friend_codes', None) or not request.user.friend_codes.exists(): + raise PermissionDenied("No friend codes available for your account.") + return super().dispatch(request, *args, **kwargs) \ No newline at end of file diff --git a/trades/models.py b/trades/models.py index b251a35..c2b3d10 100644 --- a/trades/models.py +++ b/trades/models.py @@ -254,12 +254,12 @@ class TradeAcceptance(models.Model): def clean(self): # Validate that the requested and offered cards exist in the through tables. try: - have_through_obj = self.trade_offer.trade_offer_have_cards.get(card=self.requested_card) + have_through_obj = self.trade_offer.trade_offer_have_cards.get(card_id=self.requested_card_id) except TradeOfferHaveCard.DoesNotExist: raise ValidationError("The requested card must be one of the trade offer's available cards (have_cards).") try: - want_through_obj = self.trade_offer.trade_offer_want_cards.get(card=self.offered_card) + want_through_obj = self.trade_offer.trade_offer_want_cards.get(card_id=self.offered_card_id) except TradeOfferWantCard.DoesNotExist: raise ValidationError("The offered card must be one of the trade offer's requested cards (want_cards).") @@ -279,11 +279,11 @@ class TradeAcceptance(models.Model): if self.pk: active_acceptances = active_acceptances.exclude(pk=self.pk) - requested_count = active_acceptances.filter(requested_card=self.requested_card).count() + requested_count = active_acceptances.filter(requested_card_id=self.requested_card_id).count() if requested_count >= have_through_obj.quantity: raise ValidationError("This requested card has been fully accepted.") - offered_count = active_acceptances.filter(offered_card=self.offered_card).count() + offered_count = active_acceptances.filter(offered_card_id=self.offered_card_id).count() if offered_count >= want_through_obj.quantity: raise ValidationError("This offered card has already been fully used.") diff --git a/trades/tests.py b/trades/tests.py index cf7b437..7d7a700 100644 --- a/trades/tests.py +++ b/trades/tests.py @@ -18,7 +18,7 @@ from trades.forms import ( TradeOfferAcceptForm, TradeAcceptanceTransitionForm, ) - +from tests.utils.rarity import RARITY_MAPPING # ------------------------------------------------------------------------ # Model Tests @@ -26,27 +26,29 @@ from trades.forms import ( class TradeOfferModelTest(TestCase): def setUp(self): User = get_user_model() - # Create a user and friend code for testing self.user = User.objects.create_user( username="testuser", email="test@example.com", password="password" ) self.friend_code = FriendCode.objects.create( - friend_code="FC-1234", in_game_name="TestInGame", user=self.user + friend_code="1234-5678-9012-3456", in_game_name="TestInGame", user=self.user ) # Create cards with the same rarity (valid scenario) self.card1 = Card.objects.create( - name="Card1", cardset="set1", cardnum=1, style="default", rarity_icon="R", rarity_level=1 + name="Card1", cardset="set1", cardnum=1, style="default", + rarity_icon=RARITY_MAPPING[1], rarity_level=1 ) self.card2 = Card.objects.create( - name="Card2", cardset="set1", cardnum=2, style="default", rarity_icon="R", rarity_level=1 + name="Card2", cardset="set1", cardnum=2, style="default", + rarity_icon=RARITY_MAPPING[1], rarity_level=1 ) # Create a card with a different rarity (to test invalid trade offers) self.card3 = Card.objects.create( - name="Card3", cardset="set1", cardnum=3, style="default", rarity_icon="SR", rarity_level=2 + name="Card3", cardset="set1", cardnum=3, style="default", + rarity_icon=RARITY_MAPPING[8], rarity_level=8 ) - # Create a valid trade offer with consistent rarity details. + # Create a valid trade offer with consistent rarity details self.trade_offer = TradeOffer.objects.create(initiated_by=self.friend_code) TradeOfferHaveCard.objects.create( trade_offer=self.trade_offer, card=self.card1, quantity=2 @@ -59,7 +61,7 @@ class TradeOfferModelTest(TestCase): """Test update_rarity_fields succeeds with cards sharing the same rarity.""" self.trade_offer.update_rarity_fields() self.assertEqual(self.trade_offer.rarity_level, 1) - self.assertEqual(self.trade_offer.rarity_icon, "R") + self.assertEqual(self.trade_offer.rarity_icon, "🔷") def test_update_rarity_fields_invalid(self): """If a card with a different rarity is added, update_rarity_fields should raise an error.""" @@ -84,21 +86,23 @@ class TradeAcceptanceModelTest(TestCase): username="acceptuser", email="acc@example.com", password="password" ) self.friend_code = FriendCode.objects.create( - friend_code="FC-5678", in_game_name="AccInGame", user=self.user + friend_code="1111-2222-3333-4444", in_game_name="AccInGame", user=self.user ) self.other_user = User.objects.create_user( username="initiator", email="init@example.com", password="password" ) self.initiator_friend_code = FriendCode.objects.create( - friend_code="FC-0000", in_game_name="InitInGame", user=self.other_user + friend_code="5555-6666-7777-8888", in_game_name="InitInGame", user=self.other_user ) # Create two cards (with the same rarity) self.card1 = Card.objects.create( - name="CardA", cardset="setA", cardnum=1, style="default", rarity_icon="R", rarity_level=1 + name="CardA", cardset="setA", cardnum=1, style="default", + rarity_icon=RARITY_MAPPING[2], rarity_level=2 ) self.card2 = Card.objects.create( - name="CardB", cardset="setA", cardnum=2, style="default", rarity_icon="R", rarity_level=1 + name="CardB", cardset="setA", cardnum=2, style="default", + rarity_icon=RARITY_MAPPING[2], rarity_level=2 ) # Create a trade offer by the initiator. @@ -198,16 +202,16 @@ class TradeOfferFormTest(TestCase): username="formuser", email="form@example.com", password="password" ) self.friend_code = FriendCode.objects.create( - friend_code="FC-FORM", in_game_name="FormUser", user=self.user + friend_code="9999-8888-7777-6666", in_game_name="FormUser", user=self.user ) # Create two cards with the same rarity details. self.card1 = Card.objects.create( name="FormCard1", cardset="formset", cardnum=1, style="default", - rarity_icon="R", rarity_level=1 + rarity_icon=RARITY_MAPPING[3], rarity_level=3 ) self.card2 = Card.objects.create( name="FormCard2", cardset="formset", cardnum=2, style="default", - rarity_icon="R", rarity_level=1 + rarity_icon=RARITY_MAPPING[3], rarity_level=3 ) def test_trade_offer_create_form_valid(self): @@ -325,7 +329,7 @@ class TradeViewsTest(TestCase): username="viewuser", email="view@example.com", password="password" ) self.friend_code = FriendCode.objects.create( - friend_code="FC-VIEW", in_game_name="ViewUser", user=self.user + friend_code="4444-3333-2222-1111", in_game_name="ViewUser", user=self.user ) self.user.default_friend_code = self.friend_code self.user.save(update_fields=["default_friend_code"]) @@ -334,11 +338,11 @@ class TradeViewsTest(TestCase): # Create sample cards. self.card1 = Card.objects.create( name="ViewCard1", cardset="setV", cardnum=1, style="default", - rarity_icon="R", rarity_level=1 + rarity_icon=RARITY_MAPPING[7], rarity_level=7 ) self.card2 = Card.objects.create( name="ViewCard2", cardset="setV", cardnum=2, style="default", - rarity_icon="R", rarity_level=1 + rarity_icon=RARITY_MAPPING[7], rarity_level=7 ) # Create a trade offer initiated by the logged-in user's friend code. self.trade_offer = TradeOffer.objects.create(initiated_by=self.friend_code) @@ -460,3 +464,462 @@ class TradeViewsTest(TestCase): valid_state = allowed_states[0] response = self.client.post(update_url, {"state": valid_state}) self.assertEqual(response.status_code, 302) + + +class TradeOfferSecurityTests(TestCase): + def setUp(self): + User = get_user_model() + # Create three users for testing various security scenarios + self.user1 = User.objects.create_user( + username="user1", email="user1@example.com", password="password1" + ) + self.user2 = User.objects.create_user( + username="user2", email="user2@example.com", password="password2" + ) + self.user3 = User.objects.create_user( + username="user3", email="user3@example.com", password="password3" + ) + + # Create friend codes for each user with correct format + self.fc1 = FriendCode.objects.create( + friend_code="1111-2222-3333-4444", in_game_name="User1Game", user=self.user1 + ) + self.fc2 = FriendCode.objects.create( + friend_code="5555-6666-7777-8888", in_game_name="User2Game", user=self.user2 + ) + self.fc3 = FriendCode.objects.create( + friend_code="9999-0000-1111-2222", in_game_name="User3Game", user=self.user3 + ) + + # Create test cards with proper rarity levels + self.card1 = Card.objects.create( + name="SecCard1", cardset="secset", cardnum=1, style="default", + rarity_icon=RARITY_MAPPING[3], rarity_level=3 + ) + self.card2 = Card.objects.create( + name="SecCard2", cardset="secset", cardnum=2, style="default", + rarity_icon=RARITY_MAPPING[3], rarity_level=3 + ) + + # Create a trade offer by user1 + self.trade_offer = TradeOffer.objects.create(initiated_by=self.fc1) + TradeOfferHaveCard.objects.create( + trade_offer=self.trade_offer, card=self.card1, quantity=1 + ) + TradeOfferWantCard.objects.create( + trade_offer=self.trade_offer, card=self.card2, quantity=1 + ) + + self.client = Client() + + def test_unauthorized_trade_offer_deletion(self): + """Test that users cannot delete trade offers they don't own.""" + self.client.login(username="user2", password="password2") + response = self.client.post( + reverse("trade_offer_delete", kwargs={"pk": self.trade_offer.pk}) + ) + self.assertEqual(response.status_code, 403) + self.assertTrue(TradeOffer.objects.filter(pk=self.trade_offer.pk).exists()) + + def test_unauthorized_trade_acceptance_update(self): + """Test that uninvolved users cannot update trade acceptances.""" + # Create an acceptance between user2 and user1's offer + acceptance = TradeAcceptance.objects.create( + trade_offer=self.trade_offer, + accepted_by=self.fc2, + requested_card=self.card1, + offered_card=self.card2, + state=TradeAcceptance.AcceptanceState.ACCEPTED, + ) + + # Try to update the acceptance as user3 (uninvolved) + self.client.login(username="user3", password="password3") + response = self.client.post( + reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}), + {"state": TradeAcceptance.AcceptanceState.SENT} + ) + self.assertEqual(response.status_code, 403) + + def test_cross_user_friend_code_manipulation(self): + """Test that users cannot use other users' friend codes.""" + self.client.login(username="user2", password="password2") + + # Try to create a trade offer using user1's friend code + response = self.client.get( + reverse("trade_offer_create"), + { + "initiated_by": self.fc1.pk, # User1's friend code + "have_cards": [f"{self.card1.pk}:1"], + "want_cards": [f"{self.card2.pk}:1"], + } + ) + self.assertEqual(response.status_code, 200) # Form should fail validation + self.assertFalse( + TradeOffer.objects.filter(initiated_by=self.fc1).count() > 1 + ) + + def test_authenticated_only_views(self): + """Test that authenticated-only views are properly protected.""" + # Test without login + urls_to_test = [ + reverse("trade_offer_create"), + reverse("trade_offer_my_list"), + reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), + ] + + # First ensure we're logged out + self.client.logout() + + for url in urls_to_test: + response = self.client.get(url) + self.assertRedirects( + response, + f"/accounts/login/?next={url}", + msg_prefix=f"URL {url} should require authentication" + ) + + +class TradeOfferEdgeCasesTest(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="edgeuser", email="edge@example.com", password="password" + ) + self.friend_code = FriendCode.objects.create( + friend_code="3333-4444-5555-6666", in_game_name="EdgeUser", user=self.user + ) + + # Create test cards with different rarities using proper levels and icons + self.common_card = Card.objects.create( + name="CommonCard", cardset="edgeset", cardnum=1, style="default", + rarity_icon=RARITY_MAPPING[1], rarity_level=1 + ) + self.rare_card = Card.objects.create( + name="RareCard", cardset="edgeset", cardnum=2, style="default", + rarity_icon=RARITY_MAPPING[5], rarity_level=5 + ) + self.crown_card = Card.objects.create( + name="CrownCard", cardset="edgeset", cardnum=3, style="default", + rarity_icon=RARITY_MAPPING[8], rarity_level=8 + ) + + self.client = Client() + self.client.login(username="edgeuser", password="password") + + def test_zero_quantity_trade_offer(self): + """Test that trade offers with zero quantity are handled properly.""" + response = self.client.get( + reverse("trade_offer_create"), + { + "initiated_by": self.friend_code.pk, + "have_cards": [f"{self.common_card.pk}:0"], + "want_cards": [f"{self.common_card.pk}:1"], + } + ) + self.assertEqual(response.status_code, 200) + self.assertFalse( + TradeOffer.objects.filter(initiated_by=self.friend_code).exists() + ) + + def test_negative_quantity_trade_offer(self): + """Test that trade offers with negative quantity are handled properly.""" + response = self.client.get( + reverse("trade_offer_create"), + { + "initiated_by": self.friend_code.pk, + "have_cards": [f"{self.common_card.pk}:-1"], + "want_cards": [f"{self.common_card.pk}:1"], + } + ) + self.assertEqual(response.status_code, 200) + self.assertFalse( + TradeOffer.objects.filter(initiated_by=self.friend_code).exists() + ) + + def test_mixed_rarity_trade_offer(self): + """Test that trade offers with mixed rarity cards are rejected.""" + response = self.client.get( + reverse("trade_offer_create"), + { + "initiated_by": self.friend_code.pk, + "have_cards": [f"{self.common_card.pk}:1"], + "want_cards": [f"{self.crown_card.pk}:1"], + } + ) + self.assertEqual(response.status_code, 200) + self.assertFalse( + TradeOffer.objects.filter(initiated_by=self.friend_code).exists() + ) + + def test_duplicate_card_entries(self): + """Test handling of duplicate card entries in trade offers.""" + response = self.client.get( + reverse("trade_offer_create"), + { + "initiated_by": self.friend_code.pk, + "have_cards": [ + f"{self.common_card.pk}:1", + f"{self.common_card.pk}:1" + ], + "want_cards": [f"{self.common_card.pk}:1"], + } + ) + self.assertEqual(response.status_code, 200) + self.assertFalse( + TradeOffer.objects.filter(initiated_by=self.friend_code).exists() + ) + + +class TradeSearchTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="searchuser", email="search@example.com", password="password" + ) + self.friend_code = FriendCode.objects.create( + friend_code="7777-8888-9999-0000", in_game_name="SearchUser", user=self.user + ) + + # Create test cards with proper rarity levels + self.card1 = Card.objects.create( + name="SearchCard1", cardset="sc1", cardnum=1, style="default", + rarity_icon=RARITY_MAPPING[4], rarity_level=4 + ) + self.card2 = Card.objects.create( + name="SearchCard2", cardset="sc1", cardnum=2, style="default", + rarity_icon=RARITY_MAPPING[4], rarity_level=4 + ) + self.card3 = Card.objects.create( + name="SearchCard3", cardset="sc1", cardnum=3, style="default", + rarity_icon=RARITY_MAPPING[4], rarity_level=4 + ) + + # Create some trade offers + self.trade_offer1 = TradeOffer.objects.create(initiated_by=self.friend_code) + TradeOfferHaveCard.objects.create( + trade_offer=self.trade_offer1, card=self.card1, quantity=2 + ) + TradeOfferWantCard.objects.create( + trade_offer=self.trade_offer1, card=self.card2, quantity=1 + ) + + self.trade_offer2 = TradeOffer.objects.create(initiated_by=self.friend_code) + TradeOfferHaveCard.objects.create( + trade_offer=self.trade_offer2, card=self.card2, quantity=1 + ) + TradeOfferWantCard.objects.create( + trade_offer=self.trade_offer2, card=self.card3, quantity=1 + ) + + self.client = Client() + + def test_search_by_have_cards(self): + """Test searching for trade offers by cards the user has doesn't show offers initiated by the user.""" + response = self.client.post( + reverse("trade_offer_search"), + { + "have_cards": [f"{self.card2.pk}:1"], + } + ) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) + self.assertNotContains(response, self.trade_offer2.initiated_by.in_game_name) + + def test_search_by_want_cards(self): + """Test searching for trade offers by cards the user wants doesn't show offers initiated by the user.""" + response = self.client.post( + reverse("trade_offer_search"), + { + "want_cards": [f"{self.card1.pk}:1"], + } + ) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) + self.assertNotContains(response, self.trade_offer2.initiated_by.in_game_name) + + def test_search_with_invalid_card_id(self): + """Test search behavior with invalid card IDs.""" + response = self.client.post( + reverse("trade_offer_search"), + { + "have_cards": ["999999:1"], # Non-existent card ID + } + ) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) + self.assertNotContains(response, self.trade_offer2.initiated_by.in_game_name) + + def test_search_closed_trades(self): + """Test that closed trades don't appear in search results.""" + self.trade_offer1.is_closed = True + self.trade_offer1.save() + + response = self.client.post( + reverse("trade_offer_search"), + { + "have_cards": [f"{self.card2.pk}:1"], + } + ) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) + + +class TradeAcceptanceComplexTests(TestCase): + def setUp(self): + User = get_user_model() + self.initiator = User.objects.create_user( + username="initiator", email="init@example.com", password="password" + ) + self.acceptor = User.objects.create_user( + username="acceptor", email="accept@example.com", password="password" + ) + + self.initiator_fc = FriendCode.objects.create( + friend_code="1234-5678-9012-3456", in_game_name="InitUser", user=self.initiator + ) + self.acceptor_fc = FriendCode.objects.create( + friend_code="6543-2109-8765-4321", in_game_name="AcceptUser", user=self.acceptor + ) + + # Create test cards with proper rarity levels + self.card1 = Card.objects.create( + name="ComplexCard1", cardset="cx1", cardnum=1, style="default", + rarity_icon=RARITY_MAPPING[6], rarity_level=6 + ) + self.card2 = Card.objects.create( + name="ComplexCard2", cardset="cx1", cardnum=2, style="default", + rarity_icon=RARITY_MAPPING[6], rarity_level=6 + ) + self.card3 = Card.objects.create( + name="ComplexCard3", cardset="cx1", cardnum=3, style="default", + rarity_icon=RARITY_MAPPING[6], rarity_level=6 + ) + self.card4 = Card.objects.create( + name="ComplexCard4", cardset="cx1", cardnum=4, style="default", + rarity_icon=RARITY_MAPPING[6], rarity_level=6 + ) + + # Create a trade offer with multiple quantities + self.trade_offer = TradeOffer.objects.create(initiated_by=self.initiator_fc) + TradeOfferHaveCard.objects.create( + trade_offer=self.trade_offer, card=self.card1, quantity=3 + ) + TradeOfferHaveCard.objects.create( + trade_offer=self.trade_offer, card=self.card3, quantity=1 + ) + TradeOfferWantCard.objects.create( + trade_offer=self.trade_offer, card=self.card2, quantity=3 + ) + TradeOfferWantCard.objects.create( + trade_offer=self.trade_offer, card=self.card4, quantity=1 + ) + + self.client = Client() + + def test_multiple_acceptances_quantity_limit(self): + """Test that multiple acceptances cannot exceed the offer's quantity limit.""" + self.client.login(username="acceptor", password="password") + + # Create first acceptance + response1 = self.client.post( + reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), + { + "accepted_by": self.acceptor_fc.pk, + "requested_card": self.card1.pk, + "offered_card": self.card2.pk, + } + ) + self.assertEqual(response1.status_code, 302) # Successful creation + + # Create second acceptance + response2 = self.client.post( + reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), + { + "accepted_by": self.acceptor_fc.pk, + "requested_card": self.card1.pk, + "offered_card": self.card2.pk, + } + ) + self.assertEqual(response2.status_code, 302) # Successful creation + + # Try to create a fourth acceptance (should fail as only 3 are allowed) + response3 = self.client.post( + reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), + { + "accepted_by": self.acceptor_fc.pk, + "requested_card": self.card1.pk, + "offered_card": self.card2.pk, + } + ) + self.assertEqual(response3.status_code, 302) # Successful creation + + response4 = self.client.post( + reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), + { + "accepted_by": self.acceptor_fc.pk, + "requested_card": self.card1.pk, + "offered_card": self.card2.pk, + } + ) + self.assertEqual(response4.status_code, 200) # Should fail + self.assertEqual( + self.trade_offer.acceptances.count(), 3, + "Should not allow more acceptances than the quantity limit" + ) + + def test_complex_state_transitions(self): + """Test complex state transition scenarios.""" + self.client.login(username="acceptor", password="password") + + # Create an acceptance + acceptance = TradeAcceptance.objects.create( + trade_offer=self.trade_offer, + accepted_by=self.acceptor_fc, + requested_card=self.card1, + offered_card=self.card2, + state=TradeAcceptance.AcceptanceState.ACCEPTED, + ) + + # Test invalid state transition sequence + invalid_transitions = [ + TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, # Can't thank before sending + TradeAcceptance.AcceptanceState.RECEIVED, # Can't receive before sending + TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, # Can't thank by both directly + ] + + for invalid_state in invalid_transitions: + response = self.client.post( + reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}), + {"state": invalid_state} + ) + self.assertEqual(response.status_code, 200) # Should stay on form + acceptance.refresh_from_db() + self.assertEqual( + acceptance.state, + TradeAcceptance.AcceptanceState.ACCEPTED, + f"Invalid transition to {invalid_state} should not be allowed" + ) + + # Test valid state transition sequence + valid_transitions = [ + (self.initiator, TradeAcceptance.AcceptanceState.SENT), + (self.acceptor, TradeAcceptance.AcceptanceState.RECEIVED), + (self.initiator, TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR), + (self.acceptor, TradeAcceptance.AcceptanceState.THANKED_BY_BOTH), + ] + + for user, state in valid_transitions: + self.client.login(username=user.username, password="password") + response = self.client.post( + reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}), + {"state": state} + ) + self.assertEqual(response.status_code, 302) # Should redirect on success + acceptance.refresh_from_db() + self.assertEqual( + acceptance.state, + state, + f"Valid transition to {state} should be allowed" + ) + + diff --git a/trades/views.py b/trades/views.py index fe3960f..3b0816a 100644 --- a/trades/views.py +++ b/trades/views.py @@ -25,6 +25,7 @@ from trades.templatetags.trade_offer_tags import render_trade_offer from django.template import RequestContext from playwright.sync_api import sync_playwright from django.conf import settings +from .mixins import FriendCodeRequiredMixin class TradeOfferCreateView(LoginRequiredMixin, CreateView): http_method_names = ['get'] # restricts this view to GET only @@ -108,15 +109,10 @@ class TradeOfferAllListView(ListView): ) return super().render_to_response(context, **response_kwargs) -class TradeOfferMyListView(LoginRequiredMixin, ListView): - model = TradeOffer # Fallback model; our context data holds separate filtered querysets. +class TradeOfferMyListView(LoginRequiredMixin, FriendCodeRequiredMixin, ListView): + model = TradeOffer template_name = "trades/trade_offer_my_list.html" - def dispatch(self, request, *args, **kwargs): - if request.user.is_authenticated and not request.user.friend_codes.exists(): - raise PermissionDenied("No friend codes available for your account.") - return super().dispatch(request, *args, **kwargs) - def get_selected_friend_code(self): friend_codes = self.request.user.friend_codes.all() friend_code_param = self.request.GET.get("friend_code") @@ -127,10 +123,8 @@ class TradeOfferMyListView(LoginRequiredMixin, ListView): selected_friend_code = self.request.user.default_friend_code or friend_codes.first() else: selected_friend_code = self.request.user.default_friend_code or friend_codes.first() - if not selected_friend_code: raise PermissionDenied("You do not have an active friend code associated with your account.") - return selected_friend_code def get_show_closed(self): @@ -224,15 +218,13 @@ class TradeOfferMyListView(LoginRequiredMixin, ListView): return render(self.request, "trades/_trade_offer_list_paginated.html", {"offers": offers}) return super().render_to_response(context, **response_kwargs) -class TradeOfferDeleteView(LoginRequiredMixin, DeleteView): +class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteView): model = TradeOffer success_url = reverse_lazy("trade_offer_list") template_name = "trades/trade_offer_delete.html" def dispatch(self, request, *args, **kwargs): - # Retrieve the object normally self.object = super().get_object() - # Perform the permission check here if self.object.initiated_by_id not in request.user.friend_codes.values_list("id", flat=True): raise PermissionDenied("You are not authorized to delete or close this trade offer.") return super().dispatch(request, *args, **kwargs) @@ -339,7 +331,10 @@ class TradeOfferSearchView(ListView): qs = TradeOffer.objects.filter( is_closed=False, - ).exclude(initiated_by__in=self.request.user.friend_codes.all()) + ) + + if self.request.user.is_authenticated: + qs = qs.exclude(initiated_by__in=self.request.user.friend_codes.all()) # Chain filters for offered selections (i.e. the user "has" cards). if have_selections: @@ -498,7 +493,7 @@ class TradeOfferDetailView(DetailView): return context -class TradeAcceptanceCreateView(LoginRequiredMixin, CreateView): +class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, CreateView): """ View to create a new TradeAcceptance. The URL should provide 'offer_pk' so that the proper TradeOffer can be identified. @@ -509,19 +504,17 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, CreateView): def dispatch(self, request, *args, **kwargs): self.trade_offer = self.get_trade_offer() - if self.trade_offer.initiated_by_id in request.user.friend_codes.values_list("id", flat=True) or self.trade_offer.is_closed: - raise PermissionDenied("You cannot accept this trade offer.") - if not request.user.friend_codes.exists(): - raise PermissionDenied("No friend codes available for your account.") return super().dispatch(request, *args, **kwargs) def get_trade_offer(self): - return ( - TradeOffer.objects.get(pk=self.kwargs['offer_pk']) - ) + return TradeOffer.objects.get(pk=self.kwargs['offer_pk']) def get_form_kwargs(self): kwargs = super().get_form_kwargs() + if (self.trade_offer.initiated_by_id in + self.request.user.friend_codes.values_list("id", flat=True) or + self.trade_offer.is_closed): + raise PermissionDenied("You cannot accept this trade offer.") kwargs['trade_offer'] = self.trade_offer kwargs['friend_codes'] = self.request.user.friend_codes.all() return kwargs @@ -561,7 +554,7 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, CreateView): def get_success_url(self): return reverse_lazy("trade_offer_detail", kwargs={"pk": self.trade_offer.pk}) -class TradeAcceptanceUpdateView(LoginRequiredMixin, UpdateView): +class TradeAcceptanceUpdateView(LoginRequiredMixin, FriendCodeRequiredMixin, UpdateView): """ View to update the state of an existing TradeAcceptance. The allowed state transitions are provided via the form. @@ -572,10 +565,9 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, UpdateView): def dispatch(self, request, *args, **kwargs): self.object = self.get_object() - if not request.user.friend_codes.exists(): - raise PermissionDenied("No friend codes available for your account.") friend_codes = request.user.friend_codes.values_list("id", flat=True) - if self.object.accepted_by_id not in friend_codes and self.object.trade_offer.initiated_by_id not in friend_codes: + if (self.object.accepted_by_id not in friend_codes and + self.object.trade_offer.initiated_by_id not in friend_codes): raise PermissionDenied("You are not authorized to update this acceptance.") return super().dispatch(request, *args, **kwargs)