test_pl_personnel_missing_identity.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. """SQLite-only vehicle personnel identity and partial-field regressions."""
  2. from datetime import datetime
  3. import pymysql
  4. import pytest
  5. from fastapi import HTTPException
  6. from sqlalchemy import create_engine, event
  7. from sqlalchemy.orm import Session
  8. from app.models.pl_models import PlPersonnel, PlVehiclePersonnel
  9. from app.schemas.pl.base_data import VehiclePersonnelRow
  10. from app.services.pl_personnel_service import save_vehicle_personnel, upsert_personnel
  11. @pytest.fixture
  12. def db(monkeypatch):
  13. def forbidden(*a, **k):
  14. raise AssertionError('Business database access forbidden')
  15. monkeypatch.setattr(pymysql, 'connect', forbidden)
  16. monkeypatch.setattr(pymysql.connections.Connection, 'connect', forbidden)
  17. engine = create_engine('sqlite://')
  18. PlPersonnel.__table__.create(engine)
  19. PlVehiclePersonnel.__table__.create(engine)
  20. with Session(engine, autoflush=False, expire_on_commit=False) as session:
  21. yield session
  22. engine.dispose()
  23. def person(db, identity='p', name='Person', phone='100', **extra):
  24. row = PlPersonnel(id=identity, tenant_no='t', personnel_name=name, phone_no=phone,
  25. create_by='creator', create_time=datetime(2020,1,1), **extra)
  26. db.add(row)
  27. return row
  28. def link(db, identity='link', pid='p', role='driver', **extra):
  29. row = PlVehiclePersonnel(id=identity, tenant_no='t', vehicle_id='v', personnel_id=pid,
  30. role=role, create_by='creator', create_time=datetime(2020,1,1), **extra)
  31. db.add(row)
  32. return row
  33. def body(**values):
  34. return VehiclePersonnelRow.model_validate({'personnelName':'Person', 'role':'driver', **values})
  35. def save(db, values):
  36. save_vehicle_personnel(db,'v',values,'t',user_id='editor',dept_id='dept')
  37. db.flush()
  38. @pytest.mark.parametrize('key', ['name','phone'])
  39. @pytest.mark.parametrize('standalone', [False,True])
  40. def test_ambiguous_legacy_identity_rejects_instead_of_replacing_link(db,key,standalone):
  41. person(db,'p1',phone=None if key=='name' else '100')
  42. person(db,'p2',phone=None if key=='name' else '100')
  43. old=link(db,pid='p2');db.commit()
  44. payload=body(**({'phoneNo':'100'} if key=='phone' else {}))
  45. with pytest.raises(HTTPException):
  46. if standalone:upsert_personnel(db,'t',payload,user_id='u',dept_id='d')
  47. else:save(db,[payload])
  48. assert not db.dirty and not db.new
  49. assert old.del_flag==0 and db.query(PlVehiclePersonnel).count()==1
  50. def test_unique_no_id_matches_existing_person_and_preserves_relation_identity(db):
  51. p=person(db);old=link(db);db.commit()
  52. save(db,[body(phoneNo='100')]);db.commit();save(db,[body(phoneNo='100')])
  53. assert db.query(PlPersonnel).count()==db.query(PlVehiclePersonnel).count()==1
  54. assert old.del_flag==0 and old.personnel_id==p.id
  55. assert old.create_by==p.create_by=='creator'
  56. assert old.create_time==p.create_time==datetime(2020,1,1)
  57. def test_omitted_fields_preserve_person_and_link_fields(db):
  58. p=person(db,age=42);old=link(db,remark='keep',is_default=1);db.commit()
  59. save(db,[body(personnelId='p')])
  60. assert p.phone_no=='100' and p.age==42
  61. assert old.remark=='keep' and old.is_default==1
  62. def test_explicit_null_and_zero_clear_fields(db):
  63. p=person(db,age=42);old=link(db,remark='keep',is_default=1);db.commit()
  64. save(db,[body(personnelId='p',phoneNo=None,age=None,remark=None,isDefault=0)])
  65. assert p.phone_no is None and p.age is None
  66. assert old.remark is None and old.is_default==0
  67. def test_new_person_can_have_two_roles_without_duplicate_person_creation(db):
  68. save(db,[body(phoneNo='100'),body(phoneNo='100',role='escort')]);db.commit()
  69. people=db.query(PlPersonnel).all();links=db.query(PlVehiclePersonnel).all()
  70. assert len(people)==1 and len(links)==2
  71. assert {row.personnel_id for row in links}=={people[0].id}
  72. assert {row.role for row in links}=={'driver','escort'}
  73. @pytest.mark.parametrize('reverse',[False,True])
  74. def test_explicit_identity_reserved_and_other_role_resolves_same_person(db,reverse):
  75. person(db);old=link(db);db.commit()
  76. rows=[body(personnelId='p'),body(phoneNo='100',role='escort')]
  77. save(db,rows[::-1] if reverse else rows)
  78. assert old.del_flag==0 and db.query(PlPersonnel).count()==1
  79. assert {row.personnel_id for row in db.query(PlVehiclePersonnel)}=={'p'}
  80. def test_explicit_reservation_does_not_guess_remaining_duplicate_name(db):
  81. person(db,'p1',phone=None);person(db,'p2',phone=None);link(db,pid='p1');db.commit()
  82. with pytest.raises(HTTPException):save(db,[body(personnelId='p1'),body()])
  83. assert not db.dirty and not db.new
  84. @pytest.mark.parametrize('field,values',[('phoneNo',('100','200')),('age',(20,30)),
  85. ('personnelName',('Person','Different'))])
  86. def test_same_person_conflicting_fields_across_roles_reject_before_mutation(db,field,values):
  87. p=person(db,age=42);link(db);db.commit()
  88. a=body(personnelId='p',**{field:values[0]})
  89. b=body(personnelId='p',role='escort',**{field:values[1]})
  90. with pytest.raises(HTTPException):save(db,[a,b])
  91. assert not db.dirty and not db.new and p.age==42 and p.phone_no=='100'
  92. @pytest.mark.parametrize('bad',['foreign','deleted','missing','duplicate'])
  93. def test_entire_batch_rejects_invalid_explicit_id_before_editing_first_person(db,bad):
  94. p=person(db);link(db)
  95. if bad in ('foreign','deleted'):
  96. other=person(db,'other',name='Other');other.tenant_no='other' if bad=='foreign' else 't'
  97. other.del_flag=1 if bad=='deleted' else 0
  98. db.commit()
  99. rows=[body(personnelId='p',phoneNo='new')]
  100. rows.append(body(personnelId='p' if bad=='duplicate' else 'other'))
  101. with pytest.raises(HTTPException):save(db,rows)
  102. assert not db.dirty and not db.new and p.phone_no=='100'
  103. def test_effective_defaults_cannot_create_two_default_drivers(db):
  104. person(db);person(db,'second',name='Second');old=link(db,is_default=1);db.commit()
  105. with pytest.raises(HTTPException):save(db,[body(personnelId='p'),
  106. body(personnelId='second',personnelName='Second',isDefault=1)])
  107. assert old.is_default==1 and not db.dirty and not db.new
  108. def test_add_edit_remove_and_omitted_collection_are_differential(db):
  109. p=person(db);old=link(db);person(db,'removed',name='Removed');removed=link(db,'removed-link','removed')
  110. db.commit();save_vehicle_personnel(db,'v',None,'t',user_id='u',dept_id='d')
  111. assert old.del_flag==removed.del_flag==0
  112. save(db,[body(personnelId='p',phoneNo='new'),body(personnelName='New',phoneNo='300',role='escort')])
  113. assert old.del_flag==0 and old.personnel_id==p.id and p.phone_no=='new'
  114. assert removed.del_flag==1 and db.query(PlVehiclePersonnel).filter_by(del_flag=0).count()==2
  115. save(db,[])
  116. assert db.query(PlVehiclePersonnel).filter_by(del_flag=0).count()==0
  117. assert db.query(PlPersonnel).count()==3
  118. def test_cross_role_partial_person_update_merges_without_clearing_phone(db):
  119. p=person(db,age=42);link(db);db.commit()
  120. save(db,[body(personnelId='p',phoneNo='200'),body(personnelId='p',role='escort')])
  121. assert p.phone_no=='200' and p.age==42
  122. def test_standalone_upsert_preserves_omitted_optional_fields(db):
  123. p=person(db,age=42);db.commit()
  124. identity=upsert_personnel(db,'t',body(personnelId='p'),user_id='u',dept_id='d')
  125. assert identity==p.id and p.phone_no=='100' and p.age==42
  126. def test_explicit_current_client_id_is_safe_with_duplicate_names(db):
  127. person(db,'p1',phone=None);p=person(db,'p2',phone=None);old=link(db,pid='p2');db.commit()
  128. save(db,[body(personnelId='p2',remark='')])
  129. assert old.del_flag==0 and old.personnel_id==p.id and old.remark==''
  130. assert db.query(PlVehiclePersonnel).count()==1
  131. def test_same_name_in_other_tenant_does_not_participate_in_matching(db):
  132. p=person(db);old=link(db);other=person(db,'other');other.tenant_no='other';db.commit()
  133. save(db,[body()])
  134. assert old.personnel_id==p.id and old.del_flag==0 and p.phone_no=='100'
  135. def test_empty_collection_only_removes_current_vehicle_links(db):
  136. person(db);old=link(db);other=link(db,'other-link');other.vehicle_id='other-vehicle';db.commit()
  137. save(db,[])
  138. assert old.del_flag==1 and other.del_flag==0
  139. def test_duplicate_historical_links_reject_before_person_update(db):
  140. p=person(db);link(db);link(db,'duplicate-link');db.commit()
  141. with pytest.raises(HTTPException):save(db,[body(personnelId='p',phoneNo='200')])
  142. assert p.phone_no=='100' and not db.dirty and not db.new
  143. @pytest.mark.parametrize('reverse',[False,True])
  144. def test_explicit_and_missing_id_same_role_reject_regardless_of_order(db,reverse):
  145. person(db);link(db);db.commit()
  146. rows=[body(),body(personnelId='p')]
  147. with pytest.raises(HTTPException):save(db,rows[::-1] if reverse else rows)
  148. assert not db.dirty and not db.new
  149. def test_explicit_new_name_can_be_reused_by_other_role_in_same_batch(db):
  150. p=person(db);link(db);db.commit()
  151. save(db,[body(personnelName='Renamed',role='escort'),body(personnelId='p',personnelName='Renamed')])
  152. assert p.personnel_name=='Renamed' and db.query(PlPersonnel).count()==1
  153. assert {row.personnel_id for row in db.query(PlVehiclePersonnel)}=={'p'}