340 lines
11 KiB
Python
340 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Generator
|
|
from datetime import UTC, datetime
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
from auth_helpers import install_legacy_header_auth_override
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from app.api.deps import get_db
|
|
from app.db.base import Base
|
|
from app.main import create_app
|
|
from app.models.employee import Employee
|
|
from app.models.financial_record import ExpenseClaim
|
|
from app.services.expense_cases import ExpenseCaseService
|
|
from app.services.expense_claim_workflow_constants import (
|
|
DIRECT_MANAGER_APPROVAL_STAGE,
|
|
FINANCE_APPROVAL_STAGE,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def http_context() -> Generator[tuple[TestClient, sessionmaker[Session]], None, None]:
|
|
engine = create_engine(
|
|
"sqlite+pysqlite:///:memory:",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
Base.metadata.create_all(bind=engine)
|
|
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
|
app = create_app()
|
|
install_legacy_header_auth_override(app)
|
|
|
|
def override_db() -> Generator[Session, None, None]:
|
|
with session_factory() as db:
|
|
yield db
|
|
|
|
app.dependency_overrides[get_db] = override_db
|
|
client = TestClient(app)
|
|
try:
|
|
yield client, session_factory
|
|
finally:
|
|
client.close()
|
|
app.dependency_overrides.clear()
|
|
engine.dispose()
|
|
|
|
|
|
def seed_timeline(
|
|
db: Session,
|
|
*,
|
|
claim_no: str = "RE-TIMELINE-001",
|
|
expense_type: str = "travel",
|
|
relation_type: str | None = None,
|
|
approval_stage: str = DIRECT_MANAGER_APPROVAL_STAGE,
|
|
with_case: bool = True,
|
|
tenant_id: str = "default",
|
|
) -> ExpenseClaim:
|
|
manager = Employee(
|
|
id="manager-1",
|
|
employee_no="M001",
|
|
name="李经理",
|
|
email="manager@example.com",
|
|
)
|
|
owner = Employee(
|
|
id="owner-1",
|
|
employee_no="E001",
|
|
name="张三",
|
|
email="owner@example.com",
|
|
manager=manager,
|
|
)
|
|
claim = ExpenseClaim(
|
|
id="claim-1",
|
|
claim_no=claim_no,
|
|
employee_id=owner.id,
|
|
employee_name=owner.name,
|
|
department_name="市场部",
|
|
expense_type=expense_type,
|
|
reason="客户现场差旅",
|
|
location="上海",
|
|
amount=Decimal("880.00"),
|
|
currency="CNY",
|
|
invoice_count=1,
|
|
occurred_at=datetime(2026, 7, 13, 9, 0, tzinfo=UTC),
|
|
submitted_at=datetime(2026, 7, 13, 10, 0, tzinfo=UTC),
|
|
status="submitted",
|
|
approval_stage=approval_stage,
|
|
risk_flags_json=[],
|
|
)
|
|
db.add_all([manager, owner, claim])
|
|
db.flush()
|
|
|
|
if with_case:
|
|
ExpenseCaseService(db).record_claim_event(
|
|
claim,
|
|
event_type="claim_submitted",
|
|
actor_id=owner.email,
|
|
tenant_id=tenant_id,
|
|
correlation_id="timeline-http-permission",
|
|
idempotency_key="timeline-http-permission",
|
|
previous_status="draft",
|
|
previous_approval_stage="待提交",
|
|
relation_type=relation_type,
|
|
extra_payload={
|
|
"opinion": "同意,按计划执行。",
|
|
"internal_route": "finance-secret-route",
|
|
"archived_applications": [
|
|
{
|
|
"application_claim_id": "internal-application-id",
|
|
"application_claim_no": "AP-TIMELINE-001",
|
|
"archive_event_id": "internal-archive-event-id",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
db.commit()
|
|
return claim
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("approval_stage", "headers"),
|
|
[
|
|
(
|
|
DIRECT_MANAGER_APPROVAL_STAGE,
|
|
{
|
|
"x-auth-username": "owner@example.com",
|
|
"x-auth-name": "Owner",
|
|
"x-auth-employee-no": "E001",
|
|
"x-auth-role-codes": "user",
|
|
},
|
|
),
|
|
(
|
|
DIRECT_MANAGER_APPROVAL_STAGE,
|
|
{
|
|
"x-auth-username": "manager@example.com",
|
|
"x-auth-name": "Manager",
|
|
"x-auth-employee-no": "M001",
|
|
"x-auth-role-codes": "approver",
|
|
},
|
|
),
|
|
(
|
|
FINANCE_APPROVAL_STAGE,
|
|
{
|
|
"x-auth-username": "finance@example.com",
|
|
"x-auth-name": "Finance",
|
|
"x-auth-role-codes": "finance",
|
|
},
|
|
),
|
|
(
|
|
DIRECT_MANAGER_APPROVAL_STAGE,
|
|
{
|
|
"x-auth-username": "admin",
|
|
"x-auth-name": "Admin",
|
|
"x-auth-is-admin": "true",
|
|
},
|
|
),
|
|
],
|
|
ids=["owner", "current-manager", "finance", "admin"],
|
|
)
|
|
def test_expense_case_timeline_allows_supported_viewers(
|
|
http_context: tuple[TestClient, sessionmaker[Session]],
|
|
approval_stage: str,
|
|
headers: dict[str, str],
|
|
) -> None:
|
|
client, session_factory = http_context
|
|
with session_factory() as db:
|
|
claim = seed_timeline(db, approval_stage=approval_stage)
|
|
claim_id = claim.id
|
|
|
|
response = client.get(f"/api/v1/expense-cases/by-claim/{claim_id}", headers=headers)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert set(payload) == {"id", "case_no", "current_stage", "status", "links", "events"}
|
|
assert payload["case_no"] == "CASE-RE-TIMELINE-001"
|
|
assert payload["links"] == [{"relation_type": "claim"}]
|
|
assert len(payload["events"]) == 1
|
|
event = payload["events"][0]
|
|
assert set(event) == {
|
|
"id",
|
|
"event_type",
|
|
"actor_id",
|
|
"actor_type",
|
|
"payload_json",
|
|
"occurred_at",
|
|
}
|
|
assert event["actor_id"] == "owner@example.com"
|
|
assert event["payload_json"]["opinion"] == "同意,按计划执行。"
|
|
assert event["payload_json"]["archived_applications"] == [
|
|
{"application_claim_no": "AP-TIMELINE-001"}
|
|
]
|
|
serialized = response.text
|
|
for internal_value in (
|
|
"idempotency_key",
|
|
"correlation_id",
|
|
"causation_id",
|
|
"delivery_status",
|
|
"resource_id",
|
|
"internal_route",
|
|
"internal-application-id",
|
|
"internal-archive-event-id",
|
|
):
|
|
assert internal_value not in serialized
|
|
|
|
|
|
def test_expense_case_timeline_hides_claim_existence_from_unrelated_user(
|
|
http_context: tuple[TestClient, sessionmaker[Session]],
|
|
) -> None:
|
|
client, session_factory = http_context
|
|
with session_factory() as db:
|
|
claim_id = seed_timeline(db).id
|
|
|
|
response = client.get(
|
|
f"/api/v1/expense-cases/by-claim/{claim_id}",
|
|
headers={
|
|
"x-auth-username": "unrelated@example.com",
|
|
"x-auth-name": "Unrelated User",
|
|
"x-auth-employee-no": "E999",
|
|
"x-auth-role-codes": "user",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "费用单据不存在。"
|
|
|
|
|
|
def test_expense_case_timeline_rejects_cross_tenant_lookup(
|
|
http_context: tuple[TestClient, sessionmaker[Session]],
|
|
) -> None:
|
|
client, session_factory = http_context
|
|
with session_factory() as db:
|
|
claim_id = seed_timeline(db, tenant_id="tenant-a").id
|
|
|
|
response = client.get(
|
|
f"/api/v1/expense-cases/by-claim/{claim_id}",
|
|
headers={
|
|
"x-auth-username": "owner@example.com",
|
|
"x-auth-name": "Owner",
|
|
"x-auth-employee-no": "E001",
|
|
"x-auth-role-codes": "user",
|
|
"x-auth-tenant-id": "tenant-b",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "费用单据不存在。"
|
|
|
|
|
|
def test_expense_case_timeline_returns_not_covered_for_claim_without_case(
|
|
http_context: tuple[TestClient, sessionmaker[Session]],
|
|
) -> None:
|
|
client, session_factory = http_context
|
|
with session_factory() as db:
|
|
claim_id = seed_timeline(db, with_case=False).id
|
|
|
|
response = client.get(
|
|
f"/api/v1/expense-cases/by-claim/{claim_id}",
|
|
headers={
|
|
"x-auth-username": "owner@example.com",
|
|
"x-auth-name": "Owner",
|
|
"x-auth-employee-no": "E001",
|
|
"x-auth-role-codes": "user",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "该单据尚未纳入统一费用事件。"
|
|
|
|
|
|
def test_expense_case_timeline_returns_safe_summary_for_all_linked_claims(
|
|
http_context: tuple[TestClient, sessionmaker[Session]],
|
|
) -> None:
|
|
client, session_factory = http_context
|
|
with session_factory() as db:
|
|
application = seed_timeline(
|
|
db,
|
|
claim_no="AP-TIMELINE-001",
|
|
expense_type="travel_application",
|
|
relation_type="application",
|
|
)
|
|
service = ExpenseCaseService(db)
|
|
expense_case = service.get_timeline_for_claim(application.id)
|
|
assert expense_case is not None
|
|
reimbursement = ExpenseClaim(
|
|
id="claim-2",
|
|
claim_no="RE-TIMELINE-002",
|
|
employee_id=application.employee_id,
|
|
employee_name=application.employee_name,
|
|
department_name=application.department_name,
|
|
expense_type="travel",
|
|
reason="客户现场差旅报销",
|
|
location="上海",
|
|
amount=Decimal("880.00"),
|
|
currency="CNY",
|
|
invoice_count=1,
|
|
occurred_at=application.occurred_at,
|
|
submitted_at=application.submitted_at,
|
|
status="submitted",
|
|
approval_stage=FINANCE_APPROVAL_STAGE,
|
|
risk_flags_json=[],
|
|
)
|
|
db.add(reimbursement)
|
|
db.flush()
|
|
service.record_claim_event(
|
|
reimbursement,
|
|
event_type="claim_submitted",
|
|
actor_id="owner@example.com",
|
|
expense_case=expense_case,
|
|
relation_type="generated_reimbursement",
|
|
correlation_id="linked-reimbursement",
|
|
idempotency_key="linked-reimbursement",
|
|
previous_status="draft",
|
|
previous_approval_stage="待提交",
|
|
)
|
|
db.commit()
|
|
|
|
response = client.get(
|
|
"/api/v1/expense-cases/by-claim/claim-1",
|
|
headers={
|
|
"x-auth-username": "owner@example.com",
|
|
"x-auth-name": "Owner",
|
|
"x-auth-employee-no": "E001",
|
|
"x-auth-role-codes": "user",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert {item["relation_type"] for item in payload["links"]} == {
|
|
"application",
|
|
"generated_reimbursement",
|
|
}
|
|
assert [event["event_type"] for event in payload["events"]] == [
|
|
"claim_submitted",
|
|
"claim_submitted",
|
|
]
|