186 lines
6.9 KiB
Python
186 lines
6.9 KiB
Python
"""
|
|
Tests for the controllable mock tool helper.
|
|
|
|
These tests verify the queue/blocking/release semantics that
|
|
Phase 2+ concurrency tests rely on. If these tests pass, callers
|
|
can trust that:
|
|
|
|
* queued calls are consumed in FIFO order;
|
|
* ``call_event`` fires before the body blocks;
|
|
* blocked calls don't return until ``release()`` runs;
|
|
* exceptions propagate from the call site, not from ``add_call``;
|
|
* ``release_all()`` cleans up forgotten releases in teardown.
|
|
|
|
Without trustworthy mock semantics the broader concurrency suite
|
|
becomes fire-and-hope and silently rots. See the testing skill,
|
|
"Concurrency test requirements".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from tests.server.integration.mock_tool import (
|
|
ControllableMockTool,
|
|
MockToolCall,
|
|
)
|
|
|
|
# ─── basic queue semantics ───────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_calls_consumed_in_fifo_order() -> None:
|
|
"""Queued calls fire in the same order they were added."""
|
|
tool = ControllableMockTool()
|
|
tool.add_call(result="first")
|
|
tool.add_call(result="second")
|
|
tool.add_call(result="third")
|
|
|
|
# 3 invocations because we added 3 — if the order were wrong
|
|
# the assert would surface "second" or "third" first.
|
|
assert await tool() == "first"
|
|
assert await tool() == "second"
|
|
assert await tool() == "third"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unscripted_call_uses_default_so_tests_dont_deadlock() -> None:
|
|
"""Calls past the queued count fall back to a default MockToolCall."""
|
|
tool = ControllableMockTool()
|
|
# No add_call — production code would call us anyway.
|
|
result = await tool()
|
|
# The default marker — proves the mock fired (rather than
|
|
# the production tool) without the test having to script.
|
|
assert result == "mock-tool-result"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_received_calls_records_invocations_in_order() -> None:
|
|
"""``received_calls`` lets tests assert on exactly-N invocations."""
|
|
tool = ControllableMockTool()
|
|
call_a = tool.add_call(result="a")
|
|
call_b = tool.add_call(result="b")
|
|
|
|
await tool(arg="alpha")
|
|
await tool(arg="beta")
|
|
|
|
# Exactly 2 invocations recorded; if the production code
|
|
# accidentally double-fires, this would be 3+.
|
|
assert tool.received_calls == [call_a, call_b]
|
|
assert call_a.received_arguments == {"arg": "alpha"}
|
|
assert call_b.received_arguments == {"arg": "beta"}
|
|
|
|
|
|
# ─── exception path ──────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exception_propagates_from_call_site() -> None:
|
|
"""An ``exception=`` config raises at ``__call__`` time, not at ``add_call``."""
|
|
tool = ControllableMockTool()
|
|
sentinel = ValueError("boom from mock")
|
|
tool.add_call(exception=sentinel)
|
|
|
|
with pytest.raises(ValueError, match="boom from mock"):
|
|
await tool()
|
|
# add_call must not raise — production code triggers the
|
|
# exception, not the test setup.
|
|
|
|
|
|
# ─── blocking + release semantics ────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_blocked_call_does_not_return_until_release() -> None:
|
|
"""``block=True`` holds the body until ``release()`` runs."""
|
|
tool = ControllableMockTool()
|
|
call = tool.add_call(result="unblocked", block=True)
|
|
|
|
invoke_task = asyncio.create_task(tool())
|
|
# Wait until the body has signalled entry, then prove it's
|
|
# still suspended — if it weren't blocking, the task would
|
|
# already be done by the time we ask.
|
|
await call.wait_called(timeout=1.0)
|
|
assert not invoke_task.done(), (
|
|
"Blocked call returned before release() was called — "
|
|
"block_before_response is not actually gating the body."
|
|
)
|
|
|
|
call.release()
|
|
# release() lets the body proceed — task should finish promptly.
|
|
result = await asyncio.wait_for(invoke_task, timeout=1.0)
|
|
assert result == "unblocked"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_event_fires_before_block_so_test_can_synchronize() -> None:
|
|
"""``call_event`` is set BEFORE the body waits on the block event.
|
|
|
|
This is the deterministic-race-window guarantee the testing skill
|
|
requires for concurrency tests: if the event fired AFTER the wait,
|
|
the test would block forever waiting for an event that depends on
|
|
the test releasing the mock. The test would deadlock.
|
|
"""
|
|
tool = ControllableMockTool()
|
|
call = tool.add_call(result="done", block=True)
|
|
|
|
invoke_task = asyncio.create_task(tool())
|
|
# If call_event were set after block_before_response.wait(),
|
|
# this wait_for would time out — the body would be parked on
|
|
# the block event forever and never set call_event.
|
|
await call.wait_called(timeout=1.0)
|
|
|
|
call.release()
|
|
await invoke_task
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_release_all_unblocks_every_pending_call() -> None:
|
|
"""Teardown helper unblocks both queued and consumed calls."""
|
|
tool = ControllableMockTool()
|
|
call_consumed = tool.add_call(result="consumed", block=True)
|
|
call_queued = tool.add_call(result="queued", block=True)
|
|
|
|
# Start the first invocation so it sits in the consumed list,
|
|
# blocked.
|
|
invoke_task = asyncio.create_task(tool())
|
|
await call_consumed.wait_called(timeout=1.0)
|
|
|
|
# release_all should unblock both: the in-flight one (so the
|
|
# task finishes) AND the still-queued one (so a future invoke
|
|
# wouldn't hang either).
|
|
tool.release_all()
|
|
|
|
# The blocked, in-flight call returns.
|
|
assert await asyncio.wait_for(invoke_task, timeout=1.0) == "consumed"
|
|
|
|
# The still-queued call is also pre-released — its body
|
|
# completes immediately when invoked.
|
|
assert await asyncio.wait_for(tool(), timeout=1.0) == "queued"
|
|
# Both events should be set (release_all idempotently sets both).
|
|
assert call_consumed.block_before_response is not None
|
|
assert call_consumed.block_before_response.is_set()
|
|
assert call_queued.block_before_response is not None
|
|
assert call_queued.block_before_response.is_set()
|
|
|
|
|
|
# ─── default MockToolCall shape ──────────────────────────────
|
|
|
|
|
|
def test_default_mock_call_has_no_block_event() -> None:
|
|
"""A non-blocking call has ``block_before_response=None``."""
|
|
import threading
|
|
|
|
call = MockToolCall()
|
|
# If block_before_response were always created, every call
|
|
# would have a release() side-effect — release_all wouldn't
|
|
# be able to distinguish blocking from non-blocking calls.
|
|
assert call.block_before_response is None
|
|
# call_event always exists — tests can wait on it whether
|
|
# the call blocks or not. threading.Event (not asyncio.Event)
|
|
# so cross-loop set()/wait() works between the test loop and
|
|
# DBOS's background workflow loop.
|
|
assert isinstance(call.call_event, threading.Event)
|