feat: make Product fully independent from MarketplaceProduct
- 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>
This commit is contained in:
@@ -210,3 +210,287 @@ class TestAdminVendorProductsAPI:
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.api
|
||||
@pytest.mark.admin
|
||||
@pytest.mark.products
|
||||
class TestAdminVendorProductCreateAPI:
|
||||
"""Tests for admin vendor product creation endpoints."""
|
||||
|
||||
def test_create_vendor_product_with_translations(
|
||||
self, client, admin_headers, test_vendor
|
||||
):
|
||||
"""Test creating a product with multi-language translations."""
|
||||
payload = {
|
||||
"vendor_id": test_vendor.id,
|
||||
"translations": {
|
||||
"en": {"title": "Test Product EN", "description": "English description"},
|
||||
"fr": {"title": "Test Product FR", "description": "French description"},
|
||||
},
|
||||
"vendor_sku": "CREATE_TEST_001",
|
||||
"brand": "TestBrand",
|
||||
"gtin": "1234567890123",
|
||||
"gtin_type": "ean13",
|
||||
"price": 29.99,
|
||||
"currency": "EUR",
|
||||
"tax_rate_percent": 17,
|
||||
"is_active": True,
|
||||
"is_digital": False,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/admin/vendor-products",
|
||||
json=payload,
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "id" in data
|
||||
assert data["message"] == "Product created successfully"
|
||||
|
||||
# Verify the created product
|
||||
product_id = data["id"]
|
||||
detail_response = client.get(
|
||||
f"/api/v1/admin/vendor-products/{product_id}",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert detail_response.status_code == 200
|
||||
detail = detail_response.json()
|
||||
assert detail["vendor_id"] == test_vendor.id
|
||||
assert detail["vendor_sku"] == "CREATE_TEST_001"
|
||||
assert detail["brand"] == "TestBrand"
|
||||
assert detail["is_digital"] is False
|
||||
assert detail["vendor_translations"]["en"]["title"] == "Test Product EN"
|
||||
|
||||
def test_create_digital_product(self, client, admin_headers, test_vendor):
|
||||
"""Test creating a digital product directly."""
|
||||
payload = {
|
||||
"vendor_id": test_vendor.id,
|
||||
"translations": {
|
||||
"en": {"title": "Digital Game Key", "description": "Steam game key"},
|
||||
},
|
||||
"vendor_sku": "DIGITAL_001",
|
||||
"price": 49.99,
|
||||
"is_digital": True,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/admin/vendor-products",
|
||||
json=payload,
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify the product is digital
|
||||
detail_response = client.get(
|
||||
f"/api/v1/admin/vendor-products/{data['id']}",
|
||||
headers=admin_headers,
|
||||
)
|
||||
detail = detail_response.json()
|
||||
assert detail["is_digital"] is True
|
||||
assert detail["product_type"] == "digital"
|
||||
|
||||
def test_create_product_without_marketplace_source(
|
||||
self, client, admin_headers, test_vendor
|
||||
):
|
||||
"""Test creating a direct product without marketplace source."""
|
||||
payload = {
|
||||
"vendor_id": test_vendor.id,
|
||||
"translations": {
|
||||
"en": {"title": "Direct Product", "description": "Created directly"},
|
||||
},
|
||||
"vendor_sku": "DIRECT_001",
|
||||
"brand": "DirectBrand",
|
||||
"price": 19.99,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/admin/vendor-products",
|
||||
json=payload,
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify there's no marketplace source
|
||||
detail_response = client.get(
|
||||
f"/api/v1/admin/vendor-products/{data['id']}",
|
||||
headers=admin_headers,
|
||||
)
|
||||
detail = detail_response.json()
|
||||
assert detail["marketplace_product_id"] is None
|
||||
assert detail["source_marketplace"] is None
|
||||
assert detail["source_vendor"] is None
|
||||
|
||||
def test_create_product_non_admin(self, client, auth_headers, test_vendor):
|
||||
"""Test non-admin trying to create product."""
|
||||
payload = {
|
||||
"vendor_id": test_vendor.id,
|
||||
"translations": {"en": {"title": "Test"}},
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/admin/vendor-products",
|
||||
json=payload,
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.api
|
||||
@pytest.mark.admin
|
||||
@pytest.mark.products
|
||||
class TestAdminVendorProductUpdateAPI:
|
||||
"""Tests for admin vendor product update endpoints."""
|
||||
|
||||
def test_update_vendor_product_translations(
|
||||
self, client, admin_headers, test_vendor
|
||||
):
|
||||
"""Test updating product translations by first creating a product with translations."""
|
||||
# First create a product with translations
|
||||
create_payload = {
|
||||
"vendor_id": test_vendor.id,
|
||||
"translations": {
|
||||
"en": {"title": "Original Title EN", "description": "Original desc"},
|
||||
},
|
||||
"vendor_sku": "TRANS_TEST_001",
|
||||
"price": 10.00,
|
||||
"is_active": True,
|
||||
}
|
||||
create_response = client.post(
|
||||
"/api/v1/admin/vendor-products",
|
||||
json=create_payload,
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
product_id = create_response.json()["id"]
|
||||
|
||||
# Now update the translations
|
||||
update_payload = {
|
||||
"translations": {
|
||||
"en": {"title": "Updated Title EN", "description": "Updated desc EN"},
|
||||
"de": {"title": "Updated Title DE", "description": "Updated desc DE"},
|
||||
}
|
||||
}
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/admin/vendor-products/{product_id}",
|
||||
json=update_payload,
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Re-fetch the product to verify translations were saved
|
||||
detail_response = client.get(
|
||||
f"/api/v1/admin/vendor-products/{product_id}",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert detail_response.status_code == 200
|
||||
data = detail_response.json()
|
||||
|
||||
# Check translations are present and updated
|
||||
assert "vendor_translations" in data
|
||||
assert data["vendor_translations"] is not None
|
||||
assert "en" in data["vendor_translations"]
|
||||
assert data["vendor_translations"]["en"]["title"] == "Updated Title EN"
|
||||
|
||||
def test_update_vendor_product_is_digital(
|
||||
self, client, admin_headers, test_product, db
|
||||
):
|
||||
"""Test updating product is_digital flag."""
|
||||
# First ensure it's not digital
|
||||
test_product.is_digital = False
|
||||
db.commit()
|
||||
|
||||
payload = {"is_digital": True}
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/admin/vendor-products/{test_product.id}",
|
||||
json=payload,
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["is_digital"] is True
|
||||
|
||||
def test_update_vendor_product_pricing(self, client, admin_headers, test_product):
|
||||
"""Test updating product pricing fields."""
|
||||
payload = {
|
||||
"price": 99.99,
|
||||
"sale_price": 79.99,
|
||||
"tax_rate_percent": 8,
|
||||
"availability": "in_stock",
|
||||
}
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/admin/vendor-products/{test_product.id}",
|
||||
json=payload,
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["price"] == 99.99
|
||||
assert data["sale_price"] == 79.99
|
||||
assert data["tax_rate_percent"] == 8
|
||||
assert data["availability"] == "in_stock"
|
||||
|
||||
def test_update_vendor_product_identifiers(
|
||||
self, client, admin_headers, test_product
|
||||
):
|
||||
"""Test updating product identifiers."""
|
||||
payload = {
|
||||
"vendor_sku": "UPDATED_SKU_001",
|
||||
"brand": "UpdatedBrand",
|
||||
"gtin": "9876543210123",
|
||||
"gtin_type": "ean13",
|
||||
}
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/admin/vendor-products/{test_product.id}",
|
||||
json=payload,
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["vendor_sku"] == "UPDATED_SKU_001"
|
||||
assert data["brand"] == "UpdatedBrand"
|
||||
assert data["gtin"] == "9876543210123"
|
||||
|
||||
def test_update_vendor_product_not_found(self, client, admin_headers):
|
||||
"""Test updating non-existent product."""
|
||||
payload = {"brand": "Test"}
|
||||
|
||||
response = client.patch(
|
||||
"/api/v1/admin/vendor-products/99999",
|
||||
json=payload,
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["error_code"] == "PRODUCT_NOT_FOUND"
|
||||
|
||||
def test_update_vendor_product_non_admin(self, client, auth_headers, test_product):
|
||||
"""Test non-admin trying to update product."""
|
||||
payload = {"brand": "Test"}
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/admin/vendor-products/{test_product.id}",
|
||||
json=payload,
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -72,6 +72,8 @@ class TestProductModel:
|
||||
|
||||
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
|
||||
|
||||
@@ -202,6 +204,82 @@ class TestProductModel:
|
||||
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
|
||||
@@ -209,17 +287,13 @@ class TestProductModel:
|
||||
class TestProductInventoryProperties:
|
||||
"""Test Product inventory properties including digital product handling."""
|
||||
|
||||
def test_physical_product_no_inventory_returns_zero(
|
||||
self, db, test_vendor, test_marketplace_product
|
||||
):
|
||||
def test_physical_product_no_inventory_returns_zero(self, db, test_vendor):
|
||||
"""Test physical product with no inventory entries returns 0."""
|
||||
# Ensure product is physical
|
||||
test_marketplace_product.is_digital = False
|
||||
db.commit()
|
||||
|
||||
product = Product(
|
||||
vendor_id=test_vendor.id,
|
||||
marketplace_product_id=test_marketplace_product.id,
|
||||
vendor_sku="PHYS_INV_001",
|
||||
is_digital=False,
|
||||
product_type="physical",
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
@@ -230,18 +304,15 @@ class TestProductInventoryProperties:
|
||||
assert product.total_inventory == 0
|
||||
assert product.available_inventory == 0
|
||||
|
||||
def test_physical_product_with_inventory(
|
||||
self, db, test_vendor, test_marketplace_product
|
||||
):
|
||||
def test_physical_product_with_inventory(self, db, test_vendor):
|
||||
"""Test physical product calculates inventory from entries."""
|
||||
from models.database.inventory import Inventory
|
||||
|
||||
test_marketplace_product.is_digital = False
|
||||
db.commit()
|
||||
|
||||
product = Product(
|
||||
vendor_id=test_vendor.id,
|
||||
marketplace_product_id=test_marketplace_product.id,
|
||||
vendor_sku="PHYS_INV_002",
|
||||
is_digital=False,
|
||||
product_type="physical",
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
@@ -274,16 +345,13 @@ class TestProductInventoryProperties:
|
||||
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_marketplace_product
|
||||
):
|
||||
def test_digital_product_has_unlimited_inventory(self, db, test_vendor):
|
||||
"""Test digital product returns unlimited inventory."""
|
||||
test_marketplace_product.is_digital = True
|
||||
db.commit()
|
||||
|
||||
product = Product(
|
||||
vendor_id=test_vendor.id,
|
||||
marketplace_product_id=test_marketplace_product.id,
|
||||
vendor_sku="DIG_INV_001",
|
||||
is_digital=True,
|
||||
product_type="digital",
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
@@ -294,18 +362,15 @@ class TestProductInventoryProperties:
|
||||
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_marketplace_product
|
||||
):
|
||||
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
|
||||
|
||||
test_marketplace_product.is_digital = True
|
||||
db.commit()
|
||||
|
||||
product = Product(
|
||||
vendor_id=test_vendor.id,
|
||||
marketplace_product_id=test_marketplace_product.id,
|
||||
vendor_sku="DIG_INV_002",
|
||||
is_digital=True,
|
||||
product_type="digital",
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user