111 lines
4.5 KiB
Python
111 lines
4.5 KiB
Python
"""用真实事务验证 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
|