"""用真实事务验证 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