tweaks
This commit is contained in:
@@ -7,6 +7,7 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.access import router as access_router
|
||||
from app.core.access import (
|
||||
INTERNAL_USER_SUBJECT,
|
||||
get_user_permissions,
|
||||
@@ -15,7 +16,8 @@ from app.core.access import (
|
||||
require_permission,
|
||||
user_has_permission,
|
||||
)
|
||||
from app.core.security import issue_token
|
||||
from app.core.config import settings
|
||||
from app.core.security import issue_token, verify_password
|
||||
from app.db.session import Base, get_db
|
||||
from app.models.access import Permission, Role, User
|
||||
from app.seed_access import PERMISSION_DEFINITIONS, ROLE_DEFINITIONS, SEED_USERS, seed_access
|
||||
@@ -42,6 +44,10 @@ def test_seed_creates_roles_permissions_and_users():
|
||||
assert {role.name for role in db.query(Role).all()} == set(ROLE_DEFINITIONS.keys())
|
||||
assert {p.key for p in db.query(Permission).all()} == {key for key, _ in PERMISSION_DEFINITIONS}
|
||||
assert {user.email for user in db.query(User).all()} == {entry["email"] for entry in SEED_USERS}
|
||||
for user in db.query(User).all():
|
||||
assert user.password_hash is not None
|
||||
assert user.password_hash != settings.admin_password
|
||||
assert verify_password(settings.admin_password, user.password_hash)
|
||||
|
||||
|
||||
def test_seed_is_idempotent():
|
||||
@@ -71,14 +77,20 @@ def test_admin_role_permissions_match_spec():
|
||||
assert "edit_mixes" not in granted
|
||||
|
||||
|
||||
def test_operations_role_is_mix_calculator_only():
|
||||
def test_operations_role_is_mix_calculator_and_throughput_only():
|
||||
db = _build_session()
|
||||
seed_access(db)
|
||||
|
||||
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
|
||||
granted = get_user_permissions(ops)
|
||||
|
||||
assert granted == {"view_mix_calculator", "use_mix_calculator", "save_mix_calculator_session"}
|
||||
assert granted == {
|
||||
"view_mix_calculator",
|
||||
"use_mix_calculator",
|
||||
"save_mix_calculator_session",
|
||||
"view_throughput",
|
||||
"edit_throughput",
|
||||
}
|
||||
assert not user_has_permission(ops, "edit_raw_materials")
|
||||
assert not user_has_permission(ops, "view_dashboard")
|
||||
assert not user_has_permission(ops, "manage_users")
|
||||
@@ -158,6 +170,22 @@ def _token_for(user: User) -> str:
|
||||
return issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email})
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def access_app_and_db():
|
||||
db = _build_session()
|
||||
seed_access(db)
|
||||
db.commit()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
def override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.include_router(access_router)
|
||||
return TestClient(app), db
|
||||
|
||||
|
||||
def test_route_allows_user_with_permission(app_and_db):
|
||||
client, db = app_and_db
|
||||
craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one()
|
||||
@@ -234,3 +262,55 @@ def test_require_all_permissions(app_and_db):
|
||||
|
||||
denied = client.get("/needs-all", headers={"Authorization": f"Bearer {_token_for(ops)}"})
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_internal_login_uses_user_password_hash(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
|
||||
|
||||
admin.password_hash = issue_token({"not": "a password"})
|
||||
db.commit()
|
||||
|
||||
denied = client.post(
|
||||
"/api/access/login",
|
||||
json={"email": admin.email, "password": settings.admin_password},
|
||||
)
|
||||
assert denied.status_code == 401
|
||||
|
||||
|
||||
def test_internal_user_can_change_own_password(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
|
||||
|
||||
login_response = client.post(
|
||||
"/api/access/login",
|
||||
json={"email": admin.email, "password": settings.admin_password},
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
|
||||
update_response = client.patch(
|
||||
"/api/access/me",
|
||||
json={
|
||||
"current_password": settings.admin_password,
|
||||
"new_password": "new-personal-password",
|
||||
},
|
||||
cookies=login_response.cookies,
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
|
||||
db.refresh(admin)
|
||||
assert admin.password_hash is not None
|
||||
assert verify_password("new-personal-password", admin.password_hash)
|
||||
assert not verify_password(settings.admin_password, admin.password_hash)
|
||||
|
||||
old_login = client.post(
|
||||
"/api/access/login",
|
||||
json={"email": admin.email, "password": settings.admin_password},
|
||||
)
|
||||
assert old_login.status_code == 401
|
||||
|
||||
new_login = client.post(
|
||||
"/api/access/login",
|
||||
json={"email": admin.email, "password": "new-personal-password"},
|
||||
)
|
||||
assert new_login.status_code == 200
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy import create_engine, inspect, select, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCos
|
||||
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser
|
||||
from app.schemas.mix_calculator import MixCalculatorSessionCreate
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.product import Product
|
||||
from app.models.product import Product, ProductIngredient
|
||||
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
||||
from app.services.client_access_service import build_client_access_export, ensure_user_module_permissions, serialize_client_account
|
||||
from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost, serialize_raw_material
|
||||
@@ -97,12 +97,13 @@ def test_mix_and_product_cost_breakdown():
|
||||
assert product_result["wholesale_price"] == 17.3268
|
||||
|
||||
|
||||
def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags():
|
||||
def test_mix_calculator_preview_prefers_product_specific_ingredients_and_warns_on_fractional_bags():
|
||||
db = build_session()
|
||||
|
||||
maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
||||
barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
||||
db.add_all([maize, barley])
|
||||
wheat = RawMaterial(name="Wheat", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
||||
db.add_all([maize, barley, wheat])
|
||||
db.flush()
|
||||
|
||||
mix = Mix(tenant_id="specialty-feeds", client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1)
|
||||
@@ -128,6 +129,25 @@ def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags():
|
||||
bagging_process="standard_bagging",
|
||||
)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
ProductIngredient(
|
||||
tenant_id="specialty-feeds",
|
||||
product_id=product.id,
|
||||
raw_material_id=maize.id,
|
||||
quantity_kg=300,
|
||||
sort_order=1,
|
||||
),
|
||||
ProductIngredient(
|
||||
tenant_id="specialty-feeds",
|
||||
product_id=product.id,
|
||||
raw_material_id=wheat.id,
|
||||
quantity_kg=200,
|
||||
sort_order=2,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
preview = calculate_mix_calculator_preview(
|
||||
@@ -145,8 +165,9 @@ def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags():
|
||||
|
||||
assert preview["batch_size_kg"] == 550
|
||||
assert preview["total_bags"] == 27.5
|
||||
assert preview["lines"][0]["required_kg"] == 353.5714
|
||||
assert preview["lines"][1]["required_kg"] == 196.4286
|
||||
assert [line["raw_material_name"] for line in preview["lines"]] == ["Maize", "Wheat"]
|
||||
assert preview["lines"][0]["required_kg"] == 330
|
||||
assert preview["lines"][1]["required_kg"] == 220
|
||||
assert len(preview["warnings"]) == 1
|
||||
assert "not a whole-bag quantity" in preview["warnings"][0]
|
||||
|
||||
@@ -155,7 +176,8 @@ def test_mix_calculator_options_hide_invisible_products_and_clients():
|
||||
db = build_session()
|
||||
|
||||
maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
||||
db.add(maize)
|
||||
barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
||||
db.add_all([maize, barley])
|
||||
db.flush()
|
||||
|
||||
visible_mix = Mix(tenant_id="hunter-premium-produce", client_name="Peckish", name="Visible Mix", status="active", version=1)
|
||||
@@ -197,12 +219,89 @@ def test_mix_calculator_options_hide_invisible_products_and_clients():
|
||||
),
|
||||
]
|
||||
)
|
||||
db.flush()
|
||||
|
||||
visible_product = db.scalar(select(Product).where(Product.name == "Visible Product"))
|
||||
assert visible_product is not None
|
||||
db.add_all(
|
||||
[
|
||||
ProductIngredient(
|
||||
tenant_id="hunter-premium-produce",
|
||||
product_id=visible_product.id,
|
||||
raw_material_id=maize.id,
|
||||
quantity_kg=12,
|
||||
sort_order=1,
|
||||
),
|
||||
ProductIngredient(
|
||||
tenant_id="hunter-premium-produce",
|
||||
product_id=visible_product.id,
|
||||
raw_material_id=barley.id,
|
||||
quantity_kg=8,
|
||||
sort_order=2,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
options = build_mix_calculator_options(db, tenant_id="hunter-premium-produce")
|
||||
|
||||
assert options["clients"] == ["Peckish"]
|
||||
assert [product["product_name"] for product in options["products"]] == ["Visible Product"]
|
||||
assert options["products"][0]["mix_total_kg"] == 20
|
||||
|
||||
|
||||
def test_calculate_product_cost_prefers_product_specific_ingredients():
|
||||
db = build_session()
|
||||
|
||||
maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
||||
maize.price_versions.append(RawMaterialPriceVersion(market_value=520, waste_percentage=0.02, effective_date=date(2026, 4, 1)))
|
||||
barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
||||
barley.price_versions.append(RawMaterialPriceVersion(market_value=470, waste_percentage=0.015, effective_date=date(2026, 4, 1)))
|
||||
wheat = RawMaterial(name="Wheat", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
||||
wheat.price_versions.append(RawMaterialPriceVersion(market_value=600, waste_percentage=0.01, effective_date=date(2026, 4, 1)))
|
||||
db.add_all([maize, barley, wheat])
|
||||
db.flush()
|
||||
|
||||
mix = Mix(client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1)
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
MixIngredient(mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180),
|
||||
MixIngredient(mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100),
|
||||
]
|
||||
)
|
||||
db.add(ProcessCostRule(process_name="standard_bagging", grading_cost=0.055, bagging_cost=0.04, cracking_cost=0.0))
|
||||
db.add(PackagingCostRule(sale_type="standard", unit_of_measure="20kg bag", own_bag=False, bag_cost=0.63))
|
||||
db.add(FreightCostRule(sale_type="standard", unit_of_measure="20kg bag", cost_per_unit=1.45))
|
||||
db.flush()
|
||||
product = Product(
|
||||
client_name="Specialty Feeds",
|
||||
name="Specialty Pigeon Breeder 20kg",
|
||||
mix_id=mix.id,
|
||||
sale_type="standard",
|
||||
own_bag=False,
|
||||
unit_of_measure="20kg bag",
|
||||
items_per_pallet=50,
|
||||
bagging_process="standard_bagging",
|
||||
distributor_margin=0.225,
|
||||
wholesale_margin=0.18,
|
||||
)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
ProductIngredient(product_id=product.id, raw_material_id=maize.id, quantity_kg=300, sort_order=1),
|
||||
ProductIngredient(product_id=product.id, raw_material_id=wheat.id, quantity_kg=200, sort_order=2),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
product_result = calculate_product_cost(db, product.id)
|
||||
|
||||
assert product_result["finished_product_delivered"] == 15.192
|
||||
assert product_result["distributor_price"] == 19.6026
|
||||
assert product_result["wholesale_price"] == 18.5268
|
||||
|
||||
|
||||
def test_sync_product_visibility_hides_configured_clients():
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
from datetime import date
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.db.session import Base
|
||||
from app.models.mix import Mix
|
||||
from app.models.product import Product
|
||||
from app.models.raw_material import RawMaterial
|
||||
from app.models.throughput import ProductionThroughput, ThroughputProduct
|
||||
from app.seed import seed_throughput_products_from_costing
|
||||
from app.services.throughput_service import (
|
||||
calculate_kg,
|
||||
import_names_sheet,
|
||||
import_production_sheet,
|
||||
normalise_staff_name,
|
||||
qa_passed,
|
||||
)
|
||||
|
||||
|
||||
def _session() -> Session:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, expire_on_commit=False)()
|
||||
|
||||
|
||||
def _costing_mix(db: Session, tenant_id: str = "hunter-premium-produce") -> Mix:
|
||||
raw_material = RawMaterial(
|
||||
tenant_id=tenant_id,
|
||||
name="Maize",
|
||||
unit_of_measure="tonne",
|
||||
kg_per_unit=1000,
|
||||
status="active",
|
||||
)
|
||||
db.add(raw_material)
|
||||
db.flush()
|
||||
mix = Mix(tenant_id=tenant_id, client_name="Hunter", name="Maize Mix")
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
return mix
|
||||
|
||||
|
||||
def test_calculate_kg_bags():
|
||||
assert calculate_kg(10, "bags", 20) == 200.0
|
||||
|
||||
|
||||
def test_calculate_kg_kg_ignores_bag_size():
|
||||
assert calculate_kg(550, "kg", None) == 550.0
|
||||
assert calculate_kg(550, "kg", 20) == 550.0
|
||||
|
||||
|
||||
def test_calculate_kg_zero_quantity():
|
||||
assert calculate_kg(0, "bags", 20) == 0.0
|
||||
assert calculate_kg(None, "bags", 20) == 0.0
|
||||
|
||||
|
||||
def test_staff_name_normalisation():
|
||||
assert normalise_staff_name(" Jake ") == "Jake"
|
||||
assert normalise_staff_name("jake smith") == "jake smith"
|
||||
assert normalise_staff_name("") is None
|
||||
assert normalise_staff_name(None) is None
|
||||
|
||||
|
||||
def test_qa_passed_flag():
|
||||
entry = ProductionThroughput(
|
||||
production_date=date(2026, 1, 1),
|
||||
product_name_snapshot="X",
|
||||
quantity=1,
|
||||
quantity_type="bags",
|
||||
scales_checked=True,
|
||||
label_correct=True,
|
||||
bag_sealed=True,
|
||||
pallet_good_condition=True,
|
||||
)
|
||||
assert qa_passed(entry) is True
|
||||
entry.bag_sealed = False
|
||||
assert qa_passed(entry) is False
|
||||
|
||||
|
||||
def _make_workbook() -> BytesIO:
|
||||
wb = Workbook()
|
||||
names = wb.active
|
||||
names.title = "Names"
|
||||
names.append(["Name", "Item ID"])
|
||||
names.append(["Whole Wheat 20kg", 1001])
|
||||
names.append(["Bulka Maize", 1002])
|
||||
|
||||
production = wb.create_sheet("Production")
|
||||
production.append(["#VALUE!", "Operations Throughput"])
|
||||
production.append([None] * 8 + ["TEST WEIGHT"])
|
||||
production.append([
|
||||
"DATE", "GRAIN", "BAG SIZE", "SCALES", "LABEL", "SEALED", "PALLET",
|
||||
"BOX", 1, 2, 3, 4, 5, "QTY", "STAFF", "NOTES",
|
||||
])
|
||||
production.append([date(2026, 4, 1), "Whole Wheat 20kg", 20, True, True, True, True, None, None, None, None, None, None, 100, " Jake ", None])
|
||||
production.append([date(2026, 4, 1), "Bulka Maize", None, True, True, False, True, "B7", None, None, None, None, None, 1500, "Alex", "ok"])
|
||||
production.append([date(2026, 4, 2), "Whole Wheat 20kg", 20, False, True, True, True, None, None, None, None, None, None, 50, "Jake", None])
|
||||
|
||||
buf = BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
|
||||
def test_import_names_and_production():
|
||||
from openpyxl import load_workbook
|
||||
|
||||
db = _session()
|
||||
wb = load_workbook(_make_workbook(), data_only=True)
|
||||
created, _ = import_names_sheet(db, wb, "test-tenant")
|
||||
assert created == 2
|
||||
|
||||
imported, skipped = import_production_sheet(db, wb, "test-tenant")
|
||||
assert imported == 3
|
||||
assert skipped == 0
|
||||
|
||||
entries = db.scalars(select(ProductionThroughput).order_by(ProductionThroughput.id)).all()
|
||||
bags_entry = entries[0]
|
||||
assert bags_entry.quantity_type == "bags"
|
||||
assert bags_entry.calculated_kg == 2000.0
|
||||
assert bags_entry.staff_name == "Jake" # whitespace trimmed
|
||||
|
||||
bulka_entry = entries[1]
|
||||
assert bulka_entry.quantity_type == "kg"
|
||||
assert bulka_entry.calculated_kg == 1500.0
|
||||
assert qa_passed(bulka_entry) is False # bag_sealed was False
|
||||
|
||||
# Product master should have absorbed default_bag_size for the wheat product
|
||||
wheat = db.scalar(
|
||||
select(ThroughputProduct).where(ThroughputProduct.name == "Whole Wheat 20kg")
|
||||
)
|
||||
assert wheat is not None
|
||||
assert wheat.default_bag_size == 20
|
||||
|
||||
|
||||
def test_product_name_snapshot_preserved_when_product_renamed():
|
||||
db = _session()
|
||||
product = ThroughputProduct(tenant_id="t", name="Original Name", default_bag_size=20)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
entry = ProductionThroughput(
|
||||
tenant_id="t",
|
||||
production_date=date(2026, 4, 1),
|
||||
product_id=product.id,
|
||||
product_name_snapshot=product.name,
|
||||
bag_size=20,
|
||||
quantity=10,
|
||||
quantity_type="bags",
|
||||
calculated_kg=200,
|
||||
)
|
||||
db.add(entry)
|
||||
db.flush()
|
||||
|
||||
product.name = "Renamed Product"
|
||||
db.flush()
|
||||
|
||||
reloaded = db.scalar(select(ProductionThroughput).where(ProductionThroughput.id == entry.id))
|
||||
assert reloaded.product_name_snapshot == "Original Name"
|
||||
|
||||
|
||||
def test_seed_throughput_products_from_costing_products():
|
||||
db = _session()
|
||||
mix = _costing_mix(db)
|
||||
db.add_all(
|
||||
[
|
||||
Product(
|
||||
tenant_id="hunter-premium-produce",
|
||||
client_name="Hunter",
|
||||
item_id="1001",
|
||||
name="Whole Wheat 20kg",
|
||||
mix_id=mix.id,
|
||||
sale_type="standard",
|
||||
unit_of_measure="20kg bag",
|
||||
visible=True,
|
||||
),
|
||||
Product(
|
||||
tenant_id="hunter-premium-produce",
|
||||
client_name="Hunter",
|
||||
item_id="1002",
|
||||
name="Bulka Maize",
|
||||
mix_id=mix.id,
|
||||
sale_type="bulka",
|
||||
unit_of_measure="tonne",
|
||||
visible=False,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.flush()
|
||||
|
||||
report = seed_throughput_products_from_costing(db)
|
||||
|
||||
assert report == {"created": 2, "updated": 0, "skipped": 0}
|
||||
products = db.scalars(select(ThroughputProduct).order_by(ThroughputProduct.item_id)).all()
|
||||
assert [product.name for product in products] == ["Whole Wheat 20kg", "Bulka Maize"]
|
||||
assert products[0].default_bag_size == 20
|
||||
assert products[0].is_bulka_default is False
|
||||
assert products[0].active is True
|
||||
assert products[1].default_bag_size is None
|
||||
assert products[1].is_bulka_default is True
|
||||
assert products[1].active is False
|
||||
|
||||
|
||||
def test_seed_throughput_products_from_costing_updates_existing_by_item_id():
|
||||
db = _session()
|
||||
mix = _costing_mix(db)
|
||||
db.add(
|
||||
Product(
|
||||
tenant_id="hunter-premium-produce",
|
||||
client_name="Hunter",
|
||||
item_id="1001",
|
||||
name="Updated Wheat 25kg",
|
||||
mix_id=mix.id,
|
||||
sale_type="standard",
|
||||
unit_of_measure="25kg bag",
|
||||
visible=True,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
ThroughputProduct(
|
||||
tenant_id="hunter-premium-produce",
|
||||
item_id="1001",
|
||||
name="Old Wheat",
|
||||
default_bag_size=20,
|
||||
active=False,
|
||||
notes="Seeded from costing products",
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
report = seed_throughput_products_from_costing(db)
|
||||
|
||||
assert report == {"created": 0, "updated": 1, "skipped": 0}
|
||||
products = db.scalars(select(ThroughputProduct)).all()
|
||||
assert len(products) == 1
|
||||
assert products[0].name == "Updated Wheat 25kg"
|
||||
assert products[0].default_bag_size == 25
|
||||
assert products[0].active is True
|
||||
Reference in New Issue
Block a user