272 lines
12 KiB
Python
272 lines
12 KiB
Python
from datetime import date
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
@pytest.fixture
|
|
def case(monkeypatch):
|
|
from app.models import pl_models as m
|
|
from app.models.crm_models import CrmContract, SysOss
|
|
from app.models.pl_quota_models import PlStopLegalDocument
|
|
from app.services import pl_stop_legal_document as svc
|
|
engine = create_engine('sqlite://')
|
|
for model in (m.PlPlan, m.PlPlanProduct, m.PlPlanLoad, m.PlPlanLoadProduct,
|
|
m.PlPlanDelivery, m.PlPlanDeliveryProduct, m.PlPlanChange,
|
|
m.PlPlanContract, m.PlPlanProductBrandQty, m.PlTransportDriverStopFeedback,
|
|
m.PlTransportSiteStopFeedback, m.PlTransportDbLink, CrmContract, SysOss, PlStopLegalDocument):
|
|
model.__table__.create(engine)
|
|
db = Session(engine)
|
|
user = SimpleNamespace(id='u', name='测试', dept_id='d', tenant_no='t', role='super_admin',
|
|
to_user_info=lambda: {'role': 'super_admin', 'user_id': 'u'})
|
|
db.add(m.PlPlan(id='p', plan_no='P', create_date=date.today(), belongs_company='shanxia',
|
|
plan_type='direct', tenant_no='t', transport_status='arranged', archive_status='archived'))
|
|
db.add(m.PlPlanProduct(id='pp', plan_id='p', product_id='sku', product_no='S', product_name='产品',
|
|
is_explosive=1, legal_use_cert_url='legacy.jpg', tenant_no='t'))
|
|
db.add(CrmContract(id='c', contract_no='C', contract_name='合同', customer_id='customer', contract_type='sale', tenant_no='t'))
|
|
for side, rid, cid in [('load', 'l', None), ('delivery', 'd1', 'c'), ('delivery', 'd2', None)]:
|
|
parent, product = (m.PlPlanLoad, m.PlPlanLoadProduct) if side == 'load' else (m.PlPlanDelivery, m.PlPlanDeliveryProduct)
|
|
db.add(parent(id=rid, plan_id='p', tenant_no='t', customer_name=rid))
|
|
db.add(product(id=rid+'p', plan_id='p', plan_product_id='pp', product_id='sku', product_no='S',
|
|
product_name='产品', tenant_no='t', contract_id=cid, **{side+'_id': rid}))
|
|
db.add(SysOss(id='o', url='legal/new.jpg', tenant_no='t', create_by='u'))
|
|
db.commit()
|
|
monkeypatch.setattr(svc, 'storage_base_url', lambda: 'https://files.example/bucket')
|
|
yield SimpleNamespace(db=db, user=user, svc=svc, m=m, doc=PlStopLegalDocument)
|
|
db.close()
|
|
engine.dispose()
|
|
|
|
|
|
def patch(c, items, **kwargs):
|
|
return c.svc.save_documents(c.db, c.user, 'p', {'items': items}, **kwargs)
|
|
|
|
|
|
def item(rid='d1p', url='legal/new.jpg', side='delivery'):
|
|
return {'stopType': side, 'stopProductId': rid, 'legalUseCertUrl': url}
|
|
|
|
|
|
def test_each_stop_counts_independently_and_legacy_is_separate(case):
|
|
c = case
|
|
result = patch(c, [item()])
|
|
assert (result['uploadedCount'], result['requiredCount']) == (1, 3)
|
|
assert result['legacyProducts'][0]['legalUseCertUrl'] == 'legacy.jpg'
|
|
scoped = c.svc.get_documents(c.db, c.user, 'p', contract_id='c')
|
|
assert (scoped['uploadedCount'], scoped['requiredCount']) == (1, 1)
|
|
assert scoped['items'][0]['contractNo'] == 'C'
|
|
assert c.db.get(c.m.PlPlan, 'p').archive_status == 'archived'
|
|
|
|
|
|
def test_patch_identity_omissions_and_single_clear(case):
|
|
c = case
|
|
patch(c, [item()])
|
|
row = c.db.query(c.doc).one()
|
|
original = (row.id, row.create_by, row.create_time)
|
|
for body in ({}, {'items': []}, {'items': [{'stopType': 'delivery', 'stopProductId': 'd1p'}]}):
|
|
assert c.svc.save_documents(c.db, c.user, 'p', body)['uploadedCount'] == 1
|
|
patch(c, [item(url='')])
|
|
assert (row.id, row.create_by, row.create_time) == original
|
|
assert c.svc.get_documents(c.db, c.user, 'p')['uploadedCount'] == 0
|
|
|
|
|
|
@pytest.mark.parametrize('bad', [item(), item('missing'), item('lp'), item(url='https://evil/a'), item(url='../a')])
|
|
def test_all_validation_precedes_write(case, bad):
|
|
c = case
|
|
with pytest.raises(HTTPException):
|
|
patch(c, [item(), bad])
|
|
assert c.db.query(c.doc).count() == 0
|
|
assert c.db.query(c.m.PlPlanChange).count() == 0
|
|
|
|
|
|
def test_permissions_tenant_parent_and_contract_scope(case, monkeypatch):
|
|
c = case
|
|
with pytest.raises(HTTPException):
|
|
patch(c, [item('d2p')], contract_id='c')
|
|
other = SimpleNamespace(**{**c.user.__dict__, 'tenant_no': 'other'})
|
|
with pytest.raises(HTTPException):
|
|
c.svc.get_documents(c.db, other, 'p')
|
|
c.db.get(c.m.PlPlanDelivery, 'd1').plan_id = 'other'
|
|
c.db.flush()
|
|
with pytest.raises(HTTPException):
|
|
patch(c, [item()])
|
|
monkeypatch.setattr(c.svc, 'user_can_upload_plan_cert', lambda *a: False)
|
|
with pytest.raises(HTTPException) as error:
|
|
patch(c, [])
|
|
assert error.value.status_code == 403
|
|
|
|
|
|
def test_filtering_precedes_pagination(case):
|
|
c = case
|
|
patch(c, [item()])
|
|
assert c.svc.list_documents(c.db, c.user, status='uploaded')['total'] == 0
|
|
result = c.svc.list_documents(c.db, c.user, status='uploaded', contract_id='c', page=1, page_size=1)
|
|
assert result['total'] == 1 and len(result['list']) == 1
|
|
assert result['pageSize'] == 1
|
|
assert c.svc.list_documents(c.db, c.user, keyword='missing')['total'] == 0
|
|
|
|
|
|
def test_document_protects_stop_product_until_cleared(case):
|
|
from app.services.pl_child_references import assert_stop_products_removable
|
|
c = case
|
|
patch(c, [item()])
|
|
row = c.db.get(c.m.PlPlanDeliveryProduct, 'd1p')
|
|
with pytest.raises(HTTPException) as error:
|
|
assert_stop_products_removable(c.db, 't', 'p', 'delivery', 'd1', [row])
|
|
assert error.value.status_code == 409
|
|
patch(c, [item(url='')])
|
|
assert_stop_products_removable(c.db, 't', 'p', 'delivery', 'd1', [row])
|
|
|
|
|
|
def test_static_routes_registered_before_legacy_dynamic_route():
|
|
from app.api.pl.quota_cert import legal_router
|
|
routes = [(r.path, r.methods) for r in legal_router.routes]
|
|
base = '/transport/legal-use-documents'
|
|
assert (base + '/stops', {'GET'}) in routes
|
|
assert (base + '/stops/{plan_id}', {'PATCH'}) in routes
|
|
assert next(i for i, r in enumerate(routes) if r[0] == base+'/stops') < next(i for i, r in enumerate(routes) if r[0] == base+'/{plan_id}')
|
|
|
|
|
|
def test_hidden_contract_rows_do_not_leak_and_null_is_rejected(case, monkeypatch):
|
|
c = case
|
|
monkeypatch.setattr(c.svc, 'apply_crm_owner_scope', lambda query, model, *a: query.filter(model.id != 'c'))
|
|
result = c.svc.get_documents(c.db, c.user, 'p')
|
|
assert result['requiredCount'] == 2
|
|
assert all(r['contractId'] != 'c' for r in result['items'])
|
|
assert c.svc.list_documents(c.db, c.user)['list'][0]['requiredCount'] == 2
|
|
with pytest.raises(HTTPException):
|
|
patch(c, [item()])
|
|
with pytest.raises(HTTPException):
|
|
patch(c, None)
|
|
|
|
|
|
@pytest.mark.parametrize('field,value', [('contractId', None), ('contractProductId', 'new')])
|
|
def test_contract_identity_change_protected(case, field, value):
|
|
from app.schemas.pl.plan import PlanDeliveryBody
|
|
from app.services.pl_plan_stop_service import prepare_plan_stops_write
|
|
c = case
|
|
patch(c, [item()])
|
|
body = PlanDeliveryBody(id='d1', products=[{'id': 'd1p', 'planProductId': 'pp', 'productId': 'sku',
|
|
'productNo': 'S', 'productName': '产品', field: value}])
|
|
with pytest.raises(HTTPException) as error:
|
|
prepare_plan_stops_write(c.db, 't', 'p', None, [body], [c.db.get(c.m.PlPlanProduct, 'pp')])
|
|
assert error.value.status_code == 409
|
|
|
|
|
|
def test_legacy_only_plan_is_visible_but_not_uploaded(case):
|
|
c = case
|
|
c.db.query(c.m.PlPlanLoadProduct).update({'del_flag': 1})
|
|
c.db.query(c.m.PlPlanDeliveryProduct).update({'del_flag': 1})
|
|
c.db.flush()
|
|
result = c.svc.get_documents(c.db, c.user, 'p')
|
|
assert result['legacyProducts'][0]['legalUseCertUrl'] == 'legacy.jpg'
|
|
assert result['requiredCount'] == 0 and result['documentStatus'] == 'not_required'
|
|
assert c.svc.list_documents(c.db, c.user)['total'] == 1
|
|
assert c.svc.list_documents(c.db, c.user, status='uploaded')['total'] == 0
|
|
|
|
|
|
def test_multiple_plans_filter_then_page_and_route_shape(case):
|
|
from app.api.pl.quota_cert import list_stop_documents
|
|
c = case
|
|
patch(c, [item()])
|
|
for i in range(3):
|
|
pid = 'p' + str(i)
|
|
c.db.add(c.m.PlPlan(id=pid, plan_no='P'+str(i), create_date=date.today(), belongs_company='shanxia',
|
|
plan_type='direct', tenant_no='t'))
|
|
c.db.add(c.m.PlPlanProduct(id=pid+'pp', plan_id=pid, product_id='sku', product_no='S',
|
|
product_name='产品', is_explosive=1, tenant_no='t'))
|
|
c.db.add(c.m.PlPlanDelivery(id=pid+'d', plan_id=pid, tenant_no='t'))
|
|
c.db.add(c.m.PlPlanDeliveryProduct(id=pid+'dp', delivery_id=pid+'d', plan_id=pid,
|
|
plan_product_id=pid+'pp', product_id='sku', product_no='S', product_name='产品', tenant_no='t'))
|
|
c.db.flush()
|
|
first = list_stop_documents(keyword=None, status='pending', contract_id=None, page=1, page_size=2, db=c.db, user=c.user)
|
|
second = c.svc.list_documents(c.db, c.user, status='pending', page=2, page_size=2)
|
|
assert first['total'] == second['total'] == 4
|
|
assert len(first['list']) == len(second['list']) == 2
|
|
assert {r['id'] for r in first['list']}.isdisjoint(r['id'] for r in second['list'])
|
|
assert {'uploadedCount', 'requiredCount', 'items', 'legacyProducts', 'planNo'} <= first['list'][0].keys()
|
|
|
|
|
|
def test_foreign_and_deleted_stop_products_rejected_before_write(case):
|
|
c = case
|
|
row = c.db.get(c.m.PlPlanDeliveryProduct, 'd2p')
|
|
for field, value in [('tenant_no', 'other'), ('del_flag', 1)]:
|
|
row.tenant_no, row.del_flag = 't', 0
|
|
setattr(row, field, value)
|
|
c.db.flush()
|
|
with pytest.raises(HTTPException):
|
|
patch(c, [item(), item('d2p')])
|
|
assert c.db.query(c.doc).count() == 0
|
|
|
|
|
|
def test_soft_deleted_document_not_counted_or_protecting_and_reuses_identity(case):
|
|
from app.services.pl_child_references import assert_stop_products_removable
|
|
c = case
|
|
patch(c, [item()])
|
|
row = c.db.query(c.doc).one()
|
|
identity = row.id, row.create_time
|
|
row.del_flag = 1
|
|
c.db.flush()
|
|
assert c.svc.get_documents(c.db, c.user, 'p')['uploadedCount'] == 0
|
|
assert_stop_products_removable(c.db, 't', 'p', 'delivery', 'd1', [c.db.get(c.m.PlPlanDeliveryProduct, 'd1p')])
|
|
patch(c, [item()])
|
|
assert (row.id, row.create_time) == identity and row.del_flag == 0
|
|
|
|
|
|
def test_document_reference_guard_uses_mysql_current_read(case):
|
|
"""编译真实引用查询验证锁意图;SQLite不提供MySQL RR并发语义。"""
|
|
from sqlalchemy import event
|
|
from sqlalchemy.dialects import mysql
|
|
from app.services.pl_child_references import assert_stop_products_removable
|
|
c = case
|
|
patch(c, [item()])
|
|
statements = []
|
|
|
|
def capture(state):
|
|
if state.is_select:
|
|
sql = str(state.statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True}))
|
|
if 'FROM pl_stop_legal_document' in sql:
|
|
statements.append(sql)
|
|
|
|
row = c.db.get(c.m.PlPlanDeliveryProduct, 'd1p')
|
|
event.listen(c.db, 'do_orm_execute', capture)
|
|
try:
|
|
with pytest.raises(HTTPException) as error:
|
|
assert_stop_products_removable(c.db, 't', 'p', 'delivery', 'd1',
|
|
[row])
|
|
assert error.value.status_code == 409
|
|
finally:
|
|
event.remove(c.db, 'do_orm_execute', capture)
|
|
assert len(statements) == 1
|
|
sql = statements[0]
|
|
assert 'FOR UPDATE' in sql
|
|
for predicate in ("tenant_no = 't'", "plan_id = 'p'", "stop_type = 'delivery'",
|
|
"stop_product_id IN ('d1p')", 'del_flag = 0',
|
|
'legal_use_cert_url IS NOT NULL', "legal_use_cert_url != ''"):
|
|
assert predicate in sql
|
|
|
|
|
|
def test_save_response_stop_details_keep_current_read(case):
|
|
"""保存末尾的计数也使用当前读,不能退回计划锁前的RR快照。"""
|
|
from sqlalchemy import event
|
|
from sqlalchemy.dialects import mysql
|
|
c = case
|
|
statements = []
|
|
|
|
def capture(state):
|
|
if state.is_select:
|
|
sql = str(state.statement.compile(dialect=mysql.dialect()))
|
|
if 'JOIN pl_stop_legal_document' in sql:
|
|
statements.append(sql)
|
|
|
|
event.listen(c.db, 'do_orm_execute', capture)
|
|
try:
|
|
result = patch(c, [item()])
|
|
finally:
|
|
event.remove(c.db, 'do_orm_execute', capture)
|
|
assert result['uploadedCount'] == 1
|
|
# 装送两侧均有校验读与最终响应读,均须保留当前读。
|
|
assert len(statements) == 4
|
|
assert all('FOR UPDATE' in sql for sql in statements)
|