1. 修复钉钉客户端API调用参数格式,将unionid从URL路径移至params 2. 补充服务账号mock的active状态,完善权限校验测试前置条件 3. 更新钉钉消息发送缓存计数断言,修正缓存键数量预期 4. 补充分类阶段测试的日志mock与新增字段校验 5. 新增事务端口的get_session方法测试,完善事务上下文接口覆盖 6. 修正代理访问配置默认权限等级为global,更新对应测试用例 7. 重构权限阶段未知请求处理逻辑,改为失败关闭而非默认管理员权限 8. 更新ack记录失败处理逻辑,改为抛出异常而非仅告警 9. 新增能力校验阶段异常降级测试用例 10. 重构发件箱持久化阶段测试,移除冗余查询并验证结果 11. 新增消息撤回测试的会话ID缓存获取逻辑 12. 修复映射器测试的账号ID匹配逻辑 13. 新增输入幂等阶段的缓存锁依赖,完善并发测试支持 14. 补充围栏代际回滚方法的完整测试覆盖 15. 新增路由匹配注册表的原子替换测试用例 16. 新增会话统计接口的截断状态与总数统计测试 17. 修正配置schema统计计数,更新热更新字段与总条目数 18. 补充调度任务处理器的缓存端口依赖,完善健康检查与锁机制测试
635 lines
22 KiB
Python
635 lines
22 KiB
Python
"""yuxi.channels.application.pipeline.base 单元测试。
|
||
|
||
覆盖 ``Pipeline``:
|
||
- ``run(ctx)``:正常执行所有 stage / 某 stage 失败时停止后续 stage
|
||
- ``_validateCompensateReferences()``:补偿引用校验(指向不存在的 stage 报错)
|
||
- ``_runCompensate()``:执行补偿逻辑
|
||
- ``_runCleanup()``:协程取消清理(C-O2)
|
||
- ``_findStage()``:按名称查找 stage
|
||
- ``_logStageExecution()``:日志记录
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from collections.abc import Callable
|
||
from types import SimpleNamespace
|
||
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`` 覆盖行为。
|
||
``raise_error`` 接受 ``BaseException`` 以支持 ``asyncio.CancelledError``
|
||
(C-O2 清理测试)。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
stage_id: str,
|
||
*,
|
||
result: bool = True,
|
||
raise_error: BaseException | None = None,
|
||
compensate: str | None = None,
|
||
failure: FailureStrategy = FailureStrategy.TERMINATE,
|
||
condition: Callable[[Any], bool] | None = None,
|
||
cleanup: bool = False,
|
||
) -> 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.cleanup = cleanup
|
||
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`` 与降级字段。
|
||
|
||
``streaming_started`` / ``typing_started`` 用于 C-O2 清理逻辑测试。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
trace_id: str = "trace-001",
|
||
*,
|
||
streaming_started: bool = False,
|
||
typing_started: bool = False,
|
||
) -> None:
|
||
self.trace_id = trace_id
|
||
self.degraded = False
|
||
self.degraded_reason = None
|
||
self.streaming_started = streaming_started
|
||
self.typing_started = typing_started
|
||
|
||
|
||
class _FakeTracer:
|
||
"""``TracerPort`` 桩,记录 ``startSpan`` / ``endSpan`` 调用以便断言。"""
|
||
|
||
def __init__(self) -> None:
|
||
self.start_calls: list[tuple[str, str | None]] = []
|
||
self.end_calls: list[tuple[str, str]] = []
|
||
self._seq = 0
|
||
|
||
async def startSpan(self, name, trace_id=None, parent_span_id=None): # noqa: ANN001
|
||
self._seq += 1
|
||
span = SimpleNamespace(name=name, trace_id=trace_id, span_id=f"span-{self._seq}")
|
||
self.start_calls.append((name, trace_id))
|
||
return span
|
||
|
||
async def endSpan(self, span, status="ok"): # noqa: ANN001
|
||
self.end_calls.append((span.name, status))
|
||
|
||
async def getCurrentTrace(self):
|
||
return 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
|
||
|
||
|
||
# ─── _runCleanup(C-O2 协程取消清理)──────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestPipelineRunCleanup:
|
||
"""``Pipeline.run`` 的 finally 清理逻辑(C-O2)。
|
||
|
||
覆盖协程取消时清理阶段(``cleanup=True``)的执行:CancelledError 重新
|
||
抛出、清理异常不掩盖原始取消、无流式资源时跳过清理。
|
||
"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cancelled_error_triggers_cleanup_and_reraises(self, fake_logger):
|
||
# Arrange — stream-chunk 阶段被取消,typing-stop(cleanup=True)应在
|
||
# finally 中被调用释放资源,CancelledError 在清理后重新抛出
|
||
stream_chunk = _FakeStage(
|
||
"stream-chunk",
|
||
raise_error=asyncio.CancelledError(),
|
||
failure=FailureStrategy.TERMINATE,
|
||
)
|
||
typing_stop = _FakeStage(
|
||
"typing-stop",
|
||
cleanup=True,
|
||
condition=lambda ctx: ctx.streaming_started or ctx.typing_started,
|
||
)
|
||
pipeline = Pipeline(
|
||
name="outbound",
|
||
stages=[stream_chunk, typing_stop],
|
||
logger=fake_logger,
|
||
)
|
||
ctx = _FakeCtx(trace_id="trace-cancel", streaming_started=True)
|
||
|
||
# Act / Assert — CancelledError 重新抛出,清理阶段被执行
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await pipeline.run(ctx)
|
||
|
||
stream_chunk.process.assert_awaited_once_with(ctx)
|
||
typing_stop.process.assert_awaited_once_with(ctx)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cleanup_exception_does_not_mask_cancelled_error(self, fake_logger):
|
||
# Arrange — 清理阶段自身抛异常时仅 warn 日志,不掩盖原始 CancelledError
|
||
stream_chunk = _FakeStage(
|
||
"stream-chunk",
|
||
raise_error=asyncio.CancelledError(),
|
||
failure=FailureStrategy.TERMINATE,
|
||
)
|
||
typing_stop = _FakeStage(
|
||
"typing-stop",
|
||
raise_error=RuntimeError("cleanup boom"),
|
||
cleanup=True,
|
||
condition=lambda ctx: ctx.streaming_started or ctx.typing_started,
|
||
)
|
||
pipeline = Pipeline(
|
||
name="outbound",
|
||
stages=[stream_chunk, typing_stop],
|
||
logger=fake_logger,
|
||
)
|
||
ctx = _FakeCtx(trace_id="trace-mask", streaming_started=True)
|
||
|
||
# Act / Assert — 抛出的是 CancelledError 而非清理阶段的 RuntimeError
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await pipeline.run(ctx)
|
||
|
||
typing_stop.process.assert_awaited_once_with(ctx)
|
||
fake_logger.warn.assert_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_no_cleanup_when_streaming_not_started(self, fake_logger):
|
||
# Arrange — streaming_started=False 且 typing_started=False 时,
|
||
# 即使协程被取消,清理阶段也不执行(无流式资源需释放)
|
||
stream_chunk = _FakeStage(
|
||
"stream-chunk",
|
||
raise_error=asyncio.CancelledError(),
|
||
failure=FailureStrategy.TERMINATE,
|
||
)
|
||
typing_stop = _FakeStage(
|
||
"typing-stop",
|
||
cleanup=True,
|
||
condition=lambda ctx: ctx.streaming_started or ctx.typing_started,
|
||
)
|
||
pipeline = Pipeline(
|
||
name="outbound",
|
||
stages=[stream_chunk, typing_stop],
|
||
logger=fake_logger,
|
||
)
|
||
ctx = _FakeCtx(trace_id="trace-no-stream", streaming_started=False, typing_started=False)
|
||
|
||
# Act / Assert — CancelledError 重新抛出,但清理阶段未被调用
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await pipeline.run(ctx)
|
||
|
||
typing_stop.process.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_normal_completion_skips_cleanup_when_typing_stop_already_ran(self, fake_logger):
|
||
# Arrange — 正常完成时 typing-stop 已在循环中执行并重置标志,
|
||
# finally 清理阶段 condition 不满足,不会重复执行
|
||
typing_stop = _FakeStage(
|
||
"typing-stop",
|
||
cleanup=True,
|
||
failure=FailureStrategy.SKIP,
|
||
condition=lambda ctx: ctx.streaming_started or ctx.typing_started,
|
||
)
|
||
# 模拟 typing-stop 正常执行后重置标志:用 side_effect 重置
|
||
async def _reset_flags(ctx):
|
||
ctx.streaming_started = False
|
||
ctx.typing_started = False
|
||
return True
|
||
|
||
typing_stop.process = AsyncMock(side_effect=_reset_flags)
|
||
pipeline = Pipeline(
|
||
name="outbound",
|
||
stages=[typing_stop],
|
||
logger=fake_logger,
|
||
)
|
||
ctx = _FakeCtx(trace_id="trace-normal", streaming_started=True, typing_started=True)
|
||
|
||
# Act
|
||
ok, err = await pipeline.run(ctx)
|
||
|
||
# Assert — 正常完成,typing-stop 在循环中执行 1 次,finally 不再重复执行
|
||
assert ok is True
|
||
assert err is None
|
||
assert typing_stop.process.await_count == 1
|
||
|
||
|
||
# ─── _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 — 无异常即视为通过
|
||
|
||
|
||
# ─── stage-level span(H-4 / §11.2)───────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestStageSpan:
|
||
"""``Pipeline.run`` 为每个 stage 包裹独立 span(H-4)。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_each_stage_generates_independent_span(self):
|
||
# Arrange
|
||
s1 = _FakeStage("session_resolve")
|
||
s2 = _FakeStage("route")
|
||
tracer = _FakeTracer()
|
||
pipeline = Pipeline(name="inbound", stages=[s1, s2], tracer_port=tracer)
|
||
ctx = _FakeCtx(trace_id="trace-1")
|
||
|
||
# Act
|
||
ok, _ = await pipeline.run(ctx)
|
||
|
||
# Assert — 每个 stage 各生成一个 span
|
||
assert ok is True
|
||
assert len(tracer.start_calls) == 2
|
||
assert len(tracer.end_calls) == 2
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_span_name_contains_pipeline_name_and_stage_id(self):
|
||
# Arrange
|
||
s1 = _FakeStage("session_resolve")
|
||
tracer = _FakeTracer()
|
||
pipeline = Pipeline(name="inbound", stages=[s1], tracer_port=tracer)
|
||
|
||
# Act
|
||
await pipeline.run(_FakeCtx(trace_id="trace-2"))
|
||
|
||
# Assert — span 名格式 {pipeline_name}.{stage_id}
|
||
assert tracer.start_calls[0] == ("inbound.session_resolve", "trace-2")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_span_status_ok_on_success(self):
|
||
# Arrange
|
||
s1 = _FakeStage("deliver")
|
||
tracer = _FakeTracer()
|
||
pipeline = Pipeline(name="outbound", stages=[s1], tracer_port=tracer)
|
||
|
||
# Act
|
||
await pipeline.run(_FakeCtx())
|
||
|
||
# Assert
|
||
assert tracer.end_calls[0] == ("outbound.deliver", "ok")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_span_closed_with_error_status_on_exception(self):
|
||
# Arrange — stage 抛原生异常,span 仍需在 finally 中关闭
|
||
s1 = _FakeStage(
|
||
"deliver",
|
||
raise_error=RuntimeError("boom"),
|
||
failure=FailureStrategy.TERMINATE,
|
||
)
|
||
tracer = _FakeTracer()
|
||
pipeline = Pipeline(name="outbound", stages=[s1], tracer_port=tracer)
|
||
|
||
# Act
|
||
ok, err = await pipeline.run(_FakeCtx(trace_id="trace-err"))
|
||
|
||
# Assert — span 被关闭(try/finally),状态为 error
|
||
assert ok is False
|
||
assert err is not None
|
||
assert len(tracer.end_calls) == 1
|
||
assert tracer.end_calls[0] == ("outbound.deliver", "error")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_span_closed_with_error_status_when_stage_returns_false(self):
|
||
# Arrange — stage 返回 False(非异常失败),span 状态为 error
|
||
s1 = _FakeStage("dispatch", result=False, failure=FailureStrategy.TERMINATE)
|
||
tracer = _FakeTracer()
|
||
pipeline = Pipeline(name="control-plane", stages=[s1], tracer_port=tracer)
|
||
|
||
# Act
|
||
await pipeline.run(_FakeCtx())
|
||
|
||
# Assert
|
||
assert tracer.end_calls[0] == ("control-plane.dispatch", "error")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_no_span_when_tracer_port_is_none(self):
|
||
# Arrange — tracer_port=None(默认),向后兼容不生成 span
|
||
s1 = _FakeStage("resolve")
|
||
pipeline = Pipeline(name="inbound", stages=[s1])
|
||
|
||
# Act
|
||
ok, _ = await pipeline.run(_FakeCtx())
|
||
|
||
# Assert — 无异常即通过(tracer_port 为 None 时跳过 span 包裹)
|
||
assert ok is True
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_condition_skipped_stage_has_no_span(self):
|
||
# Arrange — condition 返回 False 的 stage 不生成 span
|
||
s1 = _FakeStage("skipped", condition=lambda _ctx: False)
|
||
s2 = _FakeStage("route")
|
||
tracer = _FakeTracer()
|
||
pipeline = Pipeline(name="inbound", stages=[s1, s2], tracer_port=tracer)
|
||
|
||
# Act
|
||
await pipeline.run(_FakeCtx())
|
||
|
||
# Assert — 仅执行 stage 生成 span
|
||
assert len(tracer.start_calls) == 1
|
||
assert tracer.start_calls[0][0] == "inbound.route"
|