From 2785e0ed13e71aacc1b1aff2d5d390556b9a38c6 Mon Sep 17 00:00:00 2001 From: badbl0cks <4161747+badbl0cks@users.noreply.github.com> Date: Thu, 27 Mar 2025 00:16:44 -0700 Subject: [PATCH] Add initial trades tests --- trades/tests.py | 463 +++++++++++++++++++++++++++++++++++++++++++++++- trades/views.py | 26 +-- 2 files changed, 477 insertions(+), 12 deletions(-) diff --git a/trades/tests.py b/trades/tests.py index 7ce503c..cf7b437 100644 --- a/trades/tests.py +++ b/trades/tests.py @@ -1,3 +1,462 @@ -from django.test import TestCase +from django.test import TestCase, Client +from django.urls import reverse +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.http import QueryDict -# Create your tests here. +from accounts.models import FriendCode +from cards.models import Card +from trades.models import ( + TradeOffer, + TradeOfferHaveCard, + TradeOfferWantCard, + TradeAcceptance, +) +from trades.forms import ( + TradeOfferCreateForm, + TradeAcceptanceCreateForm, + TradeOfferAcceptForm, + TradeAcceptanceTransitionForm, +) + + +# ------------------------------------------------------------------------ +# Model Tests +# ------------------------------------------------------------------------ +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 + ) + + # 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 + ) + self.card2 = Card.objects.create( + name="Card2", cardset="set1", cardnum=2, style="default", rarity_icon="R", 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 + ) + + # 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 + ) + TradeOfferWantCard.objects.create( + trade_offer=self.trade_offer, card=self.card2, quantity=3 + ) + + def test_update_rarity_fields_valid(self): + """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") + + def test_update_rarity_fields_invalid(self): + """If a card with a different rarity is added, update_rarity_fields should raise an error.""" + with self.assertRaisesMessage( + ValidationError, "All cards in a trade offer must have the same rarity." + ): + TradeOfferHaveCard.objects.create( + trade_offer=self.trade_offer, card=self.card3, quantity=1 + ) + + def test_hash_generation(self): + """Verify that TradeOffer.hash is generated and 9 characters long ending with 'z'.""" + self.assertTrue(self.trade_offer.hash.endswith("z")) + self.assertEqual(len(self.trade_offer.hash), 9) + + +class TradeAcceptanceModelTest(TestCase): + def setUp(self): + User = get_user_model() + # Create two users for testing state transitions + self.user = User.objects.create_user( + 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 + ) + 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 + ) + + # 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 + ) + self.card2 = Card.objects.create( + name="CardB", cardset="setA", cardnum=2, style="default", rarity_icon="R", rarity_level=1 + ) + + # Create a trade offer by the initiator. + self.trade_offer = TradeOffer.objects.create( + initiated_by=self.initiator_friend_code + ) + 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 + ) + + # Create an initial acceptance in state 'ACCEPTED' + self.acceptance = TradeAcceptance.objects.create( + trade_offer=self.trade_offer, + accepted_by=self.friend_code, + requested_card=self.card1, + offered_card=self.card2, + state=TradeAcceptance.AcceptanceState.ACCEPTED, + ) + + def test_invalid_state_transition(self): + """ + Test that an invalid state transition (not allowed by the current state) + raises a ValueError. + """ + # Attempt to transition to a state that is not allowed. + with self.assertRaises(ValueError): + self.acceptance.update_state( + TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, user=self.user + ) + + def test_valid_state_transition_initiator(self): + """ + For a user that owns the trade offer (initiator), check that a valid + transition (e.g. from ACCEPTED to SENT) succeeds. + """ + allowed_transitions = dict( + self.acceptance.get_allowed_state_transitions(user=self.other_user) + ) + # 'SENT' should be among allowed states for the initiator. + self.assertIn(TradeAcceptance.AcceptanceState.SENT, allowed_transitions) + + self.acceptance.update_state( + TradeAcceptance.AcceptanceState.SENT, user=self.other_user + ) + self.assertEqual( + self.acceptance.state, TradeAcceptance.AcceptanceState.SENT + ) + + def test_signal_adjusts_qty_accepted(self): + """ + Test that creation, state change, and deletion of a TradeAcceptance + update qty_accepted counters in through models correctly. + """ + have_through = TradeOfferHaveCard.objects.get( + trade_offer=self.trade_offer, card=self.card1 + ) + want_through = TradeOfferWantCard.objects.get( + trade_offer=self.trade_offer, card=self.card2 + ) + # Initially, one active acceptance should set qty_accepted to 1. + self.assertEqual(have_through.qty_accepted, 1) + self.assertEqual(want_through.qty_accepted, 1) + + # Change state to a terminal state so that signals decrement the counter. + self.acceptance.state = TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR + self.acceptance.save() + have_through.refresh_from_db() + want_through.refresh_from_db() + self.assertEqual(have_through.qty_accepted, 0) + self.assertEqual(want_through.qty_accepted, 0) + + # Create a new acceptance and then delete it. + new_acceptance = TradeAcceptance.objects.create( + trade_offer=self.trade_offer, + accepted_by=self.friend_code, + requested_card=self.card1, + offered_card=self.card2, + state=TradeAcceptance.AcceptanceState.ACCEPTED, + ) + have_through.refresh_from_db() + self.assertEqual(have_through.qty_accepted, 1) + new_acceptance.delete() + have_through.refresh_from_db() + self.assertEqual(have_through.qty_accepted, 0) + + +# ------------------------------------------------------------------------ +# Form Tests +# ------------------------------------------------------------------------ +class TradeOfferFormTest(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + 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 + ) + # 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 + ) + self.card2 = Card.objects.create( + name="FormCard2", cardset="formset", cardnum=2, style="default", + rarity_icon="R", rarity_level=1 + ) + + def test_trade_offer_create_form_valid(self): + """ + A valid POST using colon-separated quantity strings should succeed. + """ + # Build a QueryDict with multiple values for each list field. + qd = QueryDict('', mutable=True) + qd.setlist("have_cards", [f"{self.card1.pk}:2"]) + qd.setlist("want_cards", [f"{self.card2.pk}:3"]) + # 'initiated_by' is a normal field so we can update it directly. + qd.update({"initiated_by": self.friend_code.pk}) + form = TradeOfferCreateForm(data=qd) + self.assertTrue(form.is_valid()) + + def test_trade_offer_create_form_invalid_quantity(self): + """ + If quantity cannot be parsed as an integer a ValidationError should be raised. + """ + qd = QueryDict('', mutable=True) + # Provide an invalid quantity ("two" instead of an integer). + qd.setlist("have_cards", [f"{self.card1.pk}:two"]) + qd.setlist("want_cards", [f"{self.card2.pk}:3"]) + qd.update({"initiated_by": self.friend_code.pk}) + form = TradeOfferCreateForm(data=qd) + self.assertFalse(form.is_valid()) + self.assertIn("Invalid quantity provided", str(form.errors)) + + def test_trade_offer_create_form_missing_colon(self): + """ + An entry missing a colon should be ignored. + """ + qd = QueryDict('', mutable=True) + # No colon present in the selections. + qd.setlist("have_cards", [f"{self.card1.pk}"]) + qd.setlist("want_cards", [f"{self.card2.pk}"]) + qd.update({"initiated_by": self.friend_code.pk}) + form = TradeOfferCreateForm(data=qd) + self.assertTrue(form.is_valid()) + # Since the entries are ignored, cleaned_data should have empty dictionaries. + self.assertEqual(form.cleaned_data["have_cards"], {}) + self.assertEqual(form.cleaned_data["want_cards"], {}) + + def test_trade_acceptance_create_form(self): + """Test that the TradeAcceptanceCreateForm filters available cards based on trade offer availability.""" + # Create a trade offer with available quantities. + trade_offer = TradeOffer.objects.create(initiated_by=self.friend_code) + TradeOfferHaveCard.objects.create( + trade_offer=trade_offer, card=self.card1, quantity=2 + ) + TradeOfferWantCard.objects.create( + trade_offer=trade_offer, card=self.card2, quantity=2 + ) + friend_codes = FriendCode.objects.filter(pk=self.friend_code.pk) + form_data = { + "accepted_by": self.friend_code.pk, + "requested_card": self.card1.pk, + "offered_card": self.card2.pk, + } + form = TradeAcceptanceCreateForm( + data=form_data, trade_offer=trade_offer, friend_codes=friend_codes + ) + self.assertTrue(form.is_valid()) + instance = form.save() + self.assertEqual(instance.trade_offer, trade_offer) + self.assertEqual(instance.accepted_by, self.friend_code) + + def test_trade_offer_accept_form(self): + """Test that TradeOfferAcceptForm correctly sets the friend_code queryset.""" + friend_codes = FriendCode.objects.filter(pk=self.friend_code.pk) + form = TradeOfferAcceptForm(friend_codes=friend_codes) + self.assertEqual( + list(form.fields["friend_code"].queryset), list(friend_codes) + ) + + def test_trade_acceptance_transition_form(self): + """Test that the transition form provides only allowed transitions.""" + other_user = get_user_model().objects.create_user( + username="transuser", email="trans@example.com", password="password" + ) + other_friend_code = FriendCode.objects.create( + friend_code="FC-TRANS", in_game_name="TransUser", user=other_user + ) + # Create a trade offer with initiator being our test friend code. + trade_offer = TradeOffer.objects.create(initiated_by=self.friend_code) + TradeOfferHaveCard.objects.create( + trade_offer=trade_offer, card=self.card1, quantity=1 + ) + TradeOfferWantCard.objects.create( + trade_offer=trade_offer, card=self.card2, quantity=1 + ) + acceptance = TradeAcceptance.objects.create( + trade_offer=trade_offer, + accepted_by=other_friend_code, + requested_card=self.card1, + offered_card=self.card2, + state=TradeAcceptance.AcceptanceState.ACCEPTED, + ) + form = TradeAcceptanceTransitionForm(instance=acceptance, user=other_user) + # Compare the form's state choices with the allowed transitions. + allowed = [choice[0] for choice in acceptance.get_allowed_state_transitions(user=other_user)] + form_choices = [choice[0] for choice in form.fields["state"].choices] + for choice in allowed: + self.assertIn(choice, form_choices) + + +# ------------------------------------------------------------------------ +# View Tests +# ------------------------------------------------------------------------ +class TradeViewsTest(TestCase): + def setUp(self): + User = get_user_model() + self.client = Client() + self.user = User.objects.create_user( + 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 + ) + self.user.default_friend_code = self.friend_code + self.user.save(update_fields=["default_friend_code"]) + self.client.login(username="viewuser", password="password") + + # Create sample cards. + self.card1 = Card.objects.create( + name="ViewCard1", cardset="setV", cardnum=1, style="default", + rarity_icon="R", rarity_level=1 + ) + self.card2 = Card.objects.create( + name="ViewCard2", cardset="setV", cardnum=2, style="default", + rarity_icon="R", rarity_level=1 + ) + # Create a trade offer initiated by the logged-in user's friend code. + self.trade_offer = TradeOffer.objects.create(initiated_by=self.friend_code) + 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 + ) + + def test_trade_offer_create_view_get(self): + """GET request to TradeOfferCreateView should include friend_codes and cards in context.""" + response = self.client.get(reverse("trade_offer_create")) + self.assertEqual(response.status_code, 200) + self.assertIn("friend_codes", response.context) + self.assertIn("cards", response.context) + # When there is only one friend code, the initial value should be preset. + self.assertEqual( + response.context["form"].initial.get("initiated_by"), + self.friend_code.pk, + ) + + def test_trade_offer_delete_view_permission(self): + """ + The delete view should enforce that only trade offers initiated by one + of the user's friend codes can be deleted. + """ + other_user = get_user_model().objects.create_user( + username="otheruser", email="other@example.com", password="password" + ) + other_friend_code = FriendCode.objects.create( + friend_code="FC-OTHER", in_game_name="OtherUser", user=other_user + ) + trade_offer_other = TradeOffer.objects.create(initiated_by=other_friend_code) + url = reverse("trade_offer_delete", kwargs={"pk": trade_offer_other.pk}) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_trade_offer_delete_view_close(self): + """ + If a trade offer has active acceptances, the delete view should not delete it. + Instead, if no active acceptances remain it should mark the offer as closed. + """ + # Create a trade offer with an active acceptance. + trade_offer_with_acceptance = TradeOffer.objects.create(initiated_by=self.friend_code) + # Use quantity=2 so the trade offer isn't automatically closed when one acceptance is created + TradeOfferHaveCard.objects.create( + trade_offer=trade_offer_with_acceptance, card=self.card1, quantity=2 + ) + TradeOfferWantCard.objects.create( + trade_offer=trade_offer_with_acceptance, card=self.card2, quantity=2 + ) + # Create an acceptance that takes one card from each side + TradeAcceptance.objects.create( + trade_offer=trade_offer_with_acceptance, + accepted_by=self.friend_code, + requested_card=self.card1, + offered_card=self.card2, + state=TradeAcceptance.AcceptanceState.ACCEPTED, + ) + delete_url = reverse("trade_offer_delete", kwargs={"pk": trade_offer_with_acceptance.pk}) + + # --- Patch the view's get_object() method to return our trade offer --- + from trades.views import TradeOfferDeleteView + orig_get_object = TradeOfferDeleteView.get_object + TradeOfferDeleteView.get_object = lambda self: trade_offer_with_acceptance + + try: + # First POST: with active acceptance, deletion should not close the offer. + response = self.client.post(delete_url) + trade_offer_with_acceptance.refresh_from_db() + self.assertFalse(trade_offer_with_acceptance.is_closed) + + # Now simulate no active acceptances by updating the acceptance state. + acceptance = trade_offer_with_acceptance.acceptances.first() + acceptance.state = TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR + acceptance.save(update_fields=["state"]) + + response = self.client.post(delete_url) + trade_offer_with_acceptance.refresh_from_db() + self.assertTrue(trade_offer_with_acceptance.is_closed) + finally: + # Always restore the original method. + TradeOfferDeleteView.get_object = orig_get_object + + def test_trade_acceptance_update_view(self): + """Test updating a trade acceptance via the update view.""" + trade_offer_new = TradeOffer.objects.create(initiated_by=self.friend_code) + TradeOfferHaveCard.objects.create( + trade_offer=trade_offer_new, card=self.card1, quantity=1 + ) + TradeOfferWantCard.objects.create( + trade_offer=trade_offer_new, card=self.card2, quantity=1 + ) + acceptance = TradeAcceptance.objects.create( + trade_offer=trade_offer_new, + accepted_by=self.friend_code, + requested_card=self.card1, + offered_card=self.card2, + state=TradeAcceptance.AcceptanceState.ACCEPTED, + ) + update_url = reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}) + + # First, try an invalid state update. + response = self.client.post(update_url, {"state": "INVALID_STATE"}) + self.assertEqual(response.status_code, 200) + + form = response.context.get("form") + self.assertIsNotNone(form, "Form should be present in the response context.") + self.assertIn( + "state", form.errors, + "Expected an error on the 'state' field when an invalid state is submitted." + ) + self.assertTrue(form.errors["state"], "The 'state' field should have error messages.") + + # Next, if there is an allowed valid transition, try it. + allowed_states = [choice[0] for choice in acceptance.get_allowed_state_transitions(user=self.user)] + if allowed_states: + valid_state = allowed_states[0] + response = self.client.post(update_url, {"state": valid_state}) + self.assertEqual(response.status_code, 302) diff --git a/trades/views.py b/trades/views.py index c260dae..fe3960f 100644 --- a/trades/views.py +++ b/trades/views.py @@ -230,14 +230,16 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView): template_name = "trades/trade_offer_delete.html" def dispatch(self, request, *args, **kwargs): - trade_offer = self.get_object() - if trade_offer.initiated_by_id not in request.user.friend_codes.values_list("id", flat=True): + # 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) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - trade_offer = self.get_object() + trade_offer = self.object terminal_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, @@ -255,7 +257,7 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView): return context def post(self, request, *args, **kwargs): - trade_offer = self.get_object() + trade_offer = self.object terminal_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, @@ -265,8 +267,11 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView): ] active_acceptances = trade_offer.acceptances.exclude(state__in=terminal_states) if active_acceptances.exists(): - messages.error(request, "Cannot delete or close this trade offer because there are active acceptances.") - context = self.get_context_data(object=trade_offer) + messages.error( + request, + "Cannot close this trade offer while there are active acceptances. Please reject all acceptances before closing, or finish the trades." + ) + context = self.get_context_data() return self.render_to_response(context) else: if trade_offer.acceptances.count() > 0: @@ -583,13 +588,14 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, UpdateView): def form_valid(self, form): new_state = form.cleaned_data["state"] - #match the new state to the TradeAcceptance.AcceptanceState enum - if new_state not in TradeAcceptance.AcceptanceState: + try: + # Try to cast new_state to the enum member + valid_state = TradeAcceptance.AcceptanceState(new_state) + except ValueError: form.add_error("state", "Invalid state transition.") return self.form_invalid(form) try: - # pass the new state and the current user to the update_state method - form.instance.update_state(new_state, self.request.user) + form.instance.update_state(valid_state, self.request.user) except ValueError as e: form.add_error("state", str(e)) return self.form_invalid(form)