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)