265 lines
13 KiB
Python
265 lines
13 KiB
Python
"""车油卡台账:真实数据库 CRUD、隔离及唯一性回归。"""
|
|
from datetime import datetime, timedelta
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from pydantic import ValidationError
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from app.core.db_auth import DbUserContext
|
|
from app.models.pl_vehicle_fuel_card import PlVehicleFuelCard
|
|
from app.schemas.pl.vehicle_fuel_card import CreateBody, UpdateBody
|
|
from app.services import pl_vehicle_fuel_card_service as svc
|
|
|
|
|
|
@pytest.fixture
|
|
def db():
|
|
engine = create_engine("sqlite://")
|
|
PlVehicleFuelCard.__table__.create(engine)
|
|
with sessionmaker(bind=engine)() as session:
|
|
yield session
|
|
engine.dispose()
|
|
|
|
|
|
def user(uid="u1", tenant="t1", role="super_admin"):
|
|
return DbUserContext(uid, "测试用户", "d1", role, tenant)
|
|
|
|
|
|
def create(db, plate="皖HA3779", card="1000113400007229003", tenant="t1", uid="u1"):
|
|
return svc.create_card(db, tenant, user(uid, tenant), CreateBody(licensePlate=plate, cardNo=card))
|
|
|
|
|
|
def test_crud_preserves_long_card_string_and_metadata(db):
|
|
row = create(db)
|
|
assert row.card_no == "1000113400007229003"
|
|
assert row.tenant_no == "t1" and row.create_by == "u1" and row.create_dept_id == "d1"
|
|
updated = svc.update_card(db, "t1", user(), row.id, UpdateBody(
|
|
licensePlate=" 皖lb1283 ", cardNo="1000113400009309765", remark="旧卡号:1000113400009253456"
|
|
))
|
|
result = svc._row_to_dict(updated)
|
|
assert result["licensePlate"] == "皖LB1283"
|
|
assert result["cardNo"] == "1000113400009309765"
|
|
assert result["remark"] == "旧卡号:1000113400009253456"
|
|
assert result["id"] == row.id
|
|
assert set(result) == {"id", "licensePlate", "cardNo", "remark", "createTime", "updateTime"}
|
|
|
|
|
|
def test_list_search_sort_and_pagination(db):
|
|
first = create(db)
|
|
first.create_time = datetime.now() - timedelta(days=1)
|
|
second = create(db, "皖LB1283", "1000113400009309765")
|
|
result = svc.list_cards(db, "t1", user(), page=1, page_size=1)
|
|
assert result["total"] == 2 and result["list"][0]["id"] == second.id
|
|
assert svc.list_cards(db, "t1", user(), q="7229003")["list"][0]["id"] == first.id
|
|
|
|
|
|
def test_cross_tenant_and_deleted_records_hidden(db):
|
|
row = create(db)
|
|
assert svc.list_cards(db, "t2", user(tenant="t2"))["total"] == 0
|
|
with pytest.raises(HTTPException) as exc:
|
|
svc.get_card(db, "t2", user(tenant="t2"), row.id)
|
|
assert exc.value.status_code == 404
|
|
svc.delete_cards(db, "t1", user(), [row.id])
|
|
assert db.get(PlVehicleFuelCard, row.id).del_flag == 1
|
|
assert svc.list_cards(db, "t1", user())["total"] == 0
|
|
|
|
|
|
@pytest.mark.parametrize("plate,card", [("皖HA3779", "1000113400000000001"), ("皖H21522", "1000113400007229003")])
|
|
def test_duplicate_active_bindings_rejected(db, plate, card):
|
|
create(db)
|
|
with pytest.raises(HTTPException) as exc:
|
|
create(db, plate, card)
|
|
assert exc.value.status_code == 409
|
|
assert svc.list_cards(db, "t1", user())["total"] == 1
|
|
# 同样的绑定可存在于不同租户。
|
|
create(db, plate, card, tenant="t2")
|
|
|
|
|
|
def test_deleted_bindings_can_be_recreated_more_than_once(db):
|
|
for _ in range(3):
|
|
row = create(db)
|
|
svc.delete_cards(db, "t1", user(), [row.id])
|
|
assert db.query(PlVehicleFuelCard).count() == 3
|
|
assert svc.list_cards(db, "t1", user())["total"] == 0
|
|
|
|
|
|
def test_database_constraint_guards_concurrent_duplicates(db):
|
|
create(db)
|
|
db.add(PlVehicleFuelCard(id="second", tenant_no="t1", license_plate="皖HA3779", card_no="123"))
|
|
with pytest.raises(IntegrityError):
|
|
db.commit()
|
|
db.rollback()
|
|
|
|
|
|
def test_batch_delete_is_all_or_nothing(db):
|
|
row = create(db)
|
|
other = create(db, "皖H21522", "1000113400008366476", tenant="t2")
|
|
with pytest.raises(HTTPException):
|
|
svc.delete_cards(db, "t1", user(), [row.id, other.id])
|
|
assert db.get(PlVehicleFuelCard, row.id).del_flag == 0
|
|
assert svc.delete_cards(db, "t1", user(), [row.id, row.id]) == 1
|
|
|
|
|
|
def test_data_scope_applies_to_list_get_update_delete(db, monkeypatch):
|
|
import app.core.data_scope as scope
|
|
monkeypatch.setattr(scope, "_resolve_user_role_scopes", lambda *a: [{"data_scope": "4", "details": []}])
|
|
own = create(db)
|
|
other = create(db, "皖H21522", "1000113400008366476", uid="u2")
|
|
limited = user(role="admin")
|
|
assert [r["id"] for r in svc.list_cards(db, "t1", limited)["list"]] == [own.id]
|
|
for action in [
|
|
lambda: svc.get_card(db, "t1", limited, other.id),
|
|
lambda: svc.update_card(db, "t1", limited, other.id, UpdateBody(licensePlate="皖H21522", cardNo="123")),
|
|
lambda: svc.delete_cards(db, "t1", limited, [other.id]),
|
|
]:
|
|
with pytest.raises(HTTPException):
|
|
action()
|
|
# 多角色数据范围取并集。
|
|
monkeypatch.setattr(scope, "_resolve_user_role_scopes", lambda *a: [{"data_scope": "4", "details": []}, {"data_scope": "1", "details": []}])
|
|
assert svc.list_cards(db, "t1", limited)["total"] == 2
|
|
|
|
|
|
@pytest.mark.parametrize("payload", [
|
|
{"licensePlate": " ", "cardNo": "123"},
|
|
{"licensePlate": "皖HA3779", "cardNo": 1000113400007229003},
|
|
{"licensePlate": "皖HA3779", "cardNo": "1e18"},
|
|
{"licensePlate": "皖HA3779", "cardNo": "123", "tenantNo": "t2"},
|
|
])
|
|
def test_invalid_or_client_controlled_fields_rejected(payload):
|
|
with pytest.raises(ValidationError):
|
|
CreateBody(**payload)
|
|
|
|
|
|
def test_http_auth_menu_guard_and_full_crud(monkeypatch):
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy.pool import StaticPool
|
|
from app.api.pl.vehicle_fuel_card import router
|
|
from app.api.pl.vehicle_recharge import apply_router
|
|
from app.models.pl_models import PlVehicle
|
|
from app.core.auth import get_current_user
|
|
from app.core.database import get_db
|
|
from app.core import menu_guard
|
|
|
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
|
PlVehicleFuelCard.__table__.create(engine)
|
|
PlVehicle.__table__.create(engine)
|
|
session_factory = sessionmaker(bind=engine)
|
|
|
|
def database():
|
|
with session_factory() as session:
|
|
yield session
|
|
|
|
app = FastAPI()
|
|
app.include_router(router, prefix="/api")
|
|
app.include_router(apply_router, prefix="/api/transport")
|
|
app.dependency_overrides[get_db] = database
|
|
path = "/api/transport/vehicle-fuel-card"
|
|
with TestClient(app) as client:
|
|
assert client.get(path).status_code == 401
|
|
app.dependency_overrides[get_current_user] = lambda: {"role": "admin", "user_id": "u1", "tenant_no": "t1"}
|
|
monkeypatch.setattr(menu_guard, "get_user_menu_codes", lambda *a: set())
|
|
assert client.get(path).status_code == 403
|
|
monkeypatch.setattr(menu_guard, "get_user_menu_codes", lambda *a: {"pl:vehicle-fuel-card:list"})
|
|
import app.core.data_scope as scope
|
|
monkeypatch.setattr(scope, "_resolve_user_role_scopes", lambda *a: [{"data_scope": "1", "details": []}])
|
|
payload = {"licensePlate": "皖LB1283", "cardNo": "1000113400009309765", "remark": "旧卡号:1000113400009253456"}
|
|
created = client.post(path, json=payload)
|
|
assert created.status_code == 200, created.text
|
|
record_id = created.json()["id"]
|
|
assert client.get(path).json()["list"][0]["cardNo"] == payload["cardNo"]
|
|
assert client.get(f"{path}/{record_id}").json()["remark"] == payload["remark"]
|
|
assert client.post(path, json={**payload, "tenantNo": "other"}).status_code == 422
|
|
assert client.put(f"{path}/{record_id}", json={**payload, "remark": "更新备注"}).json()["remark"] == "更新备注"
|
|
with session_factory() as session:
|
|
session.add(PlVehicle(id="v1", tenant_no="t1", vehicle_type="self", license_plate="皖LB1283", create_by="u1"))
|
|
session.commit()
|
|
# 充值查卡号不要求管理台账权限,也不会误匹配 /{id} 详情路由。
|
|
monkeypatch.setattr(menu_guard, "get_user_menu_codes", lambda *a: set())
|
|
lookup = client.get("/api/transport/vehicle-recharge-apply/fuel-card", params={"licensePlate": "皖LB1283"})
|
|
assert lookup.status_code == 200, lookup.text
|
|
assert lookup.json() == {"licensePlate": "皖LB1283", "cardNo": "1000113400009309765"}
|
|
monkeypatch.setattr(menu_guard, "get_user_menu_codes", lambda *a: {"pl:vehicle-fuel-card:list"})
|
|
assert client.post(path + "/batch-delete", json={"ids": [record_id]}).json() == {"deleted": 1}
|
|
assert client.get(path).json()["total"] == 0
|
|
engine.dispose()
|
|
|
|
|
|
def test_recharge_lookup_uses_authorized_vehicle_not_binding_creator(db, monkeypatch):
|
|
from app.models.pl_models import PlVehicle, PlVehicleRechargeApply, PlVehicleRechargeRecord
|
|
import app.core.data_scope as scope
|
|
for model in (PlVehicle, PlVehicleRechargeApply, PlVehicleRechargeRecord):
|
|
model.__table__.create(db.get_bind())
|
|
monkeypatch.setattr(scope, "_resolve_user_role_scopes", lambda *a: [{"data_scope": "4", "details": []}])
|
|
create(db, uid="system")
|
|
db.add(PlVehicle(id="vehicle", tenant_no="t1", vehicle_type="self", license_plate="皖HA3779", create_by="u1"))
|
|
db.commit()
|
|
assert svc.lookup_for_recharge(db, "t1", user(role="admin"), "皖ha3779")["cardNo"] == "1000113400007229003"
|
|
with pytest.raises(HTTPException) as exc:
|
|
svc.lookup_for_recharge(db, "t1", user("u2", role="admin"), "皖HA3779")
|
|
assert exc.value.status_code == 403
|
|
# 租户之间相同车牌不会泄露卡号。
|
|
assert svc.lookup_for_recharge(db, "t2", user(tenant="t2"), "皖HA3779")["cardNo"] is None
|
|
svc.delete_cards(db, "t1", user(), [db.query(PlVehicleFuelCard).one().id])
|
|
assert svc.lookup_for_recharge(db, "t1", user(role="admin"), "皖HA3779")["cardNo"] is None
|
|
|
|
|
|
def test_recharge_lookup_mine_vehicles_cannot_read_other_vehicles(db, monkeypatch):
|
|
create(db)
|
|
monkeypatch.setattr(svc, "should_restrict_mine_vehicles", lambda *a, **kw: True)
|
|
monkeypatch.setattr(svc, "license_plates_for_user_personnel", lambda *a: {"皖HA3779"})
|
|
caller = user(role="admin")
|
|
assert svc.lookup_for_recharge(db, "t1", caller, "皖HA3779", mine_vehicles=True)["cardNo"] == "1000113400007229003"
|
|
with pytest.raises(HTTPException) as exc:
|
|
svc.lookup_for_recharge(db, "t1", caller, "皖LB1283", mine_vehicles=True)
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
def test_recharge_lookup_mini_admin_follows_existing_mine_vehicle_exemption(db, monkeypatch):
|
|
from app.models.pl_models import PlVehicle, PlVehicleRechargeApply, PlVehicleRechargeRecord
|
|
import app.core.data_scope as scope
|
|
for model in (PlVehicle, PlVehicleRechargeApply, PlVehicleRechargeRecord):
|
|
model.__table__.create(db.get_bind())
|
|
create(db, uid="system")
|
|
db.add(PlVehicle(id="v-other", tenant_no="t1", vehicle_type="self", license_plate="皖HA3779", create_by="u-other"))
|
|
db.commit()
|
|
# helper 已确认用户绑定“管理员”角色;该角色允许 mineVehicles 查看全租户。
|
|
monkeypatch.setattr(svc, "should_restrict_mine_vehicles", lambda *a, **kw: False)
|
|
monkeypatch.setattr(scope, "_resolve_user_role_scopes", lambda *a: [{"data_scope": "4", "details": []}])
|
|
assert svc.lookup_for_recharge(db, "t1", user(role="admin"), "皖HA3779", mine_vehicles=True)["cardNo"] == "1000113400007229003"
|
|
|
|
@pytest.mark.parametrize('schema_name', ['VehicleRechargeApplyRead', 'VehicleRechargeDisburseBoardItem'])
|
|
def test_recharge_list_bindings_are_batched_scoped_and_preserve_history(db, schema_name):
|
|
from sqlalchemy import event
|
|
from app.schemas.pl import vehicle_recharge as schemas
|
|
|
|
create(db)
|
|
create(db, '皖LB1283', '1000113400009309765')
|
|
deleted = create(db, '皖H21522', '1000113400008366476')
|
|
svc.delete_cards(db, 't1', user(), [deleted.id])
|
|
create(db, '皖HA3779', '9999999999999999999', tenant='t2')
|
|
schema = getattr(schemas, schema_name)
|
|
items = [schema.model_construct(license_plate=plate, recharge_type=kind)
|
|
for plate, kind in [('皖HA3779', 'fuel'), ('皖LB1283', 'fuel'),
|
|
('皖H21522', 'fuel'), ('皖UNKNOWN', 'fuel'),
|
|
('皖HA3779', 'etc'), ('皖LB1283', 'shanxia')]]
|
|
if schema_name == 'VehicleRechargeDisburseBoardItem':
|
|
items[1].card_no = '1000113400009253456'
|
|
queries = []
|
|
def count_query(conn, cursor, statement, parameters, context, executemany):
|
|
queries.append(statement)
|
|
event.listen(db.bind, 'before_cursor_execute', count_query)
|
|
try:
|
|
result = svc.attach_recharge_list_bindings(db, 't1', items)
|
|
finally:
|
|
event.remove(db.bind, 'before_cursor_execute', count_query)
|
|
assert len(queries) == 1
|
|
assert [r.bound_fuel_card_no for r in result] == [
|
|
'1000113400007229003', '1000113400009309765', None, None, None, None]
|
|
assert result[0].model_dump(by_alias=True)['boundFuelCardNo'] == '1000113400007229003'
|
|
if schema_name == 'VehicleRechargeDisburseBoardItem':
|
|
assert result[1].card_no == '1000113400009253456'
|
|
assert svc.attach_recharge_list_bindings(db, 't1', []) == []
|