diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 4b988b4..605fb08 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -7043,6 +7043,90 @@ class TestOfferCheckout(_AuthenticatedBase): self.dbsession.refresh(offer) self.assertEqual(offer.state, OFFER_STATE_ACCEPTED) + def test_negotiated_cart_refuses_add_product(self): + """A cart bound to an accepted offer must not accept additional + product adds. Otherwise the buyer can walk away with N units at + the single-unit negotiated price (the override total is constant + regardless of line-item quantity). + """ + from ..models.cart import Cart + from ..models.cart_offer import MpsCartOffer + + offer_id = self._accepted_offer() + self.testapp.get("/log-out") + self.log_in_user(self.user2_creds) + + # First the buyer pays for their offer — this creates a cart + # with cart_offer linked. + self.testapp.post(f"/o/{offer_id}/checkout", status=302) + + # The cart has 1 unit of the offer's product. + from ..models.user import get_user_by_email + buyer = get_user_by_email(self.dbsession, self.user2_creds[0]) + match = [ + c for c in self.dbsession.query(Cart).filter(Cart.user_id == buyer.id).all() + if c.cart_offers + ] + self.assertEqual(len(match), 1) + cart = match[0] + product = cart.cart_offers[0].offer.product + self.assertEqual(cart.get_product_quantity(product), 1) + + # Now try to add the SAME product via /cart/add. + csrf_token = self.get_csrf_token(product.shop.uuid_str) + res = self.testapp.post( + "/cart/add", + { + "product_id": product.id, + "shop_id": product.shop.id, + "csrf_token": csrf_token, + }, + status=302, + ) + flash_body = res.follow().body.decode() + self.assertIn("locked", flash_body) + + # Quantity unchanged in DB. + self.dbsession.refresh(cart) + self.assertEqual(cart.get_product_quantity(product), 1) + + def test_negotiated_cart_refuses_quantity_bump(self): + """Same defense for /cart/{id}/quantity — the buyer can't bump + quantity directly on the cart page either. + """ + from ..models.cart import Cart + + offer_id = self._accepted_offer() + self.testapp.get("/log-out") + self.log_in_user(self.user2_creds) + self.testapp.post(f"/o/{offer_id}/checkout", status=302) + + from ..models.user import get_user_by_email + buyer = get_user_by_email(self.dbsession, self.user2_creds[0]) + cart = [ + c for c in self.dbsession.query(Cart).filter(Cart.user_id == buyer.id).all() + if c.cart_offers + ][0] + product = cart.cart_offers[0].offer.product + + csrf_token = self.get_csrf_token(product.shop.uuid_str) + res = self.testapp.post( + f"/cart/{cart.uuid_str}/quantity", + { + "product_id": product.id, + "quantity": "2", + "csrf_token": csrf_token, + }, + status=302, + ) + # Follow the full redirect chain until we land on a rendered page. + while 300 <= res.status_int < 400: + res = res.follow() + flash_body = res.body.decode() + self.assertIn("locked", flash_body) + self.dbsession.refresh(cart) + self.assertEqual(cart.get_product_quantity(product), 1) + def test_offer_checkout_is_single_redemption(self): """Repeated POSTs to /o/{id}/checkout must reuse the same cart — one offer, one cart, one chance to redeem. Otherwise a buyer diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index 8ce97c5..a35c4b5 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -384,6 +384,18 @@ def cart_add_product(request): elif product.is_not_sellable: msg = ("that content is not for sale or purchase.", "error") + elif request.active_cart.is_negotiated: + # A negotiated cart is single-purpose: one product, one unit, + # one negotiated price. Bumping quantity or adding other items + # would dilute the override (paying the agreed amount but + # walking with extra units / extra products). + kind = request.active_cart.negotiation_kind # "offer" | "auction" + msg = ( + f"This cart is locked to your accepted {kind}. " + f"Save or check out this cart before adding other items.", + "error", + ) + else: msg = (f'You added "{product.title}" to your cart.', "success") request.active_cart.add_product(product) @@ -486,6 +498,20 @@ def cart_quantity_product(request): ("Refusing to add a product from another shop to cart.", "error") ) return HTTPFound("/cart") + elif cart.is_negotiated: + # Negotiated carts (cart_offer / cart_auction) are locked to + # one unit of the negotiated product. Bumping quantity would + # let the buyer walk with N units at the single-unit negotiated + # price. + request.session.flash( + ( + "Quantity is locked on an accepted " + f"{cart.negotiation_kind} — the agreed price is for one " + "unit only.", + "error", + ) + ) + return HTTPFound("/cart") else: msg = ( f'You updated the quantity of "{product.title}" in the cart.',