From fdc73cfc7484d3f393f1ae380c57f52566afd895 Mon Sep 17 00:00:00 2001 From: rdwj Date: Mon, 4 May 2026 09:53:46 -0500 Subject: [PATCH] test+chore: Add real-template middleware test and enforce release convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands two related cleanups from the open backlog. * Add an integration test class that renders the real v3.x middleware Jinja2 template (committed under tests/fixtures/middleware_template/ so the suite stays offline). Each --hook-type and the no-flag case is rendered, parsed with ast, and checked for the fastmcp imports and Middleware subclass shape that the synthetic mock_mcp_project fixture doesn't exercise. Closes #3. * scripts/release.sh now constructs the release commit message from the version argument using the project's actual convention ("chore: Release fips-agents-cli vX.Y.Z" with optional " — ") rather than accepting an arbitrary commit-message string. Documented in CLAUDE.md so future contributors keep "git log --grep" reliable. Closes #4. Assisted-by: Claude Code (Opus 4.7) --- CLAUDE.md | 11 +- scripts/release.sh | 33 ++++- .../middleware_template/component.py.j2 | 39 ++++++ tests/fixtures/middleware_template/test.py.j2 | 40 ++++++ tests/test_generate.py | 117 ++++++++++++++++++ 5 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/middleware_template/component.py.j2 create mode 100644 tests/fixtures/middleware_template/test.py.j2 diff --git a/CLAUDE.md b/CLAUDE.md index fe7078c..36ff07a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,18 +91,25 @@ This command will: ```bash # 1. Update version in both files manually or use the script: -./scripts/release.sh "" +./scripts/release.sh [] -# Example: +# Examples: +./scripts/release.sh 0.1.2 ./scripts/release.sh 0.1.2 "Add new generator features" # The script handles: # - Updating version.py and pyproject.toml +# - Constructing the release commit message from the project convention # - Committing changes (including README.md changelog) # - Creating and pushing tag # - Triggering GitHub Actions ``` +**Release commit message convention**: `chore: Release fips-agents-cli vX.Y.Z`, +optionally followed by ` — `. The script always constructs the message +from the version argument; callers only supply the summary, never the full +message. This keeps `git log --grep "Release fips-agents-cli"` reliable. + **Note**: Always update the changelog in README.md before running the script. See `RELEASE_CHECKLIST.md` for detailed release procedures and troubleshooting. diff --git a/scripts/release.sh b/scripts/release.sh index af2289f..10dd98d 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -2,9 +2,20 @@ # # release.sh - Automated release script for fips-agents-cli # -# Usage: ./scripts/release.sh +# Usage: ./scripts/release.sh [] # -# Example: ./scripts/release.sh 0.1.2 "Add new feature for X" +# The release commit message is always constructed from the version using +# the project convention: +# +# chore: Release fips-agents-cli vX.Y.Z +# +# If a summary is provided, it is appended after an em-dash: +# +# chore: Release fips-agents-cli vX.Y.Z — +# +# Examples: +# ./scripts/release.sh 0.1.2 +# ./scripts/release.sh 0.1.2 "Add new generator features" # set -e # Exit on error @@ -29,14 +40,16 @@ print_info() { } # Check arguments -if [ $# -ne 2 ]; then - print_error "Usage: $0 " - echo "Example: $0 0.1.2 \"Add new feature for X\"" +if [ $# -lt 1 ] || [ $# -gt 2 ]; then + print_error "Usage: $0 []" + echo "Examples:" + echo " $0 0.1.2" + echo " $0 0.1.2 \"Add new generator features\"" exit 1 fi VERSION=$1 -COMMIT_MSG=$2 +SUMMARY=${2:-} # Validate version format (x.y.z) if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then @@ -45,6 +58,14 @@ if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then exit 1 fi +# Construct the conventional release commit message. +# Convention: "chore: Release fips-agents-cli vX.Y.Z" with optional " — ". +if [ -n "$SUMMARY" ]; then + COMMIT_MSG="chore: Release fips-agents-cli v${VERSION} — ${SUMMARY}" +else + COMMIT_MSG="chore: Release fips-agents-cli v${VERSION}" +fi + print_info "Preparing release v$VERSION" echo diff --git a/tests/fixtures/middleware_template/component.py.j2 b/tests/fixtures/middleware_template/component.py.j2 new file mode 100644 index 0000000..5f8b4cf --- /dev/null +++ b/tests/fixtures/middleware_template/component.py.j2 @@ -0,0 +1,39 @@ +"""{{ description }}""" + +import mcp.types as mt +from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext +from fastmcp.tools.tool import ToolResult + +from src.core.logging import get_logger + +log = get_logger("middleware.{{ component_name }}") + + +class {{ component_name | replace('_', ' ') | title | replace(' ', '') }}Middleware(Middleware): + """{{ description }} + + To activate, add an instance to the middleware=[] list in create_server() + (see src/core/server.py). + """ + + async def on_call_tool( + self, + context: MiddlewareContext[mt.CallToolRequestParams], + call_next: CallNext[mt.CallToolRequestParams, ToolResult], + ) -> ToolResult: + """Wrap tool execution with {{ component_name }} logic.""" + tool_name = context.message.name + + log.debug(f"{{ component_name }} middleware: before {tool_name}") + + # TODO: Add pre-execution logic here + + try: + result = await call_next(context) + except Exception as e: + log.error(f"{{ component_name }} middleware: error in {tool_name}: {e}") + raise + + # TODO: Add post-execution logic here + + return result diff --git a/tests/fixtures/middleware_template/test.py.j2 b/tests/fixtures/middleware_template/test.py.j2 new file mode 100644 index 0000000..24accc5 --- /dev/null +++ b/tests/fixtures/middleware_template/test.py.j2 @@ -0,0 +1,40 @@ +"""Tests for {{ component_name }} middleware.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock +import mcp.types as mt + +from src.middleware.{{ module_path }} import {{ component_name | replace('_', ' ') | title | replace(' ', '') }}Middleware + + +@pytest.mark.asyncio +async def test_{{ component_name }}_success(): + """Test middleware handles successful execution.""" + middleware = {{ component_name | replace('_', ' ') | title | replace(' ', '') }}Middleware() + + # Create mock context + context = MagicMock(spec=mt.CallToolRequestParams) + context.message.name = "test_tool" + + # Create mock call_next + call_next = AsyncMock(return_value="success_result") + + # Execute middleware + result = await middleware.on_call_tool(context, call_next) + + assert result == "success_result" + call_next.assert_called_once_with(context) + + +@pytest.mark.asyncio +async def test_{{ component_name }}_error(): + """Test middleware handles errors.""" + middleware = {{ component_name | replace('_', ' ') | title | replace(' ', '') }}Middleware() + + context = MagicMock(spec=mt.CallToolRequestParams) + context.message.name = "failing_tool" + + call_next = AsyncMock(side_effect=ValueError("Test error")) + + with pytest.raises(ValueError, match="Test error"): + await middleware.on_call_tool(context, call_next) diff --git a/tests/test_generate.py b/tests/test_generate.py index fd31860..6c55c0b 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -398,6 +398,123 @@ def test_generate_middleware_invalid_hook_type(self, runner, mock_mcp_project): os.chdir(original_cwd) +@pytest.fixture +def mock_mcp_project_with_real_middleware_template(tmp_path): + """Mock MCP project that uses the real middleware Jinja2 templates as fixtures. + + Templates are committed under tests/fixtures/middleware_template/ so the + test runs offline and is not coupled to mcp-server-template's git state. + Refresh the fixtures when the upstream template changes meaningfully. + """ + import shutil + from pathlib import Path + + pyproject_content = """ +[project] +name = "test-mcp-server" +version = "0.1.0" +dependencies = [ + "fastmcp>=3.0.0", +] +""" + (tmp_path / "pyproject.toml").write_text(pyproject_content) + + for component_dir in ["middleware"]: + (tmp_path / "src" / component_dir).mkdir(parents=True) + (tmp_path / "tests" / component_dir).mkdir(parents=True) + + fixture_dir = Path(__file__).parent / "fixtures" / "middleware_template" + generators_dir = tmp_path / ".fips-agents-cli" / "generators" / "middleware" + generators_dir.mkdir(parents=True) + shutil.copy(fixture_dir / "component.py.j2", generators_dir / "component.py.j2") + shutil.copy(fixture_dir / "test.py.j2", generators_dir / "test.py.j2") + + return tmp_path + + +class TestGenerateMiddlewareRealTemplate: + """Integration tests that render the real v3.x middleware template. + + These guard against regressions that would slip past the synthetic + `mock_mcp_project` fixture, which uses a stripped-down template that + doesn't exercise fastmcp imports, class structure, or the real + Jinja2 conditionals. + """ + + @pytest.mark.parametrize("hook_type", ["before_tool", "after_tool", "on_error"]) + def test_real_template_renders_for_each_hook_type( + self, runner, mock_mcp_project_with_real_middleware_template, hook_type + ): + """Each --hook-type renders valid Python against the real v3.x template.""" + import ast + import os + + project = mock_mcp_project_with_real_middleware_template + original_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke( + generate, + [ + "middleware", + f"{hook_type}_mw", + "--description", + f"{hook_type} hook middleware", + "--hook-type", + hook_type, + ], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + + rendered = project / "src" / "middleware" / f"{hook_type}_mw.py" + assert rendered.exists() + + source = rendered.read_text() + ast.parse(source) # raises if invalid Python + assert "from fastmcp.server.middleware import" in source + assert "class " in source and "Middleware(Middleware):" in source + assert "async def on_call_tool" in source + + test_file = project / "tests" / "middleware" / f"test_{hook_type}_mw.py" + assert test_file.exists() + ast.parse(test_file.read_text()) + finally: + os.chdir(original_cwd) + + def test_real_template_renders_without_hook_type( + self, runner, mock_mcp_project_with_real_middleware_template + ): + """Omitting --hook-type still produces a valid generic wrapper (backward compat).""" + import ast + import os + + project = mock_mcp_project_with_real_middleware_template + original_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke( + generate, + [ + "middleware", + "generic_mw", + "--description", + "generic middleware", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + + rendered = project / "src" / "middleware" / "generic_mw.py" + assert rendered.exists() + source = rendered.read_text() + ast.parse(source) + assert "class GenericMwMiddleware(Middleware):" in source + assert "async def on_call_tool" in source + finally: + os.chdir(original_cwd) + + class TestGenerateErrorCases: """Tests for error handling."""