- backfill: enabling torrent on a shop auto-generates .torrent for all existing products that have a product file (no manual trigger needed) - web seed (BEP 19): CDN url embedded in .torrent + magnet link so clients bootstrap via HTTP then seed to peers (no seeder process needed) - torrent_file_url: stored on Product, shown as download link on content and edit pages alongside the magnet link - migration: idempotent _column_exists guards on all add_column calls - template: grid layout (not flex) for magnet/torrent buttons on edit page - tests: 13 passing tests covering all new paths (unit + functional) including backfill trigger, web seed construction, visibility gating
3733 lines
137 KiB
Python
3733 lines
137 KiB
Python
import unittest
|
|
import uuid
|
|
|
|
import mock
|
|
|
|
from ..models import User, Coupon, Shop, is_user_name_valid
|
|
from ..models.crypto_payment import CryptoPayment
|
|
from ..models.invoice import Invoice
|
|
from ..models.meta import now_timestamp, short_id_to_bytes, id_to_uuid
|
|
from ..views.signals import classify_referrer, classify_device
|
|
|
|
mock_always_none = mock.Mock(return_value=None)
|
|
mock_always_true = mock.Mock(return_value=True)
|
|
mock_false_then_true = mock.Mock(side_effect=[False, False, True])
|
|
|
|
|
|
class TestUser(unittest.TestCase):
|
|
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
|
|
def setUp(self):
|
|
self.user = User("russell@ballestrini.net")
|
|
|
|
def test_created_timestamp_set(self):
|
|
self.assertGreater(self.user.created_timestamp, 100000)
|
|
|
|
def test_email_set(self):
|
|
self.assertEqual(self.user.email, "russell@ballestrini.net")
|
|
|
|
def test_new_password(self):
|
|
raw_password = self.user.new_password()
|
|
self.assertEqual(len(raw_password), 6)
|
|
|
|
def test_check_password_success(self):
|
|
raw_password = self.user.new_password()
|
|
self.assertTrue(self.user.check_password(raw_password))
|
|
|
|
def test_check_password_failure(self):
|
|
raw_password = self.user.new_password()
|
|
self.assertFalse(self.user.check_password("fake password"))
|
|
|
|
def test_is_user_name_valid(self):
|
|
self.assertTrue(is_user_name_valid("validusername"))
|
|
self.assertTrue(is_user_name_valid("validusername2"))
|
|
self.assertTrue(is_user_name_valid("ValidUsername"))
|
|
self.assertTrue(is_user_name_valid("valid-username"))
|
|
self.assertTrue(is_user_name_valid("valid username"))
|
|
|
|
self.assertFalse(is_user_name_valid("invalid!!!"))
|
|
self.assertFalse(is_user_name_valid(""))
|
|
|
|
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
|
|
def test_generate_user_name_no_dash_prefix(self):
|
|
u = User("tim@example.com")
|
|
self.assertFalse(u.name.startswith("-"))
|
|
|
|
|
|
class TestCart(unittest.TestCase):
|
|
|
|
# TODO: please test the fitness of the the Cart model.
|
|
#
|
|
# We have deliberately not written these tests but we should
|
|
# before going to much further. Unit tests are much faster and
|
|
# more grainular than functional tests.
|
|
#
|
|
# I'm moving forward without creating these tests because I have
|
|
# a functional test running as a safety net.
|
|
#
|
|
# That said unit tests are much better at capturing expected
|
|
# behavior and have better tooling for test and codepath coverage.
|
|
#
|
|
# Please help write unit tests soon!
|
|
|
|
def setUp(self):
|
|
pass
|
|
|
|
def validate_attached_coupon_codes(self):
|
|
"""Make sure all coupons attached to the cart met the terms."""
|
|
pass
|
|
|
|
def test_cart_requires_payment_above_threshold(self):
|
|
"""Test that cart requires payment when total is above $0.64 (64 cents)."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
# Mock the total_in_cents property to return value above threshold
|
|
with mock.patch.object(
|
|
type(cart), "total_in_cents", new_callable=mock.PropertyMock
|
|
) as mock_total:
|
|
mock_total.return_value = 65 # Above 64 cent threshold
|
|
self.assertTrue(cart.requires_payment)
|
|
|
|
def test_cart_requires_payment_at_threshold(self):
|
|
"""Test that cart requires payment at exactly $0.64 (64 cents)."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
with mock.patch.object(
|
|
type(cart), "total_in_cents", new_callable=mock.PropertyMock
|
|
) as mock_total:
|
|
mock_total.return_value = 64 # At threshold
|
|
self.assertFalse(cart.requires_payment)
|
|
|
|
def test_cart_requires_payment_below_threshold(self):
|
|
"""Test that cart does not require payment when total is below $0.64."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
with mock.patch.object(
|
|
type(cart), "total_in_cents", new_callable=mock.PropertyMock
|
|
) as mock_total:
|
|
mock_total.return_value = 30 # Below 64 cent threshold
|
|
self.assertFalse(cart.requires_payment)
|
|
|
|
def test_cart_requires_payment_zero_total(self):
|
|
"""Test that cart does not require payment when total is zero (free cart)."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
with mock.patch.object(
|
|
type(cart), "total_in_cents", new_callable=mock.PropertyMock
|
|
) as mock_total:
|
|
mock_total.return_value = 0 # Free cart
|
|
self.assertFalse(cart.requires_payment)
|
|
|
|
def test_cart_is_not_public_property(self):
|
|
"""Test cart.is_not_public property logic."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
|
|
# Test the actual logic: is_not_public should return opposite of public
|
|
cart.public = True
|
|
self.assertFalse(cart.is_not_public)
|
|
|
|
cart.public = False
|
|
self.assertTrue(cart.is_not_public)
|
|
|
|
def test_cart_empty_method(self):
|
|
"""Test cart.empty() method clears cart contents."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
cart.set_cart({"product1": 2, "product2": 3})
|
|
|
|
# Cart should have items
|
|
self.assertNotEqual(cart.get_cart(), {})
|
|
|
|
# Empty should clear everything
|
|
cart.empty()
|
|
self.assertEqual(cart.get_cart(), {})
|
|
|
|
def test_cart_set_and_get_cart(self):
|
|
"""Test cart.set_cart() and get_cart() methods."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
test_cart_data = {"product1": 2, "product2": 3}
|
|
|
|
cart.set_cart(test_cart_data)
|
|
retrieved_cart = cart.get_cart()
|
|
|
|
self.assertEqual(retrieved_cart, test_cart_data)
|
|
|
|
def test_cart_add_product(self):
|
|
"""Test cart.add_product() method."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
|
|
# Mock product with proper uuid_str
|
|
mock_product = mock.Mock()
|
|
mock_product.uuid_str = "product1" # This is what cart uses as key
|
|
|
|
# Add product
|
|
cart.add_product(mock_product)
|
|
|
|
# Should have quantity 1
|
|
self.assertEqual(cart.get_product_quantity(mock_product), 1)
|
|
|
|
# Add same product again
|
|
cart.add_product(mock_product)
|
|
|
|
# Should have quantity 2
|
|
self.assertEqual(cart.get_product_quantity(mock_product), 2)
|
|
|
|
def test_cart_remove_product(self):
|
|
"""Test cart.remove_product() method."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
|
|
# Mock product
|
|
mock_product = mock.Mock()
|
|
mock_product.uuid_str = "product1"
|
|
|
|
# Add product first
|
|
cart.add_product(mock_product)
|
|
cart.add_product(mock_product) # quantity = 2
|
|
|
|
# Remove product completely (removes all quantity)
|
|
cart.remove_product(mock_product)
|
|
self.assertEqual(cart.get_product_quantity(mock_product), 0)
|
|
|
|
def test_cart_set_product_quantity(self):
|
|
"""Test cart.set_product_quantity() method."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
|
|
# Mock product
|
|
mock_product = mock.Mock()
|
|
mock_product.uuid_str = "product1"
|
|
|
|
# Set quantity directly
|
|
cart.set_product_quantity(mock_product, 5)
|
|
self.assertEqual(cart.get_product_quantity(mock_product), 5)
|
|
|
|
# Set to zero should remove product
|
|
cart.set_product_quantity(mock_product, 0)
|
|
self.assertEqual(cart.get_product_quantity(mock_product), 0)
|
|
|
|
def test_cart_get_product_quantity(self):
|
|
"""Test cart.get_product_quantity() method."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
|
|
# Mock product
|
|
mock_product = mock.Mock()
|
|
mock_product.uuid_str = "product1"
|
|
|
|
# Should return 0 for product not in cart
|
|
self.assertEqual(cart.get_product_quantity(mock_product), 0)
|
|
|
|
# Add product and test
|
|
cart.set_product_quantity(mock_product, 3)
|
|
self.assertEqual(cart.get_product_quantity(mock_product), 3)
|
|
|
|
def test_cart_merge_in_cart(self):
|
|
"""Test cart.merge_in_cart() method."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart1 = Cart()
|
|
cart2 = Cart()
|
|
|
|
# Set up cart1
|
|
cart1.set_cart({"product1": 2, "product2": 1})
|
|
|
|
# Set up cart2
|
|
cart2.set_cart({"product2": 1, "product3": 3})
|
|
|
|
# Merge cart2 into cart1
|
|
cart1.merge_in_cart(cart2)
|
|
|
|
result = cart1.get_cart()
|
|
|
|
# Should have product1: 2, product2: 2 (1+1), product3: 3
|
|
self.assertEqual(result["product1"], 2)
|
|
self.assertEqual(result["product2"], 2) # Merged
|
|
self.assertEqual(result["product3"], 3)
|
|
|
|
def test_cart_validate_attached_coupons_no_coupons(self):
|
|
"""Test coupon validation when no coupons attached."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
|
|
# Mock the coupons property to return empty list
|
|
with mock.patch.object(
|
|
type(cart), "coupons", new_callable=mock.PropertyMock
|
|
) as mock_coupons:
|
|
mock_coupons.return_value = []
|
|
|
|
errors = cart.validate_attached_coupons()
|
|
self.assertEqual(errors, [])
|
|
|
|
def test_cart_remove_handling_if_no_physical_products(self):
|
|
"""Test remove_handling_if_no_physical_products method."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
cart.handling_option = "pickup"
|
|
cart.handling_cost_in_cents = 500
|
|
|
|
# Mock no physical products by mocking the property
|
|
with mock.patch.object(
|
|
type(cart), "physical_products", new_callable=mock.PropertyMock
|
|
) as mock_physical:
|
|
mock_physical.return_value = {}
|
|
|
|
cart.remove_handling_if_no_physical_products()
|
|
|
|
# Should clear handling
|
|
self.assertIsNone(cart.handling_option)
|
|
self.assertEqual(cart.handling_cost_in_cents, 0)
|
|
|
|
def test_cart_bust_memoized_attributes(self):
|
|
"""Test that _bust_memoized_attributes clears all cached properties."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
|
|
# Set some memoized attributes
|
|
cart._count = 5
|
|
cart._line_totals_in_cents = {"test": 100}
|
|
cart._products = {"test": "product"}
|
|
cart._physical_products = {"test": "physical"}
|
|
cart._shops = {"test": "shop"}
|
|
cart._shop_product_dict = {"test": []}
|
|
cart._shop_totals_in_cents = {"test": 100}
|
|
cart._shop_totals = {"test": 1.0}
|
|
cart._discounted_shop_totals_in_cents = {"test": 90}
|
|
cart._discounted_shop_totals = {"test": 0.9}
|
|
cart._line_totals = {"test": 1.0}
|
|
|
|
# Call the bust method
|
|
cart._bust_memoized_attributes()
|
|
|
|
# Verify all attributes are cleared
|
|
self.assertFalse(hasattr(cart, "_count"))
|
|
self.assertFalse(hasattr(cart, "_line_totals_in_cents"))
|
|
self.assertFalse(hasattr(cart, "_products"))
|
|
self.assertFalse(hasattr(cart, "_physical_products"))
|
|
self.assertFalse(hasattr(cart, "_shops"))
|
|
self.assertFalse(hasattr(cart, "_shop_product_dict"))
|
|
self.assertFalse(hasattr(cart, "_shop_totals_in_cents"))
|
|
self.assertFalse(hasattr(cart, "_shop_totals"))
|
|
self.assertFalse(hasattr(cart, "_discounted_shop_totals_in_cents"))
|
|
self.assertFalse(hasattr(cart, "_discounted_shop_totals"))
|
|
self.assertFalse(hasattr(cart, "_line_totals"))
|
|
|
|
def test_cart_set_product_quantity_max_limit(self):
|
|
"""Test that set_product_quantity enforces 999 max limit."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
mock_product = mock.Mock()
|
|
mock_product.uuid_str = "product1"
|
|
|
|
# Test setting quantity above 999
|
|
cart.set_product_quantity(mock_product, 1500)
|
|
self.assertEqual(cart.get_product_quantity(mock_product), 999)
|
|
|
|
# Test setting quantity at 999
|
|
cart.set_product_quantity(mock_product, 999)
|
|
self.assertEqual(cart.get_product_quantity(mock_product), 999)
|
|
|
|
def test_cart_merge_in_cart_with_handling_and_coupons(self):
|
|
"""Test merge_in_cart copies handling options."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart1 = Cart()
|
|
cart2 = Cart()
|
|
|
|
# Set up cart1
|
|
cart1.set_cart({"product1": 2})
|
|
cart1.handling_cost_in_cents = 100
|
|
|
|
# Set up cart2 with handling
|
|
cart2.set_cart({"product2": 3})
|
|
cart2.handling_option = "shipping"
|
|
cart2.handling_cost_in_cents = 200
|
|
|
|
# Initialize coupons as empty lists to avoid SQLAlchemy issues
|
|
cart1.coupons = []
|
|
cart2.coupons = []
|
|
|
|
# Merge cart2 into cart1
|
|
cart1.merge_in_cart(cart2)
|
|
|
|
# Check products merged
|
|
result = cart1.get_cart()
|
|
self.assertEqual(result["product1"], 2)
|
|
self.assertEqual(result["product2"], 3)
|
|
|
|
# Check handling option copied
|
|
self.assertEqual(cart1.handling_option, "shipping")
|
|
|
|
def test_cart_line_totals_property(self):
|
|
"""Test line_totals property converts cents to dollars."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
|
|
# Mock line_totals_in_cents to return test data
|
|
with mock.patch.object(
|
|
type(cart), "line_totals_in_cents", new_callable=mock.PropertyMock
|
|
) as mock_line_totals_cents:
|
|
mock_line_totals_cents.return_value = {"product1": 1500, "product2": 2000}
|
|
|
|
line_totals = cart.line_totals
|
|
|
|
# Should convert cents to dollars
|
|
self.assertEqual(line_totals["product1"], 15.00)
|
|
self.assertEqual(line_totals["product2"], 20.00)
|
|
|
|
def test_cart_shop_totals_property(self):
|
|
"""Test shop_totals property converts shop totals from cents to dollars."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
|
|
# Mock shop_totals_in_cents to return test data
|
|
with mock.patch.object(
|
|
type(cart), "shop_totals_in_cents", new_callable=mock.PropertyMock
|
|
) as mock_shop_totals_cents:
|
|
mock_shop_totals_cents.return_value = {"shop1": 2500, "shop2": 3000}
|
|
|
|
shop_totals = cart.shop_totals
|
|
|
|
# Should convert cents to dollars
|
|
self.assertEqual(shop_totals["shop1"], 25.00)
|
|
self.assertEqual(shop_totals["shop2"], 30.00)
|
|
|
|
def test_cart_discounted_shop_totals_property(self):
|
|
"""Test discounted_shop_totals property converts cents to dollars."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
|
|
# Mock discounted_shop_totals_in_cents to return test data
|
|
with mock.patch.object(
|
|
type(cart),
|
|
"discounted_shop_totals_in_cents",
|
|
new_callable=mock.PropertyMock,
|
|
) as mock_discounted_cents:
|
|
mock_discounted_cents.return_value = {"shop1": 2000, "shop2": 2500}
|
|
|
|
discounted_totals = cart.discounted_shop_totals
|
|
|
|
# Should convert cents to dollars
|
|
self.assertEqual(discounted_totals["shop1"], 20.00)
|
|
self.assertEqual(discounted_totals["shop2"], 25.00)
|
|
|
|
def test_cart_human_timestamp_properties(self):
|
|
"""Test human_updated_timestamp and human_created_timestamp properties."""
|
|
from make_post_sell.models.cart import Cart
|
|
from make_post_sell.lib.time_funcs import timestamp_to_ago_string
|
|
|
|
cart = Cart()
|
|
|
|
# Mock the timestamp_to_ago_string function
|
|
with mock.patch(
|
|
"make_post_sell.models.cart.timestamp_to_ago_string"
|
|
) as mock_ago_string:
|
|
mock_ago_string.return_value = "2 hours ago"
|
|
|
|
# Test human_updated_timestamp
|
|
result = cart.human_updated_timestamp
|
|
mock_ago_string.assert_called_with(cart.updated_timestamp)
|
|
self.assertEqual(result, "2 hours ago")
|
|
|
|
# Reset mock for second test
|
|
mock_ago_string.reset_mock()
|
|
mock_ago_string.return_value = "3 hours ago"
|
|
|
|
# Test human_created_timestamp
|
|
result = cart.human_created_timestamp
|
|
mock_ago_string.assert_called_with(cart.created_timestamp)
|
|
self.assertEqual(result, "3 hours ago")
|
|
|
|
def test_cart_total_price_in_cents_with_handling(self):
|
|
"""Test total_price_in_cents includes handling cost."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
cart.handling_cost_in_cents = 500 # $5.00 handling
|
|
|
|
# Mock line_totals_in_cents to return some total
|
|
with mock.patch.object(
|
|
type(cart), "line_totals_in_cents", new_callable=mock.PropertyMock
|
|
) as mock_line_totals:
|
|
mock_line_totals.return_value = {
|
|
"product1": 1000,
|
|
"product2": 1500,
|
|
} # $25.00
|
|
|
|
total = cart.total_price_in_cents
|
|
|
|
# Should include handling cost: 1000 + 1500 + 500 = 3000
|
|
self.assertEqual(total, 3000)
|
|
|
|
def test_cart_total_price_in_cents_no_handling(self):
|
|
"""Test total_price_in_cents without handling cost."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
cart.handling_cost_in_cents = 0 # No handling
|
|
|
|
# Mock line_totals_in_cents to return some total
|
|
with mock.patch.object(
|
|
type(cart), "line_totals_in_cents", new_callable=mock.PropertyMock
|
|
) as mock_line_totals:
|
|
mock_line_totals.return_value = {"product1": 1000} # $10.00
|
|
|
|
total = cart.total_price_in_cents
|
|
|
|
# Should just be line totals: 1000
|
|
self.assertEqual(total, 1000)
|
|
|
|
def test_cart_update_handling_cost_local_pickup(self):
|
|
"""Test update_handling_cost for local pickup (free)."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
cart.handling_option = "local_pickup"
|
|
|
|
mock_shop_location = mock.Mock()
|
|
|
|
cart.update_handling_cost(mock_shop_location)
|
|
|
|
# Local pickup should be free
|
|
self.assertEqual(cart.handling_cost_in_cents, 0)
|
|
|
|
def test_cart_update_handling_cost_local_delivery(self):
|
|
"""Test update_handling_cost for local delivery."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
cart.handling_option = "local_delivery"
|
|
|
|
mock_shop_location = mock.Mock()
|
|
mock_shop_location.local_delivery_rate_in_cents = 500 # $5.00
|
|
|
|
cart.update_handling_cost(mock_shop_location)
|
|
|
|
# Should use local delivery rate
|
|
self.assertEqual(cart.handling_cost_in_cents, 500)
|
|
|
|
def test_cart_update_handling_cost_local_shipping(self):
|
|
"""Test update_handling_cost for local shipping."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
cart.handling_option = "local_shipping"
|
|
|
|
mock_shop_location = mock.Mock()
|
|
mock_shop_location.local_shipping_rate_in_cents = 750 # $7.50
|
|
|
|
cart.update_handling_cost(mock_shop_location)
|
|
|
|
# Should use local shipping rate
|
|
self.assertEqual(cart.handling_cost_in_cents, 750)
|
|
|
|
def test_cart_update_handling_cost_international_shipping(self):
|
|
"""Test update_handling_cost for international shipping."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
cart.handling_option = "international_shipping"
|
|
|
|
mock_shop_location = mock.Mock()
|
|
mock_shop_location.international_shipping_rate_in_cents = 1500 # $15.00
|
|
|
|
cart.update_handling_cost(mock_shop_location)
|
|
|
|
# Should use international shipping rate
|
|
self.assertEqual(cart.handling_cost_in_cents, 1500)
|
|
|
|
def test_transient_cart_count_is_zero(self):
|
|
"""Test that an in-memory Cart (not added to DB) has count=0."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
self.assertEqual(cart.count, 0)
|
|
|
|
def test_transient_cart_is_empty(self):
|
|
"""Test that an in-memory Cart reports is_empty=True."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
self.assertTrue(cart.is_empty)
|
|
|
|
def test_transient_cart_dbsession_is_none(self):
|
|
"""Test that a transient Cart (never added to a DB session) has no dbsession.
|
|
|
|
This is critical for lazy cart creation: the login flow checks
|
|
session_cart.dbsession to decide whether to call dbsession.delete().
|
|
"""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
self.assertIsNone(cart.dbsession)
|
|
|
|
def test_transient_cart_has_valid_uuid(self):
|
|
"""Test that a transient Cart has a valid UUID id and uuid_str."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
self.assertIsNotNone(cart.id)
|
|
self.assertIsInstance(cart.uuid_str, str)
|
|
self.assertGreater(len(cart.uuid_str), 0)
|
|
|
|
def test_transient_cart_total_is_zero(self):
|
|
"""Test that an in-memory empty Cart returns total=0.00.
|
|
|
|
The nav bar accesses request.active_cart.total on every page.
|
|
For lazy carts, this must work without a DB session.
|
|
"""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
|
|
# Mock the products and coupons to avoid DB access
|
|
with mock.patch.object(
|
|
type(cart), "products", new_callable=mock.PropertyMock
|
|
) as mock_products:
|
|
mock_products.return_value = {}
|
|
|
|
with mock.patch.object(
|
|
type(cart), "coupons", new_callable=mock.PropertyMock
|
|
) as mock_coupons:
|
|
mock_coupons.return_value = []
|
|
|
|
self.assertEqual(cart.total, 0.00)
|
|
self.assertEqual(cart.count, 0)
|
|
|
|
def test_transient_cart_uuid_reusable(self):
|
|
"""Test that a Cart's id can be overwritten to reuse a UUID.
|
|
|
|
Lazy cart creation reuses the session UUID when recreating
|
|
an in-memory cart across requests.
|
|
"""
|
|
from make_post_sell.models.cart import Cart
|
|
import uuid as uuid_mod
|
|
|
|
cart = Cart()
|
|
original_id = cart.id
|
|
|
|
# Overwrite with a specific UUID (simulates lazy cart reuse)
|
|
new_uuid = uuid_mod.UUID("75b1de48-414b-11e7-aecf-9c4e369c7158")
|
|
cart.id = new_uuid
|
|
|
|
self.assertEqual(cart.id, new_uuid)
|
|
self.assertNotEqual(cart.id, original_id)
|
|
self.assertEqual(cart.uuid_str, str(new_uuid))
|
|
|
|
def test_cart_total_property_not_discounted(self):
|
|
"""Test Cart.total property when cart is not discounted (line 347)."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
cart = Cart()
|
|
|
|
# Mock properties to ensure cart is not discounted
|
|
with mock.patch.object(
|
|
type(cart), "is_discounted", new_callable=mock.PropertyMock
|
|
) as mock_is_discounted:
|
|
with mock.patch.object(
|
|
type(cart), "total_price", new_callable=mock.PropertyMock
|
|
) as mock_total_price:
|
|
mock_is_discounted.return_value = False # Not discounted
|
|
mock_total_price.return_value = 25.00 # Regular price
|
|
|
|
# This should hit line 347: return self.total_price
|
|
total = cart.total
|
|
self.assertEqual(total, 25.00)
|
|
mock_total_price.assert_called_once()
|
|
|
|
|
|
class TestLazySessionCart(unittest.TestCase):
|
|
"""Test the lazy cart creation logic in add_session_cart.
|
|
|
|
Session carts are NOT persisted to the database until a product is
|
|
actually added. This prevents bots and crawlers from creating
|
|
empty cart rows on every page visit.
|
|
"""
|
|
|
|
def _make_mock_request(self, session_data=None, shop=None, db_cart=None):
|
|
"""Build a mock Pyramid request for testing add_session_cart."""
|
|
request = mock.MagicMock()
|
|
request.session = dict(session_data or {})
|
|
request.shop = shop
|
|
|
|
mock_dbsession = mock.MagicMock()
|
|
request.dbsession = mock_dbsession
|
|
|
|
# Make get_cart_by_id return db_cart when called
|
|
return request, db_cart
|
|
|
|
def test_returns_persisted_cart_when_found_in_db(self):
|
|
"""When session has a cart_id and it exists in the DB, return it."""
|
|
from make_post_sell.models.cart import Cart
|
|
|
|
persisted_cart = Cart()
|
|
request, _ = self._make_mock_request(
|
|
session_data={"active_cart_id": persisted_cart.uuid_str},
|
|
)
|
|
|
|
with mock.patch(
|
|
"make_post_sell.request_methods.get_cart_by_id",
|
|
return_value=persisted_cart,
|
|
) as mock_get:
|
|
# Import and call the function under test
|
|
from make_post_sell.request_methods import includeme
|
|
|
|
# We need to test the inner function directly. Extract it by
|
|
# calling includeme and capturing the registered function.
|
|
captured = {}
|
|
|
|
def fake_add_request_method(func, name, **kwargs):
|
|
captured[name] = func
|
|
|
|
config = mock.MagicMock()
|
|
config.get_settings.return_value = {}
|
|
config.add_request_method = fake_add_request_method
|
|
includeme(config)
|
|
|
|
add_session_cart = captured["session_cart"]
|
|
result = add_session_cart(request)
|
|
|
|
mock_get.assert_called_once_with(
|
|
request.dbsession, persisted_cart.uuid_str
|
|
)
|
|
self.assertEqual(result, persisted_cart)
|
|
|
|
def test_creates_inmemory_cart_when_no_session_id(self):
|
|
"""When session has no cart_id, create an in-memory Cart and store UUID."""
|
|
request, _ = self._make_mock_request()
|
|
|
|
with mock.patch(
|
|
"make_post_sell.request_methods.get_cart_by_id",
|
|
return_value=None,
|
|
):
|
|
captured = {}
|
|
|
|
def fake_add_request_method(func, name, **kwargs):
|
|
captured[name] = func
|
|
|
|
config = mock.MagicMock()
|
|
config.get_settings.return_value = {}
|
|
config.add_request_method = fake_add_request_method
|
|
|
|
from make_post_sell.request_methods import includeme
|
|
|
|
includeme(config)
|
|
add_session_cart = captured["session_cart"]
|
|
result = add_session_cart(request)
|
|
|
|
# Should have stored the cart UUID in the session
|
|
self.assertIn("active_cart_id", request.session)
|
|
self.assertEqual(request.session["active_cart_id"], result.uuid_str)
|
|
|
|
# Cart should NOT be added to the DB session
|
|
request.dbsession.add.assert_not_called()
|
|
request.dbsession.flush.assert_not_called()
|
|
|
|
# Cart should be usable (count=0, empty)
|
|
self.assertEqual(result.count, 0)
|
|
self.assertTrue(result.is_empty)
|
|
self.assertTrue(result.active)
|
|
|
|
def test_reuses_uuid_from_session_when_cart_not_in_db(self):
|
|
"""When session has a cart_id but DB returns None, reuse the same UUID."""
|
|
import uuid as uuid_mod
|
|
|
|
stale_uuid = str(uuid_mod.uuid4())
|
|
request, _ = self._make_mock_request(
|
|
session_data={"active_cart_id": stale_uuid},
|
|
)
|
|
|
|
with mock.patch(
|
|
"make_post_sell.request_methods.get_cart_by_id",
|
|
return_value=None,
|
|
):
|
|
captured = {}
|
|
|
|
def fake_add_request_method(func, name, **kwargs):
|
|
captured[name] = func
|
|
|
|
config = mock.MagicMock()
|
|
config.get_settings.return_value = {}
|
|
config.add_request_method = fake_add_request_method
|
|
|
|
from make_post_sell.request_methods import includeme
|
|
|
|
includeme(config)
|
|
add_session_cart = captured["session_cart"]
|
|
result = add_session_cart(request)
|
|
|
|
# UUID should match what was in the session
|
|
self.assertEqual(result.uuid_str, stale_uuid)
|
|
|
|
# Session should NOT be overwritten
|
|
self.assertEqual(request.session["active_cart_id"], stale_uuid)
|
|
|
|
# Cart should NOT be added to the DB
|
|
request.dbsession.add.assert_not_called()
|
|
|
|
|
|
class TestCoupon(unittest.TestCase):
|
|
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
|
|
def setUp(self):
|
|
|
|
# if the year is 2040 and this code is still in use and these tests
|
|
# break, it is safe to raise this date another +20 years.
|
|
future_date = "2040-01-15"
|
|
past_date = "2020-01-15"
|
|
|
|
self.shop = Shop(
|
|
"my-shop",
|
|
"860-555-5555",
|
|
"1 wayward way",
|
|
"my shop description",
|
|
)
|
|
self.coupon = Coupon(
|
|
shop=self.shop,
|
|
code="a-coupon-code",
|
|
description="a valid coupon",
|
|
action_type="dollar-off",
|
|
action_value=500,
|
|
max_redemptions=100,
|
|
max_redemptions_per_user=1,
|
|
expiration_date=future_date,
|
|
cart_qualifier=20,
|
|
)
|
|
self.old_coupon = Coupon(
|
|
shop=self.shop,
|
|
code="old-coupon-code",
|
|
description="a invalid coupon",
|
|
action_type="dollar-off",
|
|
action_value=500,
|
|
max_redemptions=100,
|
|
max_redemptions_per_user=1,
|
|
expiration_date=past_date,
|
|
cart_qualifier=20,
|
|
)
|
|
self.disabled_coupon = Coupon(
|
|
shop=self.shop,
|
|
code="disabled-coupon-code",
|
|
description="a disabled coupon",
|
|
action_type="dollar-off",
|
|
action_value=500,
|
|
max_redemptions=100,
|
|
max_redemptions_per_user=1,
|
|
expiration_date=future_date,
|
|
cart_qualifier=20,
|
|
)
|
|
self.disabled_coupon.disabled = True
|
|
|
|
def test_coupon_code(self):
|
|
self.assertNotEqual(self.coupon.code, "invalid-code")
|
|
self.assertEqual(self.coupon.code, "a-coupon-code")
|
|
|
|
# make sure coupon is not expired.
|
|
self.assertTrue(self.coupon.is_valid)
|
|
self.assertTrue(self.coupon.is_not_expired)
|
|
|
|
# test inverse.
|
|
self.assertFalse(self.coupon.is_expired)
|
|
self.assertFalse(self.coupon.is_not_valid)
|
|
|
|
def test_expired_coupon_code(self):
|
|
self.assertEqual(self.old_coupon.code, "old-coupon-code")
|
|
|
|
# make sure old_coupon is_expired and is_not_valid
|
|
self.assertTrue(self.old_coupon.is_expired)
|
|
self.assertTrue(self.old_coupon.is_not_valid)
|
|
|
|
# test inverse.
|
|
self.assertFalse(self.old_coupon.is_valid)
|
|
self.assertFalse(self.old_coupon.is_not_expired)
|
|
|
|
def test_disabled_coupon_code(self):
|
|
self.assertEqual(self.disabled_coupon.code, "disabled-coupon-code")
|
|
|
|
# make sure disabled_coupon is_not_expired and is_not_valid
|
|
self.assertTrue(self.disabled_coupon.is_not_expired)
|
|
self.assertTrue(self.disabled_coupon.is_not_valid)
|
|
|
|
# test inverse.
|
|
self.assertFalse(self.disabled_coupon.is_valid)
|
|
self.assertFalse(self.disabled_coupon.is_expired)
|
|
|
|
|
|
class TestProductS3Security(unittest.TestCase):
|
|
"""Test S3 security controls for product visibility."""
|
|
|
|
def setUp(self):
|
|
from ..models.product import Product
|
|
from ..models.shop import Shop
|
|
|
|
# Create test shop and product
|
|
self.shop = Shop(
|
|
"test-shop",
|
|
"555-555-5555",
|
|
"123 Test St",
|
|
"Test shop description",
|
|
)
|
|
self.product = Product("Test Product", "Test product description")
|
|
self.product.shop = self.shop
|
|
|
|
# Mock file metadata to simulate uploaded files
|
|
self.product.json_file_metadata = (
|
|
'{"extensions": {"product": "pdf", "preview": "jpg", "thumbnail1": "jpg"}}'
|
|
)
|
|
self.product._file_metadata = {
|
|
"extensions": {"product": "pdf", "preview": "jpg", "thumbnail1": "jpg"}
|
|
}
|
|
|
|
def test_get_s3_acl_for_public_product(self):
|
|
"""Test S3 ACL logic for public products."""
|
|
self.product.visibility = 1 # public
|
|
|
|
# Product files should always be private
|
|
self.assertEqual(self.product.get_s3_acl_for_file_key("product"), "private")
|
|
|
|
# Public files should be public-read for public products
|
|
self.assertEqual(self.product.get_s3_acl_for_file_key("preview"), "public-read")
|
|
self.assertEqual(
|
|
self.product.get_s3_acl_for_file_key("thumbnail1"), "public-read"
|
|
)
|
|
|
|
def test_get_s3_acl_for_unlisted_product(self):
|
|
"""Test S3 ACL logic for unlisted products."""
|
|
self.product.visibility = 2 # unlisted
|
|
|
|
# Product files should always be private
|
|
self.assertEqual(self.product.get_s3_acl_for_file_key("product"), "private")
|
|
|
|
# Public files should be public-read for unlisted products
|
|
self.assertEqual(self.product.get_s3_acl_for_file_key("preview"), "public-read")
|
|
self.assertEqual(
|
|
self.product.get_s3_acl_for_file_key("thumbnail1"), "public-read"
|
|
)
|
|
|
|
def test_get_s3_acl_for_private_product(self):
|
|
"""Test S3 ACL logic for private products."""
|
|
self.product.visibility = 0 # private
|
|
|
|
# All files should be private for private products
|
|
self.assertEqual(self.product.get_s3_acl_for_file_key("product"), "private")
|
|
self.assertEqual(self.product.get_s3_acl_for_file_key("preview"), "private")
|
|
self.assertEqual(self.product.get_s3_acl_for_file_key("thumbnail1"), "private")
|
|
|
|
@mock.patch("builtins.print") # Mock print to avoid test output
|
|
def test_update_s3_acls_with_mock_client(self, mock_print):
|
|
"""Test S3 ACL updates with mocked S3 client."""
|
|
# Create mock S3 client
|
|
mock_s3_client = mock.Mock()
|
|
bucket_name = "test-bucket"
|
|
|
|
self.product.visibility = 1 # public
|
|
|
|
# Should call put_object_acl for each file
|
|
self.product.update_s3_acls(mock_s3_client, bucket_name)
|
|
|
|
# Verify put_object_acl was called for each file type
|
|
expected_calls = len(self.product.extensions)
|
|
self.assertEqual(mock_s3_client.put_object_acl.call_count, expected_calls)
|
|
|
|
def test_set_visibility_without_s3_client(self):
|
|
"""Test set_visibility without S3 client (no ACL updates)."""
|
|
old_visibility = self.product.visibility
|
|
new_visibility = 0 # private
|
|
|
|
# Should update visibility without error
|
|
self.product.set_visibility(new_visibility)
|
|
self.assertEqual(self.product.visibility, new_visibility)
|
|
|
|
@mock.patch("builtins.print") # Mock print to avoid test output
|
|
def test_set_visibility_with_s3_client(self, mock_print):
|
|
"""Test set_visibility with S3 client (should update ACLs)."""
|
|
mock_s3_client = mock.Mock()
|
|
bucket_name = "test-bucket"
|
|
|
|
old_visibility = 1 # public
|
|
new_visibility = 0 # private
|
|
self.product.visibility = old_visibility
|
|
|
|
# Should update visibility and call update_s3_acls
|
|
self.product.set_visibility(new_visibility, mock_s3_client, bucket_name)
|
|
|
|
self.assertEqual(self.product.visibility, new_visibility)
|
|
# Verify S3 ACL update was called
|
|
expected_calls = len(self.product.extensions)
|
|
self.assertEqual(mock_s3_client.put_object_acl.call_count, expected_calls)
|
|
|
|
def test_visibility_property_helpers(self):
|
|
"""Test visibility helper properties work correctly."""
|
|
# Test public
|
|
self.product.visibility = 1
|
|
self.assertTrue(self.product.is_public)
|
|
self.assertFalse(self.product.is_unlisted)
|
|
self.assertFalse(self.product.is_private)
|
|
|
|
# Test unlisted
|
|
self.product.visibility = 2
|
|
self.assertFalse(self.product.is_public)
|
|
self.assertTrue(self.product.is_unlisted)
|
|
self.assertFalse(self.product.is_private)
|
|
|
|
# Test private
|
|
self.product.visibility = 0
|
|
self.assertFalse(self.product.is_public)
|
|
self.assertFalse(self.product.is_unlisted)
|
|
self.assertTrue(self.product.is_private)
|
|
|
|
def test_karaoke_tracks_in_file_keys(self):
|
|
"""Karaoke tracks (instrumentals, vocals) are registered in file_keys."""
|
|
self.assertIn("instrumentals", self.product.file_keys)
|
|
self.assertIn("vocals", self.product.file_keys)
|
|
|
|
def test_karaoke_acl_public_product(self):
|
|
"""Karaoke tracks follow same ACL as product file for public products."""
|
|
self.product.visibility = 1
|
|
self.assertEqual(
|
|
self.product.get_s3_acl_for_file_key("instrumentals"),
|
|
self.product.get_s3_acl_for_file_key("product"),
|
|
)
|
|
self.assertEqual(
|
|
self.product.get_s3_acl_for_file_key("vocals"),
|
|
self.product.get_s3_acl_for_file_key("product"),
|
|
)
|
|
|
|
def test_karaoke_acl_private_product(self):
|
|
"""Karaoke tracks are private when product is private."""
|
|
self.product.visibility = 0
|
|
self.assertEqual(self.product.get_s3_acl_for_file_key("instrumentals"), "private")
|
|
self.assertEqual(self.product.get_s3_acl_for_file_key("vocals"), "private")
|
|
|
|
def test_karaoke_acl_unlisted_product(self):
|
|
"""Karaoke tracks follow same ACL as product file for unlisted products."""
|
|
self.product.visibility = 2
|
|
self.assertEqual(
|
|
self.product.get_s3_acl_for_file_key("instrumentals"),
|
|
self.product.get_s3_acl_for_file_key("product"),
|
|
)
|
|
self.assertEqual(
|
|
self.product.get_s3_acl_for_file_key("vocals"),
|
|
self.product.get_s3_acl_for_file_key("product"),
|
|
)
|
|
|
|
@mock.patch("builtins.print")
|
|
def test_update_s3_acls_includes_karaoke_tracks(self, mock_print):
|
|
"""update_s3_acls sets ACL on karaoke tracks when they exist in extensions."""
|
|
self.product._file_metadata = {
|
|
"extensions": {
|
|
"product": "mp3",
|
|
"thumbnail1": "jpg",
|
|
"instrumentals": "wav",
|
|
"vocals": "wav",
|
|
}
|
|
}
|
|
self.product.visibility = 1
|
|
|
|
mock_s3 = mock.Mock()
|
|
self.product.update_s3_acls(mock_s3, "test-bucket")
|
|
|
|
# Should call put_object_acl for all 4 file keys in extensions
|
|
self.assertEqual(mock_s3.put_object_acl.call_count, 4)
|
|
|
|
# Verify karaoke tracks were included by checking the Key arguments
|
|
called_keys = [
|
|
call.kwargs.get("Key") or call[1].get("Key")
|
|
for call in mock_s3.put_object_acl.call_args_list
|
|
]
|
|
s3_path = self.product.s3_path
|
|
self.assertIn(f"{s3_path}/instrumentals", called_keys)
|
|
self.assertIn(f"{s3_path}/vocals", called_keys)
|
|
|
|
|
|
class TestMetaFunctions(unittest.TestCase):
|
|
"""Test meta.py utility functions for base64 and UUID handling."""
|
|
|
|
def test_short_id_to_bytes(self):
|
|
"""Test short_id_to_bytes function converts base64 strings correctly."""
|
|
# Test case from the docstring
|
|
result = short_id_to_bytes("dbHeSEFLEeeuz5xONpxxWA")
|
|
expected = b"u\xb1\xdeHAK\x11\xe7\xae\xcf\x9cN6\x9cqX"
|
|
self.assertEqual(result, expected)
|
|
|
|
# Test with various base64-encoded strings
|
|
test_cases = [
|
|
"YWJjZA", # 'abcd' in base64
|
|
"dGVzdA", # 'test' in base64
|
|
"SGVsbG8", # 'Hello' in base64
|
|
]
|
|
|
|
for test_case in test_cases:
|
|
# Should not raise an exception
|
|
result = short_id_to_bytes(test_case)
|
|
self.assertIsInstance(result, bytes)
|
|
|
|
def test_id_to_uuid_with_short_id(self):
|
|
"""Test id_to_uuid function works with short base64 IDs."""
|
|
# Test case from the docstring
|
|
result = id_to_uuid("dbHeSEFLEeeuz5xONpxxWA")
|
|
expected = uuid.UUID("75b1de48-414b-11e7-aecf-9c4e369c7158")
|
|
self.assertEqual(result, expected)
|
|
|
|
def test_id_to_uuid_with_hex_string(self):
|
|
"""Test id_to_uuid function works with hex UUID strings."""
|
|
# Test case from the docstring
|
|
result = id_to_uuid("75b1de48414b11e7aecf9c4e369c7158")
|
|
expected = uuid.UUID("75b1de48-414b-11e7-aecf-9c4e369c7158")
|
|
self.assertEqual(result, expected)
|
|
|
|
def test_id_to_uuid_with_dashed_uuid(self):
|
|
"""Test id_to_uuid function works with dashed UUID strings."""
|
|
# Test case from the docstring
|
|
result = id_to_uuid("75b1de48-414b-11e7-aecf-9c4e369c7158")
|
|
expected = uuid.UUID("75b1de48-414b-11e7-aecf-9c4e369c7158")
|
|
self.assertEqual(result, expected)
|
|
|
|
def test_id_to_uuid_with_uuid_object(self):
|
|
"""Test id_to_uuid function returns UUID objects unchanged."""
|
|
test_uuid = uuid.UUID("75b1de48-414b-11e7-aecf-9c4e369c7158")
|
|
result = id_to_uuid(test_uuid)
|
|
self.assertEqual(result, test_uuid)
|
|
self.assertIs(result, test_uuid) # Should be the same object
|
|
|
|
def test_id_to_uuid_with_invalid_input(self):
|
|
"""Test id_to_uuid function returns None for invalid input."""
|
|
result = id_to_uuid("invalid-input")
|
|
self.assertIsNone(result)
|
|
|
|
result = id_to_uuid("")
|
|
self.assertIsNone(result)
|
|
|
|
def test_id_to_uuid_with_none_raises_typeerror(self):
|
|
"""Test id_to_uuid function raises TypeError for None input."""
|
|
# None input causes TypeError in uuid.UUID, not ValueError,
|
|
# so it's not caught by the exception handlers
|
|
with self.assertRaises(TypeError):
|
|
id_to_uuid(None)
|
|
|
|
|
|
class TestInvoice(unittest.TestCase):
|
|
"""Test Invoice model and its discount calculation logic."""
|
|
|
|
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
|
|
def setUp(self):
|
|
from ..models.invoice import Invoice, InvoiceLineItem
|
|
from ..models.product import Product
|
|
from ..models.price import Price
|
|
from ..models.coupon_redemption import CouponRedemption
|
|
|
|
# Create test shop
|
|
self.shop = Shop(
|
|
"test-shop",
|
|
"555-555-5555",
|
|
"123 Test St",
|
|
"Test shop description",
|
|
)
|
|
|
|
# Create test user
|
|
self.user = User("test@example.com")
|
|
|
|
# Create test product with price
|
|
self.product = Product("Test Product", "Test description")
|
|
self.product.shop = self.shop
|
|
|
|
# Create price for product
|
|
self.price = Price(self.product, 1000) # $10.00
|
|
|
|
# Create invoice
|
|
self.invoice = Invoice(self.user)
|
|
self.invoice.shop = self.shop
|
|
|
|
def test_subtotal_in_cents_single_item(self):
|
|
"""Test subtotal calculation with single line item."""
|
|
from ..models.invoice import InvoiceLineItem
|
|
|
|
# Add line item
|
|
line_item = InvoiceLineItem(
|
|
invoice=self.invoice,
|
|
product=self.product,
|
|
price=self.price,
|
|
shop=self.shop,
|
|
quantity=1,
|
|
)
|
|
self.invoice.line_items.append(line_item)
|
|
|
|
self.assertEqual(self.invoice.subtotal_in_cents, 1000)
|
|
|
|
def test_subtotal_in_cents_multiple_items(self):
|
|
"""Test subtotal calculation with multiple line items."""
|
|
from ..models.invoice import InvoiceLineItem
|
|
from ..models.product import Product
|
|
from ..models.price import Price
|
|
|
|
# Add first line item
|
|
line_item1 = InvoiceLineItem(
|
|
invoice=self.invoice,
|
|
product=self.product,
|
|
price=self.price,
|
|
shop=self.shop,
|
|
quantity=2,
|
|
)
|
|
self.invoice.line_items.append(line_item1)
|
|
|
|
# Create second product
|
|
product2 = Product("Test Product 2", "Test description 2")
|
|
product2.shop = self.shop
|
|
price2 = Price(product2, 500) # $5.00
|
|
|
|
# Add second line item
|
|
line_item2 = InvoiceLineItem(
|
|
invoice=self.invoice,
|
|
product=product2,
|
|
price=price2,
|
|
shop=self.shop,
|
|
quantity=3,
|
|
)
|
|
self.invoice.line_items.append(line_item2)
|
|
|
|
# Should be (1000 * 2) + (500 * 3) = 2000 + 1500 = 3500
|
|
self.assertEqual(self.invoice.subtotal_in_cents, 3500)
|
|
|
|
def test_discount_amount_in_cents_no_coupons(self):
|
|
"""Test discount amount when no coupons applied."""
|
|
self.assertEqual(self.invoice.discount_amount_in_cents, 0)
|
|
|
|
def test_discount_amount_in_cents_dollar_off_coupon(self):
|
|
"""Test discount amount with dollar-off coupon."""
|
|
from ..models.coupon_redemption import CouponRedemption
|
|
from ..models.invoice import InvoiceLineItem
|
|
|
|
# Add line item ($10.00)
|
|
line_item = InvoiceLineItem(
|
|
invoice=self.invoice,
|
|
product=self.product,
|
|
price=self.price,
|
|
shop=self.shop,
|
|
quantity=1,
|
|
)
|
|
self.invoice.line_items.append(line_item)
|
|
|
|
# Create $3 off coupon
|
|
coupon = Coupon(
|
|
shop=self.shop,
|
|
code="TEST3OFF",
|
|
description="$3 off",
|
|
action_type="dollar-off",
|
|
action_value=3.00, # Will be converted to 300 cents
|
|
cart_qualifier=5.00, # $5 minimum
|
|
)
|
|
|
|
# Create coupon redemption
|
|
redemption = CouponRedemption(
|
|
coupon=coupon, invoice=self.invoice, shop=self.shop, user=self.user
|
|
)
|
|
self.invoice.coupon_redemptions.append(redemption)
|
|
|
|
# Should be $3.00 off = 300 cents
|
|
self.assertEqual(self.invoice.discount_amount_in_cents, 300)
|
|
|
|
def test_discount_amount_in_cents_percent_off_coupon(self):
|
|
"""Test discount amount with percent-off coupon."""
|
|
from ..models.coupon_redemption import CouponRedemption
|
|
from ..models.invoice import InvoiceLineItem
|
|
|
|
# Add line item ($10.00)
|
|
line_item = InvoiceLineItem(
|
|
invoice=self.invoice,
|
|
product=self.product,
|
|
price=self.price,
|
|
shop=self.shop,
|
|
quantity=1,
|
|
)
|
|
self.invoice.line_items.append(line_item)
|
|
|
|
# Create 25% off coupon
|
|
coupon = Coupon(
|
|
shop=self.shop,
|
|
code="TEST25PCT",
|
|
description="25% off",
|
|
action_type="percent-off",
|
|
action_value=25, # Will be converted to 0.25
|
|
)
|
|
|
|
# Create coupon redemption
|
|
redemption = CouponRedemption(
|
|
coupon=coupon, invoice=self.invoice, shop=self.shop, user=self.user
|
|
)
|
|
self.invoice.coupon_redemptions.append(redemption)
|
|
|
|
# Should be 25% of $10.00 = $2.50 = 250 cents
|
|
self.assertEqual(self.invoice.discount_amount_in_cents, 250)
|
|
|
|
def test_discount_amount_in_cents_exceeds_subtotal(self):
|
|
"""Test discount amount when coupon exceeds subtotal."""
|
|
from ..models.coupon_redemption import CouponRedemption
|
|
from ..models.invoice import InvoiceLineItem
|
|
|
|
# Add line item ($10.00)
|
|
line_item = InvoiceLineItem(
|
|
invoice=self.invoice,
|
|
product=self.product,
|
|
price=self.price,
|
|
shop=self.shop,
|
|
quantity=1,
|
|
)
|
|
self.invoice.line_items.append(line_item)
|
|
|
|
# Create $15 off coupon (more than subtotal)
|
|
coupon = Coupon(
|
|
shop=self.shop,
|
|
code="TEST15OFF",
|
|
description="$15 off",
|
|
action_type="dollar-off",
|
|
action_value=15.00, # Will be converted to 1500 cents
|
|
)
|
|
|
|
# Create coupon redemption
|
|
redemption = CouponRedemption(
|
|
coupon=coupon, invoice=self.invoice, shop=self.shop, user=self.user
|
|
)
|
|
self.invoice.coupon_redemptions.append(redemption)
|
|
|
|
# Should be full $10.00 = 1000 cents (can't discount more than subtotal)
|
|
self.assertEqual(self.invoice.discount_amount_in_cents, 1000)
|
|
|
|
def test_total_in_cents_with_handling_and_discount(self):
|
|
"""Test total calculation with subtotal, discount, and handling."""
|
|
from ..models.coupon_redemption import CouponRedemption
|
|
from ..models.invoice import InvoiceLineItem
|
|
|
|
# Add line item ($10.00)
|
|
line_item = InvoiceLineItem(
|
|
invoice=self.invoice,
|
|
product=self.product,
|
|
price=self.price,
|
|
shop=self.shop,
|
|
quantity=1,
|
|
)
|
|
self.invoice.line_items.append(line_item)
|
|
|
|
# Add handling cost
|
|
self.invoice.handling_cost_in_cents = 500 # $5.00
|
|
|
|
# Create $3 off coupon
|
|
coupon = Coupon(
|
|
shop=self.shop,
|
|
code="TEST3OFF",
|
|
description="$3 off",
|
|
action_type="dollar-off",
|
|
action_value=3.00,
|
|
)
|
|
|
|
# Create coupon redemption
|
|
redemption = CouponRedemption(
|
|
coupon=coupon, invoice=self.invoice, shop=self.shop, user=self.user
|
|
)
|
|
self.invoice.coupon_redemptions.append(redemption)
|
|
|
|
# Should be: $10.00 - $3.00 + $5.00 = $12.00 = 1200 cents
|
|
self.assertEqual(self.invoice.total_in_cents, 1200)
|
|
|
|
def test_total_in_cents_never_negative(self):
|
|
"""Test that total never goes negative even with large discount."""
|
|
from ..models.coupon_redemption import CouponRedemption
|
|
from ..models.invoice import InvoiceLineItem
|
|
|
|
# Add line item ($10.00)
|
|
line_item = InvoiceLineItem(
|
|
invoice=self.invoice,
|
|
product=self.product,
|
|
price=self.price,
|
|
shop=self.shop,
|
|
quantity=1,
|
|
)
|
|
self.invoice.line_items.append(line_item)
|
|
|
|
# Create $20 off coupon (more than subtotal)
|
|
coupon = Coupon(
|
|
shop=self.shop,
|
|
code="TEST20OFF",
|
|
description="$20 off",
|
|
action_type="dollar-off",
|
|
action_value=20.00,
|
|
)
|
|
|
|
# Create coupon redemption
|
|
redemption = CouponRedemption(
|
|
coupon=coupon, invoice=self.invoice, shop=self.shop, user=self.user
|
|
)
|
|
self.invoice.coupon_redemptions.append(redemption)
|
|
|
|
# Should be 0, not negative
|
|
self.assertEqual(self.invoice.total_in_cents, 0)
|
|
|
|
def test_requires_payment_with_free_invoice(self):
|
|
"""Test requires_payment returns False for free invoice."""
|
|
from ..models.coupon_redemption import CouponRedemption
|
|
from ..models.invoice import InvoiceLineItem
|
|
|
|
# Add line item ($10.00)
|
|
line_item = InvoiceLineItem(
|
|
invoice=self.invoice,
|
|
product=self.product,
|
|
price=self.price,
|
|
shop=self.shop,
|
|
quantity=1,
|
|
)
|
|
self.invoice.line_items.append(line_item)
|
|
|
|
# Create $10 off coupon (makes it free)
|
|
coupon = Coupon(
|
|
shop=self.shop,
|
|
code="TEST10OFF",
|
|
description="$10 off",
|
|
action_type="dollar-off",
|
|
action_value=10.00,
|
|
)
|
|
|
|
# Create coupon redemption
|
|
redemption = CouponRedemption(
|
|
coupon=coupon, invoice=self.invoice, shop=self.shop, user=self.user
|
|
)
|
|
self.invoice.coupon_redemptions.append(redemption)
|
|
|
|
# Total should be 0
|
|
self.assertEqual(self.invoice.total_in_cents, 0)
|
|
# Should not require payment
|
|
self.assertFalse(self.invoice.requires_payment)
|
|
|
|
def test_requires_payment_at_threshold(self):
|
|
"""Test requires_payment at exactly 64 cents threshold."""
|
|
from ..models.invoice import InvoiceLineItem
|
|
from ..models.product import Product
|
|
from ..models.price import Price
|
|
|
|
# Create product with exact threshold price
|
|
product = Product("Cheap Product", "Test")
|
|
product.shop = self.shop
|
|
price = Price(product, 64) # Exactly 64 cents
|
|
|
|
# Add line item
|
|
line_item = InvoiceLineItem(
|
|
invoice=self.invoice,
|
|
product=product,
|
|
price=price,
|
|
shop=self.shop,
|
|
quantity=1,
|
|
)
|
|
self.invoice.line_items.append(line_item)
|
|
|
|
# At threshold, should not require payment
|
|
self.assertEqual(self.invoice.total_in_cents, 64)
|
|
self.assertFalse(self.invoice.requires_payment)
|
|
|
|
def test_requires_payment_above_threshold(self):
|
|
"""Test requires_payment above 64 cents threshold."""
|
|
from ..models.invoice import InvoiceLineItem
|
|
from ..models.product import Product
|
|
from ..models.price import Price
|
|
|
|
# Create product above threshold price
|
|
product = Product("Cheap Product", "Test")
|
|
product.shop = self.shop
|
|
price = Price(product, 65) # 65 cents
|
|
|
|
# Add line item
|
|
line_item = InvoiceLineItem(
|
|
invoice=self.invoice,
|
|
product=product,
|
|
price=price,
|
|
shop=self.shop,
|
|
quantity=1,
|
|
)
|
|
self.invoice.line_items.append(line_item)
|
|
|
|
# Above threshold, should require payment
|
|
self.assertEqual(self.invoice.total_in_cents, 65)
|
|
self.assertTrue(self.invoice.requires_payment)
|
|
|
|
def test_invalid_coupon_not_applied(self):
|
|
"""Test that invalid (expired/disabled) coupons don't apply discount."""
|
|
from ..models.coupon_redemption import CouponRedemption
|
|
from ..models.invoice import InvoiceLineItem
|
|
|
|
# Add line item ($10.00)
|
|
line_item = InvoiceLineItem(
|
|
invoice=self.invoice,
|
|
product=self.product,
|
|
price=self.price,
|
|
shop=self.shop,
|
|
quantity=1,
|
|
)
|
|
self.invoice.line_items.append(line_item)
|
|
|
|
# Create disabled coupon
|
|
coupon = Coupon(
|
|
shop=self.shop,
|
|
code="DISABLED",
|
|
description="$5 off",
|
|
action_type="dollar-off",
|
|
action_value=5.00,
|
|
)
|
|
coupon.disabled = True # Disable the coupon
|
|
|
|
# Create coupon redemption
|
|
redemption = CouponRedemption(
|
|
coupon=coupon, invoice=self.invoice, shop=self.shop, user=self.user
|
|
)
|
|
self.invoice.coupon_redemptions.append(redemption)
|
|
|
|
# Should have no discount since coupon is disabled
|
|
self.assertEqual(self.invoice.discount_amount_in_cents, 0)
|
|
self.assertEqual(self.invoice.total_in_cents, 1000)
|
|
|
|
def test_invoice_line_item_automatic_price_from_product_regression(self):
|
|
"""Test the production failure scenario: InvoiceLineItem automatically getting price from product.current_price."""
|
|
from ..models.invoice import InvoiceLineItem
|
|
from ..models.product import Product
|
|
from ..models.price import Price
|
|
|
|
# Create product (simulating production scenario)
|
|
product = Product("Production Test Product", "Test")
|
|
product.shop = self.shop
|
|
|
|
# Create price for product (this sets up the price_history relationship)
|
|
price = Price(product, 750) # $7.50
|
|
|
|
# Create line item explicitly passing price (this is what the fix enables)
|
|
# In production, this would call product.current_price automatically
|
|
# but unit tests need explicit price due to no database session
|
|
line_item = InvoiceLineItem(
|
|
invoice=self.invoice,
|
|
product=product,
|
|
price=price, # Explicit price for unit test
|
|
shop=self.shop,
|
|
quantity=2, # 2x $7.50 = $15.00
|
|
)
|
|
self.invoice.line_items.append(line_item)
|
|
|
|
# Test that the invoice can calculate subtotal_in_cents correctly
|
|
# This tests the line: item.price.price_in_cents * item.quantity
|
|
expected_subtotal = 750 * 2 # $15.00
|
|
self.assertEqual(self.invoice.subtotal_in_cents, expected_subtotal)
|
|
|
|
# Test that total_in_cents works (accessing the new properties)
|
|
expected_total = expected_subtotal # No discounts or handling
|
|
self.assertEqual(self.invoice.total_in_cents, expected_total)
|
|
|
|
# Test that the Price object is correctly linked
|
|
self.assertEqual(line_item.price.price_in_cents, 750)
|
|
self.assertEqual(line_item.product, product)
|
|
self.assertEqual(line_item.quantity, 2)
|
|
|
|
|
|
class TestUserCryptoRefundAddress(unittest.TestCase):
|
|
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
|
|
def setUp(self):
|
|
self.user = User("crypto@example.com")
|
|
self.shop = Shop(
|
|
name="TestShop",
|
|
phone_number="123-456-7890",
|
|
billing_address="123 Test St",
|
|
description="Test Shop",
|
|
)
|
|
|
|
def test_create_crypto_refund_address(self):
|
|
"""Test creating a crypto refund address."""
|
|
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
|
|
|
|
addr = UserCryptoRefundAddress(
|
|
user=self.user,
|
|
shop=self.shop,
|
|
coin_type="XMR",
|
|
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
|
|
label="My Monero Wallet",
|
|
)
|
|
|
|
self.assertEqual(addr.user, self.user)
|
|
self.assertEqual(addr.coin_type, "XMR")
|
|
self.assertEqual(
|
|
addr.address,
|
|
"44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
|
|
)
|
|
self.assertEqual(addr.label, "My Monero Wallet")
|
|
self.assertIsNotNone(addr.id)
|
|
self.assertGreater(addr.created_timestamp, 0)
|
|
self.assertGreater(addr.updated_timestamp, 0)
|
|
|
|
def test_create_crypto_refund_address_uppercase_coin_type(self):
|
|
"""Test that coin type is uppercased."""
|
|
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
|
|
|
|
addr = UserCryptoRefundAddress(
|
|
user=self.user,
|
|
shop=self.shop,
|
|
coin_type="btc",
|
|
address="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
|
|
)
|
|
|
|
self.assertEqual(addr.coin_type, "BTC")
|
|
|
|
def test_create_crypto_refund_address_no_label(self):
|
|
"""Test creating address without label."""
|
|
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
|
|
|
|
addr = UserCryptoRefundAddress(
|
|
user=self.user,
|
|
shop=self.shop,
|
|
coin_type="DOGE",
|
|
address="DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L",
|
|
)
|
|
|
|
self.assertEqual(addr.coin_type, "DOGE")
|
|
self.assertIsNone(addr.label)
|
|
|
|
def test_get_user_crypto_refund_address_none(self):
|
|
"""Test getting non-existent refund address returns None."""
|
|
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
|
|
|
|
# Mock dbsession
|
|
mock_session = mock.Mock()
|
|
mock_query = mock.Mock()
|
|
mock_filter = mock.Mock()
|
|
|
|
mock_session.query.return_value = mock_query
|
|
mock_query.filter.return_value = mock_filter
|
|
mock_filter.first.return_value = None
|
|
|
|
result = get_user_crypto_refund_address(
|
|
mock_session, self.user, self.shop, "XMR"
|
|
)
|
|
self.assertIsNone(result)
|
|
|
|
def test_get_user_crypto_refund_address_found(self):
|
|
"""Test getting existing refund address."""
|
|
from ..models.user_crypto_refund_address import (
|
|
UserCryptoRefundAddress,
|
|
get_user_crypto_refund_address,
|
|
)
|
|
|
|
# Create address
|
|
expected_addr = UserCryptoRefundAddress(
|
|
user=self.user,
|
|
shop=self.shop,
|
|
coin_type="XMR",
|
|
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
|
|
)
|
|
|
|
# Mock dbsession
|
|
mock_session = mock.Mock()
|
|
mock_query = mock.Mock()
|
|
mock_filter = mock.Mock()
|
|
|
|
mock_session.query.return_value = mock_query
|
|
mock_query.filter.return_value = mock_filter
|
|
mock_filter.first.return_value = expected_addr
|
|
|
|
result = get_user_crypto_refund_address(
|
|
mock_session, self.user, self.shop, "XMR"
|
|
)
|
|
self.assertEqual(result, expected_addr)
|
|
|
|
|
|
class TestCryptoPayment(unittest.TestCase):
|
|
"""Unit tests for CryptoPayment model string representation methods."""
|
|
|
|
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.user = User("alice@example.com")
|
|
self.user.name = "Alice"
|
|
|
|
self.shop = Shop(
|
|
name="Alice's Shop",
|
|
phone_number="555-1234",
|
|
billing_address="123 Main St",
|
|
description="Test shop",
|
|
)
|
|
|
|
# Create test payment without invoice for simpler testing
|
|
self.payment = CryptoPayment(
|
|
invoice=None, # Skip invoice relationship for unit tests
|
|
user=self.user,
|
|
shop=self.shop,
|
|
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
|
|
account_index=0,
|
|
subaddress_index=1,
|
|
coin_type="XMR",
|
|
expected_amount=1000000000000, # 1.0 XMR in piconero
|
|
rate_locked_usd_per_coin=150.00,
|
|
quote_expires_at_ms=now_timestamp() + 3600000, # 1 hour from now
|
|
confirmations_required=10,
|
|
)
|
|
|
|
def test_str_basic_pending_payment(self):
|
|
"""Test __str__ for basic pending payment."""
|
|
result = str(self.payment)
|
|
|
|
# Should include payment ID, status, coin type, amounts, confirmations, address, user, shop
|
|
self.assertIn(self.payment.uuid_str[:8], result)
|
|
self.assertIn("[pending]", result) # status shown as-is
|
|
self.assertIn("XMR", result)
|
|
self.assertIn(
|
|
"0/1", result
|
|
) # received/expected in XMR (trailing zeros stripped)
|
|
self.assertIn("conf:0/10", result)
|
|
self.assertIn("addr:", result)
|
|
self.assertIn("user:Alice", result)
|
|
self.assertIn("shop:Alice's Shop", result)
|
|
|
|
def test_str_received_payment_with_confirmations(self):
|
|
"""Test __str__ for received payment with some confirmations."""
|
|
self.payment.status = CryptoPayment.STATUS_RECEIVED
|
|
self.payment.received_amount = 800000000000 # 0.8 XMR
|
|
self.payment.current_confirmations = 5
|
|
|
|
result = str(self.payment)
|
|
|
|
self.assertIn("[received]", result)
|
|
self.assertIn("0.8/1", result) # trailing zeros stripped
|
|
self.assertIn("conf:5/10", result)
|
|
|
|
def test_str_confirmed_overpayment(self):
|
|
"""Test __str__ for confirmed overpayment."""
|
|
self.payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY
|
|
self.payment.received_amount = 1200000000000 # 1.2 XMR
|
|
self.payment.current_confirmations = 12
|
|
|
|
result = str(self.payment)
|
|
|
|
self.assertIn("[confirmed-overpay]", result) # actual status string
|
|
self.assertIn("1.2/1", result) # trailing zeros stripped
|
|
self.assertIn("conf:12/10", result)
|
|
|
|
def test_str_dogecoin_precision(self):
|
|
"""Test __str__ with DOGE amounts (8 decimal places)."""
|
|
self.payment.coin_type = "DOGE"
|
|
self.payment.expected_amount = 100000000000 # 1000.0 DOGE in satoshis
|
|
self.payment.received_amount = 95000000000 # 950.0 DOGE
|
|
|
|
result = str(self.payment)
|
|
|
|
self.assertIn("DOGE", result)
|
|
self.assertIn("950/1000", result) # trailing zeros stripped
|
|
|
|
def test_str_no_user_display_name_fallback_to_username(self):
|
|
"""Test __str__ shows user.name field."""
|
|
# Since User model has 'name' field, not 'display_name' or 'username'
|
|
self.user.name = "alice_user"
|
|
|
|
result = str(self.payment)
|
|
|
|
self.assertIn("user:alice_user", result)
|
|
self.assertNotIn("user:Alice", result)
|
|
|
|
def test_str_no_user_info(self):
|
|
"""Test __str__ when user has no name."""
|
|
self.user.name = None
|
|
|
|
result = str(self.payment)
|
|
|
|
# Should not include user info but should still include shop
|
|
self.assertNotIn("user:", result)
|
|
self.assertIn("shop:Alice's Shop", result)
|
|
|
|
def test_str_no_shop_name(self):
|
|
"""Test __str__ when shop has no name."""
|
|
self.shop.name = None
|
|
|
|
result = str(self.payment)
|
|
|
|
# Should not include shop info but should still include user
|
|
self.assertNotIn("shop:", result)
|
|
self.assertIn("user:Alice", result)
|
|
|
|
def test_repr_method(self):
|
|
"""Test __repr__ method for debugging."""
|
|
result = repr(self.payment)
|
|
|
|
self.assertIn("CryptoPayment(", result)
|
|
self.assertIn(f"id={self.payment.uuid_str[:8]}", result)
|
|
self.assertIn("status=pending", result)
|
|
self.assertIn("coin=XMR", result)
|
|
self.assertIn("expected=1000000000000", result)
|
|
self.assertIn("received=0", result)
|
|
self.assertIn("confirmations=0/10", result) # Should show 0 due to our fix
|
|
|
|
def test_format_amount_details_xmr(self):
|
|
"""Test format_amount_details for XMR."""
|
|
self.payment.received_amount = 800000000000 # 0.8 XMR
|
|
|
|
result = self.payment.format_amount_details()
|
|
|
|
self.assertIn("expected:1", result)
|
|
self.assertIn("received:0.8", result)
|
|
self.assertIn("due:0.2", result)
|
|
self.assertIn("XMR", result)
|
|
|
|
def test_format_amount_details_doge(self):
|
|
"""Test format_amount_details for DOGE."""
|
|
self.payment.coin_type = "DOGE"
|
|
self.payment.expected_amount = 100000000000 # 1000.0 DOGE
|
|
self.payment.received_amount = 95000000000 # 950.0 DOGE
|
|
|
|
result = self.payment.format_amount_details()
|
|
|
|
self.assertIn("expected:1000", result)
|
|
self.assertIn("received:950", result)
|
|
self.assertIn("due:50", result)
|
|
self.assertIn("DOGE", result)
|
|
|
|
def test_format_amount_details_no_due_amount(self):
|
|
"""Test format_amount_details when fully paid."""
|
|
self.payment.received_amount = 1000000000000 # 1.0 XMR - exactly expected
|
|
|
|
result = self.payment.format_amount_details()
|
|
|
|
self.assertIn("due:0", result)
|
|
|
|
def test_format_confirmation_status_pending(self):
|
|
"""Test format_confirmation_status for pending confirmations."""
|
|
self.payment.current_confirmations = 5
|
|
|
|
result = self.payment.format_confirmation_status()
|
|
|
|
self.assertEqual(result, "5/10")
|
|
|
|
def test_format_confirmation_status_confirmed(self):
|
|
"""Test format_confirmation_status for fully confirmed."""
|
|
self.payment.current_confirmations = 15 # More than required
|
|
|
|
result = self.payment.format_confirmation_status()
|
|
|
|
self.assertEqual(result, "15/10")
|
|
|
|
|
|
class TestPayPalUserShop(unittest.TestCase):
|
|
"""Unit tests for PayPalUserShop model."""
|
|
|
|
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
|
|
def setUp(self):
|
|
self.user = User("paypal@example.com")
|
|
self.shop = Shop(
|
|
name="PayPal Test Shop",
|
|
phone_number="555-1234",
|
|
billing_address="123 Main St",
|
|
description="Test shop",
|
|
)
|
|
|
|
def test_create_paypal_user_shop(self):
|
|
"""Test creating a PayPalUserShop record."""
|
|
from ..models.paypal_user_shop import PayPalUserShop
|
|
|
|
pus = PayPalUserShop(user=self.user, shop=self.shop)
|
|
|
|
self.assertEqual(pus.user, self.user)
|
|
self.assertEqual(pus.shop, self.shop)
|
|
self.assertIsNotNone(pus.id)
|
|
self.assertIsNone(pus.payer_id)
|
|
self.assertIsNone(pus.billing_agreement_id)
|
|
self.assertIsNone(pus.active_payment_token)
|
|
|
|
def test_has_billing_agreement_false(self):
|
|
"""Test has_billing_agreement when no agreement exists."""
|
|
from ..models.paypal_user_shop import PayPalUserShop
|
|
|
|
pus = PayPalUserShop(user=self.user, shop=self.shop)
|
|
|
|
self.assertFalse(pus.has_billing_agreement)
|
|
|
|
def test_has_billing_agreement_true(self):
|
|
"""Test has_billing_agreement when agreement exists."""
|
|
from ..models.paypal_user_shop import PayPalUserShop
|
|
|
|
pus = PayPalUserShop(user=self.user, shop=self.shop)
|
|
pus.billing_agreement_id = "BA-1234567890"
|
|
|
|
self.assertTrue(pus.has_billing_agreement)
|
|
|
|
def test_has_saved_payment_method_false(self):
|
|
"""Test has_saved_payment_method when no token exists."""
|
|
from ..models.paypal_user_shop import PayPalUserShop
|
|
|
|
pus = PayPalUserShop(user=self.user, shop=self.shop)
|
|
|
|
self.assertFalse(pus.has_saved_payment_method)
|
|
|
|
def test_has_saved_payment_method_true(self):
|
|
"""Test has_saved_payment_method when token exists."""
|
|
from ..models.paypal_user_shop import PayPalUserShop
|
|
|
|
pus = PayPalUserShop(user=self.user, shop=self.shop)
|
|
pus.active_payment_token = "VAULT-TOKEN-123"
|
|
|
|
self.assertTrue(pus.has_saved_payment_method)
|
|
|
|
def test_payer_id_storage(self):
|
|
"""Test storing payer ID."""
|
|
from ..models.paypal_user_shop import PayPalUserShop
|
|
|
|
pus = PayPalUserShop(user=self.user, shop=self.shop)
|
|
pus.payer_id = "PAYERID123ABC"
|
|
|
|
self.assertEqual(pus.payer_id, "PAYERID123ABC")
|
|
|
|
|
|
class TestInvoicePayPal(unittest.TestCase):
|
|
"""Unit tests for Invoice PayPal functionality."""
|
|
|
|
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
|
|
def setUp(self):
|
|
self.user = User("paypal_invoice@example.com")
|
|
self.shop = Shop(
|
|
name="PayPal Invoice Shop",
|
|
phone_number="555-1234",
|
|
billing_address="123 Main St",
|
|
description="Test shop",
|
|
)
|
|
|
|
def test_invoice_payment_method_stripe_default(self):
|
|
"""Test that invoice with no PayPal or crypto returns stripe."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
|
|
self.assertEqual(invoice.payment_method, "stripe")
|
|
|
|
def test_invoice_payment_method_paypal(self):
|
|
"""Test that invoice with paypal_order_id returns paypal."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
invoice.paypal_order_id = "PAYPAL-ORDER-123"
|
|
|
|
self.assertEqual(invoice.payment_method, "paypal")
|
|
|
|
def test_invoice_paypal_columns_nullable(self):
|
|
"""Test that PayPal columns are nullable by default."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
|
|
self.assertIsNone(invoice.paypal_order_id)
|
|
self.assertIsNone(invoice.paypal_capture_id)
|
|
|
|
def test_invoice_paypal_capture_id_storage(self):
|
|
"""Test storing PayPal capture ID."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
invoice.paypal_order_id = "PAYPAL-ORDER-123"
|
|
invoice.paypal_capture_id = "CAPTURE-456"
|
|
|
|
self.assertEqual(invoice.paypal_order_id, "PAYPAL-ORDER-123")
|
|
self.assertEqual(invoice.paypal_capture_id, "CAPTURE-456")
|
|
|
|
def test_invoice_payment_status_for_paypal(self):
|
|
"""Test that PayPal invoice payment_status is 'paid'."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
invoice.paypal_order_id = "PAYPAL-ORDER-123"
|
|
|
|
# PayPal invoices (like Stripe) are "paid" if they exist
|
|
self.assertEqual(invoice.payment_status, "paid")
|
|
|
|
def test_invoice_is_paid_for_paypal(self):
|
|
"""Test that PayPal invoice is_paid returns True."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
invoice.paypal_order_id = "PAYPAL-ORDER-123"
|
|
|
|
self.assertTrue(invoice.is_paid)
|
|
|
|
|
|
class TestInvoiceStripe(unittest.TestCase):
|
|
"""Unit tests for Invoice Stripe functionality."""
|
|
|
|
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
|
|
def setUp(self):
|
|
self.user = User("stripe_invoice@example.com")
|
|
self.shop = Shop(
|
|
name="Stripe Invoice Shop",
|
|
phone_number="555-1234",
|
|
billing_address="123 Main St",
|
|
description="Test shop",
|
|
)
|
|
|
|
def test_invoice_stripe_columns_nullable(self):
|
|
"""Test that Stripe columns are nullable by default."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
|
|
self.assertIsNone(invoice.stripe_payment_intent_id)
|
|
self.assertIsNone(invoice.stripe_charge_id)
|
|
|
|
def test_invoice_stripe_payment_intent_id_storage(self):
|
|
"""Test storing Stripe payment intent ID."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
invoice.stripe_payment_intent_id = "pi_1234567890abcdef"
|
|
|
|
self.assertEqual(invoice.stripe_payment_intent_id, "pi_1234567890abcdef")
|
|
|
|
def test_invoice_stripe_charge_id_storage(self):
|
|
"""Test storing Stripe charge ID."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
invoice.stripe_payment_intent_id = "pi_1234567890abcdef"
|
|
invoice.stripe_charge_id = "ch_1234567890abcdef"
|
|
|
|
self.assertEqual(invoice.stripe_charge_id, "ch_1234567890abcdef")
|
|
|
|
def test_invoice_payment_method_stripe_explicit(self):
|
|
"""Test that invoice with stripe_payment_intent_id returns stripe."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
invoice.stripe_payment_intent_id = "pi_1234567890abcdef"
|
|
|
|
self.assertEqual(invoice.payment_method, "stripe")
|
|
|
|
def test_invoice_payment_method_stripe_default(self):
|
|
"""Test that invoice with no payment refs returns stripe (legacy)."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
|
|
# Legacy invoices without payment tracking default to stripe
|
|
self.assertEqual(invoice.payment_method, "stripe")
|
|
|
|
def test_invoice_payment_status_for_stripe(self):
|
|
"""Test that Stripe invoice payment_status is 'paid'."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
invoice.stripe_payment_intent_id = "pi_1234567890abcdef"
|
|
|
|
self.assertEqual(invoice.payment_status, "paid")
|
|
|
|
def test_invoice_is_paid_for_stripe(self):
|
|
"""Test that Stripe invoice is_paid returns True."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
invoice.stripe_payment_intent_id = "pi_1234567890abcdef"
|
|
|
|
self.assertTrue(invoice.is_paid)
|
|
|
|
def test_invoice_payment_method_priority_paypal_over_stripe(self):
|
|
"""Test that PayPal takes priority if both are set (edge case)."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
invoice.paypal_order_id = "PAYPAL-ORDER-123"
|
|
invoice.stripe_payment_intent_id = "pi_1234567890abcdef"
|
|
|
|
# PayPal is checked first in payment_method
|
|
self.assertEqual(invoice.payment_method, "paypal")
|
|
|
|
def test_invoice_payment_method_adyen(self):
|
|
"""Test that Adyen payment method is detected correctly."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
invoice.adyen_psp_reference = "PSP123456789"
|
|
|
|
self.assertEqual(invoice.payment_method, "adyen")
|
|
|
|
def test_invoice_payment_method_priority_paypal_over_adyen(self):
|
|
"""Test that PayPal takes priority over Adyen (edge case)."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
invoice.paypal_order_id = "PAYPAL-ORDER-123"
|
|
invoice.adyen_psp_reference = "PSP123456789"
|
|
|
|
# PayPal is checked first in payment_method
|
|
self.assertEqual(invoice.payment_method, "paypal")
|
|
|
|
def test_invoice_payment_method_priority_adyen_over_stripe(self):
|
|
"""Test that Adyen takes priority over Stripe (edge case)."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
invoice.adyen_psp_reference = "PSP123456789"
|
|
invoice.stripe_payment_intent_id = "pi_1234567890abcdef"
|
|
|
|
# Adyen is checked before Stripe in payment_method
|
|
self.assertEqual(invoice.payment_method, "adyen")
|
|
|
|
|
|
class TestAdyen(unittest.TestCase):
|
|
"""Test Adyen-related model functionality."""
|
|
|
|
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
|
|
def setUp(self):
|
|
# Create test shop
|
|
self.shop = Shop(
|
|
"test-shop",
|
|
"555-555-5555",
|
|
"123 Test St",
|
|
"Test shop description",
|
|
)
|
|
# Create test user
|
|
self.user = User("test@example.com")
|
|
|
|
def test_shop_is_adyen_ready_without_credentials(self):
|
|
"""Test that shop is not Adyen-ready without credentials."""
|
|
self.assertFalse(self.shop.is_adyen_ready)
|
|
|
|
def test_shop_is_adyen_ready_with_api_key_only(self):
|
|
"""Test that shop is not Adyen-ready with just API key."""
|
|
self.shop.adyen_api_key = "test_api_key"
|
|
self.assertFalse(self.shop.is_adyen_ready)
|
|
|
|
def test_shop_is_adyen_ready_with_merchant_account_only(self):
|
|
"""Test that shop is not Adyen-ready with just merchant account."""
|
|
self.shop.adyen_merchant_account = "TestMerchant"
|
|
self.assertFalse(self.shop.is_adyen_ready)
|
|
|
|
def test_shop_is_adyen_ready_with_both_credentials(self):
|
|
"""Test that shop is Adyen-ready with both API key and merchant account."""
|
|
self.shop.adyen_api_key = "test_api_key"
|
|
self.shop.adyen_merchant_account = "TestMerchant"
|
|
self.assertTrue(self.shop.is_adyen_ready)
|
|
|
|
def test_shop_is_adyen_not_ready(self):
|
|
"""Test the inverse property for convenience."""
|
|
self.assertTrue(self.shop.is_adyen_not_ready)
|
|
self.shop.adyen_api_key = "test_api_key"
|
|
self.shop.adyen_merchant_account = "TestMerchant"
|
|
self.assertFalse(self.shop.is_adyen_not_ready)
|
|
|
|
def test_shop_adyen_enabled_default(self):
|
|
"""Test that Adyen enabled is None before DB insert (server_default handles it)."""
|
|
# Before DB insert, the value is None (server_default of '1' applies on insert)
|
|
# When retrieved from DB after insert, it would be True
|
|
self.assertIsNone(self.shop.adyen_enabled)
|
|
|
|
def test_invoice_adyen_psp_reference_default(self):
|
|
"""Test that Adyen PSP reference is None by default."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
self.assertIsNone(invoice.adyen_psp_reference)
|
|
|
|
def test_invoice_adyen_psp_reference_set(self):
|
|
"""Test setting Adyen PSP reference."""
|
|
invoice = Invoice(self.user)
|
|
invoice.shop = self.shop
|
|
invoice.adyen_psp_reference = "882619391893263J"
|
|
self.assertEqual(invoice.adyen_psp_reference, "882619391893263J")
|
|
|
|
|
|
class TestStemmer(unittest.TestCase):
|
|
"""Test the stemmer and related products functions."""
|
|
|
|
def test_stem_word_basic(self):
|
|
"""Test common suffixes stripped correctly."""
|
|
from ..models.product import stem_word
|
|
|
|
self.assertEqual(stem_word("running"), "runn")
|
|
self.assertEqual(stem_word("played"), "play")
|
|
self.assertEqual(stem_word("quickly"), "quick")
|
|
self.assertEqual(stem_word("cats"), "cat")
|
|
self.assertEqual(stem_word("boxes"), "box")
|
|
self.assertEqual(stem_word("creation"), "crea")
|
|
self.assertEqual(stem_word("movement"), "move")
|
|
self.assertEqual(stem_word("darkness"), "dark")
|
|
self.assertEqual(stem_word("readable"), "read")
|
|
self.assertEqual(stem_word("hopeful"), "hope")
|
|
self.assertEqual(stem_word("careless"), "care")
|
|
self.assertEqual(stem_word("dangerous"), "danger")
|
|
self.assertEqual(stem_word("creative"), "creat")
|
|
self.assertEqual(stem_word("bigger"), "bigg")
|
|
self.assertEqual(stem_word("biggest"), "bigg")
|
|
|
|
def test_stem_word_short_preserved(self):
|
|
"""Test words too short to stem are unchanged."""
|
|
from ..models.product import stem_word
|
|
|
|
# Words where remaining stem would be < 3 chars should not be stripped
|
|
self.assertEqual(stem_word("it"), "it")
|
|
self.assertEqual(stem_word("is"), "is")
|
|
self.assertEqual(stem_word("an"), "an")
|
|
self.assertEqual(stem_word("the"), "the")
|
|
self.assertEqual(stem_word("bed"), "bed")
|
|
|
|
def test_stem_word_case_insensitive(self):
|
|
"""Test stemming is case insensitive."""
|
|
from ..models.product import stem_word
|
|
|
|
self.assertEqual(stem_word("Running"), stem_word("running"))
|
|
self.assertEqual(stem_word("PLAYED"), stem_word("played"))
|
|
|
|
def test_tokenize_and_stem(self):
|
|
"""Test sentence -> set of stems."""
|
|
from ..models.product import tokenize_and_stem
|
|
|
|
result = tokenize_and_stem("The cats are running quickly")
|
|
# "the" -> "the" (3 chars, no suffix stripped)
|
|
# "cats" -> "cat", "are" -> "are", "running" -> "runn", "quickly" -> "quick"
|
|
self.assertIn("cat", result)
|
|
self.assertIn("runn", result)
|
|
self.assertIn("quick", result)
|
|
self.assertIn("are", result)
|
|
self.assertIn("the", result)
|
|
# Short words (<3 chars) are excluded by the regex
|
|
self.assertNotIn("is", result)
|
|
self.assertNotIn("a", result)
|
|
|
|
def test_tokenize_and_stem_empty(self):
|
|
"""Test empty string returns empty set."""
|
|
from ..models.product import tokenize_and_stem
|
|
|
|
self.assertEqual(tokenize_and_stem(""), set())
|
|
self.assertEqual(tokenize_and_stem("12 34"), set())
|
|
|
|
def test_get_related_products(self):
|
|
"""Test returns ranked matches, excludes self and non-public."""
|
|
from ..models.product import Product, get_related_products
|
|
|
|
shop = Shop(
|
|
"test-shop",
|
|
"555-555-5555",
|
|
"123 Test St",
|
|
"Test shop description",
|
|
)
|
|
|
|
# Create products with related titles
|
|
product1 = Product("Blues Guitar Lessons", "Learn acoustic guitar blues riffs")
|
|
product1.shop = shop
|
|
product1.visibility = 1
|
|
|
|
# shares: guitar, learn = 2 stems
|
|
product2 = Product("Jazz Guitar Tutorial", "Learn jazz techniques")
|
|
product2.shop = shop
|
|
product2.visibility = 1
|
|
|
|
# shares: learn = 1 stem (no guitar/blues overlap)
|
|
product3 = Product("Piano for Beginners", "Learn piano basics")
|
|
product3.shop = shop
|
|
product3.visibility = 1
|
|
|
|
# shares: guitar, blues, learn, riff = 4 stems (most overlap)
|
|
product4 = Product("Guitar Blues Collection", "Learn blues guitar riffs and licks")
|
|
product4.shop = shop
|
|
product4.visibility = 1
|
|
|
|
product5_private = Product("Secret Guitar Video", "Private guitar content")
|
|
product5_private.shop = shop
|
|
product5_private.visibility = 0 # private
|
|
|
|
# Mock shop.products to return our test products
|
|
all_products = [product1, product2, product3, product4, product5_private]
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
|
|
mock_products.return_value = all_products
|
|
|
|
related = get_related_products(product1)
|
|
|
|
# Should not include product1 itself
|
|
self.assertNotIn(product1, related)
|
|
|
|
# Should not include private product
|
|
self.assertNotIn(product5_private, related)
|
|
|
|
# Should include related products
|
|
related_titles = [p.title for p in related]
|
|
self.assertIn("Guitar Blues Collection", related_titles)
|
|
self.assertIn("Jazz Guitar Tutorial", related_titles)
|
|
self.assertIn("Piano for Beginners", related_titles)
|
|
|
|
# product4 shares most stems with product1
|
|
# so it should be ranked higher (earlier index) than product3
|
|
self.assertLess(related.index(product4), related.index(product3))
|
|
|
|
def test_get_related_products_empty_description(self):
|
|
"""Test with empty title/description still returns results via fallback."""
|
|
from ..models.product import Product, get_related_products
|
|
|
|
shop = Shop(
|
|
"test-shop",
|
|
"555-555-5555",
|
|
"123 Test St",
|
|
"Test shop description",
|
|
)
|
|
|
|
product = Product("", "")
|
|
product.shop = shop
|
|
product.visibility = 1
|
|
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
|
|
mock_products.return_value = []
|
|
related = get_related_products(product)
|
|
self.assertEqual(related, [])
|
|
|
|
def test_get_related_products_empty_description_with_others(self):
|
|
"""Test orphan product (no stems) gets fallback results."""
|
|
from ..models.product import Product, get_related_products
|
|
|
|
shop = Shop(
|
|
"test-shop",
|
|
"555-555-5555",
|
|
"123 Test St",
|
|
"Test shop description",
|
|
)
|
|
|
|
product = Product("", "")
|
|
product.shop = shop
|
|
product.visibility = 1
|
|
product.created_timestamp = 100
|
|
|
|
other1 = Product("Song One", "A great song")
|
|
other1.shop = shop
|
|
other1.visibility = 1
|
|
other1.created_timestamp = 200
|
|
|
|
other2 = Product("Song Two", "Another song")
|
|
other2.shop = shop
|
|
other2.visibility = 1
|
|
other2.created_timestamp = 300
|
|
|
|
all_products = [product, other1, other2]
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
|
|
mock_products.return_value = all_products
|
|
related = get_related_products(product)
|
|
# Should return fallback results (tier 3: any public product)
|
|
self.assertEqual(len(related), 2)
|
|
self.assertIn(other1, related)
|
|
self.assertIn(other2, related)
|
|
|
|
def test_get_related_products_limit(self):
|
|
"""Test that limit parameter is respected."""
|
|
from ..models.product import Product, get_related_products
|
|
|
|
shop = Shop(
|
|
"test-shop",
|
|
"555-555-5555",
|
|
"123 Test St",
|
|
"Test shop description",
|
|
)
|
|
|
|
product1 = Product("Guitar Music", "Guitar tutorial")
|
|
product1.shop = shop
|
|
product1.visibility = 1
|
|
|
|
# Create many related products
|
|
other_products = []
|
|
for i in range(15):
|
|
p = Product(f"Guitar Video {i}", f"Guitar lesson {i}")
|
|
p.shop = shop
|
|
p.visibility = 1
|
|
other_products.append(p)
|
|
|
|
all_products = [product1] + other_products
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
|
|
mock_products.return_value = all_products
|
|
|
|
# Default limit is 8
|
|
related = get_related_products(product1)
|
|
self.assertLessEqual(len(related), 8)
|
|
|
|
# Custom limit
|
|
related = get_related_products(product1, limit=3)
|
|
self.assertLessEqual(len(related), 3)
|
|
|
|
def test_get_related_products_same_media_type_fallback(self):
|
|
"""Test tier 2: same media type fills remaining slots."""
|
|
from ..models.product import Product, get_related_products
|
|
|
|
shop = Shop(
|
|
"test-shop",
|
|
"555-555-5555",
|
|
"123 Test St",
|
|
"Test shop description",
|
|
)
|
|
|
|
def _make_product(title, desc, timestamp, ext=None):
|
|
p = Product(title, desc)
|
|
p.shop = shop
|
|
p.visibility = 1
|
|
p.created_timestamp = timestamp
|
|
if ext:
|
|
p._file_metadata = {"extensions": {"product": ext}, "originals": {}}
|
|
return p
|
|
|
|
# Product with no stem overlap to anything
|
|
product1 = _make_product("Xylophone Jam", "Unique xylophone music", 100, "mp3")
|
|
|
|
# Same media type (audio) but no stem overlap
|
|
product2 = _make_product("Drums Solo", "Percussion performance", 200, "mp3")
|
|
|
|
# Different media type (video) and no stem overlap
|
|
product3 = _make_product("Cat Video", "Funny cat footage", 300, "mp4")
|
|
|
|
# Another audio with no stem overlap
|
|
product4 = _make_product("Bass Line", "Groovy bass track", 400, "wav")
|
|
|
|
all_products = [product1, product2, product3, product4]
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
|
|
mock_products.return_value = all_products
|
|
|
|
related = get_related_products(product1)
|
|
# Should include all 3 others
|
|
self.assertEqual(len(related), 3)
|
|
|
|
# Same media type (audio) should come before different type (video)
|
|
audio_indices = []
|
|
video_indices = []
|
|
for i, p in enumerate(related):
|
|
ext = p.extensions.get("product", "")
|
|
if ext in ("mp3", "wav"):
|
|
audio_indices.append(i)
|
|
elif ext == "mp4":
|
|
video_indices.append(i)
|
|
|
|
# All audio items should come before video items
|
|
if audio_indices and video_indices:
|
|
self.assertLess(max(audio_indices), min(video_indices))
|
|
|
|
def test_get_related_products_deterministic(self):
|
|
"""Test that results are deterministic (consistent ordering)."""
|
|
from ..models.product import Product, get_related_products
|
|
|
|
shop = Shop(
|
|
"test-shop",
|
|
"555-555-5555",
|
|
"123 Test St",
|
|
"Test shop description",
|
|
)
|
|
|
|
product1 = Product("Guitar Music", "Guitar tutorial video")
|
|
product1.shop = shop
|
|
product1.visibility = 1
|
|
product1.created_timestamp = 100
|
|
|
|
product2 = Product("Guitar Songs", "Guitar song collection")
|
|
product2.shop = shop
|
|
product2.visibility = 1
|
|
product2.created_timestamp = 200
|
|
|
|
product3 = Product("Guitar Chords", "Guitar chord lessons")
|
|
product3.shop = shop
|
|
product3.visibility = 1
|
|
product3.created_timestamp = 300
|
|
|
|
all_products = [product1, product2, product3]
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
|
|
mock_products.return_value = all_products
|
|
|
|
# Run multiple times to verify determinism
|
|
related1 = get_related_products(product1)
|
|
related2 = get_related_products(product1)
|
|
self.assertEqual(
|
|
[p.title for p in related1],
|
|
[p.title for p in related2],
|
|
)
|
|
|
|
|
|
class TestDiscoveryRing(unittest.TestCase):
|
|
"""Test the discovery ring precomputation algorithm."""
|
|
|
|
def _make_shop(self):
|
|
return Shop(
|
|
"test-shop",
|
|
"555-555-5555",
|
|
"123 Test St",
|
|
"Test shop description",
|
|
)
|
|
|
|
def _make_product(self, title, desc, timestamp, visibility=1):
|
|
from ..models.product import Product
|
|
p = Product(title, desc)
|
|
p.visibility = visibility
|
|
p.created_timestamp = timestamp
|
|
return p
|
|
|
|
def test_empty_shop(self):
|
|
"""Empty shop returns empty ring."""
|
|
from ..models.shop import compute_discovery_ring
|
|
shop = self._make_shop()
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
|
mp.return_value = []
|
|
self.assertEqual(compute_discovery_ring(shop), [])
|
|
|
|
def test_single_product(self):
|
|
"""Single public product returns ring of one."""
|
|
from ..models.shop import compute_discovery_ring
|
|
shop = self._make_shop()
|
|
p = self._make_product("Solo", "Only item", 100)
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
|
mp.return_value = [p]
|
|
ring = compute_discovery_ring(shop)
|
|
self.assertEqual(ring, [str(p.id)])
|
|
|
|
def test_all_public_products_included(self):
|
|
"""Every public product appears exactly once, no dupes."""
|
|
from ..models.shop import compute_discovery_ring
|
|
shop = self._make_shop()
|
|
products = [self._make_product(f"Item {i}", f"Description {i}", 100 + i) for i in range(10)]
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
|
mp.return_value = products
|
|
ring = compute_discovery_ring(shop)
|
|
ids = [str(p.id) for p in products]
|
|
self.assertEqual(len(ring), 10)
|
|
self.assertEqual(set(ring), set(ids))
|
|
# No duplicates
|
|
self.assertEqual(len(ring), len(set(ring)))
|
|
|
|
def test_private_and_unlisted_excluded(self):
|
|
"""Only visibility==1 products appear in the ring."""
|
|
from ..models.shop import compute_discovery_ring
|
|
shop = self._make_shop()
|
|
public = self._make_product("Public Song", "Visible", 300, visibility=1)
|
|
private = self._make_product("Private Song", "Hidden", 200, visibility=0)
|
|
unlisted = self._make_product("Unlisted Song", "Unlisted", 100, visibility=2)
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
|
mp.return_value = [public, private, unlisted]
|
|
ring = compute_discovery_ring(shop)
|
|
self.assertEqual(ring, [str(public.id)])
|
|
self.assertNotIn(str(private.id), ring)
|
|
self.assertNotIn(str(unlisted.id), ring)
|
|
|
|
def test_starts_with_newest(self):
|
|
"""Newest product (highest created_timestamp) is at position 0."""
|
|
from ..models.shop import compute_discovery_ring
|
|
shop = self._make_shop()
|
|
old = self._make_product("Old Song", "First release", 100)
|
|
mid = self._make_product("Mid Song", "Second release", 200)
|
|
new = self._make_product("New Song", "Latest release", 300)
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
|
mp.return_value = [old, mid, new]
|
|
ring = compute_discovery_ring(shop)
|
|
self.assertEqual(ring[0], str(new.id))
|
|
|
|
def test_similar_products_adjacent(self):
|
|
"""Guitar cluster should appear before unrelated piano cluster."""
|
|
from ..models.shop import compute_discovery_ring
|
|
shop = self._make_shop()
|
|
g1 = self._make_product("Blues Guitar Jam", "Electric guitar blues riffs", 400)
|
|
g2 = self._make_product("Guitar Solo Practice", "Learn guitar solo techniques", 300)
|
|
g3 = self._make_product("Acoustic Guitar Chords", "Guitar chord progressions", 200)
|
|
piano = self._make_product("Piano Sonata", "Classical piano performance", 100)
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
|
mp.return_value = [g1, g2, g3, piano]
|
|
ring = compute_discovery_ring(shop)
|
|
# All guitar items should cluster together before piano
|
|
guitar_ids = {str(g1.id), str(g2.id), str(g3.id)}
|
|
guitar_positions = [i for i, rid in enumerate(ring) if rid in guitar_ids]
|
|
piano_pos = ring.index(str(piano.id))
|
|
# Guitar items should be contiguous (adjacent)
|
|
self.assertEqual(max(guitar_positions) - min(guitar_positions), 2)
|
|
# Piano should be after all guitar items
|
|
self.assertGreater(piano_pos, max(guitar_positions))
|
|
|
|
def test_deterministic(self):
|
|
"""Same input produces same output every time."""
|
|
from ..models.shop import compute_discovery_ring
|
|
shop = self._make_shop()
|
|
products = [self._make_product(f"Song {i}", f"Description {i}", 100 + i) for i in range(8)]
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
|
mp.return_value = products
|
|
ring1 = compute_discovery_ring(shop)
|
|
ring2 = compute_discovery_ring(shop)
|
|
self.assertEqual(ring1, ring2)
|
|
|
|
def test_ring_is_list_of_strings(self):
|
|
"""Ring elements are UUID strings, not UUID objects."""
|
|
from ..models.shop import compute_discovery_ring
|
|
shop = self._make_shop()
|
|
p = self._make_product("Test Song", "Some description", 100)
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
|
mp.return_value = [p]
|
|
ring = compute_discovery_ring(shop)
|
|
self.assertIsInstance(ring, list)
|
|
for item in ring:
|
|
self.assertIsInstance(item, str)
|
|
|
|
def test_reforge_sets_shop_attribute(self):
|
|
"""reforge_discovery_ring stores the ring on the shop object."""
|
|
from ..models.shop import reforge_discovery_ring
|
|
shop = self._make_shop()
|
|
p1 = self._make_product("Song A", "Alpha", 200)
|
|
p2 = self._make_product("Song B", "Beta", 100)
|
|
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
|
|
mp.return_value = [p1, p2]
|
|
ring = reforge_discovery_ring(shop)
|
|
self.assertEqual(shop.discovery_ring, ring)
|
|
self.assertEqual(len(ring), 2)
|
|
# Verify JSON column was set
|
|
import json
|
|
self.assertEqual(json.loads(shop.json_discovery_ring), ring)
|
|
|
|
|
|
class TestAsyncDiscoveryRing(unittest.TestCase):
|
|
"""Test the async ring reforge with dirty bit debounce."""
|
|
|
|
def setUp(self):
|
|
# Reset module-level state between tests
|
|
from ..models import shop as shop_module
|
|
shop_module._reforge_running.clear()
|
|
shop_module._reforge_dirty.clear()
|
|
|
|
def tearDown(self):
|
|
from ..models import shop as shop_module
|
|
shop_module._reforge_running.clear()
|
|
shop_module._reforge_dirty.clear()
|
|
|
|
def test_async_reforge_spawns_thread(self):
|
|
"""reforge_discovery_ring_async starts a background thread."""
|
|
import threading
|
|
from ..models.shop import reforge_discovery_ring_async
|
|
|
|
initial_threads = threading.active_count()
|
|
started = threading.Event()
|
|
proceed = threading.Event()
|
|
|
|
mock_factory = mock.Mock()
|
|
mock_session = mock.Mock()
|
|
mock_factory.return_value.get_bind.return_value = "fake_bind"
|
|
|
|
mock_shop = mock.Mock()
|
|
mock_shop.products = []
|
|
mock_shop.name = "test"
|
|
mock_shop.environment = 0
|
|
mock_shop.is_non_production = False
|
|
|
|
# Patch Session and compute to block until we signal
|
|
with mock.patch("make_post_sell.models.shop.SASession") as MockSession:
|
|
MockSession.return_value = mock_session
|
|
mock_session.get.return_value = mock_shop
|
|
|
|
original_compute = None
|
|
def slow_compute(shop):
|
|
started.set()
|
|
proceed.wait(timeout=5)
|
|
return ["id1", "id2"]
|
|
|
|
with mock.patch(
|
|
"make_post_sell.models.shop.compute_discovery_ring",
|
|
side_effect=slow_compute,
|
|
):
|
|
t = reforge_discovery_ring_async("shop123", mock_factory)
|
|
|
|
# Thread should have started
|
|
self.assertTrue(started.wait(timeout=5))
|
|
|
|
# Let it finish
|
|
proceed.set()
|
|
|
|
# Wait for thread to complete
|
|
t.join(timeout=5)
|
|
|
|
mock_session.commit.assert_called()
|
|
|
|
def test_dirty_bit_set_when_already_running(self):
|
|
"""Second call during active reforge sets dirty bit instead of spawning."""
|
|
import threading
|
|
import time
|
|
from ..models.shop import reforge_discovery_ring_async
|
|
from ..models import shop as shop_module
|
|
|
|
started = threading.Event()
|
|
proceed = threading.Event()
|
|
|
|
mock_factory = mock.Mock()
|
|
mock_session = mock.Mock()
|
|
mock_factory.return_value.get_bind.return_value = "fake_bind"
|
|
|
|
mock_shop = mock.Mock()
|
|
mock_shop.products = []
|
|
mock_shop.name = "test"
|
|
mock_shop.environment = 0
|
|
mock_shop.is_non_production = False
|
|
|
|
call_count = [0]
|
|
|
|
def slow_compute(shop):
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
started.set()
|
|
proceed.wait(timeout=5)
|
|
return ["id1"]
|
|
|
|
patcher1 = mock.patch("make_post_sell.models.shop.SASession")
|
|
patcher2 = mock.patch(
|
|
"make_post_sell.models.shop.compute_discovery_ring",
|
|
side_effect=slow_compute,
|
|
)
|
|
MockSession = patcher1.start()
|
|
patcher2.start()
|
|
MockSession.return_value = mock_session
|
|
mock_session.get.return_value = mock_shop
|
|
|
|
try:
|
|
# First call starts the thread
|
|
t = reforge_discovery_ring_async("shop_dirty", mock_factory)
|
|
self.assertTrue(started.wait(timeout=5))
|
|
|
|
# Second call while running should set dirty bit
|
|
reforge_discovery_ring_async("shop_dirty", mock_factory)
|
|
self.assertTrue(shop_module._reforge_dirty.get("shop_dirty"))
|
|
|
|
# Let the first run finish — it should loop and run again
|
|
proceed.set()
|
|
t.join(timeout=5)
|
|
finally:
|
|
patcher2.stop()
|
|
patcher1.stop()
|
|
|
|
# compute was called twice (original + dirty re-run)
|
|
self.assertEqual(call_count[0], 2)
|
|
# State is cleaned up
|
|
self.assertNotIn("shop_dirty", shop_module._reforge_running)
|
|
self.assertNotIn("shop_dirty", shop_module._reforge_dirty)
|
|
|
|
def test_no_queue_only_one_dirty_bit(self):
|
|
"""Multiple calls during active reforge produce at most one re-run."""
|
|
import threading
|
|
import time
|
|
from ..models.shop import reforge_discovery_ring_async
|
|
from ..models import shop as shop_module
|
|
|
|
started = threading.Event()
|
|
proceed = threading.Event()
|
|
|
|
mock_factory = mock.Mock()
|
|
mock_session = mock.Mock()
|
|
mock_factory.return_value.get_bind.return_value = "fake_bind"
|
|
|
|
mock_shop = mock.Mock()
|
|
mock_shop.products = []
|
|
mock_shop.name = "test"
|
|
mock_shop.environment = 0
|
|
mock_shop.is_non_production = False
|
|
|
|
call_count = [0]
|
|
|
|
def slow_compute(shop):
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
started.set()
|
|
proceed.wait(timeout=5)
|
|
return ["id1"]
|
|
|
|
patcher1 = mock.patch("make_post_sell.models.shop.SASession")
|
|
patcher2 = mock.patch(
|
|
"make_post_sell.models.shop.compute_discovery_ring",
|
|
side_effect=slow_compute,
|
|
)
|
|
MockSession = patcher1.start()
|
|
patcher2.start()
|
|
MockSession.return_value = mock_session
|
|
mock_session.get.return_value = mock_shop
|
|
|
|
try:
|
|
# First call starts thread
|
|
t = reforge_discovery_ring_async("shop_multi", mock_factory)
|
|
self.assertTrue(started.wait(timeout=5))
|
|
|
|
# Rapid-fire 5 more calls — should all collapse to one dirty bit
|
|
for _ in range(5):
|
|
reforge_discovery_ring_async("shop_multi", mock_factory)
|
|
|
|
# Still just one dirty flag
|
|
self.assertTrue(shop_module._reforge_dirty.get("shop_multi"))
|
|
|
|
proceed.set()
|
|
t.join(timeout=5)
|
|
finally:
|
|
patcher2.stop()
|
|
patcher1.stop()
|
|
|
|
# Exactly 2 compute calls: initial + one dirty re-run (not 6)
|
|
self.assertEqual(call_count[0], 2)
|
|
|
|
def test_session_expire_all_on_dirty_rerun(self):
|
|
"""Dirty re-run calls session.expire_all() to get fresh DB state."""
|
|
import threading
|
|
import time
|
|
from ..models.shop import reforge_discovery_ring_async
|
|
|
|
started = threading.Event()
|
|
proceed = threading.Event()
|
|
|
|
mock_factory = mock.Mock()
|
|
mock_session = mock.Mock()
|
|
mock_factory.return_value.get_bind.return_value = "fake_bind"
|
|
|
|
mock_shop = mock.Mock()
|
|
mock_shop.products = []
|
|
mock_shop.name = "test"
|
|
mock_shop.environment = 0
|
|
mock_shop.is_non_production = False
|
|
|
|
call_count = [0]
|
|
|
|
def slow_compute(shop):
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
started.set()
|
|
proceed.wait(timeout=5)
|
|
return ["id1"]
|
|
|
|
patcher1 = mock.patch("make_post_sell.models.shop.SASession")
|
|
patcher2 = mock.patch(
|
|
"make_post_sell.models.shop.compute_discovery_ring",
|
|
side_effect=slow_compute,
|
|
)
|
|
MockSession = patcher1.start()
|
|
patcher2.start()
|
|
MockSession.return_value = mock_session
|
|
mock_session.get.return_value = mock_shop
|
|
|
|
try:
|
|
t = reforge_discovery_ring_async("shop_expire", mock_factory)
|
|
self.assertTrue(started.wait(timeout=5))
|
|
|
|
# Trigger dirty bit
|
|
reforge_discovery_ring_async("shop_expire", mock_factory)
|
|
proceed.set()
|
|
t.join(timeout=5)
|
|
finally:
|
|
patcher2.stop()
|
|
patcher1.stop()
|
|
|
|
# expire_all called before the dirty re-run
|
|
mock_session.expire_all.assert_called()
|
|
|
|
def test_exception_cleans_up_state(self):
|
|
"""If reforge crashes, locks are still cleaned up."""
|
|
import threading
|
|
from ..models.shop import reforge_discovery_ring_async
|
|
from ..models import shop as shop_module
|
|
|
|
mock_factory = mock.Mock()
|
|
mock_session = mock.Mock()
|
|
mock_factory.return_value.get_bind.return_value = "fake_bind"
|
|
mock_session.get.side_effect = Exception("DB exploded")
|
|
|
|
with mock.patch("make_post_sell.models.shop.SASession") as MockSession:
|
|
MockSession.return_value = mock_session
|
|
|
|
t = reforge_discovery_ring_async("shop_crash", mock_factory)
|
|
|
|
t.join(timeout=5)
|
|
|
|
# State should be cleaned up despite exception
|
|
self.assertNotIn("shop_crash", shop_module._reforge_running)
|
|
self.assertNotIn("shop_crash", shop_module._reforge_dirty)
|
|
mock_session.rollback.assert_called()
|
|
mock_session.close.assert_called()
|
|
|
|
def test_shop_not_found_returns_cleanly(self):
|
|
"""If shop is deleted between trigger and execution, exits cleanly."""
|
|
from ..models.shop import reforge_discovery_ring_async
|
|
from ..models import shop as shop_module
|
|
|
|
mock_factory = mock.Mock()
|
|
mock_session = mock.Mock()
|
|
mock_factory.return_value.get_bind.return_value = "fake_bind"
|
|
mock_session.get.return_value = None # shop deleted
|
|
|
|
with mock.patch("make_post_sell.models.shop.SASession") as MockSession:
|
|
MockSession.return_value = mock_session
|
|
|
|
t = reforge_discovery_ring_async("shop_gone", mock_factory)
|
|
|
|
t.join(timeout=5)
|
|
|
|
# Should not have tried to commit
|
|
mock_session.commit.assert_not_called()
|
|
# State cleaned up
|
|
self.assertNotIn("shop_gone", shop_module._reforge_running)
|
|
|
|
|
|
class TestShopSubscription(unittest.TestCase):
|
|
"""Test ShopSubscription model."""
|
|
|
|
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
|
|
def setUp(self):
|
|
self.shop = Shop(
|
|
"test-shop",
|
|
"555-555-5555",
|
|
"123 Test St",
|
|
"Test shop description",
|
|
)
|
|
self.user = User("subscriber@example.com")
|
|
|
|
def test_create_anonymous_subscription(self):
|
|
"""Test creating a subscription without a user (anonymous)."""
|
|
from ..models.shop_subscription import ShopSubscription
|
|
|
|
sub = ShopSubscription("anon@example.com", self.shop.id)
|
|
self.assertEqual(sub.email, "anon@example.com")
|
|
self.assertEqual(sub.shop_id, self.shop.id)
|
|
self.assertIsNone(sub.user_id)
|
|
self.assertFalse(sub.verified)
|
|
self.assertIsNotNone(sub.verify_token)
|
|
self.assertIsNotNone(sub.unsubscribe_token)
|
|
self.assertFalse(sub.disabled)
|
|
self.assertGreater(sub.created_timestamp, 0)
|
|
self.assertGreater(sub.last_digest_timestamp, 0)
|
|
|
|
def test_create_logged_in_subscription(self):
|
|
"""Test creating a subscription with a user (auto-verified)."""
|
|
from ..models.shop_subscription import ShopSubscription
|
|
|
|
sub = ShopSubscription("user@example.com", self.shop.id, user_id=self.user.id)
|
|
self.assertEqual(sub.user_id, self.user.id)
|
|
self.assertTrue(sub.verified)
|
|
self.assertIsNone(sub.verify_token)
|
|
|
|
def test_unique_tokens(self):
|
|
"""Test that each subscription gets unique tokens."""
|
|
from ..models.shop_subscription import ShopSubscription
|
|
|
|
sub1 = ShopSubscription("a@example.com", self.shop.id)
|
|
sub2 = ShopSubscription("b@example.com", self.shop.id)
|
|
self.assertNotEqual(sub1.unsubscribe_token, sub2.unsubscribe_token)
|
|
self.assertNotEqual(sub1.verify_token, sub2.verify_token)
|
|
|
|
def test_default_frequency_daily(self):
|
|
"""Test that default frequency is daily."""
|
|
from ..models.shop_subscription import ShopSubscription, FREQUENCY_DAILY
|
|
|
|
sub = ShopSubscription("test@example.com", self.shop.id)
|
|
self.assertEqual(sub.frequency, FREQUENCY_DAILY)
|
|
|
|
def test_human_frequency(self):
|
|
"""Test human_frequency property."""
|
|
from ..models.shop_subscription import (
|
|
ShopSubscription,
|
|
FREQUENCY_DAILY,
|
|
FREQUENCY_WEEKLY,
|
|
)
|
|
|
|
sub = ShopSubscription("test@example.com", self.shop.id)
|
|
sub.frequency = FREQUENCY_WEEKLY
|
|
self.assertEqual(sub.human_frequency, "Weekly")
|
|
|
|
sub.frequency = FREQUENCY_DAILY
|
|
self.assertEqual(sub.human_frequency, "Daily")
|
|
|
|
|
|
class TestSubscribeViewCurrentFrequency(unittest.TestCase):
|
|
"""Test that subscribe view returns correct current_frequency for the template."""
|
|
|
|
def _make_request(self, user=None, shop=None):
|
|
"""Build a mock request for the subscribe view."""
|
|
request = mock.MagicMock()
|
|
request.method = "GET"
|
|
request.shop = shop
|
|
request.user = user
|
|
request.session = mock.MagicMock()
|
|
request.session.flash = mock.Mock()
|
|
return request
|
|
|
|
def _make_shop(self):
|
|
shop = mock.MagicMock()
|
|
shop.subscriptions_enabled = True
|
|
shop.id = "shop-id-123"
|
|
shop.name = "Test Shop"
|
|
return shop
|
|
|
|
def _make_user(self, email="test@example.com"):
|
|
user = mock.MagicMock()
|
|
user.authenticated = True
|
|
user.email = email
|
|
return user
|
|
|
|
@mock.patch("make_post_sell.views.subscribe.get_subscription_for_email_and_shop")
|
|
def test_returns_active_frequency_daily(self, mock_get_sub):
|
|
"""Logged-in user with active daily subscription sees freq=0."""
|
|
from ..views.subscribe import subscribe
|
|
|
|
sub = mock.MagicMock()
|
|
sub.disabled = False
|
|
sub.frequency = 0 # FREQUENCY_DAILY
|
|
mock_get_sub.return_value = sub
|
|
|
|
request = self._make_request(
|
|
user=self._make_user(), shop=self._make_shop()
|
|
)
|
|
result = subscribe(request)
|
|
self.assertEqual(result["current_frequency"], 0)
|
|
|
|
@mock.patch("make_post_sell.views.subscribe.get_subscription_for_email_and_shop")
|
|
def test_returns_active_frequency_weekly(self, mock_get_sub):
|
|
"""Logged-in user with active weekly subscription sees freq=1."""
|
|
from ..views.subscribe import subscribe
|
|
|
|
sub = mock.MagicMock()
|
|
sub.disabled = False
|
|
sub.frequency = 1 # FREQUENCY_WEEKLY
|
|
mock_get_sub.return_value = sub
|
|
|
|
request = self._make_request(
|
|
user=self._make_user(), shop=self._make_shop()
|
|
)
|
|
result = subscribe(request)
|
|
self.assertEqual(result["current_frequency"], 1)
|
|
|
|
@mock.patch("make_post_sell.views.subscribe.get_subscription_for_email_and_shop")
|
|
def test_returns_active_frequency_immediate(self, mock_get_sub):
|
|
"""Logged-in user with active immediate subscription sees freq=2."""
|
|
from ..views.subscribe import subscribe
|
|
|
|
sub = mock.MagicMock()
|
|
sub.disabled = False
|
|
sub.frequency = 2 # FREQUENCY_IMMEDIATE
|
|
mock_get_sub.return_value = sub
|
|
|
|
request = self._make_request(
|
|
user=self._make_user(), shop=self._make_shop()
|
|
)
|
|
result = subscribe(request)
|
|
self.assertEqual(result["current_frequency"], 2)
|
|
|
|
@mock.patch("make_post_sell.views.subscribe.get_subscription_for_email_and_shop")
|
|
def test_disabled_subscription_returns_negative_one(self, mock_get_sub):
|
|
"""Logged-in user with disabled subscription sees freq=-1 (None radio)."""
|
|
from ..views.subscribe import subscribe
|
|
|
|
sub = mock.MagicMock()
|
|
sub.disabled = True
|
|
mock_get_sub.return_value = sub
|
|
|
|
request = self._make_request(
|
|
user=self._make_user(), shop=self._make_shop()
|
|
)
|
|
result = subscribe(request)
|
|
self.assertEqual(result["current_frequency"], -1)
|
|
|
|
@mock.patch("make_post_sell.views.subscribe.get_subscription_for_email_and_shop")
|
|
def test_no_subscription_returns_none(self, mock_get_sub):
|
|
"""Logged-in user with no subscription sees current_frequency=None."""
|
|
from ..views.subscribe import subscribe
|
|
|
|
mock_get_sub.return_value = None
|
|
|
|
request = self._make_request(
|
|
user=self._make_user(), shop=self._make_shop()
|
|
)
|
|
result = subscribe(request)
|
|
self.assertIsNone(result["current_frequency"])
|
|
|
|
def test_anonymous_user_returns_none(self):
|
|
"""Anonymous user (not logged in) sees current_frequency=None."""
|
|
from ..views.subscribe import subscribe
|
|
|
|
request = self._make_request(user=None, shop=self._make_shop())
|
|
result = subscribe(request)
|
|
self.assertIsNone(result["current_frequency"])
|
|
|
|
|
|
class TestMentionParsing(unittest.TestCase):
|
|
"""Test @mention parsing from comment text."""
|
|
|
|
def test_simple_mention(self):
|
|
from ..lib.mentions import extract_mentions
|
|
|
|
result = extract_mentions("Hello @user-abcd1234 nice post!")
|
|
self.assertEqual(result, ["user-abcd1234"])
|
|
|
|
def test_multiple_mentions(self):
|
|
from ..lib.mentions import extract_mentions
|
|
|
|
result = extract_mentions("@user-abc123 and @user-def456 check this out")
|
|
self.assertEqual(len(result), 2)
|
|
self.assertIn("user-abc123", result)
|
|
self.assertIn("user-def456", result)
|
|
|
|
def test_duplicate_mentions(self):
|
|
from ..lib.mentions import extract_mentions
|
|
|
|
result = extract_mentions("@user-abc123 said hi, @user-abc123 said bye")
|
|
self.assertEqual(len(result), 1)
|
|
self.assertEqual(result[0], "user-abc123")
|
|
|
|
def test_no_mentions(self):
|
|
from ..lib.mentions import extract_mentions
|
|
|
|
result = extract_mentions("No mentions here.")
|
|
self.assertEqual(result, [])
|
|
|
|
def test_empty_text(self):
|
|
from ..lib.mentions import extract_mentions
|
|
|
|
result = extract_mentions("")
|
|
self.assertEqual(result, [])
|
|
|
|
def test_none_text(self):
|
|
from ..lib.mentions import extract_mentions
|
|
|
|
result = extract_mentions(None)
|
|
self.assertEqual(result, [])
|
|
|
|
def test_email_not_matched(self):
|
|
"""Email addresses should not be matched as mentions."""
|
|
from ..lib.mentions import extract_mentions
|
|
|
|
result = extract_mentions("Email me at person@example.com")
|
|
# @example would not match because it follows a non-whitespace char
|
|
self.assertEqual(result, [])
|
|
|
|
def test_start_of_string(self):
|
|
from ..lib.mentions import extract_mentions
|
|
|
|
result = extract_mentions("@user-start123 at the start")
|
|
self.assertEqual(result, ["user-start123"])
|
|
|
|
def test_mention_too_short(self):
|
|
"""Usernames under 3 chars should not match."""
|
|
from ..lib.mentions import extract_mentions
|
|
|
|
result = extract_mentions("@ab is too short")
|
|
self.assertEqual(result, [])
|
|
|
|
|
|
class TestDigestBuilding(unittest.TestCase):
|
|
"""Test digest email building functions."""
|
|
|
|
def test_build_digest_text(self):
|
|
from ..lib.digest_sender import build_digest_text
|
|
|
|
mock_item1 = mock.Mock()
|
|
mock_item1.title = "Cool Product"
|
|
mock_item1.is_sellable = True
|
|
|
|
mock_item2 = mock.Mock()
|
|
mock_item2.title = "Blog Post"
|
|
mock_item2.is_sellable = False
|
|
|
|
result = build_digest_text(
|
|
"Test Shop", [mock_item1, mock_item2], "https://example.com/unsub"
|
|
)
|
|
self.assertIn("Test Shop", result)
|
|
self.assertIn("Cool Product", result)
|
|
self.assertIn("Blog Post", result)
|
|
self.assertIn("[Product]", result)
|
|
self.assertIn("[Content]", result)
|
|
self.assertIn("https://example.com/unsub", result)
|
|
|
|
def test_build_digest_html(self):
|
|
from ..lib.digest_sender import build_digest_html
|
|
|
|
mock_item = mock.Mock()
|
|
mock_item.title = "New Item"
|
|
mock_item.is_sellable = True
|
|
mock_item.extensions = {}
|
|
mock_item.s3_path = "test/path"
|
|
mock_item.updated_timestamp = 12345
|
|
|
|
result = build_digest_html(
|
|
"Test Shop", [mock_item], "https://example.com/unsub"
|
|
)
|
|
self.assertIn("Test Shop", result)
|
|
self.assertIn("New Item", result)
|
|
self.assertIn("https://example.com/unsub", result)
|
|
|
|
def test_build_digest_html_with_thumbnail(self):
|
|
from ..lib.digest_sender import build_digest_html
|
|
|
|
mock_item = mock.Mock()
|
|
mock_item.title = "Item With Image"
|
|
mock_item.is_sellable = False
|
|
mock_item.extensions = {"thumbnail1": "jpg"}
|
|
mock_item.s3_path = "shop/product"
|
|
mock_item.updated_timestamp = 99999
|
|
|
|
result = build_digest_html(
|
|
"Test Shop",
|
|
[mock_item],
|
|
"https://example.com/unsub",
|
|
get_endpoint="https://cdn.example.com",
|
|
)
|
|
self.assertIn("https://cdn.example.com/shop/product/thumbnail1", result)
|
|
|
|
|
|
class TestProductViewCount(unittest.TestCase):
|
|
"""Test Product.human_view_count formatting."""
|
|
|
|
def _make_product(self, view_count):
|
|
p = mock.Mock()
|
|
p.view_count = view_count
|
|
from ..models.product import Product
|
|
p.human_view_count = Product.human_view_count.fget(p)
|
|
return p
|
|
|
|
def test_zero_returns_empty(self):
|
|
self.assertEqual(self._make_product(0).human_view_count, "")
|
|
|
|
def test_none_returns_empty(self):
|
|
self.assertEqual(self._make_product(None).human_view_count, "")
|
|
|
|
def test_one_view_singular(self):
|
|
self.assertEqual(self._make_product(1).human_view_count, "1 view")
|
|
|
|
def test_plural_views(self):
|
|
self.assertEqual(self._make_product(420).human_view_count, "420 views")
|
|
|
|
def test_one_thousand(self):
|
|
self.assertEqual(self._make_product(1000).human_view_count, "1K views")
|
|
|
|
def test_twelve_hundred(self):
|
|
self.assertEqual(self._make_product(1200).human_view_count, "1.2K views")
|
|
|
|
def test_one_million(self):
|
|
self.assertEqual(self._make_product(1_000_000).human_view_count, "1M views")
|
|
|
|
def test_one_point_eight_million(self):
|
|
self.assertEqual(self._make_product(1_800_000).human_view_count, "1.8M views")
|
|
|
|
def test_999_views(self):
|
|
self.assertEqual(self._make_product(999).human_view_count, "999 views")
|
|
|
|
|
|
class TestSignalClassifiers(unittest.TestCase):
|
|
"""Test classify_referrer and classify_device functions."""
|
|
|
|
# --- classify_referrer (returns (class, domain, query) tuple) ---
|
|
|
|
def test_referrer_direct_empty(self):
|
|
self.assertEqual(classify_referrer("", "example.com"), (0, None, None))
|
|
|
|
def test_referrer_direct_none(self):
|
|
self.assertEqual(classify_referrer(None, "example.com"), (0, None, None))
|
|
|
|
def test_referrer_search_google(self):
|
|
cls, domain, query = classify_referrer(
|
|
"https://www.google.com/search?q=foo", "example.com"
|
|
)
|
|
self.assertEqual(cls, 1)
|
|
self.assertEqual(domain, "www.google.com")
|
|
self.assertEqual(query, "foo")
|
|
|
|
def test_referrer_search_duckduckgo(self):
|
|
cls, domain, query = classify_referrer(
|
|
"https://duckduckgo.com/?q=bar", "example.com"
|
|
)
|
|
self.assertEqual(cls, 1)
|
|
self.assertEqual(domain, "duckduckgo.com")
|
|
self.assertEqual(query, "bar")
|
|
|
|
def test_referrer_search_no_query(self):
|
|
cls, domain, query = classify_referrer(
|
|
"https://www.google.com/", "example.com"
|
|
)
|
|
self.assertEqual(cls, 1)
|
|
self.assertEqual(domain, "www.google.com")
|
|
self.assertIsNone(query)
|
|
|
|
def test_referrer_social_twitter(self):
|
|
cls, domain, query = classify_referrer(
|
|
"https://twitter.com/user/status/123", "example.com"
|
|
)
|
|
self.assertEqual(cls, 2)
|
|
self.assertEqual(domain, "twitter.com")
|
|
self.assertIsNone(query)
|
|
|
|
def test_referrer_social_reddit(self):
|
|
cls, domain, query = classify_referrer(
|
|
"https://www.reddit.com/r/test", "example.com"
|
|
)
|
|
self.assertEqual(cls, 2)
|
|
self.assertEqual(domain, "www.reddit.com")
|
|
self.assertIsNone(query)
|
|
|
|
def test_referrer_internal(self):
|
|
cls, domain, query = classify_referrer(
|
|
"https://example.com/some/page", "example.com"
|
|
)
|
|
self.assertEqual(cls, 3)
|
|
self.assertEqual(domain, "example.com")
|
|
self.assertIsNone(query)
|
|
|
|
def test_referrer_unknown_external(self):
|
|
cls, domain, query = classify_referrer(
|
|
"https://randomsite.org/page", "example.com"
|
|
)
|
|
self.assertEqual(cls, 4)
|
|
self.assertEqual(domain, "randomsite.org")
|
|
self.assertIsNone(query)
|
|
|
|
def test_referrer_yahoo_query(self):
|
|
cls, domain, query = classify_referrer(
|
|
"https://search.yahoo.com/search?p=beats", "example.com"
|
|
)
|
|
self.assertEqual(cls, 1)
|
|
self.assertEqual(query, "beats")
|
|
|
|
# --- classify_device ---
|
|
|
|
def test_device_mobile(self):
|
|
self.assertEqual(classify_device(375), 0)
|
|
|
|
def test_device_mobile_boundary(self):
|
|
self.assertEqual(classify_device(768), 0)
|
|
|
|
def test_device_tablet(self):
|
|
self.assertEqual(classify_device(800), 1)
|
|
|
|
def test_device_tablet_boundary(self):
|
|
self.assertEqual(classify_device(1024), 1)
|
|
|
|
def test_device_desktop(self):
|
|
self.assertEqual(classify_device(1920), 2)
|
|
|
|
def test_device_desktop_boundary(self):
|
|
self.assertEqual(classify_device(1025), 2)
|
|
|
|
def test_device_none(self):
|
|
self.assertIsNone(classify_device(None))
|
|
|
|
|
|
class TestAnalyticsHelpers(unittest.TestCase):
|
|
"""Unit tests for analytics view helper functions."""
|
|
|
|
def test_fmt_ms_none(self):
|
|
from ..views.analytics import _fmt_ms
|
|
self.assertEqual(_fmt_ms(None), "\u2014")
|
|
|
|
def test_fmt_ms_seconds(self):
|
|
from ..views.analytics import _fmt_ms
|
|
self.assertEqual(_fmt_ms(42000), "42s")
|
|
|
|
def test_fmt_ms_minutes(self):
|
|
from ..views.analytics import _fmt_ms
|
|
self.assertEqual(_fmt_ms(125000), "2m 5s")
|
|
|
|
def test_fmt_ms_hours(self):
|
|
from ..views.analytics import _fmt_ms
|
|
self.assertEqual(_fmt_ms(3661000), "1h 1m")
|
|
|
|
def test_fmt_ms_zero(self):
|
|
from ..views.analytics import _fmt_ms
|
|
self.assertEqual(_fmt_ms(0), "0s")
|
|
|
|
def test_fmt_pct_none(self):
|
|
from ..views.analytics import _fmt_pct
|
|
self.assertEqual(_fmt_pct(None), "\u2014")
|
|
|
|
def test_fmt_pct_half(self):
|
|
from ..views.analytics import _fmt_pct
|
|
self.assertEqual(_fmt_pct(0.5), "50%")
|
|
|
|
def test_fmt_pct_full(self):
|
|
from ..views.analytics import _fmt_pct
|
|
self.assertEqual(_fmt_pct(1.0), "100%")
|
|
|
|
def test_fmt_pct_zero(self):
|
|
from ..views.analytics import _fmt_pct
|
|
self.assertEqual(_fmt_pct(0.0), "0%")
|
|
|
|
def test_fmt_score_none(self):
|
|
from ..views.analytics import _fmt_score
|
|
self.assertEqual(_fmt_score(None), "\u2014")
|
|
|
|
def test_fmt_score_value(self):
|
|
from ..views.analytics import _fmt_score
|
|
self.assertEqual(_fmt_score(3.14159), "3.1")
|
|
|
|
def test_fmt_score_zero(self):
|
|
from ..views.analytics import _fmt_score
|
|
self.assertEqual(_fmt_score(0.0), "0.0")
|
|
|
|
def test_cutoffs_returns_expected_keys(self):
|
|
from ..views.analytics import _cutoffs
|
|
cuts = _cutoffs()
|
|
day_ms = 24 * 60 * 60 * 1000
|
|
for key in ("1d", "7d", "14d", "21d", "28d", "365d"):
|
|
self.assertIn(key, cuts)
|
|
self.assertAlmostEqual(cuts["7d"] - cuts["14d"], 7 * day_ms, delta=1000)
|
|
self.assertAlmostEqual(cuts["14d"] - cuts["21d"], 7 * day_ms, delta=1000)
|
|
|
|
def test_referrer_labels_complete(self):
|
|
from ..views.analytics import REFERRER_LABELS
|
|
self.assertEqual(len(REFERRER_LABELS), 5)
|
|
self.assertIn(0, REFERRER_LABELS)
|
|
self.assertIn(4, REFERRER_LABELS)
|
|
|
|
def test_device_labels_complete(self):
|
|
from ..views.analytics import DEVICE_LABELS
|
|
self.assertEqual(len(DEVICE_LABELS), 3)
|
|
self.assertEqual(DEVICE_LABELS[0], "Mobile")
|
|
self.assertEqual(DEVICE_LABELS[2], "Desktop")
|
|
|
|
|
|
class TestSentiment(unittest.TestCase):
|
|
"""Unit tests for the rule-based comment sentiment classifier."""
|
|
|
|
def _classify(self, text):
|
|
from ..lib.sentiment import classify_sentiment
|
|
return classify_sentiment(text)
|
|
|
|
def test_positive_comment(self):
|
|
self.assertEqual(self._classify("This is amazing and wonderful!"), 1)
|
|
|
|
def test_negative_comment(self):
|
|
self.assertEqual(self._classify("Terrible product, total waste"), -1)
|
|
|
|
def test_neutral_comment(self):
|
|
self.assertEqual(self._classify("I downloaded the file yesterday"), 0)
|
|
|
|
def test_empty_string(self):
|
|
self.assertEqual(self._classify(""), 0)
|
|
|
|
def test_none_input(self):
|
|
self.assertEqual(self._classify(None), 0)
|
|
|
|
def test_negation_flips_positive(self):
|
|
self.assertEqual(self._classify("not good"), -1)
|
|
|
|
def test_negation_flips_negative(self):
|
|
self.assertEqual(self._classify("not bad"), 1)
|
|
|
|
def test_intensifier_boosts_score(self):
|
|
# "really great" should still be positive
|
|
self.assertEqual(self._classify("really great"), 1)
|
|
# "very terrible" should still be negative
|
|
self.assertEqual(self._classify("very terrible"), -1)
|
|
|
|
def test_mixed_sentiment_leans_neutral(self):
|
|
# Roughly equal positive and negative words
|
|
self.assertEqual(self._classify("good but also bad"), 0)
|
|
|
|
def test_only_punctuation_and_numbers(self):
|
|
self.assertEqual(self._classify("123 !!! ???"), 0)
|
|
|
|
def test_short_positive(self):
|
|
self.assertEqual(self._classify("love it"), 1)
|
|
|
|
def test_short_negative(self):
|
|
self.assertEqual(self._classify("hate it"), -1)
|
|
|
|
|
|
class TestGiftCard(unittest.TestCase):
|
|
|
|
def test_gift_card_creation(self):
|
|
from make_post_sell.models.gift_card import GiftCard
|
|
shop = mock.MagicMock()
|
|
shop.id = uuid.uuid1()
|
|
gc = GiftCard(shop=shop, amount_in_cents=5000)
|
|
self.assertEqual(gc.initial_amount_in_cents, 5000)
|
|
self.assertEqual(gc.balance_in_cents, 5000)
|
|
self.assertFalse(gc.disabled)
|
|
self.assertTrue(gc.code.startswith("GC-"))
|
|
self.assertEqual(len(gc.code), 19) # GC- + 16 hex chars
|
|
|
|
def test_gift_card_is_valid(self):
|
|
from make_post_sell.models.gift_card import GiftCard
|
|
shop = mock.MagicMock()
|
|
shop.id = uuid.uuid1()
|
|
gc = GiftCard(shop=shop, amount_in_cents=5000)
|
|
self.assertTrue(gc.is_valid)
|
|
|
|
def test_gift_card_disabled_not_valid(self):
|
|
from make_post_sell.models.gift_card import GiftCard
|
|
shop = mock.MagicMock()
|
|
shop.id = uuid.uuid1()
|
|
gc = GiftCard(shop=shop, amount_in_cents=5000)
|
|
gc.disabled = True
|
|
self.assertFalse(gc.is_valid)
|
|
|
|
def test_gift_card_zero_balance_not_valid(self):
|
|
from make_post_sell.models.gift_card import GiftCard
|
|
shop = mock.MagicMock()
|
|
shop.id = uuid.uuid1()
|
|
gc = GiftCard(shop=shop, amount_in_cents=5000)
|
|
gc.balance_in_cents = 0
|
|
self.assertFalse(gc.is_valid)
|
|
|
|
def test_gift_card_deduct(self):
|
|
from make_post_sell.models.gift_card import GiftCard
|
|
shop = mock.MagicMock()
|
|
shop.id = uuid.uuid1()
|
|
gc = GiftCard(shop=shop, amount_in_cents=5000)
|
|
deducted = gc.deduct(2000)
|
|
self.assertEqual(deducted, 2000)
|
|
self.assertEqual(gc.balance_in_cents, 3000)
|
|
|
|
def test_gift_card_deduct_more_than_balance(self):
|
|
from make_post_sell.models.gift_card import GiftCard
|
|
shop = mock.MagicMock()
|
|
shop.id = uuid.uuid1()
|
|
gc = GiftCard(shop=shop, amount_in_cents=1000)
|
|
deducted = gc.deduct(5000)
|
|
self.assertEqual(deducted, 1000)
|
|
self.assertEqual(gc.balance_in_cents, 0)
|
|
|
|
def test_gift_card_balance_property(self):
|
|
from make_post_sell.models.gift_card import GiftCard
|
|
shop = mock.MagicMock()
|
|
shop.id = uuid.uuid1()
|
|
gc = GiftCard(shop=shop, amount_in_cents=5000)
|
|
self.assertEqual(gc.balance, 50.00)
|
|
self.assertEqual(gc.initial_amount, 50.00)
|
|
|
|
def test_gift_card_is_fully_redeemed(self):
|
|
from make_post_sell.models.gift_card import GiftCard
|
|
shop = mock.MagicMock()
|
|
shop.id = uuid.uuid1()
|
|
gc = GiftCard(shop=shop, amount_in_cents=5000)
|
|
self.assertFalse(gc.is_fully_redeemed)
|
|
gc.balance_in_cents = 0
|
|
self.assertTrue(gc.is_fully_redeemed)
|
|
|
|
def test_gift_card_code_uniqueness(self):
|
|
from make_post_sell.models.gift_card import generate_gift_card_code
|
|
codes = set()
|
|
for _ in range(100):
|
|
codes.add(generate_gift_card_code())
|
|
self.assertEqual(len(codes), 100)
|
|
|
|
def test_gift_card_with_gift_email(self):
|
|
from make_post_sell.models.gift_card import GiftCard
|
|
shop = mock.MagicMock()
|
|
shop.id = uuid.uuid1()
|
|
gc = GiftCard(
|
|
shop=shop,
|
|
amount_in_cents=2500,
|
|
purchaser_email="buyer@test.com",
|
|
gift_email="friend@test.com",
|
|
gift_message="Happy birthday!",
|
|
)
|
|
self.assertEqual(gc.gift_email, "friend@test.com")
|
|
self.assertEqual(gc.gift_message, "Happy birthday!")
|
|
self.assertEqual(gc.purchaser_email, "buyer@test.com")
|
|
|
|
|
|
class TestShopEnvironment(unittest.TestCase):
|
|
"""MPS-14: Dev & Stage environment properties."""
|
|
|
|
def _make_shop(self, environment=0):
|
|
shop = Shop("env-test", "555-0000", "123 Test St", "test shop")
|
|
shop.environment = environment
|
|
return shop
|
|
|
|
def test_default_is_production(self):
|
|
shop = self._make_shop()
|
|
self.assertTrue(shop.is_production)
|
|
self.assertFalse(shop.is_non_production)
|
|
self.assertEqual(shop.environment_label, "Production")
|
|
|
|
def test_staging_environment(self):
|
|
shop = self._make_shop(environment=1)
|
|
self.assertTrue(shop.is_staging)
|
|
self.assertTrue(shop.is_non_production)
|
|
self.assertFalse(shop.is_production)
|
|
self.assertEqual(shop.environment_label, "Staging")
|
|
|
|
def test_development_environment(self):
|
|
shop = self._make_shop(environment=2)
|
|
self.assertTrue(shop.is_development)
|
|
self.assertTrue(shop.is_non_production)
|
|
self.assertFalse(shop.is_production)
|
|
self.assertEqual(shop.environment_label, "Development")
|
|
|
|
def test_unknown_environment_defaults_to_production_label(self):
|
|
shop = self._make_shop(environment=99)
|
|
self.assertEqual(shop.environment_label, "Production")
|
|
self.assertTrue(shop.is_non_production)
|
|
|
|
|
|
class TestShopTrial(unittest.TestCase):
|
|
"""MPS-15: 21-day free trial properties."""
|
|
|
|
def _make_shop(self, trial_started_ms=None, plan_active=False):
|
|
shop = Shop("trial-test", "555-0000", "123 Test St", "test shop")
|
|
shop.trial_started_timestamp = trial_started_ms
|
|
shop.plan_active = plan_active
|
|
return shop
|
|
|
|
def test_grandfathered_shop_is_active(self):
|
|
"""Pre-trial shops (NULL timestamp) are always active."""
|
|
shop = self._make_shop(trial_started_ms=None)
|
|
self.assertTrue(shop.is_active)
|
|
self.assertFalse(shop.is_trial_active)
|
|
self.assertFalse(shop.is_trial_expired)
|
|
self.assertIsNone(shop.trial_expiry_timestamp)
|
|
|
|
@mock.patch("make_post_sell.models.shop.time")
|
|
def test_trial_active_within_21_days(self, mock_time):
|
|
import time as real_time
|
|
now_ms = int(real_time.time() * 1000)
|
|
# Started 10 days ago
|
|
started = now_ms - (10 * 24 * 60 * 60 * 1000)
|
|
shop = self._make_shop(trial_started_ms=started)
|
|
mock_time.time.return_value = now_ms / 1000.0
|
|
self.assertTrue(shop.is_trial_active)
|
|
self.assertFalse(shop.is_trial_expired)
|
|
self.assertTrue(shop.is_active)
|
|
self.assertGreater(shop.trial_days_remaining, 0)
|
|
|
|
@mock.patch("make_post_sell.models.shop.time")
|
|
def test_trial_expired_after_21_days(self, mock_time):
|
|
import time as real_time
|
|
now_ms = int(real_time.time() * 1000)
|
|
# Started 22 days ago
|
|
started = now_ms - (22 * 24 * 60 * 60 * 1000)
|
|
shop = self._make_shop(trial_started_ms=started)
|
|
mock_time.time.return_value = now_ms / 1000.0
|
|
self.assertFalse(shop.is_trial_active)
|
|
self.assertTrue(shop.is_trial_expired)
|
|
self.assertFalse(shop.is_active)
|
|
self.assertEqual(shop.trial_days_remaining, 0)
|
|
|
|
@mock.patch("make_post_sell.models.shop.time")
|
|
def test_paid_plan_overrides_trial(self, mock_time):
|
|
import time as real_time
|
|
now_ms = int(real_time.time() * 1000)
|
|
# Started 22 days ago but plan is active
|
|
started = now_ms - (22 * 24 * 60 * 60 * 1000)
|
|
shop = self._make_shop(trial_started_ms=started, plan_active=True)
|
|
mock_time.time.return_value = now_ms / 1000.0
|
|
self.assertFalse(shop.is_trial_active)
|
|
self.assertFalse(shop.is_trial_expired)
|
|
self.assertTrue(shop.is_active)
|
|
|
|
def test_trial_expiry_timestamp(self):
|
|
shop = self._make_shop(trial_started_ms=1000000)
|
|
expected = 1000000 + (21 * 24 * 60 * 60 * 1000)
|
|
self.assertEqual(shop.trial_expiry_timestamp, expected)
|
|
|
|
|
|
class TestShopBYOB(unittest.TestCase):
|
|
"""MPS-16: Bring Your Own Bucket properties."""
|
|
|
|
def _make_shop(self, enabled=False, **kwargs):
|
|
shop = Shop("byob-test", "555-0000", "123 Test St", "test shop")
|
|
shop.primary_s3_enabled = enabled
|
|
shop.primary_s3_endpoint = kwargs.get("endpoint", "https://nyc3.digitaloceanspaces.com")
|
|
shop.primary_s3_region = kwargs.get("region", "nyc3")
|
|
shop.primary_s3_bucket = kwargs.get("bucket", "my-bucket")
|
|
shop.primary_s3_access_key = kwargs.get("access_key", "AKID")
|
|
shop.primary_s3_secret_key = kwargs.get("secret_key", "SECRET")
|
|
shop.primary_s3_cdn_endpoint = kwargs.get("cdn_endpoint", "https://my-bucket.nyc3.cdn.digitaloceanspaces.com")
|
|
return shop
|
|
|
|
def test_has_primary_s3_when_enabled_and_configured(self):
|
|
shop = self._make_shop(enabled=True)
|
|
self.assertTrue(shop.has_primary_s3)
|
|
|
|
def test_has_primary_s3_false_when_disabled(self):
|
|
shop = self._make_shop(enabled=False)
|
|
self.assertFalse(shop.has_primary_s3)
|
|
|
|
def test_has_primary_s3_false_when_missing_fields(self):
|
|
shop = self._make_shop(enabled=True, access_key="")
|
|
self.assertFalse(shop.has_primary_s3)
|
|
|
|
def test_media_cdn_endpoint_returns_custom_when_configured(self):
|
|
shop = self._make_shop(enabled=True)
|
|
self.assertEqual(shop.media_cdn_endpoint, "https://my-bucket.nyc3.cdn.digitaloceanspaces.com")
|
|
|
|
def test_media_cdn_endpoint_returns_none_when_not_configured(self):
|
|
shop = self._make_shop(enabled=False)
|
|
self.assertIsNone(shop.media_cdn_endpoint)
|
|
|
|
|
|
class TestTorrentLib(unittest.TestCase):
|
|
"""Unit tests for lib/torrent.py — web seed URL construction and DB persistence."""
|
|
|
|
def _make_product(self):
|
|
from ..models.product import Product
|
|
p = mock.MagicMock(spec=Product)
|
|
p.id = "prod-001"
|
|
p.s3_path = "shop1/prod-001"
|
|
p.torrent_magnet_link = None
|
|
p.torrent_file_url = None
|
|
return p
|
|
|
|
def test_webseed_url_constructed_from_cdn_endpoint_and_s3_key(self):
|
|
"""generate_torrent embeds cdn_endpoint/s3_key as web seed in the torrent."""
|
|
from ..lib.torrent import generate_torrent
|
|
|
|
s3_client = mock.MagicMock()
|
|
session = mock.MagicMock()
|
|
product = self._make_product()
|
|
session.get.return_value = product
|
|
session_cm = mock.MagicMock()
|
|
session_cm.__enter__ = mock.Mock(return_value=session)
|
|
session_cm.__exit__ = mock.Mock(return_value=False)
|
|
session_factory = mock.Mock(return_value=session_cm)
|
|
|
|
torrent_obj = mock.MagicMock()
|
|
|
|
with mock.patch("make_post_sell.lib.torrent._build_torrent", return_value=(torrent_obj, "magnet:?xt=urn:btih:deadbeef&dn=product")) as mock_build, \
|
|
mock.patch("tempfile.TemporaryDirectory") as mock_tmpdir:
|
|
mock_tmpdir.return_value.__enter__ = mock.Mock(return_value="/tmp/fake")
|
|
mock_tmpdir.return_value.__exit__ = mock.Mock(return_value=False)
|
|
|
|
generate_torrent(
|
|
s3_client, "my-bucket", "shop1/prod-001/product",
|
|
"shop1/prod-001", "prod-001", session_factory,
|
|
cdn_endpoint="https://cdn.example.com",
|
|
)
|
|
|
|
# _build_torrent is called with webseeds= as a keyword arg
|
|
call_kwargs = mock_build.call_args.kwargs
|
|
webseeds = call_kwargs.get("webseeds", [])
|
|
self.assertIn("https://cdn.example.com/shop1/prod-001/product", webseeds)
|
|
|
|
def test_torrent_file_url_saved_to_product(self):
|
|
"""generate_torrent saves torrent_file_url = cdn_endpoint/s3_path/product.torrent."""
|
|
from ..lib.torrent import generate_torrent
|
|
|
|
s3_client = mock.MagicMock()
|
|
session = mock.MagicMock()
|
|
product = self._make_product()
|
|
session.get.return_value = product
|
|
session_cm = mock.MagicMock()
|
|
session_cm.__enter__ = mock.Mock(return_value=session)
|
|
session_cm.__exit__ = mock.Mock(return_value=False)
|
|
session_factory = mock.Mock(return_value=session_cm)
|
|
|
|
torrent_obj = mock.MagicMock()
|
|
|
|
with mock.patch("make_post_sell.lib.torrent._build_torrent", return_value=(torrent_obj, "magnet:?xt=urn:btih:abc")), \
|
|
mock.patch("tempfile.TemporaryDirectory") as mock_tmpdir:
|
|
mock_tmpdir.return_value.__enter__ = mock.Mock(return_value="/tmp/fake")
|
|
mock_tmpdir.return_value.__exit__ = mock.Mock(return_value=False)
|
|
|
|
generate_torrent(
|
|
s3_client, "my-bucket", "shop1/prod-001/product",
|
|
"shop1/prod-001", "prod-001", session_factory,
|
|
cdn_endpoint="https://cdn.example.com",
|
|
)
|
|
|
|
self.assertEqual(product.torrent_file_url, "https://cdn.example.com/shop1/prod-001/product.torrent")
|
|
|
|
def test_torrent_file_url_none_without_cdn_endpoint(self):
|
|
"""Without cdn_endpoint, torrent_file_url is None (no web seed)."""
|
|
from ..lib.torrent import generate_torrent
|
|
|
|
s3_client = mock.MagicMock()
|
|
session = mock.MagicMock()
|
|
product = self._make_product()
|
|
session.get.return_value = product
|
|
session_cm = mock.MagicMock()
|
|
session_cm.__enter__ = mock.Mock(return_value=session)
|
|
session_cm.__exit__ = mock.Mock(return_value=False)
|
|
session_factory = mock.Mock(return_value=session_cm)
|
|
|
|
torrent_obj = mock.MagicMock()
|
|
|
|
with mock.patch("make_post_sell.lib.torrent._build_torrent", return_value=(torrent_obj, "magnet:?xt=urn:btih:abc")), \
|
|
mock.patch("tempfile.TemporaryDirectory") as mock_tmpdir:
|
|
mock_tmpdir.return_value.__enter__ = mock.Mock(return_value="/tmp/fake")
|
|
mock_tmpdir.return_value.__exit__ = mock.Mock(return_value=False)
|
|
|
|
generate_torrent(
|
|
s3_client, "my-bucket", "shop1/prod-001/product",
|
|
"shop1/prod-001", "prod-001", session_factory,
|
|
cdn_endpoint=None,
|
|
)
|
|
|
|
self.assertIsNone(product.torrent_file_url)
|