fix(billing): complete billing module — fix tier change, platform support, merchant portal
- Fix admin tier change: resolve tier_code→tier_id in update_subscription(), delegate to billing_service.change_tier() for Stripe-connected subs - Add platform support to admin tiers page: platform column, filter dropdown, platform selector in create/edit modal, platform_name in tier API response - Filter used platforms in create subscription modal on merchant detail page - Enrich merchant portal API responses with tier code, tier_name, platform_name - Add eager-load of platform relationship in get_merchant_subscription() - Remove stale store_name/store_code references from merchant templates - Add merchant tier change endpoint (POST /change-tier) and tier selector UI replacing broken requestUpgrade() button - Fix subscription detail link to use platform_id instead of sub.id Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
165
app/modules/inventory/tests/unit/test_inventory_model.py
Normal file
165
app/modules/inventory/tests/unit/test_inventory_model.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# tests/unit/models/database/test_inventory.py
|
||||
"""Unit tests for Inventory database model."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.modules.inventory.models import Inventory
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.database
|
||||
class TestInventoryModel:
|
||||
"""Test Inventory model."""
|
||||
|
||||
def test_inventory_creation_with_product(self, db, test_store, test_product):
|
||||
"""Test Inventory model linked to product."""
|
||||
inventory = Inventory(
|
||||
product_id=test_product.id,
|
||||
store_id=test_store.id,
|
||||
warehouse="strassen",
|
||||
bin_location="SA-10-01",
|
||||
location="WAREHOUSE_A",
|
||||
quantity=150,
|
||||
reserved_quantity=10,
|
||||
gtin=test_product.marketplace_product.gtin,
|
||||
)
|
||||
|
||||
db.add(inventory)
|
||||
db.commit()
|
||||
db.refresh(inventory)
|
||||
|
||||
assert inventory.id is not None
|
||||
assert inventory.product_id == test_product.id
|
||||
assert inventory.store_id == test_store.id
|
||||
assert inventory.location == "WAREHOUSE_A"
|
||||
assert inventory.bin_location == "SA-10-01"
|
||||
assert inventory.quantity == 150
|
||||
assert inventory.reserved_quantity == 10
|
||||
assert inventory.available_quantity == 140 # 150 - 10
|
||||
|
||||
def test_inventory_unique_product_location(self, db, test_store, test_product):
|
||||
"""Test unique constraint on product_id + warehouse + bin_location."""
|
||||
inventory1 = Inventory(
|
||||
product_id=test_product.id,
|
||||
store_id=test_store.id,
|
||||
warehouse="strassen",
|
||||
bin_location="SA-10-01",
|
||||
location="WAREHOUSE_A",
|
||||
quantity=100,
|
||||
)
|
||||
db.add(inventory1)
|
||||
db.commit()
|
||||
|
||||
# Same product + warehouse + bin_location should fail
|
||||
with pytest.raises(IntegrityError):
|
||||
inventory2 = Inventory(
|
||||
product_id=test_product.id,
|
||||
store_id=test_store.id,
|
||||
warehouse="strassen",
|
||||
bin_location="SA-10-01",
|
||||
location="WAREHOUSE_A",
|
||||
quantity=50,
|
||||
)
|
||||
db.add(inventory2)
|
||||
db.commit()
|
||||
|
||||
def test_inventory_same_product_different_location(
|
||||
self, db, test_store, test_product
|
||||
):
|
||||
"""Test same product can have inventory in different locations."""
|
||||
inventory1 = Inventory(
|
||||
product_id=test_product.id,
|
||||
store_id=test_store.id,
|
||||
warehouse="strassen",
|
||||
bin_location="SA-10-01",
|
||||
location="WAREHOUSE_A",
|
||||
quantity=100,
|
||||
)
|
||||
db.add(inventory1)
|
||||
db.commit()
|
||||
|
||||
# Same product in different bin_location should succeed
|
||||
inventory2 = Inventory(
|
||||
product_id=test_product.id,
|
||||
store_id=test_store.id,
|
||||
warehouse="strassen",
|
||||
bin_location="SA-10-02",
|
||||
location="WAREHOUSE_B",
|
||||
quantity=50,
|
||||
)
|
||||
db.add(inventory2)
|
||||
db.commit()
|
||||
db.refresh(inventory2)
|
||||
|
||||
assert inventory2.id is not None
|
||||
assert inventory2.bin_location == "SA-10-02"
|
||||
|
||||
def test_inventory_default_values(self, db, test_store, test_product):
|
||||
"""Test Inventory model default values."""
|
||||
inventory = Inventory(
|
||||
product_id=test_product.id,
|
||||
store_id=test_store.id,
|
||||
warehouse="strassen",
|
||||
bin_location="DEF-01-01",
|
||||
location="DEFAULT_LOC",
|
||||
quantity=100,
|
||||
)
|
||||
db.add(inventory)
|
||||
db.commit()
|
||||
db.refresh(inventory)
|
||||
|
||||
assert inventory.reserved_quantity == 0 # Default
|
||||
assert inventory.available_quantity == 100 # quantity - reserved
|
||||
|
||||
def test_inventory_available_quantity_property(self, db, test_store, test_product):
|
||||
"""Test available_quantity computed property."""
|
||||
inventory = Inventory(
|
||||
product_id=test_product.id,
|
||||
store_id=test_store.id,
|
||||
warehouse="strassen",
|
||||
bin_location="PROP-01-01",
|
||||
location="PROP_TEST",
|
||||
quantity=200,
|
||||
reserved_quantity=50,
|
||||
)
|
||||
db.add(inventory)
|
||||
db.commit()
|
||||
db.refresh(inventory)
|
||||
|
||||
assert inventory.available_quantity == 150 # 200 - 50
|
||||
|
||||
def test_inventory_relationships(self, db, test_store, test_product):
|
||||
"""Test Inventory relationships."""
|
||||
inventory = Inventory(
|
||||
product_id=test_product.id,
|
||||
store_id=test_store.id,
|
||||
warehouse="strassen",
|
||||
bin_location="REL-01-01",
|
||||
location="REL_TEST",
|
||||
quantity=100,
|
||||
)
|
||||
db.add(inventory)
|
||||
db.commit()
|
||||
db.refresh(inventory)
|
||||
|
||||
assert inventory.product is not None
|
||||
assert inventory.store is not None
|
||||
assert inventory.product.id == test_product.id
|
||||
assert inventory.store.id == test_store.id
|
||||
|
||||
def test_inventory_without_gtin(self, db, test_store, test_product):
|
||||
"""Test Inventory can be created without GTIN."""
|
||||
inventory = Inventory(
|
||||
product_id=test_product.id,
|
||||
store_id=test_store.id,
|
||||
warehouse="strassen",
|
||||
bin_location="NOGTIN-01-01",
|
||||
location="NO_GTIN",
|
||||
quantity=100,
|
||||
)
|
||||
db.add(inventory)
|
||||
db.commit()
|
||||
db.refresh(inventory)
|
||||
|
||||
assert inventory.gtin is None
|
||||
299
app/modules/inventory/tests/unit/test_inventory_schema.py
Normal file
299
app/modules/inventory/tests/unit/test_inventory_schema.py
Normal file
@@ -0,0 +1,299 @@
|
||||
# tests/unit/models/schema/test_inventory.py
|
||||
"""Unit tests for inventory Pydantic schemas."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.modules.inventory.schemas import (
|
||||
InventoryAdjust,
|
||||
InventoryCreate,
|
||||
InventoryLocationResponse,
|
||||
InventoryReserve,
|
||||
InventoryResponse,
|
||||
InventoryUpdate,
|
||||
ProductInventorySummary,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
class TestInventoryCreateSchema:
|
||||
"""Test InventoryCreate schema validation."""
|
||||
|
||||
def test_valid_inventory_create(self):
|
||||
"""Test valid inventory creation data."""
|
||||
inventory = InventoryCreate(
|
||||
product_id=1,
|
||||
location="Warehouse A",
|
||||
quantity=100,
|
||||
)
|
||||
assert inventory.product_id == 1
|
||||
assert inventory.location == "Warehouse A"
|
||||
assert inventory.quantity == 100
|
||||
|
||||
def test_product_id_required(self):
|
||||
"""Test product_id is required."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
InventoryCreate(location="Warehouse A", quantity=10)
|
||||
assert "product_id" in str(exc_info.value).lower()
|
||||
|
||||
def test_location_required(self):
|
||||
"""Test location is required."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
InventoryCreate(product_id=1, quantity=10)
|
||||
assert "location" in str(exc_info.value).lower()
|
||||
|
||||
def test_quantity_required(self):
|
||||
"""Test quantity is required."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
InventoryCreate(product_id=1, location="Warehouse A")
|
||||
assert "quantity" in str(exc_info.value).lower()
|
||||
|
||||
def test_quantity_must_be_non_negative(self):
|
||||
"""Test quantity must be >= 0."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
InventoryCreate(
|
||||
product_id=1,
|
||||
location="Warehouse A",
|
||||
quantity=-5,
|
||||
)
|
||||
assert "quantity" in str(exc_info.value).lower()
|
||||
|
||||
def test_quantity_zero_is_valid(self):
|
||||
"""Test quantity of 0 is valid."""
|
||||
inventory = InventoryCreate(
|
||||
product_id=1,
|
||||
location="Warehouse A",
|
||||
quantity=0,
|
||||
)
|
||||
assert inventory.quantity == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
class TestInventoryAdjustSchema:
|
||||
"""Test InventoryAdjust schema validation."""
|
||||
|
||||
def test_positive_adjustment(self):
|
||||
"""Test positive quantity adjustment (add stock)."""
|
||||
adjustment = InventoryAdjust(
|
||||
product_id=1,
|
||||
location="Warehouse A",
|
||||
quantity=50,
|
||||
)
|
||||
assert adjustment.quantity == 50
|
||||
|
||||
def test_negative_adjustment(self):
|
||||
"""Test negative quantity adjustment (remove stock)."""
|
||||
adjustment = InventoryAdjust(
|
||||
product_id=1,
|
||||
location="Warehouse A",
|
||||
quantity=-20,
|
||||
)
|
||||
assert adjustment.quantity == -20
|
||||
|
||||
def test_zero_adjustment_is_valid(self):
|
||||
"""Test zero adjustment is technically valid."""
|
||||
adjustment = InventoryAdjust(
|
||||
product_id=1,
|
||||
location="Warehouse A",
|
||||
quantity=0,
|
||||
)
|
||||
assert adjustment.quantity == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
class TestInventoryUpdateSchema:
|
||||
"""Test InventoryUpdate schema validation."""
|
||||
|
||||
def test_partial_update(self):
|
||||
"""Test partial update with only some fields."""
|
||||
update = InventoryUpdate(quantity=150)
|
||||
assert update.quantity == 150
|
||||
assert update.reserved_quantity is None
|
||||
assert update.location is None
|
||||
|
||||
def test_empty_update_is_valid(self):
|
||||
"""Test empty update is valid."""
|
||||
update = InventoryUpdate()
|
||||
assert update.model_dump(exclude_unset=True) == {}
|
||||
|
||||
def test_quantity_must_be_non_negative(self):
|
||||
"""Test quantity must be >= 0 in update."""
|
||||
with pytest.raises(ValidationError):
|
||||
InventoryUpdate(quantity=-10)
|
||||
|
||||
def test_reserved_quantity_must_be_non_negative(self):
|
||||
"""Test reserved_quantity must be >= 0."""
|
||||
with pytest.raises(ValidationError):
|
||||
InventoryUpdate(reserved_quantity=-5)
|
||||
|
||||
def test_location_update(self):
|
||||
"""Test location can be updated."""
|
||||
update = InventoryUpdate(location="Warehouse B")
|
||||
assert update.location == "Warehouse B"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
class TestInventoryReserveSchema:
|
||||
"""Test InventoryReserve schema validation."""
|
||||
|
||||
def test_valid_reservation(self):
|
||||
"""Test valid inventory reservation."""
|
||||
reservation = InventoryReserve(
|
||||
product_id=1,
|
||||
location="Warehouse A",
|
||||
quantity=10,
|
||||
)
|
||||
assert reservation.product_id == 1
|
||||
assert reservation.quantity == 10
|
||||
|
||||
def test_quantity_must_be_positive(self):
|
||||
"""Test reservation quantity must be > 0."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
InventoryReserve(
|
||||
product_id=1,
|
||||
location="Warehouse A",
|
||||
quantity=0,
|
||||
)
|
||||
assert "quantity" in str(exc_info.value).lower()
|
||||
|
||||
def test_negative_quantity_invalid(self):
|
||||
"""Test negative reservation quantity is invalid."""
|
||||
with pytest.raises(ValidationError):
|
||||
InventoryReserve(
|
||||
product_id=1,
|
||||
location="Warehouse A",
|
||||
quantity=-5,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
class TestInventoryResponseSchema:
|
||||
"""Test InventoryResponse schema."""
|
||||
|
||||
def test_from_dict(self):
|
||||
"""Test creating response from dict."""
|
||||
from datetime import datetime
|
||||
|
||||
data = {
|
||||
"id": 1,
|
||||
"product_id": 1,
|
||||
"store_id": 1,
|
||||
"location": "Warehouse A",
|
||||
"quantity": 100,
|
||||
"reserved_quantity": 20,
|
||||
"gtin": "1234567890123",
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
}
|
||||
response = InventoryResponse(**data)
|
||||
assert response.id == 1
|
||||
assert response.quantity == 100
|
||||
assert response.reserved_quantity == 20
|
||||
|
||||
def test_available_quantity_property(self):
|
||||
"""Test available_quantity calculated property."""
|
||||
from datetime import datetime
|
||||
|
||||
data = {
|
||||
"id": 1,
|
||||
"product_id": 1,
|
||||
"store_id": 1,
|
||||
"location": "Warehouse A",
|
||||
"quantity": 100,
|
||||
"reserved_quantity": 30,
|
||||
"gtin": None,
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
}
|
||||
response = InventoryResponse(**data)
|
||||
assert response.available_quantity == 70
|
||||
|
||||
def test_available_quantity_never_negative(self):
|
||||
"""Test available_quantity is never negative."""
|
||||
from datetime import datetime
|
||||
|
||||
data = {
|
||||
"id": 1,
|
||||
"product_id": 1,
|
||||
"store_id": 1,
|
||||
"location": "Warehouse A",
|
||||
"quantity": 10,
|
||||
"reserved_quantity": 50, # Over-reserved
|
||||
"gtin": None,
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
}
|
||||
response = InventoryResponse(**data)
|
||||
assert response.available_quantity == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
class TestInventoryLocationResponseSchema:
|
||||
"""Test InventoryLocationResponse schema."""
|
||||
|
||||
def test_valid_location_response(self):
|
||||
"""Test valid location response."""
|
||||
location = InventoryLocationResponse(
|
||||
location="Warehouse A",
|
||||
quantity=100,
|
||||
reserved_quantity=20,
|
||||
available_quantity=80,
|
||||
)
|
||||
assert location.location == "Warehouse A"
|
||||
assert location.quantity == 100
|
||||
assert location.available_quantity == 80
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
class TestProductInventorySummarySchema:
|
||||
"""Test ProductInventorySummary schema."""
|
||||
|
||||
def test_valid_summary(self):
|
||||
"""Test valid inventory summary."""
|
||||
summary = ProductInventorySummary(
|
||||
product_id=1,
|
||||
store_id=1,
|
||||
product_sku="SKU-001",
|
||||
product_title="Test Product",
|
||||
total_quantity=200,
|
||||
total_reserved=50,
|
||||
total_available=150,
|
||||
locations=[
|
||||
InventoryLocationResponse(
|
||||
location="Warehouse A",
|
||||
quantity=100,
|
||||
reserved_quantity=25,
|
||||
available_quantity=75,
|
||||
),
|
||||
InventoryLocationResponse(
|
||||
location="Warehouse B",
|
||||
quantity=100,
|
||||
reserved_quantity=25,
|
||||
available_quantity=75,
|
||||
),
|
||||
],
|
||||
)
|
||||
assert summary.product_id == 1
|
||||
assert summary.total_quantity == 200
|
||||
assert len(summary.locations) == 2
|
||||
|
||||
def test_empty_locations(self):
|
||||
"""Test summary with no locations."""
|
||||
summary = ProductInventorySummary(
|
||||
product_id=1,
|
||||
store_id=1,
|
||||
product_sku=None,
|
||||
product_title="Test Product",
|
||||
total_quantity=0,
|
||||
total_reserved=0,
|
||||
total_available=0,
|
||||
locations=[],
|
||||
)
|
||||
assert summary.locations == []
|
||||
Reference in New Issue
Block a user