- Add is_digital and product_type columns to Product model - Remove is_digital/product_type properties that derived from MarketplaceProduct - Update Create form with translation tabs, GTIN type, sale price, VAT rate, image - Update Edit form to allow editing is_digital (remove disabled state) - Add Availability field to Edit form - Fix Detail page for directly created products (no marketplace source) - Update vendor_product_service to handle new fields in create/update - Add VendorProductCreate/Update schema fields for translations and is_digital - Add unit tests for is_digital column and direct product creation - Add integration tests for create/update API with new fields - Create product-architecture.md documenting the independent copy pattern - Add migration y3d4e5f6g7h8 for is_digital and product_type columns 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
403 lines
14 KiB
Python
403 lines
14 KiB
Python
# tests/unit/models/database/test_product.py
|
|
"""Unit tests for Product (vendor catalog) database model."""
|
|
|
|
import pytest
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from models.database.product import Product
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.database
|
|
class TestProductModel:
|
|
"""Test Product (vendor catalog) model."""
|
|
|
|
def test_product_creation(self, db, test_vendor, test_marketplace_product):
|
|
"""Test Product model linking vendor catalog to marketplace product."""
|
|
product = Product(
|
|
vendor_id=test_vendor.id,
|
|
marketplace_product_id=test_marketplace_product.id,
|
|
vendor_sku="VENDOR_PROD_001",
|
|
price=89.99,
|
|
currency="EUR",
|
|
availability="in stock",
|
|
is_featured=True,
|
|
is_active=True,
|
|
)
|
|
|
|
db.add(product)
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
assert product.id is not None
|
|
assert product.vendor_id == test_vendor.id
|
|
assert product.marketplace_product_id == test_marketplace_product.id
|
|
assert product.price == 89.99
|
|
assert product.is_featured is True
|
|
assert product.vendor.vendor_code == test_vendor.vendor_code
|
|
# Use get_title() method instead of .title attribute
|
|
assert product.marketplace_product.get_title(
|
|
"en"
|
|
) == test_marketplace_product.get_title("en")
|
|
|
|
def test_product_unique_per_vendor(self, db, test_vendor, test_marketplace_product):
|
|
"""Test that same marketplace product can't be added twice to vendor catalog."""
|
|
product1 = Product(
|
|
vendor_id=test_vendor.id,
|
|
marketplace_product_id=test_marketplace_product.id,
|
|
is_active=True,
|
|
)
|
|
db.add(product1)
|
|
db.commit()
|
|
|
|
# Same marketplace product to same vendor should fail
|
|
with pytest.raises(IntegrityError):
|
|
product2 = Product(
|
|
vendor_id=test_vendor.id,
|
|
marketplace_product_id=test_marketplace_product.id,
|
|
is_active=True,
|
|
)
|
|
db.add(product2)
|
|
db.commit()
|
|
|
|
def test_product_default_values(self, db, test_vendor, test_marketplace_product):
|
|
"""Test Product model default values."""
|
|
product = Product(
|
|
vendor_id=test_vendor.id,
|
|
marketplace_product_id=test_marketplace_product.id,
|
|
)
|
|
db.add(product)
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
assert product.is_active is True # Default
|
|
assert product.is_featured is False # Default
|
|
assert product.is_digital is False # Default
|
|
assert product.product_type == "physical" # Default
|
|
assert product.min_quantity == 1 # Default
|
|
assert product.display_order == 0 # Default
|
|
|
|
def test_product_vendor_override_fields(
|
|
self, db, test_vendor, test_marketplace_product
|
|
):
|
|
"""Test Product model vendor-specific override fields."""
|
|
product = Product(
|
|
vendor_id=test_vendor.id,
|
|
marketplace_product_id=test_marketplace_product.id,
|
|
vendor_sku="CUSTOM_SKU_001",
|
|
price=49.99,
|
|
sale_price=39.99,
|
|
currency="USD",
|
|
availability="limited",
|
|
condition="new",
|
|
)
|
|
db.add(product)
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
assert product.vendor_sku == "CUSTOM_SKU_001"
|
|
assert product.price == 49.99
|
|
assert product.sale_price == 39.99
|
|
assert product.currency == "USD"
|
|
assert product.availability == "limited"
|
|
|
|
def test_product_inventory_settings(
|
|
self, db, test_vendor, test_marketplace_product
|
|
):
|
|
"""Test Product model inventory settings."""
|
|
product = Product(
|
|
vendor_id=test_vendor.id,
|
|
marketplace_product_id=test_marketplace_product.id,
|
|
min_quantity=2,
|
|
max_quantity=10,
|
|
)
|
|
db.add(product)
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
assert product.min_quantity == 2
|
|
assert product.max_quantity == 10
|
|
|
|
def test_product_relationships(self, db, test_vendor, test_marketplace_product):
|
|
"""Test Product relationships."""
|
|
product = Product(
|
|
vendor_id=test_vendor.id,
|
|
marketplace_product_id=test_marketplace_product.id,
|
|
)
|
|
db.add(product)
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
assert product.vendor is not None
|
|
assert product.marketplace_product is not None
|
|
assert product.inventory_entries == [] # No inventory yet
|
|
|
|
def test_product_get_source_comparison_info(
|
|
self, db, test_vendor, test_marketplace_product
|
|
):
|
|
"""Test get_source_comparison_info method for 'view original source' feature.
|
|
|
|
Products are independent entities with all fields populated at creation.
|
|
Source values are kept for comparison only, not inheritance.
|
|
"""
|
|
# Set up marketplace product values
|
|
test_marketplace_product.price_cents = 10000 # €100.00
|
|
test_marketplace_product.brand = "SourceBrand"
|
|
db.commit()
|
|
|
|
# Create product with its own values (independent copy pattern)
|
|
product = Product(
|
|
vendor_id=test_vendor.id,
|
|
marketplace_product_id=test_marketplace_product.id,
|
|
price_cents=8999, # €89.99 - vendor's price
|
|
brand="VendorBrand", # Vendor's brand
|
|
)
|
|
db.add(product)
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
info = product.get_source_comparison_info()
|
|
|
|
# Product has its own price
|
|
assert info["price"] == 89.99
|
|
assert info["price_cents"] == 8999
|
|
assert info["price_source"] == 100.00 # Original marketplace price
|
|
|
|
# Product has its own brand
|
|
assert info["brand"] == "VendorBrand"
|
|
assert info["brand_source"] == "SourceBrand" # Original marketplace brand
|
|
|
|
# No more *_overridden keys in the pattern
|
|
assert "price_overridden" not in info
|
|
assert "brand_overridden" not in info
|
|
|
|
def test_product_fields_are_independent(
|
|
self, db, test_vendor, test_marketplace_product
|
|
):
|
|
"""Test that product fields don't inherit from marketplace product.
|
|
|
|
Products are independent entities - NULL fields stay NULL,
|
|
no inheritance/fallback logic.
|
|
"""
|
|
# Set up marketplace product values
|
|
test_marketplace_product.price_cents = 10000
|
|
test_marketplace_product.brand = "SourceBrand"
|
|
db.commit()
|
|
|
|
# Create product without copying values
|
|
product = Product(
|
|
vendor_id=test_vendor.id,
|
|
marketplace_product_id=test_marketplace_product.id,
|
|
# Not copying price_cents or brand
|
|
)
|
|
db.add(product)
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
# Fields should be NULL (not inherited)
|
|
assert product.price_cents is None
|
|
assert product.price is None
|
|
assert product.brand is None
|
|
|
|
# But we can still see the source values for comparison
|
|
info = product.get_source_comparison_info()
|
|
assert info["price_source"] == 100.00
|
|
assert info["brand_source"] == "SourceBrand"
|
|
|
|
def test_product_direct_creation_without_marketplace(self, db, test_vendor):
|
|
"""Test creating a product directly without a marketplace source.
|
|
|
|
Products can be created directly without a marketplace_product_id,
|
|
making them fully independent vendor products.
|
|
"""
|
|
product = Product(
|
|
vendor_id=test_vendor.id,
|
|
marketplace_product_id=None, # No marketplace source
|
|
vendor_sku="DIRECT_001",
|
|
brand="DirectBrand",
|
|
price=59.99,
|
|
currency="EUR",
|
|
is_digital=True,
|
|
product_type="digital",
|
|
is_active=True,
|
|
)
|
|
db.add(product)
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
assert product.id is not None
|
|
assert product.marketplace_product_id is None
|
|
assert product.marketplace_product is None
|
|
assert product.vendor_sku == "DIRECT_001"
|
|
assert product.brand == "DirectBrand"
|
|
assert product.is_digital is True
|
|
assert product.product_type == "digital"
|
|
|
|
def test_product_is_digital_column(self, db, test_vendor):
|
|
"""Test is_digital is an independent column, not derived from marketplace."""
|
|
# Create digital product without marketplace source
|
|
digital_product = Product(
|
|
vendor_id=test_vendor.id,
|
|
vendor_sku="DIGITAL_001",
|
|
is_digital=True,
|
|
product_type="digital",
|
|
)
|
|
db.add(digital_product)
|
|
db.commit()
|
|
db.refresh(digital_product)
|
|
|
|
assert digital_product.is_digital is True
|
|
assert digital_product.product_type == "digital"
|
|
|
|
# Create physical product without marketplace source
|
|
physical_product = Product(
|
|
vendor_id=test_vendor.id,
|
|
vendor_sku="PHYSICAL_001",
|
|
is_digital=False,
|
|
product_type="physical",
|
|
)
|
|
db.add(physical_product)
|
|
db.commit()
|
|
db.refresh(physical_product)
|
|
|
|
assert physical_product.is_digital is False
|
|
assert physical_product.product_type == "physical"
|
|
|
|
def test_product_type_values(self, db, test_vendor):
|
|
"""Test product_type can be set to various values."""
|
|
product_types = ["physical", "digital", "service", "subscription"]
|
|
|
|
for ptype in product_types:
|
|
product = Product(
|
|
vendor_id=test_vendor.id,
|
|
vendor_sku=f"TYPE_{ptype.upper()}",
|
|
product_type=ptype,
|
|
is_digital=(ptype == "digital"),
|
|
)
|
|
db.add(product)
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
assert product.product_type == ptype
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.database
|
|
@pytest.mark.inventory
|
|
class TestProductInventoryProperties:
|
|
"""Test Product inventory properties including digital product handling."""
|
|
|
|
def test_physical_product_no_inventory_returns_zero(self, db, test_vendor):
|
|
"""Test physical product with no inventory entries returns 0."""
|
|
product = Product(
|
|
vendor_id=test_vendor.id,
|
|
vendor_sku="PHYS_INV_001",
|
|
is_digital=False,
|
|
product_type="physical",
|
|
)
|
|
db.add(product)
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
assert product.is_digital is False
|
|
assert product.has_unlimited_inventory is False
|
|
assert product.total_inventory == 0
|
|
assert product.available_inventory == 0
|
|
|
|
def test_physical_product_with_inventory(self, db, test_vendor):
|
|
"""Test physical product calculates inventory from entries."""
|
|
from models.database.inventory import Inventory
|
|
|
|
product = Product(
|
|
vendor_id=test_vendor.id,
|
|
vendor_sku="PHYS_INV_002",
|
|
is_digital=False,
|
|
product_type="physical",
|
|
)
|
|
db.add(product)
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
# Add inventory entries
|
|
inv1 = Inventory(
|
|
product_id=product.id,
|
|
vendor_id=test_vendor.id,
|
|
warehouse="strassen",
|
|
bin_location="SA-01-01",
|
|
location="WAREHOUSE_A",
|
|
quantity=100,
|
|
reserved_quantity=10,
|
|
)
|
|
inv2 = Inventory(
|
|
product_id=product.id,
|
|
vendor_id=test_vendor.id,
|
|
warehouse="strassen",
|
|
bin_location="SA-01-02",
|
|
location="WAREHOUSE_B",
|
|
quantity=50,
|
|
reserved_quantity=5,
|
|
)
|
|
db.add_all([inv1, inv2])
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
assert product.has_unlimited_inventory is False
|
|
assert product.total_inventory == 150 # 100 + 50
|
|
assert product.available_inventory == 135 # (100-10) + (50-5)
|
|
|
|
def test_digital_product_has_unlimited_inventory(self, db, test_vendor):
|
|
"""Test digital product returns unlimited inventory."""
|
|
product = Product(
|
|
vendor_id=test_vendor.id,
|
|
vendor_sku="DIG_INV_001",
|
|
is_digital=True,
|
|
product_type="digital",
|
|
)
|
|
db.add(product)
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
assert product.is_digital is True
|
|
assert product.has_unlimited_inventory is True
|
|
assert product.total_inventory == Product.UNLIMITED_INVENTORY
|
|
assert product.available_inventory == Product.UNLIMITED_INVENTORY
|
|
|
|
def test_digital_product_ignores_inventory_entries(self, db, test_vendor):
|
|
"""Test digital product returns unlimited even with inventory entries."""
|
|
from models.database.inventory import Inventory
|
|
|
|
product = Product(
|
|
vendor_id=test_vendor.id,
|
|
vendor_sku="DIG_INV_002",
|
|
is_digital=True,
|
|
product_type="digital",
|
|
)
|
|
db.add(product)
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
# Add inventory entries (e.g., for license keys)
|
|
inv = Inventory(
|
|
product_id=product.id,
|
|
vendor_id=test_vendor.id,
|
|
warehouse="strassen",
|
|
bin_location="DIG-01-01",
|
|
location="DIGITAL_LICENSES",
|
|
quantity=10,
|
|
reserved_quantity=2,
|
|
)
|
|
db.add(inv)
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
# Digital product should still return unlimited
|
|
assert product.has_unlimited_inventory is True
|
|
assert product.total_inventory == Product.UNLIMITED_INVENTORY
|
|
assert product.available_inventory == Product.UNLIMITED_INVENTORY
|
|
|
|
def test_unlimited_inventory_constant(self):
|
|
"""Test UNLIMITED_INVENTORY constant value."""
|
|
assert Product.UNLIMITED_INVENTORY == 999999
|
|
# Should be large enough to never cause "insufficient inventory"
|
|
assert Product.UNLIMITED_INVENTORY > 100000
|