Fix friend_code max length issues in tests, and fix in_game_name length issues, also update tests to fit more scenarios

This commit is contained in:
badblocks 2025-03-27 17:26:07 -07:00
parent 0d4655bf80
commit b9c4d7a61d
10 changed files with 558 additions and 66 deletions

View file

@ -1,6 +1,14 @@
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.core.exceptions import ValidationError 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): class CustomUser(AbstractUser):
default_friend_code = models.ForeignKey("FriendCode", on_delete=models.SET_NULL, null=True, blank=True) 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"]) self.save(update_fields=["default_friend_code"])
class FriendCode(models.Model): class FriendCode(models.Model):
friend_code = models.CharField(max_length=19) friend_code = models.CharField(max_length=19, validators=[validate_friend_code])
in_game_name = models.CharField(max_length=16, null=False, blank=False) in_game_name = models.CharField(max_length=14, null=False, blank=False)
user = models.ForeignKey(CustomUser, on_delete=models.PROTECT, related_name='friend_codes') user = models.ForeignKey(CustomUser, on_delete=models.PROTECT, related_name='friend_codes')
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)

View file

@ -12,6 +12,7 @@ from accounts.models import FriendCode
from accounts.forms import FriendCodeForm, CustomUserCreationForm, UserSettingsForm from accounts.forms import FriendCodeForm, CustomUserCreationForm, UserSettingsForm
from accounts.templatetags import gravatar from accounts.templatetags import gravatar
from trades.models import TradeOffer from trades.models import TradeOffer
from tests.utils.rarity import RARITY_MAPPING
# Create your tests here. # Create your tests here.
@ -415,11 +416,11 @@ class FriendCodeViewsTests(TestCase):
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# POST request. # POST request.
new_data = {"in_game_name": "UpdatedViewGame"} new_data = {"in_game_name": "UpdatedGame"}
response = self.client.post(url, new_data) response = self.client.post(url, new_data)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.friend_code2.refresh_from_db() 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): 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.""" """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( self.trade_offer = TradeOffer.objects.create(
initiated_by=self.friend_code2, initiated_by=self.friend_code2,
is_closed=False, is_closed=False,
rarity_icon="⭐️", rarity_icon=RARITY_MAPPING[5],
rarity_level=5 rarity_level=5
) )
url = reverse("delete_friend_code", kwargs={"pk": self.friend_code2.pk}) url = reverse("delete_friend_code", kwargs={"pk": self.friend_code2.pk})

View file

@ -36,16 +36,18 @@ class Deck(models.Model):
class Card(models.Model): class Card(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
name = models.CharField(max_length=128) name = models.CharField(max_length=64)
decks = models.ManyToManyField("Deck") decks = models.ManyToManyField("Deck")
cardset = models.CharField(max_length=8) cardset = models.CharField(max_length=32)
cardnum = models.IntegerField() cardnum = models.IntegerField()
style = models.CharField(max_length=255, null=False) style = models.CharField(max_length=16)
rarity_icon = models.CharField(max_length=8) rarity_icon = models.CharField(max_length=12)
rarity_level = models.IntegerField() rarity_level = models.IntegerField()
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('cardset', 'cardnum')
def __str__(self): def __str__(self):
# For display, we show the original rarity icons. return f"{self.name} ({self.cardset} #{self.cardnum})"
return f"{self.name} {self.rarity_icon} {self.cardset}"

View file

@ -9,6 +9,7 @@ from accounts.models import CustomUser, FriendCode
from cards.models import Card, Deck, DeckNameTranslation, CardNameTranslation from cards.models import Card, Deck, DeckNameTranslation, CardNameTranslation
from trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard from trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard
from cards.templatetags import card_badge, card_multiselect from cards.templatetags import card_badge, card_multiselect
from tests.utils.rarity import RARITY_MAPPING
class CardsModelsTests(TestCase): class CardsModelsTests(TestCase):
def setUp(self): def setUp(self):
@ -19,15 +20,15 @@ class CardsModelsTests(TestCase):
name="Test Card", name="Test Card",
cardset="A", cardset="A",
cardnum=1, cardnum=1,
style="color: blue;", style="default",
rarity_icon="", rarity_icon=RARITY_MAPPING[1],
rarity_level=1 rarity_level=1
) )
# Establish many-to-many relationship. # Establish many-to-many relationship.
self.card.decks.add(self.deck) self.card.decks.add(self.deck)
def test_card_str(self): 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) self.assertEqual(str(self.card), expected)
def test_deck_str(self): def test_deck_str(self):
@ -158,8 +159,8 @@ class CardsViewsTests(TestCase):
name="Test Card", name="Test Card",
cardset="A", cardset="A",
cardnum=1, cardnum=1,
style="background: red;", style="default",
rarity_icon="", rarity_icon=RARITY_MAPPING[1],
rarity_level=1 rarity_level=1
) )

View file

@ -10,6 +10,7 @@ from collections import OrderedDict
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
import importlib import importlib
from tests.utils.rarity import RARITY_MAPPING
User = get_user_model() User = get_user_model()
@ -74,13 +75,13 @@ class HomePageViewTests(TestCase):
# Create trade offers with consistent rarities # Create trade offers with consistent rarities
cls.common_trade = TradeOffer.objects.create( cls.common_trade = TradeOffer.objects.create(
initiated_by=cls.friend_code, initiated_by=cls.friend_code,
rarity_icon='', rarity_icon=RARITY_MAPPING[1],
rarity_level=1 rarity_level=1
) )
cls.rare_trade = TradeOffer.objects.create( cls.rare_trade = TradeOffer.objects.create(
initiated_by=cls.friend_code, initiated_by=cls.friend_code,
rarity_icon='★★★', rarity_icon=RARITY_MAPPING[3],
rarity_level=3 rarity_level=3
) )

10
tests/utils/rarity.py Normal file
View file

@ -0,0 +1,10 @@
RARITY_MAPPING = {
1: "🔷",
2: "🔷🔷",
3: "🔷🔷🔷",
4: "🔷🔷🔷🔷",
5: "⭐️",
6: "⭐️⭐️",
7: "⭐️⭐️⭐️",
8: "👑"
}

View file

@ -1,4 +1,5 @@
from cards.models import Card from cards.models import Card
from django.core.exceptions import PermissionDenied
class TradeOfferContextMixin: class TradeOfferContextMixin:
def get_context_data(self, **kwargs): 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() selected_friend_code = self.request.user.default_friend_code or friend_codes.first()
context["selected_friend_code"] = selected_friend_code context["selected_friend_code"] = selected_friend_code
return context 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)

View file

@ -254,12 +254,12 @@ class TradeAcceptance(models.Model):
def clean(self): def clean(self):
# Validate that the requested and offered cards exist in the through tables. # Validate that the requested and offered cards exist in the through tables.
try: 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: except TradeOfferHaveCard.DoesNotExist:
raise ValidationError("The requested card must be one of the trade offer's available cards (have_cards).") raise ValidationError("The requested card must be one of the trade offer's available cards (have_cards).")
try: 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: except TradeOfferWantCard.DoesNotExist:
raise ValidationError("The offered card must be one of the trade offer's requested cards (want_cards).") 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: if self.pk:
active_acceptances = active_acceptances.exclude(pk=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: if requested_count >= have_through_obj.quantity:
raise ValidationError("This requested card has been fully accepted.") 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: if offered_count >= want_through_obj.quantity:
raise ValidationError("This offered card has already been fully used.") raise ValidationError("This offered card has already been fully used.")

View file

@ -18,7 +18,7 @@ from trades.forms import (
TradeOfferAcceptForm, TradeOfferAcceptForm,
TradeAcceptanceTransitionForm, TradeAcceptanceTransitionForm,
) )
from tests.utils.rarity import RARITY_MAPPING
# ------------------------------------------------------------------------ # ------------------------------------------------------------------------
# Model Tests # Model Tests
@ -26,27 +26,29 @@ from trades.forms import (
class TradeOfferModelTest(TestCase): class TradeOfferModelTest(TestCase):
def setUp(self): def setUp(self):
User = get_user_model() User = get_user_model()
# Create a user and friend code for testing
self.user = User.objects.create_user( self.user = User.objects.create_user(
username="testuser", email="test@example.com", password="password" username="testuser", email="test@example.com", password="password"
) )
self.friend_code = FriendCode.objects.create( 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) # Create cards with the same rarity (valid scenario)
self.card1 = Card.objects.create( 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( 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) # Create a card with a different rarity (to test invalid trade offers)
self.card3 = Card.objects.create( 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) self.trade_offer = TradeOffer.objects.create(initiated_by=self.friend_code)
TradeOfferHaveCard.objects.create( TradeOfferHaveCard.objects.create(
trade_offer=self.trade_offer, card=self.card1, quantity=2 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.""" """Test update_rarity_fields succeeds with cards sharing the same rarity."""
self.trade_offer.update_rarity_fields() self.trade_offer.update_rarity_fields()
self.assertEqual(self.trade_offer.rarity_level, 1) 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): def test_update_rarity_fields_invalid(self):
"""If a card with a different rarity is added, update_rarity_fields should raise an error.""" """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" username="acceptuser", email="acc@example.com", password="password"
) )
self.friend_code = FriendCode.objects.create( 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( self.other_user = User.objects.create_user(
username="initiator", email="init@example.com", password="password" username="initiator", email="init@example.com", password="password"
) )
self.initiator_friend_code = FriendCode.objects.create( 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) # Create two cards (with the same rarity)
self.card1 = Card.objects.create( 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( 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. # Create a trade offer by the initiator.
@ -198,16 +202,16 @@ class TradeOfferFormTest(TestCase):
username="formuser", email="form@example.com", password="password" username="formuser", email="form@example.com", password="password"
) )
self.friend_code = FriendCode.objects.create( 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. # Create two cards with the same rarity details.
self.card1 = Card.objects.create( self.card1 = Card.objects.create(
name="FormCard1", cardset="formset", cardnum=1, style="default", 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( self.card2 = Card.objects.create(
name="FormCard2", cardset="formset", cardnum=2, style="default", 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): def test_trade_offer_create_form_valid(self):
@ -325,7 +329,7 @@ class TradeViewsTest(TestCase):
username="viewuser", email="view@example.com", password="password" username="viewuser", email="view@example.com", password="password"
) )
self.friend_code = FriendCode.objects.create( 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.default_friend_code = self.friend_code
self.user.save(update_fields=["default_friend_code"]) self.user.save(update_fields=["default_friend_code"])
@ -334,11 +338,11 @@ class TradeViewsTest(TestCase):
# Create sample cards. # Create sample cards.
self.card1 = Card.objects.create( self.card1 = Card.objects.create(
name="ViewCard1", cardset="setV", cardnum=1, style="default", 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( self.card2 = Card.objects.create(
name="ViewCard2", cardset="setV", cardnum=2, style="default", 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. # Create a trade offer initiated by the logged-in user's friend code.
self.trade_offer = TradeOffer.objects.create(initiated_by=self.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] valid_state = allowed_states[0]
response = self.client.post(update_url, {"state": valid_state}) response = self.client.post(update_url, {"state": valid_state})
self.assertEqual(response.status_code, 302) 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"
)

View file

@ -25,6 +25,7 @@ from trades.templatetags.trade_offer_tags import render_trade_offer
from django.template import RequestContext from django.template import RequestContext
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
from django.conf import settings from django.conf import settings
from .mixins import FriendCodeRequiredMixin
class TradeOfferCreateView(LoginRequiredMixin, CreateView): class TradeOfferCreateView(LoginRequiredMixin, CreateView):
http_method_names = ['get'] # restricts this view to GET only 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) return super().render_to_response(context, **response_kwargs)
class TradeOfferMyListView(LoginRequiredMixin, ListView): class TradeOfferMyListView(LoginRequiredMixin, FriendCodeRequiredMixin, ListView):
model = TradeOffer # Fallback model; our context data holds separate filtered querysets. model = TradeOffer
template_name = "trades/trade_offer_my_list.html" 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): def get_selected_friend_code(self):
friend_codes = self.request.user.friend_codes.all() friend_codes = self.request.user.friend_codes.all()
friend_code_param = self.request.GET.get("friend_code") 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() selected_friend_code = self.request.user.default_friend_code or friend_codes.first()
else: else:
selected_friend_code = self.request.user.default_friend_code or friend_codes.first() selected_friend_code = self.request.user.default_friend_code or friend_codes.first()
if not selected_friend_code: if not selected_friend_code:
raise PermissionDenied("You do not have an active friend code associated with your account.") raise PermissionDenied("You do not have an active friend code associated with your account.")
return selected_friend_code return selected_friend_code
def get_show_closed(self): 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 render(self.request, "trades/_trade_offer_list_paginated.html", {"offers": offers})
return super().render_to_response(context, **response_kwargs) return super().render_to_response(context, **response_kwargs)
class TradeOfferDeleteView(LoginRequiredMixin, DeleteView): class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteView):
model = TradeOffer model = TradeOffer
success_url = reverse_lazy("trade_offer_list") success_url = reverse_lazy("trade_offer_list")
template_name = "trades/trade_offer_delete.html" template_name = "trades/trade_offer_delete.html"
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
# Retrieve the object normally
self.object = super().get_object() 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): 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.") raise PermissionDenied("You are not authorized to delete or close this trade offer.")
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@ -339,7 +331,10 @@ class TradeOfferSearchView(ListView):
qs = TradeOffer.objects.filter( qs = TradeOffer.objects.filter(
is_closed=False, 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). # Chain filters for offered selections (i.e. the user "has" cards).
if have_selections: if have_selections:
@ -498,7 +493,7 @@ class TradeOfferDetailView(DetailView):
return context return context
class TradeAcceptanceCreateView(LoginRequiredMixin, CreateView): class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, CreateView):
""" """
View to create a new TradeAcceptance. View to create a new TradeAcceptance.
The URL should provide 'offer_pk' so that the proper TradeOffer can be identified. 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): def dispatch(self, request, *args, **kwargs):
self.trade_offer = self.get_trade_offer() 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) return super().dispatch(request, *args, **kwargs)
def get_trade_offer(self): def get_trade_offer(self):
return ( return TradeOffer.objects.get(pk=self.kwargs['offer_pk'])
TradeOffer.objects.get(pk=self.kwargs['offer_pk'])
)
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() 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['trade_offer'] = self.trade_offer
kwargs['friend_codes'] = self.request.user.friend_codes.all() kwargs['friend_codes'] = self.request.user.friend_codes.all()
return kwargs return kwargs
@ -561,7 +554,7 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, CreateView):
def get_success_url(self): def get_success_url(self):
return reverse_lazy("trade_offer_detail", kwargs={"pk": self.trade_offer.pk}) 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. View to update the state of an existing TradeAcceptance.
The allowed state transitions are provided via the form. The allowed state transitions are provided via the form.
@ -572,10 +565,9 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, UpdateView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.object = self.get_object() 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) 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.") raise PermissionDenied("You are not authorized to update this acceptance.")
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)