Files
shanxia2.0/backend/tests/test_pl_stop_legal_document.py
T

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)