MPS-20 + MPS-21: fix physical Add To Cart bypassing auction/offer

Defect: product.j2 always rendered "Add To Cart" at list price for
physical products, even when pricing_mode=1 (auction only) or
pricing_mode=3 (offer only). A buyer browsing a physical auction
could click Add To Cart and pay list price, completely bypassing
the bidding flow.

Fix: wrap the physical Add To Cart / Sold Out branches in
{% if product.is_buy_now_allowed %} so they only render in modes
that permit direct purchase (0 fixed, 2 auction+buy_now, 4 offer+
buy_now). Modes 1 (auction-only) and 3 (offer-only) hide both the
Add To Cart button and the Sold Out indicator entirely; buyers must
use the View Auction or Make Offer entry points.

Tests:
- test_digital_fixed_price_shows_add_to_cart
- test_digital_auction_only_hides_add_to_cart
- test_digital_auction_with_buy_now_shows_add_to_cart
- test_digital_offer_only_hides_add_to_cart
- test_physical_auction_only_hides_add_to_cart (the regression fix)
- test_physical_offer_only_hides_add_to_cart (same)

Total: 949 tests pass (was 943 + 6).
This commit is contained in:
russell@unturf.com 2026-05-10 09:26:02 -04:00
parent d79082c6ff
commit 364af1fcca
No known key found for this signature in database
2 changed files with 116 additions and 7 deletions

View file

@ -202,18 +202,24 @@
{% endif %}
{% if product.is_physical %}
{% set inventory = product.inventories | selectattr('shop_location_id', 'equalto', request.shop_location.id) | first %}
{% if inventory and inventory.quantity > 0 %}
{# MPS-20 + MPS-21: physical Add To Cart only renders when
pricing_mode permits direct purchase (modes 0, 2, 4).
Auction-only (1) and offer-only (3) modes hide it so buyers
use the bidding or offer flow instead. #}
{% if product.is_buy_now_allowed %}
{% set inventory = product.inventories | selectattr('shop_location_id', 'equalto', request.shop_location.id) | first %}
{% if inventory and inventory.quantity > 0 %}
<form method="POST" action="{{ request.route_url('cart_add_product') }}">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="product_id" value="{{ product.id }}">
<button type="submit" class="product-download-button mps-button">Add To Cart</button>
</form>
{% else %}
<button class="product-download-button mps-button sold-out-button" disabled>Sold Out</button>
{% if request.shop.shop_locations.all()|length > 1 %}
<p>This item is sold out at this location.
<br> You can <a href="{{ request.route_url('shop_locations', shop_id=request.shop.id, _query={'next': request.url}) }}">switch shop locations</a> to check availability.</p>
{% else %}
<button class="product-download-button mps-button sold-out-button" disabled>Sold Out</button>
{% if request.shop.shop_locations.all()|length > 1 %}
<p>This item is sold out at this location.
<br> You can <a href="{{ request.route_url('shop_locations', shop_id=request.shop.id, _query={'next': request.url}) }}">switch shop locations</a> to check availability.</p>
{% endif %}
{% endif %}
{% endif %}
{% else %}

View file

@ -6464,3 +6464,106 @@ class TestOfferCheckout(_AuthenticatedBase):
# Redirect back to offer page, not /cart.
self.assertIn("/o/", res.location)
self.assertNotIn("/cart", res.location)
class TestBuyNowGating(_AuthenticatedBase):
"""MPS-20 + MPS-21: Add To Cart only renders when pricing_mode allows
direct purchase (modes 0, 2, 4). For both physical and digital products."""
def _make_product(self, pricing_mode=0, is_physical=False,
is_ready=True):
from ..models.product import Product
shop = self._create_shop_helper(user_creds=self.user1_creds)
product = Product(title="Gated", description="...")
product.shop = shop
product.price_in_cents = 5000
product.is_physical = is_physical
product.is_sellable = True
product.pricing_mode = pricing_mode
if is_ready:
import json
if is_physical:
# Physical needs thumbnail1 in extensions for is_ready.
product.json_file_metadata = json.dumps(
{"originals": {}, "extensions": {"thumbnail1": "jpg"}},
)
else:
product.json_file_metadata = json.dumps(
{"originals": {}, "extensions": {"product": "pdf"}},
)
self.dbsession.add(product)
self.dbsession.flush()
# If auction mode, create the MpsAuction the form_section handler
# would have created, so the View Auction link renders.
if pricing_mode in (1, 2):
from ..models.auction import MpsAuction, AUCTION_STATE_ACTIVE, now_timestamp
auction = MpsAuction(
product=product, shop=shop,
start_price_in_cents=product.price_in_cents,
)
auction.state = AUCTION_STATE_ACTIVE
auction.start_timestamp = now_timestamp() - 60_000
auction.end_timestamp = now_timestamp() + 3_600_000
self.dbsession.add(auction)
self.dbsession.flush()
product_id = product.uuid_str
product_slug = product.slug
if is_physical:
from ..models.inventory import Inventory
loc = shop.shop_locations.first()
if loc is not None:
inv = Inventory(
product=product, shop_location=loc, quantity=10,
)
self.dbsession.add(inv)
self.dbsession.flush()
transaction.commit()
return product_id, product_slug
# The fix: pricing_mode=1 (auction only) and pricing_mode=3 (offer only)
# must hide the Add To Cart button on both physical and digital products.
def test_digital_fixed_price_shows_add_to_cart(self):
pid, slug = self._make_product(pricing_mode=0, is_physical=False)
res = self.testapp.get(f"/p/{pid}/{slug}", status=200)
self.assertIn(b"Add To Cart", res.body)
def test_digital_auction_only_hides_add_to_cart(self):
pid, slug = self._make_product(pricing_mode=1, is_physical=False)
res = self.testapp.get(f"/p/{pid}/{slug}", status=200)
self.assertNotIn(b"Add To Cart", res.body)
self.assertIn(b"View live auction", res.body)
def test_digital_auction_with_buy_now_shows_add_to_cart(self):
pid, slug = self._make_product(pricing_mode=2, is_physical=False)
res = self.testapp.get(f"/p/{pid}/{slug}", status=200)
self.assertIn(b"Add To Cart", res.body)
self.assertIn(b"View live auction", res.body)
def test_digital_offer_only_hides_add_to_cart(self):
pid, slug = self._make_product(pricing_mode=3, is_physical=False)
res = self.testapp.get(f"/p/{pid}/{slug}", status=200)
self.assertNotIn(b"Add To Cart", res.body)
# Physical product gating: the regression fix. Physical products in
# auction-only or offer-only mode must NOT render Add To Cart at list
# price (which would bypass the bidding/negotiation flow).
# Sold Out IS expected for physical products without inventory matching
# the request shop_location — that's normal physical-product behavior;
# we only assert the absence of Add To Cart, not the presence.
def test_physical_auction_only_hides_add_to_cart(self):
# Pre-fix this rendered Add To Cart at list price.
pid, slug = self._make_product(pricing_mode=1, is_physical=True)
res = self.testapp.get(f"/p/{pid}/{slug}", status=200)
self.assertNotIn(b"Add To Cart", res.body)
self.assertNotIn(b"sold-out-button", res.body) # gated entirely
self.assertIn(b"View live auction", res.body)
def test_physical_offer_only_hides_add_to_cart(self):
pid, slug = self._make_product(pricing_mode=3, is_physical=True)
res = self.testapp.get(f"/p/{pid}/{slug}", status=200)
self.assertNotIn(b"Add To Cart", res.body)
self.assertNotIn(b"sold-out-button", res.body)