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

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', []) == []