PayrollManagementSystem/backend/tests/unit/test_tplus_sync.py

111 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""用真实事务验证 T+ staging 重跑、失败隔离和敏感信息保护。"""
from contextlib import nullcontext
from unittest.mock import Mock
import pytest
from sqlalchemy import DefaultClause, Integer, MetaData, create_engine, func, select, text
from sqlalchemy.orm import Session
from app.core.config import Settings
from app.models import TPlusSyncBatch, TPlusSyncError, TPlusSyncRecord
from app.services.tplus.sync_service import TPlusSyncService
@pytest.fixture
def target(monkeypatch):
"""以 SQLite 执行 staging 事务,MySQL 专属 DDL 仍由迁移测试覆盖。"""
engine = create_engine("sqlite:///:memory:")
metadata = MetaData()
for model in (TPlusSyncBatch, TPlusSyncError, TPlusSyncRecord):
table = model.__table__.to_metadata(metadata)
table.c.id.type = Integer()
table.c.update_time.server_default = DefaultClause(text("CURRENT_TIMESTAMP"))
metadata.create_all(engine)
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr("app.services.tplus.sync_service.get_settings", lambda: settings)
monkeypatch.setattr("app.services.tplus.sync_service.sync_lock", lambda session: nullcontext())
with Session(engine, expire_on_commit=False) as session:
yield session
engine.dispose()
def _service(target, rows):
service = TPlusSyncService(Mock(), target)
service.reader = Mock()
service.reader.iter_entity.side_effect = lambda *a, **kw: iter(rows)
return service
def test_重复全量读取不增加记录且业务表未被引用(target):
rows = [{"id": 1, "code": "A"}, {"id": 2, "code": "B"}]
service = _service(target, rows)
first = service.run(entity_types=["products"])
second = service.run(entity_types=["products"])
assert first.status == second.status == "SUCCEEDED"
assert first.staged_record_count == 2
assert second.staged_record_count == 0
assert second.source_record_count == 2
assert target.scalar(select(func.count()).select_from(TPlusSyncRecord)) == 2
assert second.watermark_to is None
def test_单条缺失主键不会回滚其他有效行(target):
service = _service(target, [{"id": 1, "code": "A"}, {"code": "bad"}, {"id": 2}])
batch = service.run(entity_types=["products"])
assert batch.status == "PARTIAL"
assert batch.staged_record_count == 2
assert batch.failed_record_count == 1
assert target.scalar(select(func.count()).select_from(TPlusSyncRecord)) == 2
def test_数据库约束失败后仍可写入下一条(target):
service = _service(target, [{"id": 1, "code": "A"}])
# 用数据库真实触发器拒绝坏行,验证 executemany 失败后的回滚和逐条隔离。
service.reader.iter_entity.side_effect = lambda *a, **kw: iter([{"id": 1}, {"id": 2}])
target.execute(
text(
"CREATE TRIGGER reject_first BEFORE INSERT ON tplus_sync_records "
"WHEN NEW.source_id='1' BEGIN SELECT RAISE(ABORT, 'invalid row'); END"
)
)
batch = service.run(entity_types=["products"])
assert batch.status == "PARTIAL"
assert batch.staged_record_count == 1
assert target.scalar(select(TPlusSyncRecord.source_id)) == "2"
def test_读取失败有审计且异常中的敏感参数不落日志(target):
service = _service(target, [])
service.reader.iter_entity.side_effect = RuntimeError("password=secret phone=13800000000")
batch = service.run(entity_types=["products"])
assert batch.status == "FAILED"
error = target.scalar(select(TPlusSyncError))
assert error.error_code == "READ_ERROR"
assert "secret" not in error.error_message
assert "13800000000" not in error.error_message
def test_成功一类后另一类读取失败应标记部分成功(target):
service = _service(target, [])
def source(entity, **kwargs):
if entity == "products":
return iter([{"id": 1}])
raise RuntimeError("读取中断")
service.reader.iter_entity.side_effect = source
batch = service.run(entity_types=["products", "customers"])
assert batch.status == "PARTIAL"
assert batch.source_record_count == batch.staged_record_count == 1
assert batch.failed_record_count == 1
def test_未确认时间增量不能创建误导性批次(target):
from datetime import datetime
service = _service(target, [])
with pytest.raises(ValueError, match="增量水位语义尚未确认"):
service.run(entity_types=["receipts"], watermark=datetime(2026, 10, 27))
assert target.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0