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

256 lines
12 KiB
Python

"""证件建档与多合同范围原子保存,SQLite 验证。"""
import pytest
from fastapi import HTTPException
from test_pl_quota_contract_scope import case, binding
def save(c, data, cert_id='g1', cert_type='purchase'):
from app.services import pl_quota_cert_bindings as svc
return svc.save_cert(c.db, c.user, cert_type, data, cert_id)
def scopes():
return [{'contractId': 'c1', 'products': [{'contractProductId': 'p1'}]},
{'contractId': 'c2', 'products': [{'contractProductId': 'p3'}]}]
def test_roundtrip_omission_empty_and_other_cert_preserved(case):
c = case
c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding('g2')]})
result = save(c, {'contractBindings': scopes()})
original = result['contractBindings']
assert len(original) == 2
payload = [{k: v for k, v in x.items() if k in ('id', 'contractId', 'linkStatus', 'products', 'remark', 'orderNum')} for x in original]
for item in payload:
item['products'] = [{k: v for k, v in p.items() if k in ('id', 'contractProductId', 'scopeStatus')} for p in item['products']]
assert save(c, {'contractBindings': payload})['contractBindings'] == original
assert save(c, {'remark': 'only face'})['contractBindings'] == original
assert save(c, {'contractBindings': []})['contractBindings'] == []
assert c.svc.get_contract_bindings(c.db, c.user, 'c1')[0]['certId'] == 'g2'
def test_second_invalid_contract_does_not_mutate_face_or_first(case):
c = case
data = scopes()
data[1]['products'][0]['contractProductId'] = 'p1'
with pytest.raises(HTTPException):
save(c, {'remark': 'must not write', 'contractBindings': data})
assert not c.db.new and not c.db.dirty
assert c.db.query(c.m.PlContractCertLink).count() == 0
assert c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one().remark is None
@pytest.mark.parametrize('change', ['duplicate', 'foreign_id', 'foreign_product', 'null'])
def test_bad_identity_rejected_before_writes(case, change):
data = scopes()
if change == 'duplicate': data.append(data[0])
if change == 'foreign_id': data[0]['id'] = 'foreign'
if change == 'foreign_product': data[0]['products'][0]['id'] = 'foreign'
if change == 'null': data = None
with pytest.raises(HTTPException):
save(case, {'contractBindings': data})
assert case.db.query(case.m.PlContractCertLink).count() == 0
def test_create_with_bindings_and_bad_create_has_no_pending_row(case):
c = case
body = dict(certNo='new', productNameOnCert='硫酸', totalQty='300', quantityUnit='吨',
validUseText='多次有效', buyerName='买方', sellerName='卖方', contractBindings=scopes())
broken = {**body, 'contractBindings': [{'contractId': 'missing', 'products': []}]}
with pytest.raises(HTTPException): save(c, broken, None)
assert not c.db.new and not c.db.dirty
result = save(c, body, None)
assert len(result['contractBindings']) == 2
assert result['id'] not in ('g1', 'g2')
def test_hidden_contract_cannot_be_cleared_but_face_can_change(case, monkeypatch):
from app.services import pl_quota_cert_bindings as svc
from app.models.crm_models import CrmContract
c = case
save(c, {'contractBindings': scopes()})
original = c.svc.ensure_crm_owner_row_in_scope
def deny_second(db, row, *args):
if row.id == 'c2': raise ValueError('hidden')
return original(db, row, *args)
monkeypatch.setattr(c.svc, 'ensure_crm_owner_row_in_scope', deny_second)
monkeypatch.setattr(svc, '_visible_contract_ids', lambda db, user: db.query(CrmContract.id).filter(CrmContract.id == 'c1'))
with pytest.raises(HTTPException) as error:
save(c, {'contractBindings': []})
assert error.value.status_code == 403
result = save(c, {'remark': 'allowed'})
assert result['remark'] == 'allowed'
assert [x['contractId'] for x in result['contractBindings']] == ['c1']
assert c.db.query(c.m.PlContractCertLink).filter_by(del_flag=0).count() == 2
def test_transport_locks_all_certificates_before_contracts_and_current_reads(case, monkeypatch):
from app.services import pl_quota_cert_service as certs
from sqlalchemy import event
from app.models.crm_models import CrmContract, CrmContractProduct
c = case
c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding('g2')]})
certs.save_cert(c.db, c.user, 'transport', dict(certNo='T1', productNameOnCert='硫酸',
totalQty='300', quantityUnit='吨', validUseText='多次有效', shipperName='发货人'), None)
target = c.db.query(c.m.PlTransportCert).one().id
events = []
original = certs._get_row
contract = c.svc._contract
def cert_lock(db, user, kind, rid, lock=False):
if lock: events.append(('cert', kind, rid))
return original(db, user, kind, rid, lock)
def contract_lock(db, user, cid, lock=False):
if lock: events.append(('contract', cid))
return contract(db, user, cid, lock)
monkeypatch.setattr(certs, '_get_row', cert_lock)
monkeypatch.setattr(c.svc, '_contract', contract_lock)
reads = []
def capture(state):
if state.is_select and state.statement._for_update_arg is not None:
reads.append((state.bind_mapper.class_, state.load_options._populate_existing))
event.listen(c.db, 'do_orm_execute', capture)
try:
result = save(c, {'contractBindings': scopes(), 'purchaseRefs': [
{'referenceType': 'purchase_permit', 'referenceNo': 'g1', 'purchaseCertId': 'g1'}]}, target, 'transport')
finally:
event.remove(c.db, 'do_orm_execute', capture)
assert events == [('cert', 'purchase', 'g1'), ('cert', 'purchase', 'g2'),
('cert', 'transport', target), ('contract', 'c1'), ('contract', 'c2'),
('cert', 'transport', target)] # 响应当前读仅重复已持有的目标证锁。
for model in (CrmContract, CrmContractProduct, c.m.PlContractCertLink,
c.m.PlTransportCertPurchaseRef):
assert any(m == model and refresh for m, refresh in reads)
assert len(result['contractBindings']) == 2 and len(result['purchaseRefs']) == 1
def test_new_key_after_lock_retries_without_face_mutation(case, monkeypatch):
c = case
original = c.svc._contract
def changed(db, user, cid, lock=False):
row = original(db, user, cid, lock)
if lock and cid == 'c1':
db.add(c.m.PlContractCertLink(id='concurrent', tenant_no='t', contract_id='c1',
cert_type='purchase', cert_id='g2', contract_no_snapshot='c1', scope_revision=1, link_status='active'))
db.flush()
return row
monkeypatch.setattr(c.svc, '_contract', changed)
with pytest.raises(HTTPException) as error:
save(c, {'remark': 'must not write', 'contractBindings': scopes()})
assert error.value.status_code == 409
assert c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one().remark is None
def test_referenced_scope_cannot_be_removed_or_disabled(case):
c = case
result = save(c, {'contractBindings': scopes()})
link = next(x for x in result['contractBindings'] if x['contractId'] == 'c1')
c.db.add(c.m.PlPlanCertUsageItem(id='item', usage_id='usage', tenant_no='t',
contract_cert_link_id=link['id'], contract_cert_product_id=link['products'][0]['id'],
contract_scope_revision=1, contract_id='c1', contract_product_id='p1', plan_product_id='pp',
source_stop_type='delivery', source_stop_id='stop', source_stop_product_id='sp',
allocation_seq=1, source_unit='吨', planned_source_qty=10))
c.db.flush()
for bindings in ([], [{**scopes()[0], 'linkStatus': 'inactive'}, scopes()[1]]):
with pytest.raises(HTTPException) as error:
save(c, {'remark': 'do not write', 'contractBindings': bindings})
assert error.value.status_code == 409
assert c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one().remark is None
def test_void_new_or_existing_certificate_cannot_expand(case):
c = case
with pytest.raises(HTTPException):
save(c, {'status': 'void', 'contractBindings': scopes()})
assert c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one().status == 'draft'
def test_get_reports_partial_binding_scope_for_editor(case, monkeypatch):
from app.services import pl_quota_cert_bindings as svc
from app.models.crm_models import CrmContract
c = case
result = save(c, {'contractBindings': scopes()})
assert result['bindingsEditable'] is True
monkeypatch.setattr(svc, '_visible_contract_ids', lambda db, user: db.query(CrmContract.id).filter(CrmContract.id == 'c1'))
result = svc.get_cert(c.db, c.user, 'purchase', 'g1')
assert result['bindingsEditable'] is False
assert len(result['contractBindings']) == 1
def test_foreign_tenant_contract_rejected_and_no_changes(case):
from app.models.crm_models import CrmContract
c = case
c.db.query(CrmContract).filter_by(id='c2').one().tenant_no = 'other'
c.db.commit()
with pytest.raises(HTTPException) as error:
save(c, {'remark': 'must not write', 'contractBindings': scopes()})
assert error.value.status_code == 404
assert c.db.query(c.m.PlContractCertLink).count() == 0
assert not c.db.dirty and not c.db.new
def test_transport_refs_and_face_not_written_when_second_contract_invalid(case):
from app.services import pl_quota_cert_service as certs
c = case
result = certs.save_cert(c.db, c.user, 'transport', dict(certNo='T1', productNameOnCert='硫酸',
totalQty='300', quantityUnit='吨', validUseText='多次有效', shipperName='发货人'))
data = scopes()
data[1]['products'] = [{'contractProductId': 'p1'}]
with pytest.raises(HTTPException):
save(c, {'remark': 'must not write', 'contractBindings': data, 'purchaseRefs': [
{'referenceType': 'purchase_permit', 'referenceNo': 'g1', 'purchaseCertId': 'g1'}]}, result['id'], 'transport')
assert c.db.query(c.m.PlTransportCert).one().remark is None
assert c.db.query(c.m.PlTransportCertPurchaseRef).count() == 0
assert c.db.query(c.m.PlContractCertLink).count() == 0
assert not c.db.dirty and not c.db.new
@pytest.mark.parametrize('with_bindings', [True, False])
def test_save_response_uses_current_reads_for_face_bindings_products_and_visibility(case, monkeypatch, with_bindings):
"""SQLite 不模拟 RR;捕获实际 ORM 返回查询并以 MySQL 方言验证当前读意图。"""
from sqlalchemy import event
from sqlalchemy.dialects import mysql
from app.services import pl_quota_cert_bindings as svc
c = case
save(c, {'contractBindings': scopes()})
reads = []
returning = [False]
original = svc.get_cert
def response(*args, **kwargs):
returning[0] = True
try:
return original(*args, **kwargs)
finally:
returning[0] = False
def capture(state):
if returning[0] and state.is_select:
reads.append((state.bind_mapper.class_, str(state.statement.compile(dialect=mysql.dialect())),
state.load_options._populate_existing))
monkeypatch.setattr(svc, 'get_cert', response)
event.listen(c.db, 'do_orm_execute', capture)
try:
result = save(c, {'contractBindings': scopes()} if with_bindings else {'remark': 'face only'})
finally:
event.remove(c.db, 'do_orm_execute', capture)
assert len(result['contractBindings']) == 2
for model in (c.m.PlPurchaseCert, c.m.PlContractCertLink, c.m.PlContractCertProduct):
queries = [(sql, refresh) for m, sql, refresh in reads if m == model]
assert queries, model.__name__
assert all('FOR UPDATE' in sql and refresh for sql, refresh in queries), queries
def test_plain_get_does_not_acquire_write_locks(case):
from sqlalchemy import event
from app.services import pl_quota_cert_bindings as svc
c = case
save(c, {'contractBindings': scopes()})
reads = []
def capture(state):
if state.is_select:
reads.append(state.statement._for_update_arg is not None)
event.listen(c.db, 'do_orm_execute', capture)
try:
result = svc.get_cert(c.db, c.user, 'purchase', 'g1')
finally:
event.remove(c.db, 'do_orm_execute', capture)
assert len(result['contractBindings']) == 2 and not any(reads)