From 2ff944c28205e6d210ce5f8f56224f7ab64acebd Mon Sep 17 00:00:00 2001 From: elijahr Date: Thu, 5 Mar 2026 23:09:57 -0600 Subject: [PATCH 1/5] docs: add project CLAUDE.md with full-certainty plugin contract --- CLAUDE.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..608fe56 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +# bigfoot — Project Instructions + +## Certainty is the Contract + +bigfoot's entire value proposition is certainty: when a test passes, you **know** exactly what happened — not just that nothing crashed. + +### Full Assertion Certainty is Mandatory for All Plugins + +Every plugin MUST enforce that all recorded fields are asserted. This is non-negotiable. + +**The rule:** `assertable_fields(interaction)` MUST return `frozenset(interaction.details.keys())` minus any fields explicitly excluded for ergonomic reasons (e.g., fields that are already implicit from the source sentinel). The default implementation in `BasePlugin` enforces this. + +**What is PROHIBITED:** +- Auto-asserting interactions without requiring explicit `assert_interaction()` calls (no more `mark_asserted()` at record time without user assertion) +- Returning `frozenset()` from `assertable_fields()` without a documented, specific reason +- Recording data in `interaction.details` that callers are not required to assert + +**What is REQUIRED:** +- Every field stored in `interaction.details` must be assertable and required by default +- If a field is not meaningful to assert (e.g., internal metadata), exclude it from `details` entirely — do not record it and then silently skip it +- New plugins must use `frozenset(interaction.details.keys())` as their `assertable_fields` implementation unless there is a specific, documented ergonomic reason to exclude a field + +### Ergonomic Assertion Helpers are Encouraged + +Plugins may (and should) provide typed assertion helper methods on their proxy objects to make asserting common patterns ergonomic. These helpers MUST still enforce full field coverage — they are wrappers around `assert_interaction()`, not bypasses. + +### The Test of Certainty + +Ask yourself: if every `assert_interaction()` call in a test were removed, would the test still pass? If yes, the plugin is not providing certainty. Fix it. From c9406a8723774f8770e1cce3102aedebdbd6e790 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 6 Mar 2026 01:05:35 -0600 Subject: [PATCH 2/5] feat: full assertion certainty for all plugins (v0.5.0) - Add AutoAssertError: raised immediately if mark_asserted() is called while record() is in progress, preventing auto-assert at runtime - Add _recording.py shared ContextVar module (avoids circular import between _base_plugin and _timeline) - BasePlugin.assertable_fields() is now a concrete default returning frozenset(interaction.details.keys()); no longer abstract - StateMachinePlugin._execute_step() accepts details= dict and return_interaction= flag; removes mark_asserted() call - SocketPlugin, DatabasePlugin, SmtpPlugin, PopenPlugin, AsyncWebSocketPlugin, SyncWebSocketPlugin: named per-step details dicts, typed assertion helpers (assert_connect, assert_send, etc.) - DatabasePlugin: new connect step; initial state disconnected - PopenPlugin: init renamed to spawn; _FakeStream retained as no-op - RedisPlugin: auto-assert removed; all three fields required; assert_command() typed helper added - SubprocessPlugin: run requires all four fields (command, returncode, stdout, stderr); which requires both fields (name, returns) - HttpPlugin: 5 fields expanded to 7; headers/body renamed to request_headers/request_body; response_headers and response_body added; HttpAssertionBuilder and assert_request() chained helper added - All tests updated with explicit assertions; 573 tests pass --- CHANGELOG.md | 24 ++ CLAUDE.md | 13 +- README.md | 12 +- pyproject.toml | 2 +- src/bigfoot/__init__.py | 2 + src/bigfoot/_base_plugin.py | 21 +- src/bigfoot/_errors.py | 9 + src/bigfoot/_recording.py | 12 + src/bigfoot/_state_machine_plugin.py | 66 +++++- src/bigfoot/_timeline.py | 11 + src/bigfoot/plugins/database_plugin.py | 122 +++++++++- src/bigfoot/plugins/http.py | 158 +++++++++++-- src/bigfoot/plugins/popen_plugin.py | 194 +++++++++------- src/bigfoot/plugins/redis_plugin.py | 64 +++++- src/bigfoot/plugins/smtp_plugin.py | 204 +++++++++++++++-- src/bigfoot/plugins/socket_plugin.py | 174 ++++++++++++-- src/bigfoot/plugins/subprocess.py | 26 ++- src/bigfoot/plugins/websocket_plugin.py | 289 +++++++++++++++++++++--- tests/dogfood/test_dogfood.py | 18 +- tests/integration/test_integration.py | 6 +- tests/unit/test_base_plugin.py | 72 +++--- tests/unit/test_database_plugin.py | 55 ++++- tests/unit/test_errors.py | 29 +++ tests/unit/test_http_plugin.py | 188 ++++++++++++++- tests/unit/test_init.py | 1 + tests/unit/test_popen_plugin.py | 166 ++++++++------ tests/unit/test_redis_plugin.py | 111 +++++++-- tests/unit/test_smtp_plugin.py | 9 + tests/unit/test_socket_plugin.py | 21 ++ tests/unit/test_state_machine_plugin.py | 53 +++-- tests/unit/test_subprocess_plugin.py | 41 ++-- tests/unit/test_timeline.py | 50 ++++ tests/unit/test_websocket_plugin.py | 32 +++ 33 files changed, 1863 insertions(+), 392 deletions(-) create mode 100644 src/bigfoot/_recording.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b948000..09bf680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-03-06 + +### Added + +- `AutoAssertError` — raised immediately when `mark_asserted()` is called while `record()` is in progress; prevents the auto-assert anti-pattern at runtime rather than silently passing tests +- `HttpPlugin.assert_request()` — chained builder returning `HttpAssertionBuilder`; call `.assert_response(status, headers, body)` to assert all 7 HTTP fields in one ergonomic expression +- Named per-step assertion helpers on all state-machine plugin proxies: `socket_mock.assert_connect/send/recv/close`, `db_mock.assert_connect/execute/commit/rollback/close`, `smtp_mock.assert_connect/ehlo/helo/starttls/login/sendmail/send_message/quit`, `popen_mock.assert_spawn/communicate/wait`, `async_websocket_mock.assert_connect/send/recv/close`, `sync_websocket_mock.assert_connect/send/recv/close` +- `redis_mock.assert_command(command, args, kwargs)` — typed helper for Redis command assertions +- `DatabasePlugin` now records a `connect` step when `sqlite3.connect()` is called; initial state changed from `"connected"` to `"disconnected"` + +### Changed + +- **BREAKING:** `HttpPlugin` interaction fields renamed and expanded: `headers` → `request_headers`, `body` → `request_body`; two new required fields added: `response_headers` and `response_body`. All 7 fields are now required in `assert_interaction()` calls. +- **BREAKING:** `SubprocessPlugin` now requires all fields in assertions: `run` interactions require `command`, `returncode`, `stdout`, `stderr`; `which` interactions require `name` and `returns`. +- **BREAKING:** `RedisPlugin` interactions are no longer auto-asserted; callers must explicitly call `assert_interaction()` or `assert_command()`. All three fields (`command`, `args`, `kwargs`) are now required. +- **BREAKING:** All `StateMachinePlugin` subclasses (Socket, Database, Smtp, Popen, AsyncWebSocket, SyncWebSocket) now use named per-step fields in `interaction.details` instead of generic `{method, args, kwargs}`. Interactions are no longer auto-asserted. +- **BREAKING:** `PopenPlugin` step renamed: `"init"` → `"spawn"`. Stream operations (`stdin.write`, `stdout.read`, `stderr.read`) removed; `_FakeStream.read()` returns `b""`, `write()` returns `0`. +- `BasePlugin.assertable_fields()` changed from `@abstractmethod` to a concrete default returning `frozenset(interaction.details.keys())`. Subclasses may still override for steps with no assertable fields. + +### Fixed + +- Auto-assert anti-pattern eliminated from `StateMachinePlugin` and `RedisPlugin`: interactions no longer marked asserted at record time; tests that omit `assert_interaction()` calls now correctly fail at `verify_all()`. +- `Timeline.mark_asserted()` raises `AutoAssertError` if called while `record()` is in progress, providing an immediate and actionable error message when plugins attempt to auto-assert. + ## [0.4.1] - 2026-03-05 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 608fe56..0111a6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,8 +10,19 @@ Every plugin MUST enforce that all recorded fields are asserted. This is non-neg **The rule:** `assertable_fields(interaction)` MUST return `frozenset(interaction.details.keys())` minus any fields explicitly excluded for ergonomic reasons (e.g., fields that are already implicit from the source sentinel). The default implementation in `BasePlugin` enforces this. +### Auto-Assert is PROHIBITED + +**Auto-asserting interactions is not acceptable under any circumstances.** Auto-assert means calling `timeline.mark_asserted(interaction)` at the time an interaction is *recorded*, before the test author has explicitly called `assert_interaction()`. This defeats the entire purpose of bigfoot. + +Do **not** implement auto-assert. Do **not** suggest auto-assert as a design option. Do **not** add it back under any framing ("convenience", "ergonomic default", "opt-in certainty", etc.). It is wrong. It was already removed from StateMachinePlugin and RedisPlugin for this reason. + +If you encounter code that calls `timeline.mark_asserted()` inside a `record()` or intercept hook (anywhere other than inside `assert_interaction()`), treat it as a bug and fix it. + +The codebase also enforces this at runtime: `Timeline.mark_asserted()` raises `AutoAssertError` immediately if called while `Timeline.record()` is in progress (detected via `ContextVar`). Any test that triggers the auto-assert pattern will fail loudly rather than silently passing. Do not remove or work around this guard. + **What is PROHIBITED:** -- Auto-asserting interactions without requiring explicit `assert_interaction()` calls (no more `mark_asserted()` at record time without user assertion) +- Auto-asserting interactions without requiring explicit `assert_interaction()` calls +- Calling `timeline.mark_asserted(interaction)` from any plugin record/intercept method - Returning `frozenset()` from `assertable_fields()` without a documented, specific reason - Recording data in `interaction.details` that callers are not required to assert diff --git a/README.md b/README.md index 34eda91..274efd1 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,22 @@ def test_payment_flow(): response = httpx.post("https://api.stripe.com/v1/charges", json={"amount": 5000}) + # Option A: explicit assert_interaction (all 7 fields required) bigfoot.assert_interaction( bigfoot.http.request, method="POST", url="https://api.stripe.com/v1/charges", - headers=IsMapping(), # use dirty-equals or ANY for headers - body=None, + request_headers=IsMapping(), # use dirty-equals or ANY for headers + request_body=None, status=200, + response_headers=IsMapping(), + response_body=IsMapping() | IsInstance(str), ) + # Option B: chained helper + bigfoot.http.assert_request( + method="POST", url="https://api.stripe.com/v1/charges", + headers=IsMapping(), body=None, + ).assert_response(status=200, headers=IsMapping(), body=IsMapping() | IsInstance(str)) assert response.json()["id"] == "ch_123" # verify_all() called automatically at test teardown ``` diff --git a/pyproject.toml b/pyproject.toml index 4367fa0..a4d0a57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bigfoot" -version = "0.4.1" +version = "0.5.0" description = "A pluggable interaction auditor for Python tests." requires-python = ">=3.11" readme = "README.md" diff --git a/src/bigfoot/__init__.py b/src/bigfoot/__init__.py index b238cbe..f012f6b 100644 --- a/src/bigfoot/__init__.py +++ b/src/bigfoot/__init__.py @@ -10,6 +10,7 @@ from bigfoot._context import _get_test_verifier_or_raise from bigfoot._errors import ( AssertionInsideSandboxError, + AutoAssertError, BigfootError, ConflictError, InteractionMismatchError, @@ -72,6 +73,7 @@ # Errors "BigfootError", "AssertionInsideSandboxError", + "AutoAssertError", "InvalidStateError", "NoActiveVerifierError", "UnmockedInteractionError", diff --git a/src/bigfoot/_base_plugin.py b/src/bigfoot/_base_plugin.py index 60519ba..5e98cf1 100644 --- a/src/bigfoot/_base_plugin.py +++ b/src/bigfoot/_base_plugin.py @@ -3,6 +3,8 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any +from bigfoot._recording import _recording_in_progress + if TYPE_CHECKING: from bigfoot._timeline import Interaction from bigfoot._verifier import StrictVerifier @@ -58,15 +60,20 @@ def format_unmocked_hint( def format_assert_hint(self, interaction: "Interaction") -> str: """Copy-pasteable code to assert this interaction (resolves UnassertedInteractionsError).""" - @abstractmethod def assertable_fields(self, interaction: "Interaction") -> frozenset[str]: """Return the set of field names that must appear in **expected when asserting this interaction. + Default implementation: return all keys in interaction.details as assertable + fields. This is correct for any plugin that stores only user-assertable data + in details. Plugins with no-data steps (close, commit, etc.) must override + this to return frozenset() for those steps. + The verifier calls this after matching by source_id to enforce completeness: any field in the returned set that is absent from **expected causes MissingAssertionFieldsError to be raised. """ + return frozenset(interaction.details.keys()) @abstractmethod def get_unused_mocks(self) -> list[Any]: @@ -79,8 +86,12 @@ def format_unused_mock_hint(self, mock_config: object) -> str: def record(self, interaction: "Interaction") -> None: """Concrete method: append interaction to the verifier's shared timeline. - This is NOT abstract -- all plugins share the same implementation. - Calls self.verifier._timeline.append(interaction), which assigns the - sequence number atomically under the timeline lock. + Sets _recording_in_progress for the duration of the append so that + Timeline.mark_asserted() can detect the auto-assert anti-pattern and + raise AutoAssertError immediately. """ - self.verifier._timeline.append(interaction) + token = _recording_in_progress.set(True) + try: + self.verifier._timeline.append(interaction) + finally: + _recording_in_progress.reset(token) diff --git a/src/bigfoot/_errors.py b/src/bigfoot/_errors.py index b3d1751..3096c41 100644 --- a/src/bigfoot/_errors.py +++ b/src/bigfoot/_errors.py @@ -188,6 +188,15 @@ def __init__(self, missing_fields: frozenset[str]) -> None: ) +class AutoAssertError(BigfootError): + """Raised when mark_asserted() is called while record() is in progress. + + This indicates the auto-assert anti-pattern: a plugin calling + timeline.mark_asserted() immediately after record() inside its intercept + hook, bypassing the requirement for explicit test assertions. + """ + + class InvalidStateError(BigfootError): """Raised when a state-machine method is called from an invalid state. diff --git a/src/bigfoot/_recording.py b/src/bigfoot/_recording.py new file mode 100644 index 0000000..164fc90 --- /dev/null +++ b/src/bigfoot/_recording.py @@ -0,0 +1,12 @@ +"""Shared ContextVar for the auto-assert runtime guard. + +Imported by both _base_plugin and _timeline to avoid a circular import: +_base_plugin imports _timeline (for Interaction type), so _timeline +cannot import from _base_plugin. Both import from this module instead. +""" + +from contextvars import ContextVar + +_recording_in_progress: ContextVar[bool] = ContextVar( + "_recording_in_progress", default=False +) diff --git a/src/bigfoot/_state_machine_plugin.py b/src/bigfoot/_state_machine_plugin.py index dc43a7c..3ce23fe 100644 --- a/src/bigfoot/_state_machine_plugin.py +++ b/src/bigfoot/_state_machine_plugin.py @@ -41,6 +41,27 @@ class ScriptStep: registration_traceback: str = field(default_factory=lambda: "".join(traceback.format_stack())) +# --------------------------------------------------------------------------- +# _StepSentinel +# --------------------------------------------------------------------------- + + +class _StepSentinel: + """Opaque handle representing a specific state-machine step. + + Used as the source filter argument in assert_interaction() calls. + Each step (connect, send, recv, etc.) has its own sentinel instance + on the plugin, accessible as a property. + + Attributes: + source_id: The string source_id recorded in Interaction objects + for this step. + """ + + def __init__(self, source_id: str) -> None: + self.source_id = source_id + + # --------------------------------------------------------------------------- # SessionHandle # --------------------------------------------------------------------------- @@ -227,6 +248,9 @@ def _execute_step( args: tuple[Any, ...], kwargs: dict[str, Any], source_id: str, + details: dict[str, Any] | None = None, + *, + return_interaction: bool = False, ) -> Any: # noqa: ANN401 """Execute the next script step for the given handle and method. @@ -234,9 +258,18 @@ def _execute_step( 1. Validate that method is allowed from the current state. 2. Pop the next ScriptStep (FIFO). 3. Advance handle._state. - 4. Record the Interaction on the timeline and immediately mark it asserted. + 4. Record the Interaction on the timeline (NOT auto-asserted). 5. If step.raises is set, raise it; otherwise return step.returns. + Args: + details: Named fields dict to store in the Interaction. When None, + falls back to the legacy format {"method": method, "args": args, + "kwargs": kwargs}. All concrete plugins in this release pass + explicit dicts. + return_interaction: When True, returns (result, interaction) tuple + instead of just result. Used by recv() implementations that need + to update interaction.details["data"] after the step executes. + Raises: InvalidStateError: If the current state is not a valid from-state for this method, or if the method is not in _transitions(). @@ -280,32 +313,45 @@ def _execute_step( # Advance state handle._state = method_transitions[handle._state] - # Record interaction and immediately mark asserted + # Build details dict — use caller-supplied named fields or legacy fallback + resolved_details: dict[str, Any] = ( + details if details is not None + else {"method": method, "args": args, "kwargs": kwargs} + ) + + # Record interaction on the timeline — test authors must assert explicitly interaction = Interaction( source_id=source_id, sequence=0, - details={"method": method, "args": args, "kwargs": kwargs}, + details=resolved_details, plugin=self, ) self.record(interaction) - self.verifier._timeline.mark_asserted(interaction) + # No mark_asserted() — auto-assert anti-pattern is prohibited # Execute step if step.raises is not None: raise step.raises - return step.returns + + result = step.returns + if return_interaction: + return result, interaction + return result # ------------------------------------------------------------------ # BasePlugin: overridden concrete methods # ------------------------------------------------------------------ def matches(self, interaction: Interaction, expected: dict[str, Any]) -> bool: - """Always returns True — state machine interactions are auto-matched.""" - return True + """Placeholder — each concrete StateMachine plugin task (5–12) overrides this. - def assertable_fields(self, interaction: Interaction) -> frozenset[str]: - """Returns empty frozenset — no fields are required in assert_interaction().""" - return frozenset() + Retained here so all concrete subclasses remain instantiable during the + transition period before each per-plugin task provides a typed override. + BasePlugin.matches() is abstract; this placeholder satisfies that + requirement at the StateMachinePlugin level until every concrete class + defines its own implementation. + """ + return True # ------------------------------------------------------------------ # BasePlugin: get_unused_mocks diff --git a/src/bigfoot/_timeline.py b/src/bigfoot/_timeline.py index 0fbff5e..632dfc7 100644 --- a/src/bigfoot/_timeline.py +++ b/src/bigfoot/_timeline.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any +from bigfoot._recording import _recording_in_progress + if TYPE_CHECKING: from bigfoot._base_plugin import BasePlugin @@ -54,6 +56,15 @@ def find_any_unasserted( return None def mark_asserted(self, interaction: Interaction) -> None: + from bigfoot._errors import AutoAssertError # noqa: PLC0415 — avoids circular import at module level + + if _recording_in_progress.get(): + raise AutoAssertError( + f"mark_asserted() was called while record() is in progress for " + f"source_id={interaction.source_id!r}. This is the auto-assert " + f"anti-pattern: remove the mark_asserted() call from your plugin's " + f"intercept hook. Test authors must call assert_interaction() explicitly." + ) with self._lock: interaction._asserted = True diff --git a/src/bigfoot/plugins/database_plugin.py b/src/bigfoot/plugins/database_plugin.py index 58e5c30..ea7b170 100644 --- a/src/bigfoot/plugins/database_plugin.py +++ b/src/bigfoot/plugins/database_plugin.py @@ -2,12 +2,15 @@ import sqlite3 import threading -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from bigfoot._context import _get_verifier_or_raise -from bigfoot._state_machine_plugin import SessionHandle, StateMachinePlugin +from bigfoot._state_machine_plugin import SessionHandle, StateMachinePlugin, _StepSentinel from bigfoot._timeline import Interaction +if TYPE_CHECKING: + from bigfoot._verifier import StrictVerifier + # --------------------------------------------------------------------------- # Source ID constants # --------------------------------------------------------------------------- @@ -77,7 +80,8 @@ def __init__(self, connection: "_FakeConnection") -> None: def execute(self, sql: str, params: object = ()) -> "_FakeCursorProxy": handle = self._connection._plugin._lookup_session(self._connection) result = self._connection._plugin._execute_step( - handle, "execute", (sql,), {"params": params}, _SOURCE_EXECUTE + handle, "execute", (sql,), {"params": params}, _SOURCE_EXECUTE, + details={"sql": sql, "parameters": params}, ) self._connection._last_cursor = _FakeCursor(result) return self @@ -117,7 +121,8 @@ def __init__(self, plugin: "DatabasePlugin") -> None: def execute(self, sql: str, params: object = ()) -> _FakeCursorProxy: handle = self._plugin._lookup_session(self) result = self._plugin._execute_step( - handle, "execute", (sql,), {"params": params}, _SOURCE_EXECUTE + handle, "execute", (sql,), {"params": params}, _SOURCE_EXECUTE, + details={"sql": sql, "parameters": params}, ) self._last_cursor = _FakeCursor(result) return _FakeCursorProxy(self) @@ -127,15 +132,15 @@ def cursor(self) -> _FakeCursorProxy: def commit(self) -> None: handle = self._plugin._lookup_session(self) - self._plugin._execute_step(handle, "commit", (), {}, _SOURCE_COMMIT) + self._plugin._execute_step(handle, "commit", (), {}, _SOURCE_COMMIT, details={}) def rollback(self) -> None: handle = self._plugin._lookup_session(self) - self._plugin._execute_step(handle, "rollback", (), {}, _SOURCE_ROLLBACK) + self._plugin._execute_step(handle, "rollback", (), {}, _SOURCE_ROLLBACK, details={}) def close(self) -> None: handle = self._plugin._lookup_session(self) - self._plugin._execute_step(handle, "close", (), {}, _SOURCE_CLOSE) + self._plugin._execute_step(handle, "close", (), {}, _SOURCE_CLOSE, details={}) self._plugin._release_session(self) @@ -148,6 +153,11 @@ def _patched_connect(database: str, **_kwargs: object) -> _FakeConnection: plugin = _get_database_plugin() fake_conn = _FakeConnection(plugin) plugin._bind_connection(fake_conn) + handle = plugin._lookup_session(fake_conn) + plugin._execute_step( + handle, "connect", (database,), {}, _SOURCE_CONNECT, + details={"database": database}, + ) return fake_conn @@ -167,15 +177,44 @@ class DatabasePlugin(StateMachinePlugin): # Saved original, restored when count reaches 0. _original_connect: ClassVar[Any] = None # noqa: ANN401 + def __init__(self, verifier: "StrictVerifier") -> None: + super().__init__(verifier) + self._connect_sentinel = _StepSentinel(_SOURCE_CONNECT) + self._execute_sentinel = _StepSentinel(_SOURCE_EXECUTE) + self._commit_sentinel = _StepSentinel(_SOURCE_COMMIT) + self._rollback_sentinel = _StepSentinel(_SOURCE_ROLLBACK) + self._close_sentinel = _StepSentinel(_SOURCE_CLOSE) + + @property + def connect(self) -> _StepSentinel: + return self._connect_sentinel + + @property + def execute(self) -> _StepSentinel: + return self._execute_sentinel + + @property + def commit(self) -> _StepSentinel: + return self._commit_sentinel + + @property + def rollback(self) -> _StepSentinel: + return self._rollback_sentinel + + @property + def close(self) -> _StepSentinel: + return self._close_sentinel + # ------------------------------------------------------------------ # StateMachinePlugin abstract methods # ------------------------------------------------------------------ def _initial_state(self) -> str: - return "connected" + return "disconnected" def _transitions(self) -> dict[str, dict[str, str]]: return { + "connect": {"disconnected": "connected"}, "execute": {"connected": "in_transaction", "in_transaction": "in_transaction"}, "commit": {"in_transaction": "connected"}, "rollback": {"in_transaction": "connected"}, @@ -246,8 +285,71 @@ def format_unmocked_hint( def format_assert_hint(self, interaction: Interaction) -> str: sm = "bigfoot.db_mock" - method = interaction.details.get("method", "?") - return f" # {sm}: session step '{method}' recorded (state-machine, auto-asserted)" + sid = interaction.source_id + if sid == _SOURCE_CONNECT: + database = interaction.details.get("database", "?") + return f" {sm}.assert_connect(database={database!r})" + if sid == _SOURCE_EXECUTE: + sql = interaction.details.get("sql", "?") + params = interaction.details.get("parameters", ()) + return f" {sm}.assert_execute(sql={sql!r}, parameters={params!r})" + if sid == _SOURCE_COMMIT: + return f" {sm}.assert_commit()" + if sid == _SOURCE_ROLLBACK: + return f" {sm}.assert_rollback()" + if sid == _SOURCE_CLOSE: + return f" {sm}.assert_close()" + return f" # {sm}: unknown source_id={sid!r}" + + def matches(self, interaction: Interaction, expected: dict[str, Any]) -> bool: + """Field-by-field comparison with dirty-equals support.""" + try: + for key, expected_val in expected.items(): + actual_val = interaction.details.get(key) + if expected_val != actual_val: + return False + return True + except Exception: + return False + + def assertable_fields(self, interaction: Interaction) -> frozenset[str]: + """Return assertable fields for each step type.""" + if interaction.source_id == _SOURCE_CONNECT: + return frozenset({"database"}) + if interaction.source_id == _SOURCE_EXECUTE: + return frozenset({"sql", "parameters"}) + if interaction.source_id in (_SOURCE_COMMIT, _SOURCE_ROLLBACK, _SOURCE_CLOSE): + return frozenset() + return frozenset(interaction.details.keys()) + + def assert_connect(self, *, database: str) -> None: + """Assert the next database connect interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction( + self._connect_sentinel, database=database + ) + + def assert_execute(self, *, sql: str, parameters: object) -> None: + """Assert the next database execute interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction( + self._execute_sentinel, sql=sql, parameters=parameters + ) + + def assert_commit(self) -> None: + """Assert the next database commit interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction(self._commit_sentinel) + + def assert_rollback(self) -> None: + """Assert the next database rollback interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction(self._rollback_sentinel) + + def assert_close(self) -> None: + """Assert the next database close interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction(self._close_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: step: Any = mock_config # noqa: ANN401 diff --git a/src/bigfoot/plugins/http.py b/src/bigfoot/plugins/http.py index 77eb9f3..59d1078 100644 --- a/src/bigfoot/plugins/http.py +++ b/src/bigfoot/plugins/http.py @@ -83,6 +83,63 @@ def __init__(self, plugin: "HttpPlugin") -> None: self.source_id = "http:request" +# --------------------------------------------------------------------------- +# HttpAssertionBuilder +# --------------------------------------------------------------------------- + + +class HttpAssertionBuilder: + """Fluent builder for asserting HTTP interactions. + + Usage:: + + http.assert_request("GET", "https://example.com/api") \\ + .assert_response(200, {}, "") + + ``assert_request()`` is lazy: it records the expected request fields but does + not touch the timeline. ``assert_response()`` finalises the assertion by + calling ``verifier.assert_interaction()`` with all seven fields. + """ + + def __init__( + self, + verifier: "StrictVerifier", + sentinel: HttpRequestSentinel, + method: str, + url: str, + headers: dict[str, Any], + body: str, + ) -> None: + self._verifier = verifier + self._sentinel = sentinel + self._method = method + self._url = url + self._headers = headers + self._body = body + + def assert_response( + self, + status: int, + headers: dict[str, Any], + body: str, + ) -> None: + """Assert the full interaction: request fields + response fields. + + This is the terminal step that calls ``verifier.assert_interaction()`` + with all seven assertable fields. + """ + self._verifier.assert_interaction( + self._sentinel, + method=self._method, + url=self._url, + request_headers=self._headers, + request_body=self._body, + status=status, + response_headers=headers, + response_body=body, + ) + + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- @@ -145,6 +202,27 @@ def request(self) -> HttpRequestSentinel: """Sentinel used as source argument in verifier.assert_interaction().""" return self._sentinel + def assert_request( + self, + method: str, + url: str, + headers: dict[str, Any] | None = None, + body: str = "", + ) -> "HttpAssertionBuilder": + """Return an HttpAssertionBuilder pre-loaded with expected request fields. + + Call ``.assert_response()`` on the returned builder to complete the + assertion with all seven fields. + """ + return HttpAssertionBuilder( + verifier=self.verifier, + sentinel=self._sentinel, + method=method, + url=url, + headers=headers if headers is not None else {}, + body=body, + ) + def mock_response( self, method: str, @@ -431,9 +509,11 @@ def _record_http_interaction( self, method: str, url: str, - headers: dict[str, str], - body: str, + request_headers: dict[str, str], + request_body: str, status: int, + response_headers: dict[str, str], + response_body: str, ) -> None: interaction = Interaction( source_id="http:request", @@ -441,9 +521,11 @@ def _record_http_interaction( details={ "method": method.upper(), "url": url, - "headers": dict(headers), - "body": body, + "request_headers": dict(request_headers), + "request_body": request_body, "status": status, + "response_headers": dict(response_headers), + "response_body": response_body, }, plugin=self, ) @@ -474,12 +556,15 @@ def _handle_httpx_request( ) body_str = request.content.decode("utf-8", errors="replace") + resp_body_str = config.response_body.decode("utf-8", errors="replace") self._record_http_interaction( method=method, url=url, - headers=dict(request.headers), - body=body_str, + request_headers=dict(request.headers), + request_body=body_str, status=config.response_status, + response_headers=dict(config.response_headers), + response_body=resp_body_str, ) return httpx.Response( @@ -497,9 +582,11 @@ def _execute_httpx_pass_through( self._record_http_interaction( method=request.method, url=str(request.url), - headers=dict(request.headers), - body=request.content.decode("utf-8", errors="replace"), + request_headers=dict(request.headers), + request_body=request.content.decode("utf-8", errors="replace"), status=response.status_code, + response_headers=dict(response.headers), + response_body=response.text, ) return response @@ -525,12 +612,15 @@ async def _handle_httpx_async_request( ) body_str = request.content.decode("utf-8", errors="replace") + resp_body_str = config.response_body.decode("utf-8", errors="replace") self._record_http_interaction( method=method, url=url, - headers=dict(request.headers), - body=body_str, + request_headers=dict(request.headers), + request_body=body_str, status=config.response_status, + response_headers=dict(config.response_headers), + response_body=resp_body_str, ) return httpx.Response( @@ -548,9 +638,11 @@ async def _execute_httpx_async_pass_through( self._record_http_interaction( method=request.method, url=str(request.url), - headers=dict(request.headers), - body=request.content.decode("utf-8", errors="replace"), + request_headers=dict(request.headers), + request_body=request.content.decode("utf-8", errors="replace"), status=response.status_code, + response_headers=dict(response.headers), + response_body=response.text, ) return response @@ -584,12 +676,15 @@ def _handle_requests_request( else: body_str = str(request.body) + resp_body_str = config.response_body.decode("utf-8", errors="replace") self._record_http_interaction( method=method, url=url, - headers=dict(request.headers), - body=body_str, + request_headers=dict(request.headers), + request_body=body_str, status=config.response_status, + response_headers=dict(config.response_headers), + response_body=resp_body_str, ) response = requests.Response() @@ -621,9 +716,11 @@ def _execute_requests_pass_through( self._record_http_interaction( method=method, url=url, - headers=dict(request.headers), - body=body_str, + request_headers=dict(request.headers), + request_body=body_str, status=response.status_code, + response_headers=dict(response.headers), + response_body=response.text, ) return response @@ -655,12 +752,15 @@ def _handle_urllib_request(self, req: urllib.request.Request) -> urllib.response else str(data) # pragma: no cover ) + resp_body_str = config.response_body.decode("utf-8", errors="replace") self._record_http_interaction( method=method, url=url, - headers=headers_dict, - body=body_str, + request_headers=headers_dict, + request_body=body_str, status=config.response_status, + response_headers=dict(config.response_headers), + response_body=resp_body_str, ) msg = HTTPMessage() @@ -698,9 +798,11 @@ def _execute_urllib_pass_through( self._record_http_interaction( method=method, url=url, - headers=dict(req.headers), - body="", + request_headers=dict(req.headers), + request_body="", status=response.getcode() or 200, + response_headers={}, + response_body="", ) return response @@ -771,23 +873,29 @@ def format_unmocked_hint( def format_assert_hint(self, interaction: Interaction) -> str: method = interaction.details.get("method", "GET") url = interaction.details.get("url", "?") - headers = interaction.details.get("headers", {}) - body = interaction.details.get("body", "") + request_headers = interaction.details.get("request_headers", {}) + request_body = interaction.details.get("request_body", "") status = interaction.details.get("status", 200) + response_headers = interaction.details.get("response_headers", {}) + response_body = interaction.details.get("response_body", "") return ( f"verifier.assert_interaction(\n" f" http.request,\n" f' method="{method}",\n' f' url="{url}",\n' - f" headers={headers!r},\n" - f" body={body!r},\n" + f" request_headers={request_headers!r},\n" + f" request_body={request_body!r},\n" f" status={status},\n" + f" response_headers={response_headers!r},\n" + f" response_body={response_body!r},\n" f")" ) def assertable_fields(self, interaction: Interaction) -> frozenset[str]: """Return the field names required in **expected when asserting an HTTP interaction.""" - return frozenset({"method", "url", "headers", "body", "status"}) + return frozenset( + {"method", "url", "request_headers", "request_body", "status", "response_headers", "response_body"} + ) def get_unused_mocks(self) -> list[HttpMockConfig]: return [c for c in self._mock_queue if c.required] diff --git a/src/bigfoot/plugins/popen_plugin.py b/src/bigfoot/plugins/popen_plugin.py index 4ca9ffa..4cfbf16 100644 --- a/src/bigfoot/plugins/popen_plugin.py +++ b/src/bigfoot/plugins/popen_plugin.py @@ -11,21 +11,21 @@ import subprocess import threading -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from bigfoot._context import _get_verifier_or_raise from bigfoot._errors import ConflictError -from bigfoot._state_machine_plugin import StateMachinePlugin +from bigfoot._state_machine_plugin import StateMachinePlugin, _StepSentinel from bigfoot._timeline import Interaction +if TYPE_CHECKING: + from bigfoot._verifier import StrictVerifier + # --------------------------------------------------------------------------- # Source ID constants # --------------------------------------------------------------------------- -_SOURCE_INIT = "subprocess:popen:init" -_SOURCE_STDIN_WRITE = "subprocess:popen:stdin.write" -_SOURCE_STDOUT_READ = "subprocess:popen:stdout.read" -_SOURCE_STDERR_READ = "subprocess:popen:stderr.read" +_SOURCE_SPAWN = "subprocess:popen:spawn" _SOURCE_COMMUNICATE = "subprocess:popen:communicate" _SOURCE_WAIT = "subprocess:popen:wait" @@ -50,7 +50,7 @@ def _find_popen_plugin() -> "PopenPlugin": - verifier = _get_verifier_or_raise(_SOURCE_INIT) + verifier = _get_verifier_or_raise(_SOURCE_SPAWN) for plugin in verifier._plugins: if isinstance(plugin, PopenPlugin): return plugin @@ -66,59 +66,24 @@ def _find_popen_plugin() -> "PopenPlugin": class _FakeStream: - """Fake file-like object delegating read/write to PopenPlugin._execute_step.""" - - def __init__( - self, - plugin: "PopenPlugin", - popen_instance: "_FakePopen", - read_method: str | None, - write_method: str | None, - ) -> None: - self._plugin = plugin - self._popen = popen_instance - self._read_method = read_method - self._write_method = write_method - - def write(self, data: bytes) -> Any: # noqa: ANN401 - if self._write_method is None: - raise io_error("write") - handle = self._plugin._lookup_session(self._popen) - return self._plugin._execute_step( - handle, - self._write_method, - (data,), - {}, - f"subprocess:popen:{self._write_method}", - ) + """Fake file-like stream. Stream I/O is not recorded by bigfoot. - def read(self, size: int = -1) -> Any: # noqa: ANN401 - if self._read_method is None: - raise io_error("read") - handle = self._plugin._lookup_session(self._popen) - return self._plugin._execute_step( - handle, - self._read_method, - (size,), - {}, - f"subprocess:popen:{self._read_method}", - ) + .write() returns 0 (no bytes written). .read() returns b"" (no data). + Use communicate() to observe stdin input and stdout/stderr output via + the named fields in the spawn interaction. + """ - def readline(self) -> Any: # noqa: ANN401 - if self._read_method is None: - raise io_error("readline") - handle = self._plugin._lookup_session(self._popen) - return self._plugin._execute_step( - handle, - self._read_method, - (), - {}, - f"subprocess:popen:{self._read_method}.readline", - ) + def write(self, data: bytes) -> int: + """No-op write. Returns 0. Not recorded on the timeline.""" + return 0 + def read(self, size: int = -1) -> bytes: + """No-op read. Returns b"". Not recorded on the timeline.""" + return b"" -def io_error(op: str) -> OSError: - return OSError(f"_FakeStream does not support {op}") + def readline(self) -> bytes: + """No-op readline. Returns b"". Not recorded on the timeline.""" + return b"" # --------------------------------------------------------------------------- @@ -139,11 +104,15 @@ def __init__( **kwargs: Any, # noqa: ANN401 ) -> None: plugin = _find_popen_plugin() - plugin._bind_connection(self) # partial init - plugin._execute_step(plugin._lookup_session(self), "init", (args,), {}, _SOURCE_INIT) - self.stdin: _FakeStream | None = _FakeStream(plugin, self, None, "stdin.write") - self.stdout: _FakeStream | None = _FakeStream(plugin, self, "stdout.read", None) - self.stderr: _FakeStream | None = _FakeStream(plugin, self, "stderr.read", None) + plugin._bind_connection(self) + command = list(args) if hasattr(args, "__iter__") and not isinstance(args, str) else [args] + plugin._execute_step( + plugin._lookup_session(self), "spawn", (args,), {}, _SOURCE_SPAWN, + details={"command": command, "stdin": stdin if isinstance(stdin, (bytes, type(None))) else None}, + ) + self.stdin: _FakeStream = _FakeStream() + self.stdout: _FakeStream = _FakeStream() + self.stderr: _FakeStream = _FakeStream() self.returncode: int | None = None self.pid: int = 12345 # fake PID @@ -154,7 +123,10 @@ def communicate( ) -> tuple[bytes, bytes]: plugin = _find_popen_plugin() handle = plugin._lookup_session(self) - result = plugin._execute_step(handle, "communicate", (), {}, _SOURCE_COMMUNICATE) + result = plugin._execute_step( + handle, "communicate", (), {}, _SOURCE_COMMUNICATE, + details={"input": input}, + ) # result is (stdout: bytes, stderr: bytes, returncode: int) 3-tuple out_bytes, err_bytes, returncode = result self.returncode = returncode @@ -165,7 +137,10 @@ def wait(self, timeout: float | None = None) -> int: return self.returncode plugin = _find_popen_plugin() handle = plugin._lookup_session(self) - result = plugin._execute_step(handle, "wait", (), {}, _SOURCE_WAIT) + result = plugin._execute_step( + handle, "wait", (), {}, _SOURCE_WAIT, + details={}, + ) # result is returncode int self.returncode = int(result) return self.returncode @@ -200,6 +175,24 @@ class PopenPlugin(StateMachinePlugin): # Saved original, restored when count reaches 0. _original_popen: ClassVar[Any] = None + def __init__(self, verifier: "StrictVerifier") -> None: + super().__init__(verifier) + self._spawn_sentinel = _StepSentinel(_SOURCE_SPAWN) + self._communicate_sentinel = _StepSentinel(_SOURCE_COMMUNICATE) + self._wait_sentinel = _StepSentinel(_SOURCE_WAIT) + + @property + def spawn(self) -> _StepSentinel: + return self._spawn_sentinel + + @property + def communicate(self) -> _StepSentinel: + return self._communicate_sentinel + + @property + def wait(self) -> _StepSentinel: + return self._wait_sentinel + # ------------------------------------------------------------------ # StateMachinePlugin abstract methods # ------------------------------------------------------------------ @@ -209,16 +202,13 @@ def _initial_state(self) -> str: def _transitions(self) -> dict[str, dict[str, str]]: return { - "init": {"created": "running"}, - "stdin.write": {"running": "running"}, - "stdout.read": {"running": "running"}, - "stderr.read": {"running": "running"}, + "spawn": {"created": "running"}, "communicate": {"running": "terminated"}, "wait": {"running": "terminated"}, } def _unmocked_source_id(self) -> str: - return "subprocess:popen:init" + return "subprocess:popen:spawn" # ------------------------------------------------------------------ # BasePlugin lifecycle @@ -270,14 +260,24 @@ def _check_conflicts(self) -> None: # ------------------------------------------------------------------ def format_interaction(self, interaction: Interaction) -> str: - method = interaction.details.get("method", "?") - args = interaction.details.get("args", ()) - parts = [repr(a) for a in args] - return f"[PopenPlugin] popen.{method}({', '.join(parts)})" + if interaction.source_id == _SOURCE_SPAWN: + command = interaction.details.get("command", []) + return f"[PopenPlugin] popen.spawn({command!r})" + if interaction.source_id == _SOURCE_COMMUNICATE: + inp = interaction.details.get("input") + return f"[PopenPlugin] popen.communicate(input={inp!r})" + if interaction.source_id == _SOURCE_WAIT: + return "[PopenPlugin] popen.wait()" + return f"[PopenPlugin] popen.?(source_id={interaction.source_id!r})" def format_mock_hint(self, interaction: Interaction) -> str: - method = interaction.details.get("method", "?") - return f" bigfoot.popen_mock.new_session().expect({method!r}, returns=...)" + if interaction.source_id == _SOURCE_SPAWN: + return " bigfoot.popen_mock.new_session().expect('spawn', returns=None)" + if interaction.source_id == _SOURCE_COMMUNICATE: + return " bigfoot.popen_mock.new_session().expect('communicate', returns=(b'', b'', 0))" + if interaction.source_id == _SOURCE_WAIT: + return " bigfoot.popen_mock.new_session().expect('wait', returns=0)" + return f" bigfoot.popen_mock.new_session().expect('?', returns=...)" def format_unmocked_hint( self, @@ -294,8 +294,50 @@ def format_unmocked_hint( def format_assert_hint(self, interaction: Interaction) -> str: pm = "bigfoot.popen_mock" - method = interaction.details.get("method", "?") - return f" # {pm}: session step '{method}' recorded (state-machine, auto-asserted)" + sid = interaction.source_id + if sid == _SOURCE_SPAWN: + command = interaction.details.get("command", []) + stdin = interaction.details.get("stdin") + return f" {pm}.assert_spawn(command={command!r}, stdin={stdin!r})" + if sid == _SOURCE_COMMUNICATE: + inp = interaction.details.get("input") + return f" {pm}.assert_communicate(input={inp!r})" + if sid == _SOURCE_WAIT: + return f" {pm}.assert_wait()" + return f" # {pm}: unknown source_id={sid!r}" + + def matches(self, interaction: Interaction, expected: dict[str, Any]) -> bool: + """Field-by-field comparison with dirty-equals support.""" + try: + for key, expected_val in expected.items(): + actual_val = interaction.details.get(key) + if expected_val != actual_val: + return False + return True + except Exception: + return False + + def assertable_fields(self, interaction: Interaction) -> frozenset[str]: + """Return assertable fields for each step type.""" + if interaction.source_id == _SOURCE_WAIT: + return frozenset() + return frozenset(interaction.details.keys()) + + def assert_spawn(self, *, command: list[str], stdin: bytes | None) -> None: + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction( + self._spawn_sentinel, command=command, stdin=stdin + ) + + def assert_communicate(self, *, input: bytes | None) -> None: # noqa: A002 + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction( + self._communicate_sentinel, input=input + ) + + def assert_wait(self) -> None: + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction(self._wait_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: step: Any = mock_config diff --git a/src/bigfoot/plugins/redis_plugin.py b/src/bigfoot/plugins/redis_plugin.py index e114536..60df5e2 100644 --- a/src/bigfoot/plugins/redis_plugin.py +++ b/src/bigfoot/plugins/redis_plugin.py @@ -70,6 +70,18 @@ def _get_redis_plugin() -> RedisPlugin: ) +# --------------------------------------------------------------------------- +# Sentinel +# --------------------------------------------------------------------------- + + +class _RedisSentinel: + """Opaque handle for a Redis command; used as source filter in assert_interaction.""" + + def __init__(self, source_id: str) -> None: + self.source_id = source_id + + # --------------------------------------------------------------------------- # Patched execute_command # --------------------------------------------------------------------------- @@ -99,7 +111,7 @@ def _patched_execute_command(redis_self: object, command: str, *args: Any, **kwa plugin=plugin, ) plugin.record(interaction) - plugin.verifier._timeline.mark_asserted(interaction) + # No mark_asserted() — test authors must call assert_interaction() or assert_command() if config.raises is not None: raise config.raises @@ -194,12 +206,19 @@ def deactivate(self) -> None: # ------------------------------------------------------------------ def matches(self, interaction: Interaction, expected: dict[str, Any]) -> bool: - """Always returns True -- Redis interactions are auto-matched.""" - return True + """Field-by-field comparison with dirty-equals support.""" + try: + for key, expected_val in expected.items(): + actual_val = interaction.details.get(key) + if expected_val != actual_val: + return False + return True + except Exception: + return False def assertable_fields(self, interaction: Interaction) -> frozenset[str]: - """Returns empty frozenset -- no fields are required in assert_interaction().""" - return frozenset() + """All three fields (command, args, kwargs) are required in assert_interaction().""" + return frozenset({"command", "args", "kwargs"}) def get_unused_mocks(self) -> list[RedisMockConfig]: """Return all RedisMockConfig with required=True still in any queue.""" @@ -236,8 +255,17 @@ def format_unmocked_hint( ) def format_assert_hint(self, interaction: Interaction) -> str: + sm = "bigfoot.redis_mock" command = interaction.details.get("command", "?") - return f" # bigfoot.redis_mock: command {command!r} recorded (stateless, auto-asserted)" + args = interaction.details.get("args", ()) + kwargs = interaction.details.get("kwargs", {}) + return ( + f" {sm}.assert_command(\n" + f" command={command!r},\n" + f" args={args!r},\n" + f" kwargs={kwargs!r},\n" + f" )" + ) def format_unused_mock_hint(self, mock_config: object) -> str: config: RedisMockConfig = mock_config # type: ignore[assignment] @@ -247,3 +275,27 @@ def format_unused_mock_hint(self, mock_config: object) -> str: f"redis.{command}(...) was mocked (required=True) but never called.\n" f"Registered at:\n{tb}" ) + + def assert_command( + self, + command: str, + args: tuple[Any, ...] = (), + kwargs: dict[str, Any] | None = None, + ) -> None: + """Typed helper: assert the next Redis command interaction. + + Wraps assert_interaction() for ergonomic use. All three fields + (command, args, kwargs) are required. + """ + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + + kw = kwargs if kwargs is not None else {} + cmd_upper = command.upper() + source_id = f"redis:{cmd_upper.lower()}" + sentinel = _RedisSentinel(source_id) + _get_test_verifier_or_raise().assert_interaction( + sentinel, + command=cmd_upper, + args=args, + kwargs=kw, + ) diff --git a/src/bigfoot/plugins/smtp_plugin.py b/src/bigfoot/plugins/smtp_plugin.py index 7d57359..16c912d 100644 --- a/src/bigfoot/plugins/smtp_plugin.py +++ b/src/bigfoot/plugins/smtp_plugin.py @@ -2,18 +2,34 @@ import smtplib import threading -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from bigfoot._context import _get_verifier_or_raise -from bigfoot._state_machine_plugin import StateMachinePlugin +from bigfoot._state_machine_plugin import StateMachinePlugin, _StepSentinel from bigfoot._timeline import Interaction +if TYPE_CHECKING: + from bigfoot._verifier import StrictVerifier + # --------------------------------------------------------------------------- # Import-time constant -- captured BEFORE any patches are installed. # --------------------------------------------------------------------------- _ORIGINAL_SMTP: Any = smtplib.SMTP +# --------------------------------------------------------------------------- +# Source ID constants +# --------------------------------------------------------------------------- + +_SOURCE_CONNECT = "smtp:connect" +_SOURCE_EHLO = "smtp:ehlo" +_SOURCE_HELO = "smtp:helo" +_SOURCE_STARTTLS = "smtp:starttls" +_SOURCE_LOGIN = "smtp:login" +_SOURCE_SENDMAIL = "smtp:sendmail" +_SOURCE_SEND_MESSAGE = "smtp:send_message" +_SOURCE_QUIT = "smtp:quit" + # --------------------------------------------------------------------------- # Module-level helper: find the SmtpPlugin on the active verifier # --------------------------------------------------------------------------- @@ -43,27 +59,34 @@ def __init__(self, host: str = "", port: int = 0, **kwargs: Any) -> None: # noq plugin._bind_connection(self) # partial init handle = plugin._lookup_session(self) # ALWAYS execute connect step unconditionally (matches real smtplib.SMTP behavior) - plugin._execute_step(handle, "connect", (host,), {"port": port}, "smtp:connect") + plugin._execute_step( + handle, "connect", (host,), {"port": port}, _SOURCE_CONNECT, + details={"host": host, "port": port}, + ) def ehlo(self, name: str = "") -> tuple[int, bytes]: plugin = _find_smtp_plugin() handle = plugin._lookup_session(self) - return plugin._execute_step(handle, "ehlo", (name,), {}, "smtp:ehlo") # type: ignore[no-any-return] + return plugin._execute_step(handle, "ehlo", (name,), {}, _SOURCE_EHLO, # type: ignore[no-any-return] + details={"name": name}) def helo(self, name: str = "") -> tuple[int, bytes]: plugin = _find_smtp_plugin() handle = plugin._lookup_session(self) - return plugin._execute_step(handle, "helo", (name,), {}, "smtp:helo") # type: ignore[no-any-return] + return plugin._execute_step(handle, "helo", (name,), {}, _SOURCE_HELO, # type: ignore[no-any-return] + details={"name": name}) def starttls(self, **kwargs: Any) -> tuple[int, bytes]: # noqa: ANN401 plugin = _find_smtp_plugin() handle = plugin._lookup_session(self) - return plugin._execute_step(handle, "starttls", (), kwargs, "smtp:starttls") # type: ignore[no-any-return] + return plugin._execute_step(handle, "starttls", (), kwargs, _SOURCE_STARTTLS, # type: ignore[no-any-return] + details={}) def login(self, user: str, password: str) -> tuple[int, bytes]: plugin = _find_smtp_plugin() handle = plugin._lookup_session(self) - return plugin._execute_step(handle, "login", (user, password), {}, "smtp:login") # type: ignore[no-any-return] + return plugin._execute_step(handle, "login", (user, password), {}, _SOURCE_LOGIN, # type: ignore[no-any-return] + details={"user": user, "password": password}) def sendmail( self, @@ -76,7 +99,8 @@ def sendmail( plugin = _find_smtp_plugin() handle = plugin._lookup_session(self) return plugin._execute_step( # type: ignore[no-any-return] - handle, "sendmail", (from_addr, to_addrs, msg), {}, "smtp:sendmail" + handle, "sendmail", (from_addr, to_addrs, msg), {}, _SOURCE_SENDMAIL, + details={"from_addr": from_addr, "to_addrs": to_addrs, "msg": msg}, ) def send_message( @@ -89,12 +113,14 @@ def send_message( ) -> dict[str, tuple[int, bytes]]: plugin = _find_smtp_plugin() handle = plugin._lookup_session(self) - return plugin._execute_step(handle, "send_message", (msg,), {}, "smtp:send_message") # type: ignore[no-any-return] + return plugin._execute_step(handle, "send_message", (msg,), {}, _SOURCE_SEND_MESSAGE, # type: ignore[no-any-return] + details={"msg": msg}) def quit(self) -> tuple[int, bytes]: plugin = _find_smtp_plugin() handle = plugin._lookup_session(self) - result = plugin._execute_step(handle, "quit", (), {}, "smtp:quit") + result = plugin._execute_step(handle, "quit", (), {}, _SOURCE_QUIT, + details={}) plugin._release_session(self) return result # type: ignore[no-any-return] @@ -123,6 +149,49 @@ class SmtpPlugin(StateMachinePlugin): # Saved original, restored when count reaches 0. _original_smtp: ClassVar[Any] = None + def __init__(self, verifier: "StrictVerifier") -> None: + super().__init__(verifier) + self._connect_sentinel = _StepSentinel(_SOURCE_CONNECT) + self._ehlo_sentinel = _StepSentinel(_SOURCE_EHLO) + self._helo_sentinel = _StepSentinel(_SOURCE_HELO) + self._starttls_sentinel = _StepSentinel(_SOURCE_STARTTLS) + self._login_sentinel = _StepSentinel(_SOURCE_LOGIN) + self._sendmail_sentinel = _StepSentinel(_SOURCE_SENDMAIL) + self._send_message_sentinel = _StepSentinel(_SOURCE_SEND_MESSAGE) + self._quit_sentinel = _StepSentinel(_SOURCE_QUIT) + + @property + def connect(self) -> _StepSentinel: + return self._connect_sentinel + + @property + def ehlo(self) -> _StepSentinel: + return self._ehlo_sentinel + + @property + def helo(self) -> _StepSentinel: + return self._helo_sentinel + + @property + def starttls(self) -> _StepSentinel: + return self._starttls_sentinel + + @property + def login(self) -> _StepSentinel: + return self._login_sentinel + + @property + def sendmail(self) -> _StepSentinel: + return self._sendmail_sentinel + + @property + def send_message(self) -> _StepSentinel: + return self._send_message_sentinel + + @property + def quit(self) -> _StepSentinel: + return self._quit_sentinel + # ------------------------------------------------------------------ # StateMachinePlugin abstract methods # ------------------------------------------------------------------ @@ -182,13 +251,30 @@ def deactivate(self) -> None: # ------------------------------------------------------------------ def format_interaction(self, interaction: Interaction) -> str: - method = interaction.details.get("method", "?") - args = interaction.details.get("args", ()) - parts = [repr(a) for a in args] - return f"[SmtpPlugin] smtp.{method}({', '.join(parts)})" + sid = interaction.source_id + method = sid.split(":", 1)[-1] if ":" in sid else sid + details = interaction.details + if sid == _SOURCE_CONNECT: + return f"[SmtpPlugin] smtp.connect(host={details.get('host', '?')!r}, port={details.get('port', 0)!r})" + if sid == _SOURCE_EHLO: + return f"[SmtpPlugin] smtp.ehlo(name={details.get('name', '')!r})" + if sid == _SOURCE_HELO: + return f"[SmtpPlugin] smtp.helo(name={details.get('name', '')!r})" + if sid == _SOURCE_STARTTLS: + return "[SmtpPlugin] smtp.starttls()" + if sid == _SOURCE_LOGIN: + return f"[SmtpPlugin] smtp.login(user={details.get('user', '?')!r})" + if sid == _SOURCE_SENDMAIL: + return f"[SmtpPlugin] smtp.sendmail(from_addr={details.get('from_addr', '?')!r}, to_addrs={details.get('to_addrs')!r})" + if sid == _SOURCE_SEND_MESSAGE: + return f"[SmtpPlugin] smtp.send_message(msg={details.get('msg')!r})" + if sid == _SOURCE_QUIT: + return "[SmtpPlugin] smtp.quit()" + return f"[SmtpPlugin] smtp.{method}(...)" def format_mock_hint(self, interaction: Interaction) -> str: - method = interaction.details.get("method", "?") + sid = interaction.source_id + method = sid.split(":")[-1] if ":" in sid else sid return f" bigfoot.smtp_mock.new_session().expect({method!r}, returns=...)" def format_unmocked_hint( @@ -205,9 +291,91 @@ def format_unmocked_hint( ) def format_assert_hint(self, interaction: Interaction) -> str: - pm = "bigfoot.smtp_mock" - method = interaction.details.get("method", "?") - return f" # {pm}: session step '{method}' recorded (state-machine, auto-asserted)" + sm = "bigfoot.smtp_mock" + sid = interaction.source_id + if sid == _SOURCE_CONNECT: + host = interaction.details.get("host", "?") + port = interaction.details.get("port", 0) + return f" {sm}.assert_connect(host={host!r}, port={port!r})" + if sid == _SOURCE_EHLO: + name = interaction.details.get("name", "") + return f" {sm}.assert_ehlo(name={name!r})" + if sid == _SOURCE_HELO: + name = interaction.details.get("name", "") + return f" {sm}.assert_helo(name={name!r})" + if sid == _SOURCE_STARTTLS: + return f" {sm}.assert_starttls()" + if sid == _SOURCE_LOGIN: + user = interaction.details.get("user", "?") + password = interaction.details.get("password", "?") + return f" {sm}.assert_login(user={user!r}, password={password!r})" + if sid == _SOURCE_SENDMAIL: + from_addr = interaction.details.get("from_addr", "?") + to_addrs = interaction.details.get("to_addrs") + msg = interaction.details.get("msg") + return f" {sm}.assert_sendmail(from_addr={from_addr!r}, to_addrs={to_addrs!r}, msg={msg!r})" + if sid == _SOURCE_SEND_MESSAGE: + msg = interaction.details.get("msg") + return f" {sm}.assert_send_message(msg={msg!r})" + if sid == _SOURCE_QUIT: + return f" {sm}.assert_quit()" + return f" # {sm}: unknown source_id={sid!r}" + + def matches(self, interaction: Interaction, expected: dict[str, Any]) -> bool: + """Field-by-field comparison with dirty-equals support.""" + try: + for key, expected_val in expected.items(): + actual_val = interaction.details.get(key) + if expected_val != actual_val: + return False + return True + except Exception: + return False + + def assertable_fields(self, interaction: Interaction) -> frozenset[str]: + """Return assertable fields for each step type.""" + no_data = {_SOURCE_STARTTLS, _SOURCE_QUIT} + if interaction.source_id in no_data: + return frozenset() + return frozenset(interaction.details.keys()) + + def assert_connect(self, *, host: str, port: int) -> None: + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction( + self._connect_sentinel, host=host, port=port + ) + + def assert_ehlo(self, *, name: str) -> None: + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction(self._ehlo_sentinel, name=name) + + def assert_helo(self, *, name: str) -> None: + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction(self._helo_sentinel, name=name) + + def assert_starttls(self) -> None: + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction(self._starttls_sentinel) + + def assert_login(self, *, user: str, password: str) -> None: + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction( + self._login_sentinel, user=user, password=password + ) + + def assert_sendmail(self, *, from_addr: str, to_addrs: Any, msg: Any) -> None: # noqa: ANN401 + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction( + self._sendmail_sentinel, from_addr=from_addr, to_addrs=to_addrs, msg=msg + ) + + def assert_send_message(self, *, msg: Any) -> None: # noqa: ANN401 + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction(self._send_message_sentinel, msg=msg) + + def assert_quit(self) -> None: + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction(self._quit_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: step: Any = mock_config diff --git a/src/bigfoot/plugins/socket_plugin.py b/src/bigfoot/plugins/socket_plugin.py index 23e0f51..ef62379 100644 --- a/src/bigfoot/plugins/socket_plugin.py +++ b/src/bigfoot/plugins/socket_plugin.py @@ -2,12 +2,15 @@ import socket import threading -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from bigfoot._context import _get_verifier_or_raise -from bigfoot._state_machine_plugin import StateMachinePlugin +from bigfoot._state_machine_plugin import StateMachinePlugin, _StepSentinel from bigfoot._timeline import Interaction +if TYPE_CHECKING: + from bigfoot._verifier import StrictVerifier + # --------------------------------------------------------------------------- # Source ID constants # --------------------------------------------------------------------------- @@ -70,6 +73,34 @@ class SocketPlugin(StateMachinePlugin): _original_recv: ClassVar[Any] = None _original_close: ClassVar[Any] = None + def __init__(self, verifier: "StrictVerifier") -> None: + super().__init__(verifier) + self._connect_sentinel = _StepSentinel(_SOURCE_CONNECT) + self._send_sentinel = _StepSentinel(_SOURCE_SEND) + self._sendall_sentinel = _StepSentinel(_SOURCE_SENDALL) + self._recv_sentinel = _StepSentinel(_SOURCE_RECV) + self._close_sentinel = _StepSentinel(_SOURCE_CLOSE) + + @property + def connect(self) -> _StepSentinel: + return self._connect_sentinel + + @property + def send(self) -> _StepSentinel: + return self._send_sentinel + + @property + def sendall(self) -> _StepSentinel: + return self._sendall_sentinel + + @property + def recv(self) -> _StepSentinel: + return self._recv_sentinel + + @property + def close(self) -> _StepSentinel: + return self._close_sentinel + # ------------------------------------------------------------------ # StateMachinePlugin abstract methods # ------------------------------------------------------------------ @@ -117,10 +148,19 @@ def _install_patches(self) -> None: SocketPlugin._original_recv = socket.socket.recv SocketPlugin._original_close = socket.socket.close - def _patched_connect(sock_self: socket.socket, address: tuple[object, ...]) -> None: + def _patched_connect(sock_self: socket.socket, address: object) -> None: plugin = _get_socket_plugin() handle = plugin._bind_connection(sock_self) - plugin._execute_step(handle, "connect", (address,), {}, _SOURCE_CONNECT) + if isinstance(address, tuple) and len(address) >= 2: + host = str(address[0]) + port = int(address[1]) + else: + host = str(address) + port = 0 + plugin._execute_step( + handle, "connect", (address,), {}, _SOURCE_CONNECT, + details={"host": host, "port": port}, + ) def _patched_send( sock_self: socket.socket, @@ -130,7 +170,10 @@ def _patched_send( plugin = _get_socket_plugin() handle = plugin._lookup_session(sock_self) return int( - plugin._execute_step(handle, "send", (data,), {"flags": flags}, _SOURCE_SEND) + plugin._execute_step( + handle, "send", (data,), {"flags": flags}, _SOURCE_SEND, + details={"data": bytes(data)}, + ) ) def _patched_sendall( @@ -140,20 +183,30 @@ def _patched_sendall( ) -> None: plugin = _get_socket_plugin() handle = plugin._lookup_session(sock_self) - plugin._execute_step(handle, "sendall", (data,), {"flags": flags}, _SOURCE_SENDALL) + plugin._execute_step( + handle, "sendall", (data,), {"flags": flags}, _SOURCE_SENDALL, + details={"data": bytes(data)}, + ) def _patched_recv(sock_self: socket.socket, bufsize: int, flags: int = 0) -> bytes: plugin = _get_socket_plugin() handle = plugin._lookup_session(sock_self) - result = plugin._execute_step( - handle, "recv", (bufsize,), {"flags": flags}, _SOURCE_RECV + result, interaction = plugin._execute_step( + handle, "recv", (bufsize,), {"flags": flags}, _SOURCE_RECV, + details={"size": bufsize, "data": b""}, + return_interaction=True, ) - return bytes(result) + data = bytes(result) + interaction.details["data"] = data + return data def _patched_close(sock_self: socket.socket) -> None: plugin = _get_socket_plugin() handle = plugin._lookup_session(sock_self) - plugin._execute_step(handle, "close", (), {}, _SOURCE_CLOSE) + plugin._execute_step( + handle, "close", (), {}, _SOURCE_CLOSE, + details={}, + ) plugin._release_session(sock_self) socket.socket.connect = _patched_connect # type: ignore[method-assign, assignment] @@ -184,15 +237,24 @@ def _restore_patches(self) -> None: # ------------------------------------------------------------------ def format_interaction(self, interaction: Interaction) -> str: - method = interaction.details.get("method", "?") - args = interaction.details.get("args", ()) - kwargs = interaction.details.get("kwargs", {}) - parts = [repr(a) for a in args] - parts += [f"{k}={v!r}" for k, v in kwargs.items() if k != "flags" or v != 0] - return f"[SocketPlugin] socket.{method}({', '.join(parts)})" + sid = interaction.source_id + method = sid.split(":", 1)[-1] if ":" in sid else sid + details = interaction.details + if sid == _SOURCE_CONNECT: + return f"[SocketPlugin] socket.connect(({details.get('host', '?')!r}, {details.get('port', 0)!r}))" + if sid == _SOURCE_SEND: + return f"[SocketPlugin] socket.send({details.get('data', b'')!r})" + if sid == _SOURCE_SENDALL: + return f"[SocketPlugin] socket.sendall({details.get('data', b'')!r})" + if sid == _SOURCE_RECV: + return f"[SocketPlugin] socket.recv({details.get('size', 0)!r})" + if sid == _SOURCE_CLOSE: + return "[SocketPlugin] socket.close()" + return f"[SocketPlugin] socket.{method}(...)" def format_mock_hint(self, interaction: Interaction) -> str: - method = interaction.details.get("method", "?") + sid = interaction.source_id + method = sid.split(":", 1)[-1] if ":" in sid else sid return f" bigfoot.socket_mock.new_session().expect({method!r}, returns=...)" def format_unmocked_hint( @@ -210,8 +272,82 @@ def format_unmocked_hint( def format_assert_hint(self, interaction: Interaction) -> str: sm = "bigfoot.socket_mock" - method = interaction.details.get("method", "?") - return f" # {sm}: session step '{method}' recorded (state-machine, auto-asserted)" + sid = interaction.source_id + if sid == _SOURCE_CONNECT: + host = interaction.details.get("host", "?") + port = interaction.details.get("port", 0) + return f" {sm}.assert_connect(host={host!r}, port={port!r})" + if sid == _SOURCE_SEND: + data = interaction.details.get("data", b"") + return f" {sm}.assert_send(data={data!r})" + if sid == _SOURCE_SENDALL: + data = interaction.details.get("data", b"") + return f" {sm}.assert_sendall(data={data!r})" + if sid == _SOURCE_RECV: + size = interaction.details.get("size", 0) + data = interaction.details.get("data", b"") + return f" {sm}.assert_recv(size={size!r}, data={data!r})" + if sid == _SOURCE_CLOSE: + return f" {sm}.assert_close()" + return f" # {sm}: unknown source_id={sid!r}" + + def matches(self, interaction: Interaction, expected: dict[str, Any]) -> bool: + """Field-by-field comparison with dirty-equals support.""" + try: + for key, expected_val in expected.items(): + actual_val = interaction.details.get(key) + if expected_val != actual_val: + return False + return True + except Exception: + return False + + def assertable_fields(self, interaction: Interaction) -> frozenset[str]: + """Return assertable fields for each step type.""" + if interaction.source_id == _SOURCE_CONNECT: + return frozenset({"host", "port"}) + if interaction.source_id == _SOURCE_SEND: + return frozenset({"data"}) + if interaction.source_id == _SOURCE_SENDALL: + return frozenset({"data"}) + if interaction.source_id == _SOURCE_RECV: + return frozenset({"size", "data"}) + if interaction.source_id == _SOURCE_CLOSE: + return frozenset() + return frozenset(interaction.details.keys()) + + def assert_connect(self, *, host: str, port: int) -> None: + """Assert the next socket connect interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction( + self._connect_sentinel, host=host, port=port + ) + + def assert_send(self, *, data: bytes) -> None: + """Assert the next socket send interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction( + self._send_sentinel, data=data + ) + + def assert_sendall(self, *, data: bytes) -> None: + """Assert the next socket sendall interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction( + self._sendall_sentinel, data=data + ) + + def assert_recv(self, *, size: int, data: bytes) -> None: + """Assert the next socket recv interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction( + self._recv_sentinel, size=size, data=data + ) + + def assert_close(self) -> None: + """Assert the next socket close interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + _get_test_verifier_or_raise().assert_interaction(self._close_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: step: Any = mock_config diff --git a/src/bigfoot/plugins/subprocess.py b/src/bigfoot/plugins/subprocess.py index df99340..0272171 100644 --- a/src/bigfoot/plugins/subprocess.py +++ b/src/bigfoot/plugins/subprocess.py @@ -463,17 +463,35 @@ def format_assert_hint(self, interaction: "Interaction") -> str: sm = "bigfoot.subprocess_mock" if interaction.source_id == _SOURCE_RUN: cmd = interaction.details.get("command", []) - return f" {sm}.assert_interaction({sm}.run, command={cmd!r})" + rc = interaction.details.get("returncode", 0) + stdout = interaction.details.get("stdout", "") + stderr = interaction.details.get("stderr", "") + return ( + f" {sm}.assert_interaction(\n" + f" {sm}.run,\n" + f" command={cmd!r},\n" + f" returncode={rc!r},\n" + f" stdout={stdout!r},\n" + f" stderr={stderr!r},\n" + f" )" + ) if interaction.source_id == _SOURCE_WHICH: name = interaction.details.get("name", "?") - return f" {sm}.assert_interaction({sm}.which, name={name!r})" + returns = interaction.details.get("returns") + return ( + f" {sm}.assert_interaction(\n" + f" {sm}.which,\n" + f" name={name!r},\n" + f" returns={returns!r},\n" + f" )" + ) return f" # unknown source_id={interaction.source_id!r}" def assertable_fields(self, interaction: "Interaction") -> frozenset[str]: if interaction.source_id == _SOURCE_RUN: - return frozenset({"command"}) + return frozenset({"command", "returncode", "stdout", "stderr"}) if interaction.source_id == _SOURCE_WHICH: - return frozenset({"name"}) + return frozenset({"name", "returns"}) return frozenset() def get_unused_mocks(self) -> list[tuple[str, dict[str, Any], str]]: diff --git a/src/bigfoot/plugins/websocket_plugin.py b/src/bigfoot/plugins/websocket_plugin.py index 51ba211..288d8a9 100644 --- a/src/bigfoot/plugins/websocket_plugin.py +++ b/src/bigfoot/plugins/websocket_plugin.py @@ -3,13 +3,31 @@ from __future__ import annotations import threading -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from bigfoot._context import _get_verifier_or_raise from bigfoot._errors import UnmockedInteractionError -from bigfoot._state_machine_plugin import SessionHandle, StateMachinePlugin +from bigfoot._state_machine_plugin import SessionHandle, StateMachinePlugin, _StepSentinel from bigfoot._timeline import Interaction +if TYPE_CHECKING: + from bigfoot._verifier import StrictVerifier + + +# --------------------------------------------------------------------------- +# Source ID constants +# --------------------------------------------------------------------------- + +_ASYNC_SOURCE_CONNECT = "websocket:async:connect" +_ASYNC_SOURCE_SEND = "websocket:async:send" +_ASYNC_SOURCE_RECV = "websocket:async:recv" +_ASYNC_SOURCE_CLOSE = "websocket:async:close" + +_SYNC_SOURCE_CONNECT = "websocket:sync:connect" +_SYNC_SOURCE_SEND = "websocket:sync:send" +_SYNC_SOURCE_RECV = "websocket:sync:recv" +_SYNC_SOURCE_CLOSE = "websocket:sync:close" + # --------------------------------------------------------------------------- # Optional dependency guards # --------------------------------------------------------------------------- @@ -70,13 +88,25 @@ def __init__(self, handle: SessionHandle, plugin: AsyncWebSocketPlugin) -> None: self._plugin = plugin async def send(self, data: Any) -> Any: # noqa: ANN401 - return self._plugin._execute_step(self._handle, "send", (data,), {}, "websocket:async:send") + return self._plugin._execute_step( + self._handle, "send", (data,), {}, _ASYNC_SOURCE_SEND, + details={"message": data}, + ) async def recv(self) -> Any: # noqa: ANN401 - return self._plugin._execute_step(self._handle, "recv", (), {}, "websocket:async:recv") + result, interaction = self._plugin._execute_step( + self._handle, "recv", (), {}, _ASYNC_SOURCE_RECV, + details={"message": None}, + return_interaction=True, + ) + interaction.details["message"] = result + return result async def close(self) -> Any: # noqa: ANN401 - result = self._plugin._execute_step(self._handle, "close", (), {}, "websocket:async:close") + result = self._plugin._execute_step( + self._handle, "close", (), {}, _ASYNC_SOURCE_CLOSE, + details={}, + ) self._plugin._release_session(self) return result @@ -90,9 +120,10 @@ class _FakeAsyncWebSocketCM: not when the async with block is entered. """ - def __init__(self, handle: SessionHandle, plugin: AsyncWebSocketPlugin) -> None: + def __init__(self, handle: SessionHandle, plugin: AsyncWebSocketPlugin, uri: str) -> None: self._handle = handle self._plugin = plugin + self._uri = uri self._fake_ws: _FakeAsyncWebSocket | None = None async def __aenter__(self) -> _FakeAsyncWebSocket: @@ -101,7 +132,10 @@ async def __aenter__(self) -> _FakeAsyncWebSocket: # Register in active_sessions now that we have the fake_ws identity. self._plugin._register_connection(self._handle, fake_ws) # Execute the "connect" transition step. - self._plugin._execute_step(self._handle, "connect", (), {}, "websocket:async:connect") + self._plugin._execute_step( + self._handle, "connect", (), {}, _ASYNC_SOURCE_CONNECT, + details={"uri": self._uri}, + ) return fake_ws async def __aexit__( @@ -131,6 +165,33 @@ class AsyncWebSocketPlugin(StateMachinePlugin): # Saved original, restored when count reaches 0. _original_connect: ClassVar[Any] = None + # ------------------------------------------------------------------ + # Plugin init: create per-instance sentinels + # ------------------------------------------------------------------ + + def __init__(self, verifier: "StrictVerifier") -> None: + super().__init__(verifier) + self._connect_sentinel = _StepSentinel(_ASYNC_SOURCE_CONNECT) + self._send_sentinel = _StepSentinel(_ASYNC_SOURCE_SEND) + self._recv_sentinel = _StepSentinel(_ASYNC_SOURCE_RECV) + self._close_sentinel = _StepSentinel(_ASYNC_SOURCE_CLOSE) + + @property + def connect(self) -> _StepSentinel: + return self._connect_sentinel + + @property + def send(self) -> _StepSentinel: + return self._send_sentinel + + @property + def recv(self) -> _StepSentinel: + return self._recv_sentinel + + @property + def close(self) -> _StepSentinel: + return self._close_sentinel + # ------------------------------------------------------------------ # StateMachinePlugin abstract methods # ------------------------------------------------------------------ @@ -182,6 +243,7 @@ def _install_patches(self) -> None: def _patched_websockets_connect(*args: Any, **kwargs: Any) -> _FakeAsyncWebSocketCM: # noqa: ANN401 plugin = _get_async_websocket_plugin() + uri = args[0] if args else kwargs.get("uri", "") # Pop from queue at websockets.connect() call time (FIFO). with plugin._registry_lock: if not plugin._session_queue: @@ -194,7 +256,7 @@ def _patched_websockets_connect(*args: Any, **kwargs: Any) -> _FakeAsyncWebSocke hint=hint, ) handle = plugin._session_queue.popleft() - return _FakeAsyncWebSocketCM(handle, plugin) + return _FakeAsyncWebSocketCM(handle, plugin, uri) _ws.connect = _patched_websockets_connect # type: ignore[misc,assignment] @@ -210,16 +272,24 @@ def _restore_patches(self) -> None: # ------------------------------------------------------------------ def format_interaction(self, interaction: Interaction) -> str: - method = interaction.details.get("method", "?") - args = interaction.details.get("args", ()) - kwargs = interaction.details.get("kwargs", {}) - parts = [repr(a) for a in args] - parts += [f"{k}={v!r}" for k, v in kwargs.items()] - return f"[AsyncWebSocketPlugin] websockets.{method}({', '.join(parts)})" + sid = interaction.source_id + if sid == _ASYNC_SOURCE_CONNECT: + uri = interaction.details.get("uri", "?") + return f"[AsyncWebSocketPlugin] websockets.connect({uri!r})" + if sid == _ASYNC_SOURCE_SEND: + message = interaction.details.get("message") + return f"[AsyncWebSocketPlugin] websockets.send({message!r})" + if sid == _ASYNC_SOURCE_RECV: + message = interaction.details.get("message") + return f"[AsyncWebSocketPlugin] websockets.recv() -> {message!r}" + if sid == _ASYNC_SOURCE_CLOSE: + return "[AsyncWebSocketPlugin] websockets.close()" + return f"[AsyncWebSocketPlugin] websockets.?() source_id={sid!r}" def format_mock_hint(self, interaction: Interaction) -> str: - method = interaction.details.get("method", "?") - return f" bigfoot.async_websocket_mock.new_session().expect({method!r}, returns=...)" + sid = interaction.source_id + step = sid.split(":")[-1] if ":" in sid else sid + return f" bigfoot.async_websocket_mock.new_session().expect({step!r}, returns=...)" def format_unmocked_hint( self, @@ -236,8 +306,60 @@ def format_unmocked_hint( def format_assert_hint(self, interaction: Interaction) -> str: sm = "bigfoot.async_websocket_mock" - method = interaction.details.get("method", "?") - return f" # {sm}: session step '{method}' recorded (state-machine, auto-asserted)" + sid = interaction.source_id + if sid == _ASYNC_SOURCE_CONNECT: + uri = interaction.details.get("uri", "?") + return f" {sm}.assert_connect(uri={uri!r})" + if sid == _ASYNC_SOURCE_SEND: + message = interaction.details.get("message") + return f" {sm}.assert_send(message={message!r})" + if sid == _ASYNC_SOURCE_RECV: + message = interaction.details.get("message") + return f" {sm}.assert_recv(message={message!r})" + if sid == _ASYNC_SOURCE_CLOSE: + return f" {sm}.assert_close()" + return f" # {sm}: unknown source_id={sid!r}" + + def matches(self, interaction: Interaction, expected: dict[str, Any]) -> bool: + """Field-by-field comparison with dirty-equals support.""" + try: + for key, expected_val in expected.items(): + actual_val = interaction.details.get(key) + if expected_val != actual_val: + return False + return True + except Exception: + return False + + def assertable_fields(self, interaction: Interaction) -> frozenset[str]: + """Return assertable fields for each step type.""" + if interaction.source_id == _ASYNC_SOURCE_CLOSE: + return frozenset() + return frozenset(interaction.details.keys()) + + def assert_connect(self, *, uri: str) -> None: + """Assert the next async websocket connect interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + + _get_test_verifier_or_raise().assert_interaction(self._connect_sentinel, uri=uri) + + def assert_send(self, *, message: Any) -> None: # noqa: ANN401 + """Assert the next async websocket send interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + + _get_test_verifier_or_raise().assert_interaction(self._send_sentinel, message=message) + + def assert_recv(self, *, message: Any) -> None: # noqa: ANN401 + """Assert the next async websocket recv interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + + _get_test_verifier_or_raise().assert_interaction(self._recv_sentinel, message=message) + + def assert_close(self) -> None: + """Assert the next async websocket close interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + + _get_test_verifier_or_raise().assert_interaction(self._close_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: step: Any = mock_config @@ -261,13 +383,25 @@ def __init__(self, handle: SessionHandle, plugin: SyncWebSocketPlugin) -> None: self._plugin = plugin def send(self, data: Any) -> Any: # noqa: ANN401 - return self._plugin._execute_step(self._handle, "send", (data,), {}, "websocket:sync:send") + return self._plugin._execute_step( + self._handle, "send", (data,), {}, _SYNC_SOURCE_SEND, + details={"message": data}, + ) def recv(self) -> Any: # noqa: ANN401 - return self._plugin._execute_step(self._handle, "recv", (), {}, "websocket:sync:recv") + result, interaction = self._plugin._execute_step( + self._handle, "recv", (), {}, _SYNC_SOURCE_RECV, + details={"message": None}, + return_interaction=True, + ) + interaction.details["message"] = result + return result def close(self) -> Any: # noqa: ANN401 - result = self._plugin._execute_step(self._handle, "close", (), {}, "websocket:sync:close") + result = self._plugin._execute_step( + self._handle, "close", (), {}, _SYNC_SOURCE_CLOSE, + details={}, + ) self._plugin._release_session(self) return result @@ -288,6 +422,33 @@ class SyncWebSocketPlugin(StateMachinePlugin): # Saved original, restored when count reaches 0. _original_create_connection: ClassVar[Any] = None + # ------------------------------------------------------------------ + # Plugin init: create per-instance sentinels + # ------------------------------------------------------------------ + + def __init__(self, verifier: "StrictVerifier") -> None: + super().__init__(verifier) + self._connect_sentinel = _StepSentinel(_SYNC_SOURCE_CONNECT) + self._send_sentinel = _StepSentinel(_SYNC_SOURCE_SEND) + self._recv_sentinel = _StepSentinel(_SYNC_SOURCE_RECV) + self._close_sentinel = _StepSentinel(_SYNC_SOURCE_CLOSE) + + @property + def connect(self) -> _StepSentinel: + return self._connect_sentinel + + @property + def send(self) -> _StepSentinel: + return self._send_sentinel + + @property + def recv(self) -> _StepSentinel: + return self._recv_sentinel + + @property + def close(self) -> _StepSentinel: + return self._close_sentinel + # ------------------------------------------------------------------ # StateMachinePlugin abstract methods # ------------------------------------------------------------------ @@ -339,6 +500,7 @@ def _install_patches(self) -> None: def _patched_create_connection(*args: Any, **kwargs: Any) -> _FakeSyncWebSocket: # noqa: ANN401 plugin = _get_sync_websocket_plugin() + uri = args[0] if args else kwargs.get("url", "") # Pop from queue immediately at create_connection() call time (FIFO). with plugin._registry_lock: if not plugin._session_queue: @@ -355,7 +517,10 @@ def _patched_create_connection(*args: Any, **kwargs: Any) -> _FakeSyncWebSocket: fake_ws = _FakeSyncWebSocket(handle, plugin) plugin._register_connection(handle, fake_ws) # Execute the "connect" transition step. - plugin._execute_step(handle, "connect", (), {}, "websocket:sync:connect") + plugin._execute_step( + handle, "connect", (), {}, _SYNC_SOURCE_CONNECT, + details={"uri": uri}, + ) return fake_ws _wsc.create_connection = _patched_create_connection @@ -372,16 +537,24 @@ def _restore_patches(self) -> None: # ------------------------------------------------------------------ def format_interaction(self, interaction: Interaction) -> str: - method = interaction.details.get("method", "?") - args = interaction.details.get("args", ()) - kwargs = interaction.details.get("kwargs", {}) - parts = [repr(a) for a in args] - parts += [f"{k}={v!r}" for k, v in kwargs.items()] - return f"[SyncWebSocketPlugin] websocket.{method}({', '.join(parts)})" + sid = interaction.source_id + if sid == _SYNC_SOURCE_CONNECT: + uri = interaction.details.get("uri", "?") + return f"[SyncWebSocketPlugin] websocket.create_connection({uri!r})" + if sid == _SYNC_SOURCE_SEND: + message = interaction.details.get("message") + return f"[SyncWebSocketPlugin] websocket.send({message!r})" + if sid == _SYNC_SOURCE_RECV: + message = interaction.details.get("message") + return f"[SyncWebSocketPlugin] websocket.recv() -> {message!r}" + if sid == _SYNC_SOURCE_CLOSE: + return "[SyncWebSocketPlugin] websocket.close()" + return f"[SyncWebSocketPlugin] websocket.?() source_id={sid!r}" def format_mock_hint(self, interaction: Interaction) -> str: - method = interaction.details.get("method", "?") - return f" bigfoot.sync_websocket_mock.new_session().expect({method!r}, returns=...)" + sid = interaction.source_id + step = sid.split(":")[-1] if ":" in sid else sid + return f" bigfoot.sync_websocket_mock.new_session().expect({step!r}, returns=...)" def format_unmocked_hint( self, @@ -398,8 +571,60 @@ def format_unmocked_hint( def format_assert_hint(self, interaction: Interaction) -> str: sm = "bigfoot.sync_websocket_mock" - method = interaction.details.get("method", "?") - return f" # {sm}: session step '{method}' recorded (state-machine, auto-asserted)" + sid = interaction.source_id + if sid == _SYNC_SOURCE_CONNECT: + uri = interaction.details.get("uri", "?") + return f" {sm}.assert_connect(uri={uri!r})" + if sid == _SYNC_SOURCE_SEND: + message = interaction.details.get("message") + return f" {sm}.assert_send(message={message!r})" + if sid == _SYNC_SOURCE_RECV: + message = interaction.details.get("message") + return f" {sm}.assert_recv(message={message!r})" + if sid == _SYNC_SOURCE_CLOSE: + return f" {sm}.assert_close()" + return f" # {sm}: unknown source_id={sid!r}" + + def matches(self, interaction: Interaction, expected: dict[str, Any]) -> bool: + """Field-by-field comparison with dirty-equals support.""" + try: + for key, expected_val in expected.items(): + actual_val = interaction.details.get(key) + if expected_val != actual_val: + return False + return True + except Exception: + return False + + def assertable_fields(self, interaction: Interaction) -> frozenset[str]: + """Return assertable fields for each step type.""" + if interaction.source_id == _SYNC_SOURCE_CLOSE: + return frozenset() + return frozenset(interaction.details.keys()) + + def assert_connect(self, *, uri: str) -> None: + """Assert the next sync websocket connect interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + + _get_test_verifier_or_raise().assert_interaction(self._connect_sentinel, uri=uri) + + def assert_send(self, *, message: Any) -> None: # noqa: ANN401 + """Assert the next sync websocket send interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + + _get_test_verifier_or_raise().assert_interaction(self._send_sentinel, message=message) + + def assert_recv(self, *, message: Any) -> None: # noqa: ANN401 + """Assert the next sync websocket recv interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + + _get_test_verifier_or_raise().assert_interaction(self._recv_sentinel, message=message) + + def assert_close(self) -> None: + """Assert the next sync websocket close interaction.""" + from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + + _get_test_verifier_or_raise().assert_interaction(self._close_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: step: Any = mock_config diff --git a/tests/dogfood/test_dogfood.py b/tests/dogfood/test_dogfood.py index ef42252..a7705fd 100644 --- a/tests/dogfood/test_dogfood.py +++ b/tests/dogfood/test_dogfood.py @@ -353,9 +353,11 @@ def test_http_plugin_full_cycle_httpx() -> None: bigfoot.http.request, method="GET", url="https://api.stripe.com/v1/charges", - headers=AnyThing(), - body="", + request_headers=AnyThing(), + request_body="", status=200, + response_headers=AnyThing(), + response_body=AnyThing(), ) # _bigfoot_auto_verifier fixture calls verify_all() at teardown @@ -396,9 +398,11 @@ def test_mock_and_http_plugins_tracked_in_global_fifo_order() -> None: bigfoot.http.request, method="POST", url="https://api.example.com/data", - headers=AnyThing(), - body=AnyThing(), + request_headers=AnyThing(), + request_body=AnyThing(), status=201, + response_headers=AnyThing(), + response_body=AnyThing(), ) @@ -502,7 +506,9 @@ def test_http_pass_through_routes_to_real_backend() -> None: bigfoot.http.request, method="GET", url="https://real-api.example.com/data", - headers=AnyThing(), - body="", + request_headers=AnyThing(), + request_body="", status=200, + response_headers=AnyThing(), + response_body=AnyThing(), ) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index f571b48..0553a87 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -254,9 +254,11 @@ def test_http_plugin_mock_response_full_round_trip() -> None: http.request, method="GET", url="https://api.example.com/items", - headers=AnyThing(), - body=AnyThing(), + request_headers=AnyThing(), + request_body=AnyThing(), status=200, + response_headers=AnyThing(), + response_body=AnyThing(), ) verifier.verify_all() diff --git a/tests/unit/test_base_plugin.py b/tests/unit/test_base_plugin.py index a19b4d0..31e7807 100644 --- a/tests/unit/test_base_plugin.py +++ b/tests/unit/test_base_plugin.py @@ -235,7 +235,6 @@ def test_record_not_in_abstract_methods() -> None: "format_mock_hint", "format_unmocked_hint", "format_assert_hint", - "assertable_fields", "get_unused_mocks", "format_unused_mock_hint", } @@ -507,41 +506,37 @@ def format_unused_mock_hint(self, mock_config: Any) -> str: MissingFormatAssertHint(_StubVerifier()) # type: ignore[abstract] -def test_missing_assertable_fields_prevents_instantiation() -> None: - """A subclass missing assertable_fields() cannot be instantiated.""" +def test_missing_assertable_fields_uses_default() -> None: + """A subclass missing assertable_fields() instantiates fine and uses the concrete default.""" + # After Task 3, assertable_fields() is no longer abstract. + # A subclass that does not override it inherits the default. - class MissingAssertableFields(BasePlugin): # type: ignore[abstract] + class MissingAssertableFields(BasePlugin): def activate(self) -> None: pass - def deactivate(self) -> None: pass - def matches(self, interaction: Interaction, expected: dict[str, Any]) -> bool: return True - def format_interaction(self, interaction: Interaction) -> str: return "" - def format_mock_hint(self, interaction: Interaction) -> str: return "" - def format_unmocked_hint( self, source_id: str, args: tuple[Any, ...], kwargs: dict[str, Any] ) -> str: return "" - def format_assert_hint(self, interaction: Interaction) -> str: return "" - def get_unused_mocks(self) -> list[Any]: return [] - def format_unused_mock_hint(self, mock_config: Any) -> str: return "" - with pytest.raises(TypeError): - MissingAssertableFields(_StubVerifier()) # type: ignore[abstract] + verifier = _StubVerifier() + p = MissingAssertableFields(verifier) + i = Interaction(source_id="x", sequence=0, details={"k": "v"}, plugin=p) + assert p.assertable_fields(i) == frozenset({"k"}) def test_missing_get_unused_mocks_prevents_instantiation() -> None: @@ -640,41 +635,36 @@ def test_record_appends_multiple_interactions_in_order() -> None: assert verifier._timeline.appended == [i1, i2, i3] -def test_assertable_fields_is_abstract() -> None: - """Concrete subclass that omits assertable_fields() cannot be instantiated.""" +def test_assertable_fields_has_concrete_default() -> None: + """A concrete subclass that omits assertable_fields() CAN be instantiated; it inherits the default.""" from bigfoot._base_plugin import BasePlugin - class _IncompletePlugin(BasePlugin): + class _PluginWithoutAssertableFields(BasePlugin): def activate(self) -> None: ... def deactivate(self) -> None: ... def matches(self, interaction: Interaction, expected: dict) -> bool: return True # type: ignore[override] - def format_interaction(self, interaction: Interaction) -> str: return "" - def format_mock_hint(self, interaction: Interaction) -> str: return "" - def format_unmocked_hint(self, source_id: str, args: tuple, kwargs: dict) -> str: return "" # type: ignore[override] - def format_assert_hint(self, interaction: Interaction) -> str: return "" - def get_unused_mocks(self) -> list: return [] - def format_unused_mock_hint(self, mock_config: object) -> str: return "" - - # assertable_fields deliberately omitted + # assertable_fields deliberately omitted — should use default from bigfoot._verifier import StrictVerifier - v = StrictVerifier() - with pytest.raises(TypeError, match="abstract"): - _IncompletePlugin(v) # type: ignore[abstract] + p = _PluginWithoutAssertableFields(v) + # Default implementation returns frozenset of details keys + interaction = Interaction(source_id="x", sequence=0, details={"a": 1, "b": 2}, plugin=p) + result = p.assertable_fields(interaction) + assert result == frozenset({"a", "b"}) def test_assertable_fields_contract_returns_frozenset() -> None: @@ -715,3 +705,29 @@ def assertable_fields(self, interaction: Interaction) -> frozenset: result = p.assertable_fields(None) # type: ignore[arg-type] assert isinstance(result, frozenset) assert result == frozenset() + + +def test_assertable_fields_default_returns_details_keys() -> None: + """Default assertable_fields() returns frozenset of all keys in interaction.details.""" + from bigfoot._base_plugin import BasePlugin + + class _DefaultPlugin(BasePlugin): + def activate(self) -> None: ... + def deactivate(self) -> None: ... + def matches(self, i: Interaction, e: dict) -> bool: return True # type: ignore[override] + def format_interaction(self, i: Interaction) -> str: return "" + def format_mock_hint(self, i: Interaction) -> str: return "" + def format_unmocked_hint(self, s: str, a: tuple, k: dict) -> str: return "" # type: ignore[override] + def format_assert_hint(self, i: Interaction) -> str: return "" + def get_unused_mocks(self) -> list: return [] + def format_unused_mock_hint(self, m: object) -> str: return "" + + from bigfoot._verifier import StrictVerifier + v = StrictVerifier() + p = _DefaultPlugin(v) + interaction2 = Interaction(source_id="x", sequence=0, details={"x": 1, "y": 2}, plugin=p) + assert p.assertable_fields(interaction2) == frozenset({"x", "y"}) + + # Empty details returns empty frozenset + interaction3 = Interaction(source_id="x", sequence=0, details={}, plugin=p) + assert p.assertable_fields(interaction3) == frozenset() diff --git a/tests/unit/test_database_plugin.py b/tests/unit/test_database_plugin.py index 794bed2..b52258e 100644 --- a/tests/unit/test_database_plugin.py +++ b/tests/unit/test_database_plugin.py @@ -57,7 +57,7 @@ def clean_install_count() -> None: # ESCAPE: Nothing reasonable -- exact string equality. def test_initial_state() -> None: v, p = _make_verifier_with_plugin() - assert p._initial_state() == "connected" + assert p._initial_state() == "disconnected" # ESCAPE: test_transitions_structure @@ -69,6 +69,7 @@ def test_initial_state() -> None: def test_transitions_structure() -> None: v, p = _make_verifier_with_plugin() assert p._transitions() == { + "connect": {"disconnected": "connected"}, "execute": {"connected": "in_transaction", "in_transaction": "in_transaction"}, "commit": {"in_transaction": "connected"}, "rollback": {"in_transaction": "connected"}, @@ -165,6 +166,7 @@ def test_reference_counting_nested() -> None: def test_basic_execute_fetchall() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() + session.expect("connect", returns=None) session.expect("execute", returns=[[1, "Alice"], [2, "Bob"]]) session.expect("close", returns=None) @@ -174,6 +176,9 @@ def test_basic_execute_fetchall() -> None: rows = cursor.fetchall() conn.close() + v.assert_interaction(p.connect, database=":memory:") + v.assert_interaction(p.execute, sql="SELECT id, name FROM users", parameters=()) + v.assert_interaction(p.close) assert rows == [[1, "Alice"], [2, "Bob"]] @@ -194,6 +199,7 @@ def test_basic_execute_fetchall() -> None: def test_execute_fetchone() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() + session.expect("connect", returns=None) session.expect("execute", returns=[[1, "Alice"], [2, "Bob"]]) session.expect("close", returns=None) @@ -204,6 +210,9 @@ def test_execute_fetchone() -> None: second = cursor.fetchone() conn.close() + v.assert_interaction(p.connect, database=":memory:") + v.assert_interaction(p.execute, sql="SELECT id, name FROM users", parameters=()) + v.assert_interaction(p.close) assert first == [1, "Alice"] assert second == [2, "Bob"] @@ -225,6 +234,7 @@ def test_execute_fetchone() -> None: def test_cursor_execute_fetchall() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() + session.expect("connect", returns=None) session.expect("execute", returns=[["x"], ["y"]]) session.expect("close", returns=None) @@ -235,6 +245,9 @@ def test_cursor_execute_fetchall() -> None: rows = cursor.fetchall() conn.close() + v.assert_interaction(p.connect, database=":memory:") + v.assert_interaction(p.execute, sql="SELECT val FROM t", parameters=()) + v.assert_interaction(p.close) assert rows == [["x"], ["y"]] @@ -256,6 +269,7 @@ def test_cursor_execute_fetchall() -> None: def test_commit_state_transition() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() + session.expect("connect", returns=None) session.expect("execute", returns=[]) session.expect("commit", returns=None) session.expect("execute", returns=[]) # only valid if commit reset state to "connected" @@ -268,6 +282,11 @@ def test_commit_state_transition() -> None: conn.execute("INSERT INTO t VALUES (2)") # would fail if state stuck at "in_transaction" conn.close() + v.assert_interaction(p.connect, database=":memory:") + v.assert_interaction(p.execute, sql="INSERT INTO t VALUES (1)", parameters=()) + v.assert_interaction(p.commit) + v.assert_interaction(p.execute, sql="INSERT INTO t VALUES (2)", parameters=()) + v.assert_interaction(p.close) assert p.get_unused_mocks() == [] @@ -287,6 +306,7 @@ def test_commit_state_transition() -> None: def test_rollback_state_transition() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() + session.expect("connect", returns=None) session.expect("execute", returns=[]) session.expect("rollback", returns=None) session.expect("execute", returns=[]) # only valid if rollback reset state to "connected" @@ -299,6 +319,11 @@ def test_rollback_state_transition() -> None: conn.execute("INSERT INTO t VALUES (2)") # would fail if state stuck at "in_transaction" conn.close() + v.assert_interaction(p.connect, database=":memory:") + v.assert_interaction(p.execute, sql="INSERT INTO t VALUES (1)", parameters=()) + v.assert_interaction(p.rollback) + v.assert_interaction(p.execute, sql="INSERT INTO t VALUES (2)", parameters=()) + v.assert_interaction(p.close) assert p.get_unused_mocks() == [] @@ -319,6 +344,7 @@ def test_rollback_state_transition() -> None: def test_close_releases_session() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() + session.expect("connect", returns=None) session.expect("execute", returns=[]) session.expect("close", returns=None) @@ -327,6 +353,9 @@ def test_close_releases_session() -> None: conn.execute("SELECT 1") conn.close() + v.assert_interaction(p.connect, database=":memory:") + v.assert_interaction(p.execute, sql="SELECT 1", parameters=()) + v.assert_interaction(p.close) assert len(p._active_sessions) == 0 assert p.get_unused_mocks() == [] @@ -349,7 +378,8 @@ def test_close_releases_session() -> None: def test_commit_before_execute_raises_invalid_state() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() - # No execute step needed -- we expect commit to fail immediately + # Connect step brings us to "connected"; commit from "connected" is invalid + session.expect("connect", returns=None) session.expect("close", returns=None) with v.sandbox(): @@ -358,6 +388,8 @@ def test_commit_before_execute_raises_invalid_state() -> None: conn.commit() conn.close() + v.assert_interaction(p.connect, database=":memory:") + v.assert_interaction(p.close) exc = exc_info.value assert exc.source_id == "db:commit" assert exc.method == "commit" @@ -383,6 +415,7 @@ def test_commit_before_execute_raises_invalid_state() -> None: def test_get_unused_mocks_returns_unconsumed_steps() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() + session.expect("connect", returns=None) session.expect("execute", returns=[]) session.expect("commit", returns=None) # will NOT be consumed @@ -391,6 +424,8 @@ def test_get_unused_mocks_returns_unconsumed_steps() -> None: conn.execute("SELECT 1") # deliberately NOT calling commit or close + v.assert_interaction(p.connect, database=":memory:") + v.assert_interaction(p.execute, sql="SELECT 1", parameters=()) unused: list[ScriptStep] = p.get_unused_mocks() assert len(unused) == 1 assert unused[0].method == "commit" @@ -498,6 +533,7 @@ def test_db_mock_proxy_raises_outside_context() -> None: def test_fetchone_exhaustion_returns_none() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() + session.expect("connect", returns=None) session.expect("execute", returns=[[42]]) session.expect("close", returns=None) @@ -508,6 +544,9 @@ def test_fetchone_exhaustion_returns_none() -> None: second = cursor.fetchone() conn.close() + v.assert_interaction(p.connect, database=":memory:") + v.assert_interaction(p.execute, sql="SELECT val FROM t", parameters=()) + v.assert_interaction(p.close) assert first == [42] assert second is None @@ -529,6 +568,7 @@ def test_fetchone_exhaustion_returns_none() -> None: def test_fetchmany() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() + session.expect("connect", returns=None) session.expect("execute", returns=[[1], [2], [3], [4]]) session.expect("close", returns=None) @@ -539,6 +579,9 @@ def test_fetchmany() -> None: second_batch = cursor.fetchmany(2) conn.close() + v.assert_interaction(p.connect, database=":memory:") + v.assert_interaction(p.execute, sql="SELECT val FROM t", parameters=()) + v.assert_interaction(p.close) assert first_batch == [[1], [2]] assert second_batch == [[3], [4]] @@ -558,6 +601,7 @@ def test_fetchmany() -> None: def test_execute_returns_none_gives_empty_fetchall() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() + session.expect("connect", returns=None) session.expect("execute", returns=None) session.expect("close", returns=None) @@ -567,6 +611,9 @@ def test_execute_returns_none_gives_empty_fetchall() -> None: rows = cursor.fetchall() conn.close() + v.assert_interaction(p.connect, database=":memory:") + v.assert_interaction(p.execute, sql="INSERT INTO t VALUES (1)", parameters=()) + v.assert_interaction(p.close) assert rows == [] @@ -585,6 +632,7 @@ def test_execute_returns_none_gives_empty_fetchall() -> None: def test_cursor_iter() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() + session.expect("connect", returns=None) session.expect("execute", returns=[[1], [2], [3]]) session.expect("close", returns=None) @@ -594,6 +642,9 @@ def test_cursor_iter() -> None: collected = list(cursor) conn.close() + v.assert_interaction(p.connect, database=":memory:") + v.assert_interaction(p.execute, sql="SELECT val FROM t", parameters=()) + v.assert_interaction(p.close) assert collected == [[1], [2], [3]] diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index ec0645b..e76fd43 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -8,6 +8,7 @@ from bigfoot._errors import ( AssertionInsideSandboxError, + AutoAssertError, BigfootError, ConflictError, InteractionMismatchError, @@ -42,6 +43,7 @@ def test_all_errors_subclass_bigfoot_error() -> None: assert issubclass(AssertionInsideSandboxError, BigfootError) assert issubclass(NoActiveVerifierError, BigfootError) assert issubclass(MissingAssertionFieldsError, BigfootError) + assert issubclass(AutoAssertError, BigfootError) def test_all_errors_subclass_exception() -> None: @@ -56,6 +58,7 @@ def test_all_errors_subclass_exception() -> None: assert issubclass(AssertionInsideSandboxError, Exception) assert issubclass(NoActiveVerifierError, Exception) assert issubclass(MissingAssertionFieldsError, Exception) + assert issubclass(AutoAssertError, Exception) # --------------------------------------------------------------------------- @@ -582,3 +585,29 @@ def test_invalid_state_error_catchable_as_bigfoot_error() -> None: current_state="c", valid_states=frozenset({"v"}), ) + + +# --------------------------------------------------------------------------- +# AutoAssertError +# --------------------------------------------------------------------------- + + +def test_auto_assert_error_is_bigfoot_error() -> None: + """AutoAssertError is a subclass of BigfootError.""" + from bigfoot._errors import AutoAssertError, BigfootError + assert issubclass(AutoAssertError, BigfootError) + + +def test_auto_assert_error_message() -> None: + """AutoAssertError stores the message passed to it.""" + from bigfoot._errors import AutoAssertError + err = AutoAssertError("test message") + assert "test message" in str(err) + + +def test_auto_assert_error_exported_from_bigfoot() -> None: + """AutoAssertError is accessible from the top-level bigfoot module.""" + import bigfoot + assert hasattr(bigfoot, "AutoAssertError") + from bigfoot import AutoAssertError + assert AutoAssertError is not None diff --git a/tests/unit/test_http_plugin.py b/tests/unit/test_http_plugin.py index e90a2f9..18403da 100644 --- a/tests/unit/test_http_plugin.py +++ b/tests/unit/test_http_plugin.py @@ -21,6 +21,7 @@ _HTTPX_ORIGINAL_ASYNC_HANDLE, _HTTPX_ORIGINAL_HANDLE, _REQUESTS_ORIGINAL_SEND, + HttpAssertionBuilder, HttpPlugin, ) @@ -640,7 +641,15 @@ def test_format_assert_hint_returns_correct_snippet() -> None: interaction = Interaction( source_id="http:request", sequence=0, - details={"method": "GET", "url": "https://api.example.com/data", "status": 200}, + details={ + "method": "GET", + "url": "https://api.example.com/data", + "status": 200, + "request_headers": {}, + "request_body": "", + "response_headers": {}, + "response_body": "", + }, plugin=p, ) result = p.format_assert_hint(interaction) @@ -649,9 +658,11 @@ def test_format_assert_hint_returns_correct_snippet() -> None: " http.request,\n" ' method="GET",\n' ' url="https://api.example.com/data",\n' - " headers={},\n" - " body='',\n" + " request_headers={},\n" + " request_body='',\n" " status=200,\n" + " response_headers={},\n" + " response_body='',\n" ")" ) @@ -1061,8 +1072,8 @@ def test_restore_patches_is_idempotent_when_originals_are_none() -> None: # --------------------------------------------------------------------------- -def test_http_plugin_assertable_fields_returns_all_five() -> None: - """assertable_fields() returns frozenset of all five HTTP details.""" +def test_http_plugin_assertable_fields_returns_all_seven() -> None: + """assertable_fields() returns frozenset of all seven HTTP details.""" v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1071,14 +1082,18 @@ def test_http_plugin_assertable_fields_returns_all_five() -> None: details={ "method": "GET", "url": "https://example.com", - "headers": {}, - "body": "", + "request_headers": {}, + "request_body": "", "status": 200, + "response_headers": {}, + "response_body": "", }, plugin=p, ) result = p.assertable_fields(interaction) - assert result == frozenset({"method", "url", "headers", "body", "status"}) + assert result == frozenset( + {"method", "url", "request_headers", "request_body", "status", "response_headers", "response_body"} + ) # --------------------------------------------------------------------------- @@ -1206,3 +1221,160 @@ def test_unused_pass_through_rule_does_not_raise_at_verify_all() -> None: pass # No requests made v.verify_all() # Must not raise + + +# --------------------------------------------------------------------------- +# HttpAssertionBuilder tests +# --------------------------------------------------------------------------- + + +# ESCAPE: test_assert_request_returns_http_assertion_builder +# CLAIM: p.assert_request() returns an HttpAssertionBuilder instance. +# PATH: HttpPlugin.assert_request() -> HttpAssertionBuilder(...). +# CHECK: isinstance check. +# MUTATION: Returning None or a different type fails isinstance. +# ESCAPE: Nothing reasonable -- isinstance check on the exact class. +def test_assert_request_returns_http_assertion_builder() -> None: + v, p = _make_verifier_with_plugin() + builder = p.assert_request("GET", "https://example.com/api") + assert isinstance(builder, HttpAssertionBuilder) + + +# ESCAPE: test_assert_request_stores_method_and_url +# CLAIM: HttpAssertionBuilder stores method and url from assert_request(). +# PATH: assert_request() passes method/url to HttpAssertionBuilder.__init__. +# CHECK: builder._method == "GET", builder._url == "https://example.com/api". +# MUTATION: Swapping method and url would fail both checks. +# ESCAPE: Nothing reasonable -- exact attribute equality. +def test_assert_request_stores_method_and_url() -> None: + v, p = _make_verifier_with_plugin() + builder = p.assert_request("GET", "https://example.com/api") + assert builder._method == "GET" + assert builder._url == "https://example.com/api" + + +# ESCAPE: test_assert_request_default_headers_and_body +# CLAIM: assert_request() defaults headers to {} and body to "". +# PATH: assert_request() uses `headers if headers is not None else {}` and body="". +# CHECK: builder._headers == {}, builder._body == "". +# MUTATION: Defaulting headers to None would leave None stored. +# ESCAPE: Nothing reasonable -- exact equality. +def test_assert_request_default_headers_and_body() -> None: + v, p = _make_verifier_with_plugin() + builder = p.assert_request("POST", "https://example.com/submit") + assert builder._headers == {} + assert builder._body == "" + + +# ESCAPE: test_assert_request_with_explicit_headers_and_body +# CLAIM: assert_request() passes through explicit headers and body. +# PATH: assert_request(headers=..., body=...) -> builder stores them. +# CHECK: builder._headers and builder._body match what was passed. +# MUTATION: Ignoring the kwargs and using defaults would fail. +# ESCAPE: Nothing reasonable -- exact dict/str equality. +def test_assert_request_with_explicit_headers_and_body() -> None: + v, p = _make_verifier_with_plugin() + builder = p.assert_request( + "POST", + "https://example.com/submit", + headers={"Authorization": "Bearer tok"}, + body='{"key": "val"}', + ) + assert builder._headers == {"Authorization": "Bearer tok"} + assert builder._body == '{"key": "val"}' + + +# ESCAPE: test_assert_response_calls_assert_interaction_with_all_seven_fields +# CLAIM: HttpAssertionBuilder.assert_response() calls verifier.assert_interaction() +# with all seven fields (method, url, request_headers, request_body, status, +# response_headers, response_body). +# PATH: assert_response() -> verifier.assert_interaction(sentinel, **all_seven). +# CHECK: Full interaction is found in timeline after a real mock request. +# MUTATION: Omitting any field from assert_interaction call leaves it unasserted. +# ESCAPE: Verifier raises if any required field is missing from the expected dict. +def test_assert_response_calls_assert_interaction_with_all_seven_fields() -> None: + v, p = _make_verifier_with_plugin() + p.mock_response( + "GET", + "https://api.example.com/data", + json={"key": "value"}, + status=200, + headers={"content-type": "application/json"}, + ) + + with v.sandbox(): + httpx.get("https://api.example.com/data") + + # Capture actual recorded headers so we can assert them exactly + interactions = v._timeline.all_unasserted() + assert len(interactions) == 1 + recorded_request_headers = interactions[0].details["request_headers"] + + # Use the builder to assert all seven fields + p.assert_request( + "GET", + "https://api.example.com/data", + headers=recorded_request_headers, + ).assert_response( + status=200, + headers={"content-type": "application/json"}, + body='{"key": "value"}', + ) + + # All interactions asserted -- verify_all must not raise + v.verify_all() + + +# ESCAPE: test_assert_response_is_terminal_marks_interaction_asserted +# CLAIM: After assert_response(), the interaction is marked asserted on the timeline. +# PATH: assert_response() -> assert_interaction() -> timeline.mark_asserted(). +# CHECK: v._timeline.all_unasserted() is empty after assert_response(). +# MUTATION: Not calling assert_interaction() leaves interaction unasserted. +# ESCAPE: Nothing reasonable -- empty list check is definitive. +def test_assert_response_is_terminal_marks_interaction_asserted() -> None: + v, p = _make_verifier_with_plugin() + p.mock_response("POST", "https://api.example.com/create", json={"id": 1}, status=201) + + with v.sandbox(): + httpx.post("https://api.example.com/create", json={"payload": "x"}) + + interactions = v._timeline.all_unasserted() + assert len(interactions) == 1 + + # Capture the actual recorded fields to use in assertion + recorded = interactions[0].details + + p.assert_request( + "POST", + "https://api.example.com/create", + headers=recorded["request_headers"], + body=recorded["request_body"], + ).assert_response( + status=201, + headers={"content-type": "application/json"}, + body='{"id": 1}', + ) + + assert len(v._timeline.all_unasserted()) == 0 + + +# ESCAPE: test_assert_request_lazy_does_not_touch_timeline +# CLAIM: Calling assert_request() alone (without assert_response()) does not +# modify the timeline. +# PATH: assert_request() only stores fields; timeline is untouched until +# assert_response() is called. +# CHECK: all_unasserted() still contains the interaction after assert_request(). +# MUTATION: If assert_request() touches the timeline the interaction would disappear. +# ESCAPE: Nothing reasonable -- count check is definitive. +def test_assert_request_lazy_does_not_touch_timeline() -> None: + v, p = _make_verifier_with_plugin() + p.mock_response("GET", "https://api.example.com/lazy", json={"lazy": True}) + + with v.sandbox(): + httpx.get("https://api.example.com/lazy") + + # Call assert_request but NOT assert_response + p.assert_request("GET", "https://api.example.com/lazy") + + # Timeline interaction should still be unasserted + assert len(v._timeline.all_unasserted()) == 1 diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 3fc93c7..f5050ec 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -30,6 +30,7 @@ def test_all_contains_expected_names() -> None: # Errors "BigfootError", "AssertionInsideSandboxError", + "AutoAssertError", "InvalidStateError", "NoActiveVerifierError", "UnmockedInteractionError", diff --git a/tests/unit/test_popen_plugin.py b/tests/unit/test_popen_plugin.py index c9a4a6e..3c662fa 100644 --- a/tests/unit/test_popen_plugin.py +++ b/tests/unit/test_popen_plugin.py @@ -71,24 +71,21 @@ def test_initial_state() -> None: def test_transitions_structure() -> None: v, p = _make_verifier_with_plugin() assert p._transitions() == { - "init": {"created": "running"}, - "stdin.write": {"running": "running"}, - "stdout.read": {"running": "running"}, - "stderr.read": {"running": "running"}, + "spawn": {"created": "running"}, "communicate": {"running": "terminated"}, "wait": {"running": "terminated"}, } # ESCAPE: test_unmocked_source_id -# CLAIM: _unmocked_source_id() returns "subprocess:popen:init". +# CLAIM: _unmocked_source_id() returns "subprocess:popen:spawn". # PATH: Direct call on plugin instance. -# CHECK: result == "subprocess:popen:init". +# CHECK: result == "subprocess:popen:spawn". # MUTATION: Returning a different string fails the equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_unmocked_source_id() -> None: v, p = _make_verifier_with_plugin() - assert p._unmocked_source_id() == "subprocess:popen:init" + assert p._unmocked_source_id() == "subprocess:popen:spawn" # --------------------------------------------------------------------------- @@ -147,26 +144,28 @@ def test_reference_counting_nested() -> None: # --------------------------------------------------------------------------- -# Basic subprocess.Popen() call: init step +# Basic subprocess.Popen() call: spawn step # --------------------------------------------------------------------------- -# ESCAPE: test_popen_init_step_consumed -# CLAIM: subprocess.Popen(["cmd"]) inside a sandbox consumes the "init" step and +# ESCAPE: test_popen_spawn_step_consumed +# CLAIM: subprocess.Popen(["cmd"]) inside a sandbox consumes the "spawn" step and # returns a _FakePopen instance. # PATH: sandbox -> activate -> _FakePopen.__init__ -> _bind_connection -> -# _execute_step(handle, "init", ...) -> step consumed -> state = "running". -# CHECK: result is an instance of _FakePopen; handle state is "running" after init. -# MUTATION: Not consuming the "init" step leaves it in _script; state stays "created". +# _execute_step(handle, "spawn", ...) -> step consumed -> state = "running". +# CHECK: result is an instance of _FakePopen; handle state is "running" after spawn. +# MUTATION: Not consuming the "spawn" step leaves it in _script; state stays "created". # ESCAPE: Returning an instance that is not _FakePopen would fail the isinstance check. -def test_popen_init_step_consumed() -> None: +def test_popen_spawn_step_consumed() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() - session.expect("init", returns=None) + session.expect("spawn", returns=None) with v.sandbox(): proc = subprocess.Popen(["ls", "-la"]) + v.assert_interaction(p.spawn, command=["ls", "-la"], stdin=None) + assert type(proc).__name__ == "_FakePopen" assert proc.pid == 12345 # Session should be in "running" state; check via _active_sessions @@ -176,87 +175,84 @@ def test_popen_init_step_consumed() -> None: # --------------------------------------------------------------------------- -# stdin.write() step +# stdin.write() -- no-op, not recorded on timeline # --------------------------------------------------------------------------- -# ESCAPE: test_stdin_write_step -# CLAIM: proc.stdin.write(b"data") inside a sandbox consumes the "stdin.write" step -# and returns the configured value. -# PATH: _FakePopen.__init__ -> init step consumed; proc.stdin.write -> _execute_step -# (handle, "stdin.write", ...) -> returns configured value. -# CHECK: write_result == 5 (the configured return value); state stays "running". -# MUTATION: Returning wrong value (e.g., None) fails the equality check. -# ESCAPE: Returning 4 instead of 5 fails the equality check. -def test_stdin_write_step() -> None: +# ESCAPE: test_stdin_write_noop +# CLAIM: proc.stdin.write(b"data") returns 0 and does NOT consume any script step. +# PATH: _FakeStream.write() -> returns 0 directly, no _execute_step call. +# CHECK: write_result == 0; state stays "running" (no additional step consumed). +# MUTATION: Returning a different value (e.g., 5) fails the equality check. +# ESCAPE: Nothing reasonable -- exact integer equality. +def test_stdin_write_noop() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() - session.expect("init", returns=None) - session.expect("stdin.write", returns=5) + session.expect("spawn", returns=None) with v.sandbox(): proc = subprocess.Popen(["cmd"], stdin=subprocess.PIPE) write_result = proc.stdin.write(b"hello") - assert write_result == 5 + v.assert_interaction(p.spawn, command=["cmd"], stdin=None) + + assert write_result == 0 assert len(p._active_sessions) == 1 handle = list(p._active_sessions.values())[0] assert handle._state == "running" # --------------------------------------------------------------------------- -# stdout.read() step +# stdout.read() -- no-op, not recorded on timeline # --------------------------------------------------------------------------- -# ESCAPE: test_stdout_read_step -# CLAIM: proc.stdout.read() inside a sandbox consumes the "stdout.read" step -# and returns the configured bytes. -# PATH: _FakePopen.__init__ -> init step consumed; proc.stdout.read -> _execute_step -# (handle, "stdout.read", ...) -> returns configured value. -# CHECK: read_result == b"output data"; state stays "running". -# MUTATION: Returning b"wrong" instead of b"output data" fails the equality check. +# ESCAPE: test_stdout_read_noop +# CLAIM: proc.stdout.read() returns b"" and does NOT consume any script step. +# PATH: _FakeStream.read() -> returns b"" directly, no _execute_step call. +# CHECK: read_result == b""; state stays "running". +# MUTATION: Returning b"something" instead fails the equality check. # ESCAPE: Nothing reasonable -- exact bytes equality. -def test_stdout_read_step() -> None: +def test_stdout_read_noop() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() - session.expect("init", returns=None) - session.expect("stdout.read", returns=b"output data") + session.expect("spawn", returns=None) with v.sandbox(): proc = subprocess.Popen(["cmd"], stdout=subprocess.PIPE) read_result = proc.stdout.read() - assert read_result == b"output data" + v.assert_interaction(p.spawn, command=["cmd"], stdin=None) + + assert read_result == b"" assert len(p._active_sessions) == 1 handle = list(p._active_sessions.values())[0] assert handle._state == "running" # --------------------------------------------------------------------------- -# stderr.read() step +# stderr.read() -- no-op, not recorded on timeline # --------------------------------------------------------------------------- -# ESCAPE: test_stderr_read_step -# CLAIM: proc.stderr.read() inside a sandbox consumes the "stderr.read" step -# and returns the configured bytes. -# PATH: _FakePopen.__init__ -> init step; proc.stderr.read -> _execute_step -# (handle, "stderr.read", ...) -> returns configured value. -# CHECK: read_result == b"error output"; state stays "running". +# ESCAPE: test_stderr_read_noop +# CLAIM: proc.stderr.read() returns b"" and does NOT consume any script step. +# PATH: _FakeStream.read() -> returns b"" directly, no _execute_step call. +# CHECK: read_result == b""; state stays "running". # MUTATION: Returning b"other error" instead fails the equality check. # ESCAPE: Nothing reasonable -- exact bytes equality. -def test_stderr_read_step() -> None: +def test_stderr_read_noop() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() - session.expect("init", returns=None) - session.expect("stderr.read", returns=b"error output") + session.expect("spawn", returns=None) with v.sandbox(): proc = subprocess.Popen(["cmd"], stderr=subprocess.PIPE) read_result = proc.stderr.read() - assert read_result == b"error output" + v.assert_interaction(p.spawn, command=["cmd"], stdin=None) + + assert read_result == b"" assert len(p._active_sessions) == 1 handle = list(p._active_sessions.values())[0] assert handle._state == "running" @@ -271,7 +267,7 @@ def test_stderr_read_step() -> None: # CLAIM: proc.communicate() inside a sandbox consumes the "communicate" step, # returns (stdout, stderr) tuple, sets proc.returncode, and transitions # state to "terminated". -# PATH: _FakePopen.__init__ -> init step; communicate -> _execute_step +# PATH: _FakePopen.__init__ -> spawn step; communicate -> _execute_step # (handle, "communicate", ...) -> 3-tuple (stdout, stderr, returncode) -> # proc.returncode set; state = "terminated". # CHECK: stdout == b"out"; stderr == b"err"; proc.returncode == 0; @@ -281,13 +277,16 @@ def test_stderr_read_step() -> None: def test_communicate_step() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() - session.expect("init", returns=None) + session.expect("spawn", returns=None) session.expect("communicate", returns=(b"out", b"err", 0)) with v.sandbox(): proc = subprocess.Popen(["cmd"]) stdout, stderr = proc.communicate() + v.assert_interaction(p.spawn, command=["cmd"], stdin=None) + v.assert_interaction(p.communicate, input=None) + assert stdout == b"out" assert stderr == b"err" assert proc.returncode == 0 @@ -305,13 +304,16 @@ def test_communicate_step() -> None: def test_communicate_nonzero_returncode() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() - session.expect("init", returns=None) + session.expect("spawn", returns=None) session.expect("communicate", returns=(b"", b"fail output", 1)) with v.sandbox(): proc = subprocess.Popen(["cmd"]) stdout, stderr = proc.communicate() + v.assert_interaction(p.spawn, command=["cmd"], stdin=None) + v.assert_interaction(p.communicate, input=None) + assert stdout == b"" assert stderr == b"fail output" assert proc.returncode == 1 @@ -327,7 +329,7 @@ def test_communicate_nonzero_returncode() -> None: # configured returncode int, sets proc.returncode, and transitions state # to "terminated". The session remains in _active_sessions (wait() is # idempotent and does not release the session). -# PATH: _FakePopen.__init__ -> init step; wait -> _execute_step +# PATH: _FakePopen.__init__ -> spawn step; wait -> _execute_step # (handle, "wait", ...) -> int returncode -> proc.returncode set -> # state = "terminated". # CHECK: wait_result == 42; proc.returncode == 42; session state == "terminated". @@ -336,13 +338,16 @@ def test_communicate_nonzero_returncode() -> None: def test_wait_step() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() - session.expect("init", returns=None) + session.expect("spawn", returns=None) session.expect("wait", returns=42) with v.sandbox(): proc = subprocess.Popen(["cmd"]) wait_result = proc.wait() + v.assert_interaction(p.spawn, command=["cmd"], stdin=None) + v.assert_interaction(p.wait) + assert wait_result == 42 assert proc.returncode == 42 handle = list(p._active_sessions.values())[0] @@ -362,7 +367,7 @@ def test_wait_step() -> None: def test_wait_is_idempotent() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() - session.expect("init", returns=None) + session.expect("spawn", returns=None) session.expect("wait", returns=7) with v.sandbox(): @@ -371,6 +376,9 @@ def test_wait_is_idempotent() -> None: second = proc.wait() third = proc.wait() + v.assert_interaction(p.spawn, command=["cmd"], stdin=None) + v.assert_interaction(p.wait) + assert first == 7 assert second == 7 assert third == 7 @@ -394,7 +402,7 @@ def test_wait_is_idempotent() -> None: def test_poll_returns_returncode_without_consuming_step() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() - session.expect("init", returns=None) + session.expect("spawn", returns=None) session.expect("communicate", returns=(b"", b"", 0)) with v.sandbox(): @@ -403,6 +411,9 @@ def test_poll_returns_returncode_without_consuming_step() -> None: proc.communicate() assert proc.poll() == 0 # returncode set by communicate + v.assert_interaction(p.spawn, command=["cmd"], stdin=None) + v.assert_interaction(p.communicate, input=None) + # --------------------------------------------------------------------------- # pid attribute @@ -418,11 +429,13 @@ def test_poll_returns_returncode_without_consuming_step() -> None: def test_fake_popen_pid_attribute() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() - session.expect("init", returns=None) + session.expect("spawn", returns=None) with v.sandbox(): proc = subprocess.Popen(["cmd"]) + v.assert_interaction(p.spawn, command=["cmd"], stdin=None) + assert proc.pid == 12345 @@ -443,7 +456,7 @@ def test_fake_popen_pid_attribute() -> None: def test_communicate_twice_raises_invalid_state() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() - session.expect("init", returns=None) + session.expect("spawn", returns=None) session.expect("communicate", returns=(b"out", b"", 0)) # Second communicate: no step registered (irrelevant -- InvalidStateError fires first) @@ -453,6 +466,9 @@ def test_communicate_twice_raises_invalid_state() -> None: with pytest.raises(InvalidStateError) as exc_info: proc.communicate() + v.assert_interaction(p.spawn, command=["cmd"], stdin=None) + v.assert_interaction(p.communicate, input=None) + exc = exc_info.value assert exc.method == "communicate" assert exc.current_state == "terminated" @@ -465,9 +481,9 @@ def test_communicate_twice_raises_invalid_state() -> None: # ESCAPE: test_get_unused_mocks_unconsumed_steps -# CLAIM: When two steps are expected but only "init" is consumed (no communicate/wait), +# CLAIM: When two steps are expected but only "spawn" is consumed (no communicate/wait), # get_unused_mocks() returns the one unconsumed required step. -# PATH: new_session with two steps -> init consumed -> session in _active_sessions +# PATH: new_session with two steps -> spawn consumed -> session in _active_sessions # with one remaining required step -> get_unused_mocks() returns it. # CHECK: len(unused) == 1; unused[0].method == "communicate". # MUTATION: Not scanning _active_sessions for remaining steps would return []. @@ -475,13 +491,14 @@ def test_communicate_twice_raises_invalid_state() -> None: def test_get_unused_mocks_unconsumed_steps() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() - session.expect("init", returns=None) + session.expect("spawn", returns=None) session.expect("communicate", returns=(b"", b"", 0)) # will NOT be consumed with v.sandbox(): subprocess.Popen(["cmd"]) # deliberately NOT calling communicate or wait + v.assert_interaction(p.spawn, command=["cmd"], stdin=None) unused: list[ScriptStep] = p.get_unused_mocks() assert len(unused) == 1 assert unused[0].method == "communicate" @@ -492,19 +509,19 @@ def test_get_unused_mocks_unconsumed_steps() -> None: # steps returned by get_unused_mocks(). # PATH: new_session with two steps enqueued -> no Popen() call -> _session_queue # still holds handle -> get_unused_mocks() iterates _session_queue. -# CHECK: len(unused) == 2; methods are ["init", "communicate"] in order. +# CHECK: len(unused) == 2; methods are ["spawn", "communicate"] in order. # MUTATION: Not iterating _session_queue would return []. # ESCAPE: Returning items in LIFO order would fail the method ordering check. def test_get_unused_mocks_queued_session_never_bound() -> None: v, p = _make_verifier_with_plugin() session = p.new_session() - session.expect("init", returns=None) + session.expect("spawn", returns=None) session.expect("communicate", returns=(b"", b"", 0)) # Never call Popen; the session stays in the queue unused: list[ScriptStep] = p.get_unused_mocks() assert len(unused) == 2 - assert unused[0].method == "init" + assert unused[0].method == "spawn" assert unused[1].method == "communicate" @@ -515,10 +532,10 @@ def test_get_unused_mocks_queued_session_never_bound() -> None: # ESCAPE: test_popen_with_empty_queue_raises_unmocked # CLAIM: If no session is queued when subprocess.Popen() fires, UnmockedInteractionError -# is raised with source_id == "subprocess:popen:init". +# is raised with source_id == "subprocess:popen:spawn". # PATH: _FakePopen.__init__ -> _bind_connection -> queue empty -> -# UnmockedInteractionError(source_id="subprocess:popen:init"). -# CHECK: UnmockedInteractionError raised; exc.source_id == "subprocess:popen:init". +# UnmockedInteractionError(source_id="subprocess:popen:spawn"). +# CHECK: UnmockedInteractionError raised; exc.source_id == "subprocess:popen:spawn". # MUTATION: Returning a dummy session for empty queue would not raise. # ESCAPE: Raising with wrong source_id fails the source_id check. def test_popen_with_empty_queue_raises_unmocked() -> None: @@ -529,7 +546,7 @@ def test_popen_with_empty_queue_raises_unmocked() -> None: with pytest.raises(UnmockedInteractionError) as exc_info: subprocess.Popen(["cmd"]) - assert exc_info.value.source_id == "subprocess:popen:init" + assert exc_info.value.source_id == "subprocess:popen:spawn" # --------------------------------------------------------------------------- @@ -550,7 +567,7 @@ def test_popen_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) -> None: session = bigfoot.popen_mock.new_session() assert isinstance(session, SessionHandle) - result = session.expect("init", returns=None, required=False) + result = session.expect("spawn", returns=None, required=False) assert result is session # expect() returns self for chaining @@ -670,7 +687,7 @@ def test_conflict_error_popen_already_patched() -> None: # ESCAPE: test_full_session_via_sandbox -# CLAIM: A complete Popen session (init -> communicate) runs end-to-end through +# CLAIM: A complete Popen session (spawn -> communicate) runs end-to-end through # the module-level bigfoot.sandbox() API, returning the scripted values. # PATH: bigfoot.popen_mock.new_session() -> sandbox -> _FakePopen.__init__ -> communicate. # CHECK: stdout == b"build output"; stderr == b""; proc.returncode == 0. @@ -678,13 +695,16 @@ def test_conflict_error_popen_already_patched() -> None: # ESCAPE: Nothing reasonable -- exact bytes equality on all three fields. def test_full_session_via_sandbox(bigfoot_verifier: StrictVerifier) -> None: session = bigfoot.popen_mock.new_session() - session.expect("init", returns=None) + session.expect("spawn", returns=None) session.expect("communicate", returns=(b"build output", b"", 0)) with bigfoot.sandbox(): proc = subprocess.Popen(["make", "all"]) stdout, stderr = proc.communicate() + bigfoot.popen_mock.assert_spawn(command=["make", "all"], stdin=None) + bigfoot.popen_mock.assert_communicate(input=None) + assert stdout == b"build output" assert stderr == b"" assert proc.returncode == 0 diff --git a/tests/unit/test_redis_plugin.py b/tests/unit/test_redis_plugin.py index 4242cdd..5471f88 100644 --- a/tests/unit/test_redis_plugin.py +++ b/tests/unit/test_redis_plugin.py @@ -5,7 +5,7 @@ import pytest from bigfoot._context import _current_test_verifier -from bigfoot._errors import UnmockedInteractionError +from bigfoot._errors import InteractionMismatchError, UnmockedInteractionError from bigfoot._verifier import StrictVerifier redis = pytest.importorskip("redis") @@ -388,33 +388,44 @@ def test_unmocked_error_after_queue_exhausted() -> None: # --------------------------------------------------------------------------- -# ESCAPE: test_matches_always_true -# CLAIM: matches() always returns True regardless of interaction or expected. -# PATH: matches(interaction, expected) -> True. -# CHECK: result is True. -# MUTATION: Returning False always fails the check. -# ESCAPE: Nothing reasonable -- exact boolean. -def test_matches_always_true() -> None: +# ESCAPE: test_matches_field_comparison +# CLAIM: matches() does field-by-field comparison; returns True when fields match, False otherwise. +# PATH: matches(interaction, expected) -> compare each expected key against details. +# CHECK: Empty expected matches anything; non-matching field returns False; matching field True. +# MUTATION: Returning True always fails the non-matching field check. +# ESCAPE: Nothing reasonable -- exact boolean equality on distinct cases. +def test_matches_field_comparison() -> None: from bigfoot._timeline import Interaction v, p = _make_verifier_with_plugin() - interaction = Interaction(source_id="redis:get", sequence=0, details={}, plugin=p) + interaction = Interaction( + source_id="redis:get", + sequence=0, + details={"command": "GET", "args": ("mykey",), "kwargs": {}}, + plugin=p, + ) + # Empty expected matches any interaction assert p.matches(interaction, {}) is True - assert p.matches(interaction, {"foo": "bar"}) is True - - -# ESCAPE: test_assertable_fields_empty -# CLAIM: assertable_fields() returns frozenset(). -# PATH: assertable_fields(interaction) -> frozenset(). -# CHECK: result == frozenset(). -# MUTATION: Returning frozenset({"command"}) fails the equality check. + # Field that matches returns True + assert p.matches(interaction, {"command": "GET"}) is True + # Field that does not match returns False + assert p.matches(interaction, {"command": "SET"}) is False + # Field not present in details returns False + assert p.matches(interaction, {"foo": "bar"}) is False + + +# ESCAPE: test_assertable_fields_all_three +# CLAIM: assertable_fields() returns frozenset({"command", "args", "kwargs"}). +# PATH: assertable_fields(interaction) -> frozenset({"command", "args", "kwargs"}). +# CHECK: result == frozenset({"command", "args", "kwargs"}). +# MUTATION: Returning frozenset() skips completeness enforcement entirely. # ESCAPE: Nothing reasonable -- exact equality. -def test_assertable_fields_empty() -> None: +def test_assertable_fields_all_three() -> None: from bigfoot._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction(source_id="redis:get", sequence=0, details={}, plugin=p) - assert p.assertable_fields(interaction) == frozenset() + assert p.assertable_fields(interaction) == frozenset({"command", "args", "kwargs"}) # --------------------------------------------------------------------------- @@ -499,8 +510,8 @@ def test_format_unmocked_hint() -> None: # ESCAPE: test_format_assert_hint -# CLAIM: format_assert_hint returns copy-pasteable comment for an auto-asserted interaction. -# PATH: format_assert_hint(interaction) -> string. +# CLAIM: format_assert_hint returns assert_command() syntax with all three fields. +# PATH: format_assert_hint(interaction) -> string with assert_command syntax. # CHECK: result == exact expected string. # MUTATION: Wrong hint text fails equality check. # ESCAPE: Different format fails the equality check. @@ -511,11 +522,17 @@ def test_format_assert_hint() -> None: interaction = Interaction( source_id="redis:get", sequence=0, - details={"command": "GET"}, + details={"command": "GET", "args": ("mykey",), "kwargs": {}}, plugin=p, ) result = p.format_assert_hint(interaction) - assert result == (" # bigfoot.redis_mock: command 'GET' recorded (stateless, auto-asserted)") + assert result == ( + " bigfoot.redis_mock.assert_command(\n" + " command='GET',\n" + " args=('mykey',),\n" + " kwargs={},\n" + " )" + ) # ESCAPE: test_format_unused_mock_hint @@ -556,6 +573,7 @@ def test_redis_mock_proxy_mock_command(bigfoot_verifier: StrictVerifier) -> None result = r.execute_command("GET", "somekey") assert result == "proxy_value" + bigfoot.redis_mock.assert_command("GET", args=("somekey",), kwargs={}) # ESCAPE: test_redis_mock_proxy_raises_outside_context @@ -593,3 +611,50 @@ def test_redis_plugin_in_all() -> None: assert bigfoot.RedisPlugin is _RedisPlugin assert type(bigfoot.redis_mock).__name__ == "_RedisProxy" + + +# --------------------------------------------------------------------------- +# New tests: no auto-assert, assert_command() typed helper +# --------------------------------------------------------------------------- + + +def test_redis_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) -> None: + """Redis interactions are NOT auto-asserted — they land on the timeline unasserted.""" + import bigfoot + + bigfoot.redis_mock.mock_command("GET", returns=b"value") + with bigfoot.sandbox(): + client = redis.Redis() + client.execute_command("GET", "key") + # At this point the interaction is on the timeline but NOT asserted + timeline = bigfoot_verifier._timeline + interactions = timeline.all_unasserted() + assert len(interactions) == 1 + assert interactions[0].source_id == "redis:get" + # Assert it so verify_all() at teardown succeeds + bigfoot.redis_mock.assert_command("GET", args=("key",), kwargs={}) + + +def test_assert_command_typed_helper(bigfoot_verifier: StrictVerifier) -> None: + """assert_command() asserts the next Redis interaction.""" + import bigfoot + + bigfoot.redis_mock.mock_command("SET", returns=True) + with bigfoot.sandbox(): + client = redis.Redis() + client.execute_command("SET", "key", "value") + bigfoot.redis_mock.assert_command("SET", args=("key", "value"), kwargs={}) + + +def test_assert_command_wrong_args_raises(bigfoot_verifier: StrictVerifier) -> None: + """assert_command() with wrong args raises InteractionMismatchError.""" + import bigfoot + + bigfoot.redis_mock.mock_command("GET", returns=b"val") + with bigfoot.sandbox(): + client = redis.Redis() + client.execute_command("GET", "key") + with pytest.raises(InteractionMismatchError): + bigfoot.redis_mock.assert_command("GET", args=("wrong_key",), kwargs={}) + # Now assert correctly so teardown passes + bigfoot.redis_mock.assert_command("GET", args=("key",), kwargs={}) diff --git a/tests/unit/test_smtp_plugin.py b/tests/unit/test_smtp_plugin.py index 19eef9e..07b5ba0 100644 --- a/tests/unit/test_smtp_plugin.py +++ b/tests/unit/test_smtp_plugin.py @@ -546,3 +546,12 @@ def test_full_session_via_sandbox(bigfoot_verifier: StrictVerifier) -> None: assert sendmail_result == {} assert quit_result == (221, b"Bye") + + bigfoot.smtp_mock.assert_connect(host="mail.example.com", port=25) + bigfoot.smtp_mock.assert_ehlo(name="") + bigfoot.smtp_mock.assert_sendmail( + from_addr="from@example.com", + to_addrs=["to@example.com"], + msg="Subject: test\r\n\r\ntest", + ) + bigfoot.smtp_mock.assert_quit() diff --git a/tests/unit/test_socket_plugin.py b/tests/unit/test_socket_plugin.py index 9df8af5..63b6872 100644 --- a/tests/unit/test_socket_plugin.py +++ b/tests/unit/test_socket_plugin.py @@ -210,6 +210,11 @@ def test_basic_connect_send_recv_close() -> None: assert recv_result == b"pong" assert close_result is None + v.assert_interaction(p.connect, host="127.0.0.1", port=9999) + v.assert_interaction(p.send, data=b"ping") + v.assert_interaction(p.recv, size=1024, data=b"pong") + v.assert_interaction(p.close) + # --------------------------------------------------------------------------- # InvalidStateError: recv before connect @@ -298,6 +303,13 @@ def test_fifo_two_sessions() -> None: assert first_recv_result == b"first" assert second_recv_result == b"second" + v.assert_interaction(p.connect, host="127.0.0.1", port=9999) + v.assert_interaction(p.connect, host="127.0.0.1", port=9998) + v.assert_interaction(p.recv, size=1024, data=b"first") + v.assert_interaction(p.recv, size=1024, data=b"second") + v.assert_interaction(p.close) + v.assert_interaction(p.close) + # --------------------------------------------------------------------------- # get_unused_mocks: unconsumed required steps @@ -324,6 +336,8 @@ def test_get_unused_mocks_returns_unconsumed_steps() -> None: sock.connect(("127.0.0.1", 9999)) # deliberately NOT calling recv or close + v.assert_interaction(p.connect, host="127.0.0.1", port=9999) + unused: list[ScriptStep] = p.get_unused_mocks() assert len(unused) == 1 assert unused[0].method == "recv" @@ -444,6 +458,10 @@ def test_sendall_step() -> None: assert sendall_result is None + v.assert_interaction(p.connect, host="127.0.0.1", port=9999) + v.assert_interaction(p.sendall, data=b"hello world") + v.assert_interaction(p.close) + # --------------------------------------------------------------------------- # close() releases session @@ -469,5 +487,8 @@ def test_close_releases_session() -> None: sock.connect(("127.0.0.1", 9999)) sock.close() + v.assert_interaction(p.connect, host="127.0.0.1", port=9999) + v.assert_interaction(p.close) + assert len(p._active_sessions) == 0 assert p.get_unused_mocks() == [] diff --git a/tests/unit/test_state_machine_plugin.py b/tests/unit/test_state_machine_plugin.py index e6e5b63..d0c9d61 100644 --- a/tests/unit/test_state_machine_plugin.py +++ b/tests/unit/test_state_machine_plugin.py @@ -66,6 +66,9 @@ def format_assert_hint(self, interaction: Interaction) -> str: def format_unused_mock_hint(self, mock_config: object) -> str: return "Unused mock" + def matches(self, interaction: "Interaction", expected: dict[str, Any]) -> bool: + return True + # --------------------------------------------------------------------------- # Helpers @@ -730,16 +733,19 @@ def test_execute_step_records_interaction_on_timeline() -> None: assert all_interactions[0].source_id == "test:source" -def test_execute_step_auto_marks_interaction_asserted() -> None: - """_execute_step() immediately marks the recorded interaction as asserted.""" +def test_execute_step_does_not_auto_mark_interaction_asserted() -> None: + """_execute_step() does NOT auto-mark the recorded interaction as asserted. + + Test authors must call assert_interaction() explicitly. Auto-assert is prohibited. + """ # ESCAPE: - # CLAIM: The interaction recorded by _execute_step() has _asserted=True. - # PATH: _execute_step() calls self.verifier._timeline.mark_asserted(interaction). - # CHECK: interaction._asserted is True. - # MUTATION: Not calling mark_asserted() would leave _asserted=False, causing - # UnassertedInteractionsError at teardown. + # CLAIM: The interaction recorded by _execute_step() has _asserted=False. + # PATH: _execute_step() calls self.record() but NOT mark_asserted(). + # CHECK: interaction._asserted is False. + # MUTATION: Adding a mark_asserted() call would set _asserted=True, defeating bigfoot's + # certainty guarantee. # ESCAPE: Nothing reasonable. - # IMPACT: Every test using StateMachinePlugin would fail with UnassertedInteractionsError. + # IMPACT: Test authors could no longer trust that unasserted interactions cause test failures. v = _make_verifier() plugin = _TestPlugin(v) conn = _make_connection_obj() @@ -750,7 +756,7 @@ def test_execute_step_auto_marks_interaction_asserted() -> None: plugin._execute_step(handle, "go", (), {}, "test:source") interaction = v._timeline._interactions[0] - assert interaction._asserted is True + assert interaction._asserted is False def test_execute_step_raises_configured_exception() -> None: @@ -835,16 +841,23 @@ def test_execute_step_raises_unmocked_when_script_empty() -> None: plugin._execute_step(handle, "go", (), {}, "test:source") -def test_execute_step_auto_mark_prevents_unasserted_error_at_teardown() -> None: - """verify_all() does NOT raise UnassertedInteractionsError after _execute_step().""" +def test_execute_step_unasserted_interaction_raises_at_teardown() -> None: + """verify_all() raises UnassertedInteractionsError when _execute_step() interactions are not asserted. + + Auto-assert is prohibited. Test authors must call assert_interaction() explicitly. + Without explicit assertions, verify_all() correctly detects unasserted interactions. + """ # ESCAPE: - # CLAIM: The interaction recorded by _execute_step() is marked asserted, so - # verify_all() does not raise UnassertedInteractionsError. - # PATH: _execute_step() records + marks asserted; verify_all() finds no unasserted interactions. - # CHECK: verify_all() does not raise. - # MUTATION: Not calling mark_asserted() would leave _asserted=False, causing the raise. + # CLAIM: verify_all() raises UnassertedInteractionsError after _execute_step() when + # no assert_interaction() call is made. + # PATH: _execute_step() records but does NOT mark asserted; verify_all() finds + # unasserted interactions and raises. + # CHECK: pytest.raises(UnassertedInteractionsError) confirms the error fires. + # MUTATION: Adding mark_asserted() back to _execute_step() would suppress the error. # ESCAPE: Nothing reasonable. - # IMPACT: Every test using StateMachinePlugin would fail at teardown. + # IMPACT: Tests that forgot assert_interaction() would silently pass instead of failing. + from bigfoot._errors import UnassertedInteractionsError + v = _make_verifier() plugin = _TestPlugin(v) conn = _make_connection_obj() @@ -854,9 +867,9 @@ def test_execute_step_auto_mark_prevents_unasserted_error_at_teardown() -> None: plugin._execute_step(handle, "go", (), {}, "test:source") - # Should not raise — the only interaction was auto-marked asserted, - # and the step was consumed so get_unused_mocks() returns []. - v.verify_all() + # Must raise — the interaction was recorded but NOT asserted. + with pytest.raises(UnassertedInteractionsError): + v.verify_all() def test_execute_step_sequential_fifo_order() -> None: diff --git a/tests/unit/test_subprocess_plugin.py b/tests/unit/test_subprocess_plugin.py index 4867e20..a5f245c 100644 --- a/tests/unit/test_subprocess_plugin.py +++ b/tests/unit/test_subprocess_plugin.py @@ -158,7 +158,7 @@ def test_mock_run_returns_completed_process() -> None: assert result.args == ["echo", "hello"] # Assert the interaction was recorded; use verifier directly to avoid unasserted error - v.assert_interaction(p.run, command=["echo", "hello"]) + v.assert_interaction(p.run, command=["echo", "hello"], returncode=0, stdout="hello", stderr="") # ESCAPE: test_mock_run_fifo_order @@ -184,8 +184,8 @@ def test_mock_run_fifo_order() -> None: assert result2.returncode == 0 assert result2.stdout == "abc123" - v.assert_interaction(p.run, command=["git", "status"]) - v.assert_interaction(p.run, command=["git", "log"]) + v.assert_interaction(p.run, command=["git", "status"], returncode=0, stdout="nothing to commit", stderr="") + v.assert_interaction(p.run, command=["git", "log"], returncode=0, stdout="abc123", stderr="") # ESCAPE: test_mock_run_command_mismatch_raises @@ -274,7 +274,7 @@ def test_mock_run_in_sandbox(bigfoot_verifier: StrictVerifier) -> None: assert result.stderr == "" assert result.args == ["make", "build"] - bigfoot.assert_interaction(bigfoot.subprocess_mock.run, command=["make", "build"]) + bigfoot.assert_interaction(bigfoot.subprocess_mock.run, command=["make", "build"], returncode=0, stdout="ok", stderr="") # ESCAPE: test_unregistered_run_in_sandbox_raises @@ -341,7 +341,7 @@ def test_mock_which_registered_returns_path() -> None: assert result == "/usr/bin/git" - v.assert_interaction(p.which, name="git") + v.assert_interaction(p.which, name="git", returns="/usr/bin/git") # ESCAPE: test_mock_which_unregistered_returns_none @@ -408,7 +408,7 @@ def test_assert_interaction_run(bigfoot_verifier: StrictVerifier) -> None: subprocess.run(["pytest", "--tb=short"]) # Must not raise - bigfoot.assert_interaction(bigfoot.subprocess_mock.run, command=["pytest", "--tb=short"]) + bigfoot.assert_interaction(bigfoot.subprocess_mock.run, command=["pytest", "--tb=short"], returncode=0, stdout="passed", stderr="") # ESCAPE: test_assert_interaction_which @@ -425,7 +425,7 @@ def test_assert_interaction_which(bigfoot_verifier: StrictVerifier) -> None: shutil.which("python3") # Must not raise - bigfoot.assert_interaction(bigfoot.subprocess_mock.which, name="python3") + bigfoot.assert_interaction(bigfoot.subprocess_mock.which, name="python3", returns="/usr/bin/python3") # --------------------------------------------------------------------------- @@ -503,32 +503,31 @@ def test_subprocess_mock_proxy_raises_outside_sandbox() -> None: # ESCAPE: test_assertable_fields_run -# CLAIM: SubprocessPlugin.assertable_fields(interaction) returns frozenset({"command"}) +# CLAIM: SubprocessPlugin.assertable_fields(interaction) returns all four run fields # when interaction.source_id == "subprocess:run". -# PATH: assertable_fields checks source_id == _SOURCE_RUN -> return frozenset({"command"}). -# CHECK: result == frozenset({"command"}). -# MUTATION: Returning an empty frozenset skips completeness enforcement on run assertions. -# ESCAPE: frozenset({"command", "extra_field"}) would also fail the equality check. +# PATH: assertable_fields checks source_id == _SOURCE_RUN -> return frozenset({"command", "returncode", "stdout", "stderr"}). +# CHECK: result == frozenset({"command", "returncode", "stdout", "stderr"}). +# MUTATION: Returning only frozenset({"command"}) skips completeness enforcement on returncode/stdout/stderr. +# ESCAPE: frozenset({"command"}) would fail the equality check. def test_assertable_fields_run() -> None: v, p = _make_verifier_with_plugin() interaction = Interaction(source_id="subprocess:run", sequence=0, details={}, plugin=p) result = p.assertable_fields(interaction) - assert result == frozenset({"command"}) + assert result == frozenset({"command", "returncode", "stdout", "stderr"}) # ESCAPE: test_assertable_fields_which -# CLAIM: SubprocessPlugin.assertable_fields(interaction) returns frozenset({"name"}) +# CLAIM: SubprocessPlugin.assertable_fields(interaction) returns frozenset({"name", "returns"}) # when interaction.source_id == "subprocess:which". -# PATH: assertable_fields checks source_id == _SOURCE_WHICH -> return frozenset({"name"}). -# CHECK: result == frozenset({"name"}). -# MUTATION: Returning frozenset({"name", "returns"}) changes the required assertion fields. -# ESCAPE: frozenset() (empty) would pass frozenset equality incorrectly -- but the assertion -# uses exact equality so it would fail. +# PATH: assertable_fields checks source_id == _SOURCE_WHICH -> return frozenset({"name", "returns"}). +# CHECK: result == frozenset({"name", "returns"}). +# MUTATION: Returning frozenset({"name"}) skips completeness enforcement on the returns field. +# ESCAPE: frozenset() (empty) would also fail the equality check. def test_assertable_fields_which() -> None: v, p = _make_verifier_with_plugin() interaction = Interaction(source_id="subprocess:which", sequence=0, details={}, plugin=p) result = p.assertable_fields(interaction) - assert result == frozenset({"name"}) + assert result == frozenset({"name", "returns"}) # ESCAPE: test_assertable_fields_unknown_source @@ -617,7 +616,7 @@ def test_called_required_which_does_not_raise(clean_install_count) -> None: assert result == "/usr/bin/git" # Assert the interaction to avoid UnassertedInteractionsError - v.assert_interaction(p.which, name="git") + v.assert_interaction(p.which, name="git", returns="/usr/bin/git") # verify_all() must not raise: the mock was called and asserted v.verify_all() diff --git a/tests/unit/test_timeline.py b/tests/unit/test_timeline.py index d3508e1..0707e29 100644 --- a/tests/unit/test_timeline.py +++ b/tests/unit/test_timeline.py @@ -90,3 +90,53 @@ def append_many() -> None: def test_interaction_asserted_flag_defaults_false() -> None: i = _make_interaction() assert i._asserted is False + + +def test_mark_asserted_outside_record_succeeds() -> None: + """mark_asserted() called after record() has returned succeeds normally.""" + from bigfoot._timeline import Interaction, Timeline + + # We need a real plugin-like object that uses BasePlugin.record() + # Use ConcretePlugin-style stub with a real Timeline + timeline = Timeline() + + class _StubPlugin: + def __init__(self) -> None: + class _V: + _timeline = timeline + def _register_plugin(self, p: object) -> None: + pass + self.verifier = _V() + + def record(self, interaction: Interaction) -> None: + from bigfoot._recording import _recording_in_progress + token = _recording_in_progress.set(True) + try: + self.verifier._timeline.append(interaction) + finally: + _recording_in_progress.reset(token) + + plugin = _StubPlugin() + interaction = Interaction(source_id="test:x", sequence=0, details={}, plugin=MagicMock()) + plugin.record(interaction) + # Now outside record() — mark_asserted should succeed + timeline.mark_asserted(interaction) + assert interaction._asserted is True + + +def test_mark_asserted_inside_record_raises_auto_assert_error() -> None: + """mark_asserted() called while _recording_in_progress is True raises AutoAssertError.""" + import pytest + from bigfoot._errors import AutoAssertError + from bigfoot._recording import _recording_in_progress + from bigfoot._timeline import Interaction, Timeline + + timeline = Timeline() + interaction = Interaction(source_id="test:y", sequence=0, details={}, plugin=MagicMock()) + # Manually set the ContextVar to simulate record() being in progress + token = _recording_in_progress.set(True) + try: + with pytest.raises(AutoAssertError): + timeline.mark_asserted(interaction) + finally: + _recording_in_progress.reset(token) diff --git a/tests/unit/test_websocket_plugin.py b/tests/unit/test_websocket_plugin.py index 8d90b30..3debbf6 100644 --- a/tests/unit/test_websocket_plugin.py +++ b/tests/unit/test_websocket_plugin.py @@ -218,6 +218,11 @@ async def test_async_basic_connect_send_recv_close() -> None: assert recv_result == "hello" assert close_result is None + v.assert_interaction(p.connect, uri="ws://localhost:8765") + v.assert_interaction(p.send, message="ping") + v.assert_interaction(p.recv, message="hello") + v.assert_interaction(p.close) + # --------------------------------------------------------------------------- # InvalidStateError: recv before connect (state machine) @@ -293,6 +298,15 @@ async def test_async_fifo_two_sessions() -> None: assert first_recv_result == "first" assert second_recv_result == "second" + # Timeline order: connect1, connect2, recv1, recv2, close1, close2 + # The nested async with blocks fire __aenter__ for cm1 then cm2 before any recv. + v.assert_interaction(p.connect, uri="ws://localhost:8765") + v.assert_interaction(p.connect, uri="ws://localhost:8765") + v.assert_interaction(p.recv, message="first") + v.assert_interaction(p.recv, message="second") + v.assert_interaction(p.close) + v.assert_interaction(p.close) + # --------------------------------------------------------------------------- # ImportError when websockets not installed @@ -354,6 +368,9 @@ async def test_async_close_releases_session() -> None: assert len(p._active_sessions) == 0 assert p.get_unused_mocks() == [] + v.assert_interaction(p.connect, uri="ws://localhost:8765") + v.assert_interaction(p.close) + # --------------------------------------------------------------------------- # Module-level proxy: bigfoot.async_websocket_mock @@ -538,6 +555,11 @@ def test_sync_basic_connect_send_recv_close() -> None: assert recv_result == "hello" assert close_result is None + v.assert_interaction(p.connect, uri="ws://localhost:8765") + v.assert_interaction(p.send, message="ping") + v.assert_interaction(p.recv, message="hello") + v.assert_interaction(p.close) + # --------------------------------------------------------------------------- # InvalidStateError: wrong state @@ -655,6 +677,9 @@ def test_sync_close_releases_session() -> None: assert len(p._active_sessions) == 0 assert p.get_unused_mocks() == [] + v.assert_interaction(p.connect, uri="ws://localhost:8765") + v.assert_interaction(p.close) + # --------------------------------------------------------------------------- # FIFO ordering: two sequential sync sessions @@ -692,6 +717,13 @@ def test_sync_fifo_two_sessions() -> None: assert first_recv_result == "first" assert second_recv_result == "second" + v.assert_interaction(p.connect, uri="ws://localhost:8765") + v.assert_interaction(p.connect, uri="ws://localhost:8766") + v.assert_interaction(p.recv, message="first") + v.assert_interaction(p.recv, message="second") + v.assert_interaction(p.close) + v.assert_interaction(p.close) + # --------------------------------------------------------------------------- # Module-level proxy: bigfoot.sync_websocket_mock From 6a48b6b65cd4e5bd02cb8f739a6b23bbcc4a4e13 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 6 Mar 2026 01:10:46 -0600 Subject: [PATCH 3/5] fix: resolve ruff lint errors for CI Fix 13 ruff violations: sort imports (I001) in _timeline.py and test_timeline.py, remove bare f-string (F541) in popen_plugin.py, remove quoted annotations (UP037) in websocket_plugin.py and test_state_machine_plugin.py, and wrap long lines (E501) in http.py, popen_plugin.py, smtp_plugin.py, and socket_plugin.py. --- src/bigfoot/_timeline.py | 4 +++- src/bigfoot/plugins/http.py | 5 ++++- src/bigfoot/plugins/popen_plugin.py | 12 +++++++++--- src/bigfoot/plugins/smtp_plugin.py | 16 +++++++++++++--- src/bigfoot/plugins/socket_plugin.py | 5 ++++- src/bigfoot/plugins/websocket_plugin.py | 4 ++-- tests/unit/test_state_machine_plugin.py | 2 +- tests/unit/test_timeline.py | 1 + 8 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/bigfoot/_timeline.py b/src/bigfoot/_timeline.py index 632dfc7..c3973c7 100644 --- a/src/bigfoot/_timeline.py +++ b/src/bigfoot/_timeline.py @@ -56,7 +56,9 @@ def find_any_unasserted( return None def mark_asserted(self, interaction: Interaction) -> None: - from bigfoot._errors import AutoAssertError # noqa: PLC0415 — avoids circular import at module level + from bigfoot._errors import ( + AutoAssertError, # noqa: PLC0415 — avoids circular import at module level + ) if _recording_in_progress.get(): raise AutoAssertError( diff --git a/src/bigfoot/plugins/http.py b/src/bigfoot/plugins/http.py index 59d1078..014f6f9 100644 --- a/src/bigfoot/plugins/http.py +++ b/src/bigfoot/plugins/http.py @@ -894,7 +894,10 @@ def format_assert_hint(self, interaction: Interaction) -> str: def assertable_fields(self, interaction: Interaction) -> frozenset[str]: """Return the field names required in **expected when asserting an HTTP interaction.""" return frozenset( - {"method", "url", "request_headers", "request_body", "status", "response_headers", "response_body"} + { + "method", "url", "request_headers", "request_body", + "status", "response_headers", "response_body", + } ) def get_unused_mocks(self) -> list[HttpMockConfig]: diff --git a/src/bigfoot/plugins/popen_plugin.py b/src/bigfoot/plugins/popen_plugin.py index 4cfbf16..8a96809 100644 --- a/src/bigfoot/plugins/popen_plugin.py +++ b/src/bigfoot/plugins/popen_plugin.py @@ -108,7 +108,10 @@ def __init__( command = list(args) if hasattr(args, "__iter__") and not isinstance(args, str) else [args] plugin._execute_step( plugin._lookup_session(self), "spawn", (args,), {}, _SOURCE_SPAWN, - details={"command": command, "stdin": stdin if isinstance(stdin, (bytes, type(None))) else None}, + details={ + "command": command, + "stdin": stdin if isinstance(stdin, (bytes, type(None))) else None, + }, ) self.stdin: _FakeStream = _FakeStream() self.stdout: _FakeStream = _FakeStream() @@ -274,10 +277,13 @@ def format_mock_hint(self, interaction: Interaction) -> str: if interaction.source_id == _SOURCE_SPAWN: return " bigfoot.popen_mock.new_session().expect('spawn', returns=None)" if interaction.source_id == _SOURCE_COMMUNICATE: - return " bigfoot.popen_mock.new_session().expect('communicate', returns=(b'', b'', 0))" + return ( + " bigfoot.popen_mock.new_session()" + ".expect('communicate', returns=(b'', b'', 0))" + ) if interaction.source_id == _SOURCE_WAIT: return " bigfoot.popen_mock.new_session().expect('wait', returns=0)" - return f" bigfoot.popen_mock.new_session().expect('?', returns=...)" + return " bigfoot.popen_mock.new_session().expect('?', returns=...)" def format_unmocked_hint( self, diff --git a/src/bigfoot/plugins/smtp_plugin.py b/src/bigfoot/plugins/smtp_plugin.py index 16c912d..ba57abd 100644 --- a/src/bigfoot/plugins/smtp_plugin.py +++ b/src/bigfoot/plugins/smtp_plugin.py @@ -255,7 +255,10 @@ def format_interaction(self, interaction: Interaction) -> str: method = sid.split(":", 1)[-1] if ":" in sid else sid details = interaction.details if sid == _SOURCE_CONNECT: - return f"[SmtpPlugin] smtp.connect(host={details.get('host', '?')!r}, port={details.get('port', 0)!r})" + return ( + f"[SmtpPlugin] smtp.connect(" + f"host={details.get('host', '?')!r}, port={details.get('port', 0)!r})" + ) if sid == _SOURCE_EHLO: return f"[SmtpPlugin] smtp.ehlo(name={details.get('name', '')!r})" if sid == _SOURCE_HELO: @@ -265,7 +268,11 @@ def format_interaction(self, interaction: Interaction) -> str: if sid == _SOURCE_LOGIN: return f"[SmtpPlugin] smtp.login(user={details.get('user', '?')!r})" if sid == _SOURCE_SENDMAIL: - return f"[SmtpPlugin] smtp.sendmail(from_addr={details.get('from_addr', '?')!r}, to_addrs={details.get('to_addrs')!r})" + return ( + f"[SmtpPlugin] smtp.sendmail(" + f"from_addr={details.get('from_addr', '?')!r}, " + f"to_addrs={details.get('to_addrs')!r})" + ) if sid == _SOURCE_SEND_MESSAGE: return f"[SmtpPlugin] smtp.send_message(msg={details.get('msg')!r})" if sid == _SOURCE_QUIT: @@ -313,7 +320,10 @@ def format_assert_hint(self, interaction: Interaction) -> str: from_addr = interaction.details.get("from_addr", "?") to_addrs = interaction.details.get("to_addrs") msg = interaction.details.get("msg") - return f" {sm}.assert_sendmail(from_addr={from_addr!r}, to_addrs={to_addrs!r}, msg={msg!r})" + return ( + f" {sm}.assert_sendmail(" + f"from_addr={from_addr!r}, to_addrs={to_addrs!r}, msg={msg!r})" + ) if sid == _SOURCE_SEND_MESSAGE: msg = interaction.details.get("msg") return f" {sm}.assert_send_message(msg={msg!r})" diff --git a/src/bigfoot/plugins/socket_plugin.py b/src/bigfoot/plugins/socket_plugin.py index ef62379..3894b9a 100644 --- a/src/bigfoot/plugins/socket_plugin.py +++ b/src/bigfoot/plugins/socket_plugin.py @@ -241,7 +241,10 @@ def format_interaction(self, interaction: Interaction) -> str: method = sid.split(":", 1)[-1] if ":" in sid else sid details = interaction.details if sid == _SOURCE_CONNECT: - return f"[SocketPlugin] socket.connect(({details.get('host', '?')!r}, {details.get('port', 0)!r}))" + return ( + f"[SocketPlugin] socket.connect((" + f"{details.get('host', '?')!r}, {details.get('port', 0)!r}))" + ) if sid == _SOURCE_SEND: return f"[SocketPlugin] socket.send({details.get('data', b'')!r})" if sid == _SOURCE_SENDALL: diff --git a/src/bigfoot/plugins/websocket_plugin.py b/src/bigfoot/plugins/websocket_plugin.py index 288d8a9..c1aa561 100644 --- a/src/bigfoot/plugins/websocket_plugin.py +++ b/src/bigfoot/plugins/websocket_plugin.py @@ -169,7 +169,7 @@ class AsyncWebSocketPlugin(StateMachinePlugin): # Plugin init: create per-instance sentinels # ------------------------------------------------------------------ - def __init__(self, verifier: "StrictVerifier") -> None: + def __init__(self, verifier: StrictVerifier) -> None: super().__init__(verifier) self._connect_sentinel = _StepSentinel(_ASYNC_SOURCE_CONNECT) self._send_sentinel = _StepSentinel(_ASYNC_SOURCE_SEND) @@ -426,7 +426,7 @@ class SyncWebSocketPlugin(StateMachinePlugin): # Plugin init: create per-instance sentinels # ------------------------------------------------------------------ - def __init__(self, verifier: "StrictVerifier") -> None: + def __init__(self, verifier: StrictVerifier) -> None: super().__init__(verifier) self._connect_sentinel = _StepSentinel(_SYNC_SOURCE_CONNECT) self._send_sentinel = _StepSentinel(_SYNC_SOURCE_SEND) diff --git a/tests/unit/test_state_machine_plugin.py b/tests/unit/test_state_machine_plugin.py index d0c9d61..88494e0 100644 --- a/tests/unit/test_state_machine_plugin.py +++ b/tests/unit/test_state_machine_plugin.py @@ -66,7 +66,7 @@ def format_assert_hint(self, interaction: Interaction) -> str: def format_unused_mock_hint(self, mock_config: object) -> str: return "Unused mock" - def matches(self, interaction: "Interaction", expected: dict[str, Any]) -> bool: + def matches(self, interaction: Interaction, expected: dict[str, Any]) -> bool: return True diff --git a/tests/unit/test_timeline.py b/tests/unit/test_timeline.py index 0707e29..e7a05c7 100644 --- a/tests/unit/test_timeline.py +++ b/tests/unit/test_timeline.py @@ -127,6 +127,7 @@ def record(self, interaction: Interaction) -> None: def test_mark_asserted_inside_record_raises_auto_assert_error() -> None: """mark_asserted() called while _recording_in_progress is True raises AutoAssertError.""" import pytest + from bigfoot._errors import AutoAssertError from bigfoot._recording import _recording_in_progress from bigfoot._timeline import Interaction, Timeline From 6b9ea1ff552ada871de26cd7e6783c8d94e2bfd5 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 6 Mar 2026 01:18:09 -0600 Subject: [PATCH 4/5] fix: rename assert_request/assert_response params for consistency Rename HttpAssertionBuilder parameters to match the interaction field naming convention: headers -> request_headers, body -> request_body in assert_request(); headers -> response_headers, body -> response_body in assert_response(). Update tests and README example accordingly. --- README.md | 4 ++-- src/bigfoot/plugins/http.py | 28 +++++++++++------------ tests/unit/test_http_plugin.py | 41 +++++++++++++++++----------------- 3 files changed, 37 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 274efd1..c9a7b9b 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ def test_payment_flow(): # Option B: chained helper bigfoot.http.assert_request( method="POST", url="https://api.stripe.com/v1/charges", - headers=IsMapping(), body=None, - ).assert_response(status=200, headers=IsMapping(), body=IsMapping() | IsInstance(str)) + request_headers=IsMapping(), request_body=None, + ).assert_response(status=200, response_headers=IsMapping(), response_body=IsMapping() | IsInstance(str)) assert response.json()["id"] == "ch_123" # verify_all() called automatically at test teardown ``` diff --git a/src/bigfoot/plugins/http.py b/src/bigfoot/plugins/http.py index 014f6f9..5704d07 100644 --- a/src/bigfoot/plugins/http.py +++ b/src/bigfoot/plugins/http.py @@ -107,21 +107,21 @@ def __init__( sentinel: HttpRequestSentinel, method: str, url: str, - headers: dict[str, Any], - body: str, + request_headers: dict[str, Any], + request_body: str, ) -> None: self._verifier = verifier self._sentinel = sentinel self._method = method self._url = url - self._headers = headers - self._body = body + self._request_headers = request_headers + self._request_body = request_body def assert_response( self, status: int, - headers: dict[str, Any], - body: str, + response_headers: dict[str, Any], + response_body: str, ) -> None: """Assert the full interaction: request fields + response fields. @@ -132,11 +132,11 @@ def assert_response( self._sentinel, method=self._method, url=self._url, - request_headers=self._headers, - request_body=self._body, + request_headers=self._request_headers, + request_body=self._request_body, status=status, - response_headers=headers, - response_body=body, + response_headers=response_headers, + response_body=response_body, ) @@ -206,8 +206,8 @@ def assert_request( self, method: str, url: str, - headers: dict[str, Any] | None = None, - body: str = "", + request_headers: dict[str, Any] | None = None, + request_body: str = "", ) -> "HttpAssertionBuilder": """Return an HttpAssertionBuilder pre-loaded with expected request fields. @@ -219,8 +219,8 @@ def assert_request( sentinel=self._sentinel, method=method, url=url, - headers=headers if headers is not None else {}, - body=body, + request_headers=request_headers if request_headers is not None else {}, + request_body=request_body, ) def mock_response( diff --git a/tests/unit/test_http_plugin.py b/tests/unit/test_http_plugin.py index 18403da..03d7ea6 100644 --- a/tests/unit/test_http_plugin.py +++ b/tests/unit/test_http_plugin.py @@ -1254,22 +1254,23 @@ def test_assert_request_stores_method_and_url() -> None: # ESCAPE: test_assert_request_default_headers_and_body -# CLAIM: assert_request() defaults headers to {} and body to "". -# PATH: assert_request() uses `headers if headers is not None else {}` and body="". -# CHECK: builder._headers == {}, builder._body == "". -# MUTATION: Defaulting headers to None would leave None stored. +# CLAIM: assert_request() defaults request_headers to {} and request_body to "". +# PATH: assert_request() uses `request_headers if request_headers is not None else {}` +# and request_body="". +# CHECK: builder._request_headers == {}, builder._request_body == "". +# MUTATION: Defaulting request_headers to None would leave None stored. # ESCAPE: Nothing reasonable -- exact equality. def test_assert_request_default_headers_and_body() -> None: v, p = _make_verifier_with_plugin() builder = p.assert_request("POST", "https://example.com/submit") - assert builder._headers == {} - assert builder._body == "" + assert builder._request_headers == {} + assert builder._request_body == "" # ESCAPE: test_assert_request_with_explicit_headers_and_body -# CLAIM: assert_request() passes through explicit headers and body. -# PATH: assert_request(headers=..., body=...) -> builder stores them. -# CHECK: builder._headers and builder._body match what was passed. +# CLAIM: assert_request() passes through explicit request_headers and request_body. +# PATH: assert_request(request_headers=..., request_body=...) -> builder stores them. +# CHECK: builder._request_headers and builder._request_body match what was passed. # MUTATION: Ignoring the kwargs and using defaults would fail. # ESCAPE: Nothing reasonable -- exact dict/str equality. def test_assert_request_with_explicit_headers_and_body() -> None: @@ -1277,11 +1278,11 @@ def test_assert_request_with_explicit_headers_and_body() -> None: builder = p.assert_request( "POST", "https://example.com/submit", - headers={"Authorization": "Bearer tok"}, - body='{"key": "val"}', + request_headers={"Authorization": "Bearer tok"}, + request_body='{"key": "val"}', ) - assert builder._headers == {"Authorization": "Bearer tok"} - assert builder._body == '{"key": "val"}' + assert builder._request_headers == {"Authorization": "Bearer tok"} + assert builder._request_body == '{"key": "val"}' # ESCAPE: test_assert_response_calls_assert_interaction_with_all_seven_fields @@ -1314,11 +1315,11 @@ def test_assert_response_calls_assert_interaction_with_all_seven_fields() -> Non p.assert_request( "GET", "https://api.example.com/data", - headers=recorded_request_headers, + request_headers=recorded_request_headers, ).assert_response( status=200, - headers={"content-type": "application/json"}, - body='{"key": "value"}', + response_headers={"content-type": "application/json"}, + response_body='{"key": "value"}', ) # All interactions asserted -- verify_all must not raise @@ -1347,12 +1348,12 @@ def test_assert_response_is_terminal_marks_interaction_asserted() -> None: p.assert_request( "POST", "https://api.example.com/create", - headers=recorded["request_headers"], - body=recorded["request_body"], + request_headers=recorded["request_headers"], + request_body=recorded["request_body"], ).assert_response( status=201, - headers={"content-type": "application/json"}, - body='{"id": 1}', + response_headers={"content-type": "application/json"}, + response_body='{"id": 1}', ) assert len(v._timeline.all_unasserted()) == 0 From 4cb17ab7568ac7073600442cdfa549ead6801253 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 6 Mar 2026 01:26:07 -0600 Subject: [PATCH 5/5] revert: restore short param names in assert_request/assert_response Revert the Gemini-suggested rename. assert_request(headers=, body=) and assert_response(status, headers=, body=) are unambiguous in context -- the method name already implies request/response scope. The prefixed form (request_headers=, response_body=) is redundant and breaks DRY. --- README.md | 4 ++-- src/bigfoot/plugins/http.py | 28 +++++++++++------------ tests/unit/test_http_plugin.py | 41 +++++++++++++++++----------------- 3 files changed, 36 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index c9a7b9b..274efd1 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ def test_payment_flow(): # Option B: chained helper bigfoot.http.assert_request( method="POST", url="https://api.stripe.com/v1/charges", - request_headers=IsMapping(), request_body=None, - ).assert_response(status=200, response_headers=IsMapping(), response_body=IsMapping() | IsInstance(str)) + headers=IsMapping(), body=None, + ).assert_response(status=200, headers=IsMapping(), body=IsMapping() | IsInstance(str)) assert response.json()["id"] == "ch_123" # verify_all() called automatically at test teardown ``` diff --git a/src/bigfoot/plugins/http.py b/src/bigfoot/plugins/http.py index 5704d07..014f6f9 100644 --- a/src/bigfoot/plugins/http.py +++ b/src/bigfoot/plugins/http.py @@ -107,21 +107,21 @@ def __init__( sentinel: HttpRequestSentinel, method: str, url: str, - request_headers: dict[str, Any], - request_body: str, + headers: dict[str, Any], + body: str, ) -> None: self._verifier = verifier self._sentinel = sentinel self._method = method self._url = url - self._request_headers = request_headers - self._request_body = request_body + self._headers = headers + self._body = body def assert_response( self, status: int, - response_headers: dict[str, Any], - response_body: str, + headers: dict[str, Any], + body: str, ) -> None: """Assert the full interaction: request fields + response fields. @@ -132,11 +132,11 @@ def assert_response( self._sentinel, method=self._method, url=self._url, - request_headers=self._request_headers, - request_body=self._request_body, + request_headers=self._headers, + request_body=self._body, status=status, - response_headers=response_headers, - response_body=response_body, + response_headers=headers, + response_body=body, ) @@ -206,8 +206,8 @@ def assert_request( self, method: str, url: str, - request_headers: dict[str, Any] | None = None, - request_body: str = "", + headers: dict[str, Any] | None = None, + body: str = "", ) -> "HttpAssertionBuilder": """Return an HttpAssertionBuilder pre-loaded with expected request fields. @@ -219,8 +219,8 @@ def assert_request( sentinel=self._sentinel, method=method, url=url, - request_headers=request_headers if request_headers is not None else {}, - request_body=request_body, + headers=headers if headers is not None else {}, + body=body, ) def mock_response( diff --git a/tests/unit/test_http_plugin.py b/tests/unit/test_http_plugin.py index 03d7ea6..18403da 100644 --- a/tests/unit/test_http_plugin.py +++ b/tests/unit/test_http_plugin.py @@ -1254,23 +1254,22 @@ def test_assert_request_stores_method_and_url() -> None: # ESCAPE: test_assert_request_default_headers_and_body -# CLAIM: assert_request() defaults request_headers to {} and request_body to "". -# PATH: assert_request() uses `request_headers if request_headers is not None else {}` -# and request_body="". -# CHECK: builder._request_headers == {}, builder._request_body == "". -# MUTATION: Defaulting request_headers to None would leave None stored. +# CLAIM: assert_request() defaults headers to {} and body to "". +# PATH: assert_request() uses `headers if headers is not None else {}` and body="". +# CHECK: builder._headers == {}, builder._body == "". +# MUTATION: Defaulting headers to None would leave None stored. # ESCAPE: Nothing reasonable -- exact equality. def test_assert_request_default_headers_and_body() -> None: v, p = _make_verifier_with_plugin() builder = p.assert_request("POST", "https://example.com/submit") - assert builder._request_headers == {} - assert builder._request_body == "" + assert builder._headers == {} + assert builder._body == "" # ESCAPE: test_assert_request_with_explicit_headers_and_body -# CLAIM: assert_request() passes through explicit request_headers and request_body. -# PATH: assert_request(request_headers=..., request_body=...) -> builder stores them. -# CHECK: builder._request_headers and builder._request_body match what was passed. +# CLAIM: assert_request() passes through explicit headers and body. +# PATH: assert_request(headers=..., body=...) -> builder stores them. +# CHECK: builder._headers and builder._body match what was passed. # MUTATION: Ignoring the kwargs and using defaults would fail. # ESCAPE: Nothing reasonable -- exact dict/str equality. def test_assert_request_with_explicit_headers_and_body() -> None: @@ -1278,11 +1277,11 @@ def test_assert_request_with_explicit_headers_and_body() -> None: builder = p.assert_request( "POST", "https://example.com/submit", - request_headers={"Authorization": "Bearer tok"}, - request_body='{"key": "val"}', + headers={"Authorization": "Bearer tok"}, + body='{"key": "val"}', ) - assert builder._request_headers == {"Authorization": "Bearer tok"} - assert builder._request_body == '{"key": "val"}' + assert builder._headers == {"Authorization": "Bearer tok"} + assert builder._body == '{"key": "val"}' # ESCAPE: test_assert_response_calls_assert_interaction_with_all_seven_fields @@ -1315,11 +1314,11 @@ def test_assert_response_calls_assert_interaction_with_all_seven_fields() -> Non p.assert_request( "GET", "https://api.example.com/data", - request_headers=recorded_request_headers, + headers=recorded_request_headers, ).assert_response( status=200, - response_headers={"content-type": "application/json"}, - response_body='{"key": "value"}', + headers={"content-type": "application/json"}, + body='{"key": "value"}', ) # All interactions asserted -- verify_all must not raise @@ -1348,12 +1347,12 @@ def test_assert_response_is_terminal_marks_interaction_asserted() -> None: p.assert_request( "POST", "https://api.example.com/create", - request_headers=recorded["request_headers"], - request_body=recorded["request_body"], + headers=recorded["request_headers"], + body=recorded["request_body"], ).assert_response( status=201, - response_headers={"content-type": "application/json"}, - response_body='{"id": 1}', + headers={"content-type": "application/json"}, + body='{"id": 1}', ) assert len(v._timeline.all_unasserted()) == 0