"""周期和自动重试只能生成原始采集,验证重启防重与人工介入边界。""" from datetime import datetime, timedelta import pytest from sqlalchemy import func, select from app.core.config import Settings from app.models import TPlusAutomationState, TPlusSyncBatch, TPlusSyncError from app.services.tplus import automation from tests.unit.test_tplus_workbench import work_session as work_session NOW = datetime(2026, 10, 30, 10) @pytest.fixture def settings(): return Settings(_env_file=None, app_secret_key="x" * 32, t_plus_enabled=True, t_plus_database="test_source", t_plus_user="reader", t_plus_odbc_driver="unit", t_plus_automation_enabled=True) def run(s, settings, now=NOW): return automation.tick(s, settings=settings, now=now) def fail(s, number, *, status="FAILED", code="SOURCE_TIMEOUT", at=NOW): row = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number)) row.status, row.finished_at = status, at s.add(TPlusSyncError(batch_no=number, entity_type="system", source_table="system", error_code=code, error_message="固定错误说明")) s.commit() def test_默认关闭不会排队(work_session, settings): result = run(work_session, settings.model_copy(update={"t_plus_automation_enabled": False})) assert result == {"status": "DISABLED", "queued": False} assert work_session.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0 def test_同周期重复检查和重启只有一次九类采集(work_session, settings): s = work_session first = run(s, settings) for _ in range(3): assert not run(s, settings)["queued"] batch = s.scalar(select(TPlusSyncBatch)) assert batch.batch_no == first["batch_no"] and batch.sync_mode == "FULL" assert len(batch.entity_types_json) == 9 and batch.trigger_type == "SCHEDULED" assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1 def test_停机多周只补最近一次(work_session, settings): s = work_session first = run(s, settings) batch = s.scalar(select(TPlusSyncBatch)) batch.status, batch.finished_at = "SUCCEEDED", NOW s.commit() later = run(s, settings, NOW + timedelta(weeks=4)) assert later["queued"] and later["period"] != first["period"] assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 2 def test_人工任务占用时不堆积周期任务(work_session, settings): s = work_session s.add(TPlusSyncBatch(batch_no="manual", sync_mode="FULL", status="RUNNING", source_database="test_source", entity_types_json=["personnel"])) s.commit() assert run(s, settings)["status"] == "DEFERRED" assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1 def test_明确超时仅按五十五分钟最多重试两次(work_session, settings): s = work_session first = run(s, settings) fail(s, first["batch_no"]) assert not run(s, settings, NOW + timedelta(minutes=4))["queued"] second = run(s, settings, NOW + timedelta(minutes=5)) assert second["queued"] fail(s, second["batch_no"], at=NOW + timedelta(minutes=5)) assert not run(s, settings, NOW + timedelta(minutes=19))["queued"] third = run(s, settings, NOW + timedelta(minutes=20)) assert third["queued"] fail(s, third["batch_no"], at=NOW + timedelta(minutes=21)) assert not run(s, settings, NOW + timedelta(hours=2))["queued"] rows = list(s.scalars(select(TPlusSyncBatch).order_by(TPlusSyncBatch.id))) assert [(r.trigger_type, r.automatic_attempt) for r in rows] == [("SCHEDULED", 0), ("AUTO_RETRY", 1), ("AUTO_RETRY", 2)] assert rows[1].parent_batch_no == rows[0].batch_no and rows[2].parent_batch_no == rows[1].batch_no @pytest.mark.parametrize("status,code", [("PARTIAL", "SOURCE_TIMEOUT"), ("FAILED", "SOURCE_CONFIG_ERROR"), ("FAILED", "STAGE_ERROR"), ("FAILED", "EXECUTION_ERROR")]) def test_部分失败或不明确错误不自动重试(work_session, settings, status, code): s = work_session first = run(s, settings) fail(s, first["batch_no"], status=status, code=code) assert not run(s, settings, NOW + timedelta(hours=2))["queued"] assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1 def test_ISO年周和到期时间边界稳定(): assert automation.due_period(datetime(2027, 1, 1, 10))[0] == "2026-W53" assert automation.due_period(datetime(2026, 11, 2, 1, 59))[0] == "2026-W44" assert automation.due_period(datetime(2026, 11, 2, 2))[0] == "2026-W45" def test_站内提示覆盖排队超时和无成功同步(work_session, settings): s = work_session run(s, settings) batch = s.scalar(select(TPlusSyncBatch)) batch.create_time = NOW - timedelta(minutes=16) s.commit() result = automation.overview(s, settings=settings, now=NOW) assert {r["code"] for r in result["alerts"]} >= {"QUEUE_OVERDUE", "SYNC_STALE", "WORKER_OFFLINE"} def test_读取心跳不把当前采集写成工资(work_session, settings): from sqlalchemy.orm import sessionmaker s = work_session automation.heartbeat(sessionmaker(s.get_bind()), settings, "owner", "RUNNING", now=NOW) s.expire_all() state = s.scalar(select(TPlusAutomationState)) assert state.heartbeat_at == NOW and state.worker_phase == "RUNNING" assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0 @pytest.mark.parametrize("status,code", [("SUCCEEDED", None), ("FAILED", "SOURCE_CONFIG_ERROR"), ("FAILED", "SOURCE_TIMEOUT")]) def test_人工重试结果接入周期且不重置自动次数(work_session, settings, monkeypatch, status, code): from uuid import uuid4 from app.schemas.tplus import TPlusRetryIn from app.services.tplus import task_service monkeypatch.setattr(task_service, "get_settings", lambda: settings) s = work_session first = run(s, settings) fail(s, first["batch_no"]) auto = run(s, settings, NOW + timedelta(minutes=5)) fail(s, auto["batch_no"], at=NOW + timedelta(minutes=5)) manual = task_service.retry_task(s, auto["batch_no"], TPlusRetryIn(request_key=uuid4())) assert run(s, settings, NOW + timedelta(minutes=6))["status"] == "DEFERRED" if code: fail(s, manual.batch_no, status=status, code=code, at=NOW + timedelta(minutes=6)) else: manual.status, manual.finished_at = status, NOW + timedelta(minutes=6) s.commit() outcome = run(s, settings, NOW + timedelta(minutes=30)) assert outcome["queued"] == (code == "SOURCE_TIMEOUT") state = s.scalar(select(TPlusAutomationState)) assert state.retry_attempts == (2 if outcome["queued"] else 1) if status == "SUCCEEDED": assert state.last_success_at == manual.finished_at if outcome["queued"]: child = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == outcome["batch_no"])) assert child.parent_batch_no == manual.batch_no def test_停止标记阻止周期排队且不报告进程离线(work_session, settings, tmp_path): marker = tmp_path / "stop.requested" marker.touch() stopped = settings.model_copy(update={"t_plus_stop_file": str(marker)}) assert run(work_session, stopped)["status"] == "STOP_REQUESTED" assert not automation.overview(work_session, settings=stopped, now=NOW)["alerts"]