ForcePilot/backend/test/unit/channels/application/pipeline/test_base.py
Kris b323922cdc style: 清理测试文件中的多余空行和导入顺序
本次提交清理了多个单元测试文件中的多余空行,调整了部分导入的顺序,同时修复了一处feishu错误翻译的导入顺序问题,移除了application/conftest.py中未使用的fake_report_repo fixture,优化代码格式提升可读性。
2026-07-06 20:52:20 +08:00

358 lines
12 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.

"""yuxi.channels.application.pipeline.base 单元测试。
覆盖 ``Pipeline``
- ``run(ctx)``:正常执行所有 stage / 某 stage 失败时停止后续 stage
- ``_validateCompensateReferences()``:补偿引用校验(指向不存在的 stage 报错)
- ``_runCompensate()``:执行补偿逻辑
- ``_findStage()``:按名称查找 stage
- ``_logStageExecution()``:日志记录
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from unittest.mock import AsyncMock
import pytest
from yuxi.channels.application.pipeline.base import Pipeline
from yuxi.channels.contract.errors import PipelineConfigError, ValidationError
from yuxi.channels.contract.plugin.extension_point import FailureStrategy
pytestmark = pytest.mark.unit
# ─── helper ────────────────────────────────────────────────────────────────
class _FakeStage:
"""满足 ``AppStage`` Protocol 的可编排测试 stage。
``process`` 默认返回 True可通过 ``result`` / ``raise_error`` 覆盖行为。
"""
def __init__(
self,
stage_id: str,
*,
result: bool = True,
raise_error: Exception | None = None,
compensate: str | None = None,
failure: FailureStrategy = FailureStrategy.TERMINATE,
condition: Callable[[Any], bool] | None = None,
) -> None:
self.id = stage_id
self.reads: tuple[str, ...] = ()
self.writes: tuple[str, ...] = ()
self.idempotent = True
self.thread_safe = True
self.failure = failure
self.compensate = compensate
self.condition = condition
self._result = result
self._raise_error = raise_error
self.process = AsyncMock(return_value=self._result)
if raise_error is not None:
self.process = AsyncMock(side_effect=raise_error)
class _FakeCtx:
"""管道上下文桩,携带 ``trace_id`` 与降级字段。"""
def __init__(self, trace_id: str = "trace-001") -> None:
self.trace_id = trace_id
self.degraded = False
self.degraded_reason = None
# ─── run ───────────────────────────────────────────────────────────────────
@pytest.mark.unit
class TestPipelineRun:
"""``Pipeline.run`` 顺序执行与失败策略。"""
@pytest.mark.asyncio
async def test_run_executes_all_stages_when_all_succeed(self):
# Arrange
s1 = _FakeStage("s1")
s2 = _FakeStage("s2")
pipeline = Pipeline(name="test", stages=[s1, s2])
ctx = _FakeCtx()
# Act
ok, err = await pipeline.run(ctx)
# Assert
assert ok is True
assert err is None
s1.process.assert_awaited_once_with(ctx)
s2.process.assert_awaited_once_with(ctx)
@pytest.mark.asyncio
async def test_run_stops_subsequent_stages_on_terminate_failure(self):
# Arrange
s1 = _FakeStage("s1", result=False, failure=FailureStrategy.TERMINATE)
s2 = _FakeStage("s2")
pipeline = Pipeline(name="test", stages=[s1, s2])
ctx = _FakeCtx()
# Act
ok, err = await pipeline.run(ctx)
# Assert
assert ok is False
s1.process.assert_awaited_once_with(ctx)
s2.process.assert_not_awaited()
@pytest.mark.asyncio
async def test_run_skip_strategy_continues_next_stage(self):
# Arrange
s1 = _FakeStage("s1", result=False, failure=FailureStrategy.SKIP)
s2 = _FakeStage("s2", result=True)
pipeline = Pipeline(name="test", stages=[s1, s2])
ctx = _FakeCtx()
# Act
ok, err = await pipeline.run(ctx)
# Assert
assert ok is True
assert err is None
s2.process.assert_awaited_once_with(ctx)
@pytest.mark.asyncio
async def test_run_degrade_strategy_marks_context_and_continues(self):
# Arrange
s1 = _FakeStage("s1", result=False, failure=FailureStrategy.DEGRADE)
s2 = _FakeStage("s2", result=True)
pipeline = Pipeline(name="test", stages=[s1, s2])
ctx = _FakeCtx()
# Act
ok, err = await pipeline.run(ctx)
# Assert
assert ok is True
assert ctx.degraded is True
@pytest.mark.asyncio
async def test_run_condition_false_skips_stage(self):
# Arrange
s1 = _FakeStage("s1", condition=lambda _ctx: False)
s2 = _FakeStage("s2")
pipeline = Pipeline(name="test", stages=[s1, s2])
ctx = _FakeCtx()
# Act
ok, err = await pipeline.run(ctx)
# Assert
assert ok is True
s1.process.assert_not_awaited()
s2.process.assert_awaited_once_with(ctx)
@pytest.mark.asyncio
async def test_run_native_exception_translated_to_internal_error(self):
# Arrange
native = RuntimeError("boom")
s1 = _FakeStage("s1", raise_error=native, failure=FailureStrategy.TERMINATE)
pipeline = Pipeline(name="test", stages=[s1])
ctx = _FakeCtx(trace_id="trace-native")
# Act
ok, err = await pipeline.run(ctx)
# Assert
assert ok is False
assert err is not None
assert err.error_code == "INTERNAL"
assert err.trace_id == "trace-native"
assert err.cause is native
# ─── _validateCompensateReferences ─────────────────────────────────────────
@pytest.mark.unit
class TestValidateCompensateReferences:
"""``_validateCompensateReferences`` 构造期补偿引用校验。"""
def test_valid_compensate_reference_does_not_raise(self):
# Arrange
compensate_stage = _FakeStage("rollback")
s1 = _FakeStage("s1", compensate="rollback")
# Act
pipeline = Pipeline(name="test", stages=[s1, compensate_stage])
# Assert
assert pipeline.stages == [s1, compensate_stage]
def test_unknown_compensate_reference_raises_pipeline_config_error(self):
# Arrange
s1 = _FakeStage("s1", compensate="nonexistent")
# Act / Assert
with pytest.raises(PipelineConfigError) as exc_info:
Pipeline(name="test", stages=[s1])
assert exc_info.value.pipeline == "test"
assert exc_info.value.stage_id == "s1"
assert exc_info.value.compensate == "nonexistent"
def test_none_compensate_does_not_raise(self):
# Arrange
s1 = _FakeStage("s1", compensate=None)
s2 = _FakeStage("s2", compensate=None)
# Act
pipeline = Pipeline(name="test", stages=[s1, s2])
# Assert
assert len(pipeline.stages) == 2
# ─── _runCompensate ────────────────────────────────────────────────────────
@pytest.mark.unit
class TestRunCompensate:
"""``_runCompensate`` 补偿执行逻辑。"""
@pytest.mark.asyncio
async def test_run_compensate_executes_compensate_stage_and_returns_original_error(self):
# Arrange
compensate_stage = _FakeStage("rollback")
s1 = _FakeStage("s1", compensate="rollback", failure=FailureStrategy.COMPENSATE)
pipeline = Pipeline(name="test", stages=[s1, compensate_stage])
original_err = ValidationError("field", "boom", trace_id="trace-001")
# Act
result = await pipeline._runCompensate(_FakeCtx(), "rollback", original_err)
# Assert
assert result is original_err
compensate_stage.process.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_compensate_returns_original_error_when_name_is_none(self):
# Arrange
pipeline = Pipeline(name="test", stages=[_FakeStage("s1")])
original_err = ValidationError("field", "boom")
# Act
result = await pipeline._runCompensate(_FakeCtx(), None, original_err)
# Assert
assert result is original_err
@pytest.mark.asyncio
async def test_run_compensate_returns_original_error_when_stage_not_found(self):
# Arrange
pipeline = Pipeline(name="test", stages=[_FakeStage("s1")])
original_err = ValidationError("field", "boom")
# Act
result = await pipeline._runCompensate(_FakeCtx(), "missing", original_err)
# Assert
assert result is original_err
@pytest.mark.asyncio
async def test_run_compensate_swallows_compensate_exception_and_logs(self, fake_logger):
# Arrange
compensate_stage = _FakeStage("rollback", raise_error=RuntimeError("compensate failed"))
s1 = _FakeStage("s1", compensate="rollback")
pipeline = Pipeline(name="test", stages=[s1, compensate_stage], logger=fake_logger)
original_err = ValidationError("field", "boom", trace_id="trace-001")
# Act
result = await pipeline._runCompensate(_FakeCtx(), "rollback", original_err)
# Assert
assert result is original_err
fake_logger.error.assert_awaited_once()
# ─── _findStage ────────────────────────────────────────────────────────────
@pytest.mark.unit
class TestFindStage:
"""``_findStage`` 按 id 查找阶段。"""
def test_find_stage_returns_matching_stage(self):
# Arrange
target = _FakeStage("target")
other = _FakeStage("other")
pipeline = Pipeline(name="test", stages=[other, target])
# Act
result = pipeline._findStage("target")
# Assert
assert result is target
def test_find_stage_returns_none_when_not_found(self):
# Arrange
pipeline = Pipeline(name="test", stages=[_FakeStage("s1")])
# Act
result = pipeline._findStage("missing")
# Assert
assert result is None
# ─── _logStageExecution ────────────────────────────────────────────────────
@pytest.mark.unit
class TestLogStageExecution:
"""``_logStageExecution`` 结构化日志输出。"""
@pytest.mark.asyncio
async def test_log_stage_execution_calls_info_on_success(self, fake_logger):
# Arrange
pipeline = Pipeline(name="test", stages=[_FakeStage("s1")], logger=fake_logger)
ctx = _FakeCtx(trace_id="trace-log")
# Act
await pipeline._logStageExecution(ctx, "s1", 12.5, True, None)
# Assert
fake_logger.info.assert_awaited_once()
call_args = fake_logger.info.call_args
assert call_args.kwargs["trace_id"] == "trace-log"
assert call_args.kwargs["stage_id"] == "s1"
assert call_args.kwargs["status"] == "ok"
assert call_args.kwargs["duration_ms"] == 12.5
@pytest.mark.asyncio
async def test_log_stage_execution_calls_warn_on_failure(self, fake_logger):
# Arrange
pipeline = Pipeline(name="test", stages=[_FakeStage("s1")], logger=fake_logger)
ctx = _FakeCtx(trace_id="trace-fail")
err = ValidationError("field", "boom")
# Act
await pipeline._logStageExecution(ctx, "s1", 5.0, False, err)
# Assert
fake_logger.warn.assert_awaited_once()
call_args = fake_logger.warn.call_args
assert call_args.kwargs["status"] == "failed"
assert call_args.kwargs["error_type"] == "ValidationError"
@pytest.mark.asyncio
async def test_log_stage_execution_noop_when_logger_is_none(self):
# Arrange
pipeline = Pipeline(name="test", stages=[_FakeStage("s1")], logger=None)
ctx = _FakeCtx()
# Act
await pipeline._logStageExecution(ctx, "s1", 1.0, True, None)
# Assert — 无异常即视为通过