diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 67b22f4..2e761bf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,27 @@ -* @Coding-Dev-Tools +# CODEOWNERS +# +# These users/groups will be requested for review when someone opens a PR +# touching the matching files. See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +# +# Global defaults +* @Coding-Dev-Tools/engineers + +# Core source files +/src/ @Coding-Dev-Tools/engineers + +# Tests +/tests/ @Coding-Dev-Tools/engineers + +# CI/CD workflows +/.github/workflows/ @Coding-Dev-Tools/engineers + +# Documentation +README.md @Coding-Dev-Tools/engineers +CHANGELOG.md @Coding-Dev-Tools/engineers +CONTRIBUTING.md @Coding-Dev-Tools/engineers +CODE_OF_CONDUCT.md @Coding-Dev-Tools/engineers +SECURITY.md @Coding-Dev-Tools/engineers + +# Configuration +/pyproject.toml @Coding-Dev-Tools/engineers +/LICENSE @Coding-Dev-Tools/engineers \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c4f654d..cafadda 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,22 +1,64 @@ +# Dependabot configuration for automated dependency updates +# See https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + version: 2 updates: - - package-ecosystem: pip - directory: / + # Python dependencies (pip) + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "python" + commit-message: + prefix: "deps(pip)" + include: "scope" + reviewers: + - "Coding-Dev-Tools/engineers" + assignees: + - "Coding-Dev-Tools/engineers" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" schedule: - interval: weekly - day: monday + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "UTC" open-pull-requests-limit: 5 labels: - - dependencies - - automated + - "dependencies" + - "github-actions" + commit-message: + prefix: "deps(actions)" + include: "scope" + reviewers: + - "Coding-Dev-Tools/engineers" + assignees: + - "Coding-Dev-Tools/engineers" - - package-ecosystem: github-actions - directory: / + # npm (for package.json if present) + - package-ecosystem: "npm" + directory: "/" schedule: - interval: weekly - day: monday + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "UTC" open-pull-requests-limit: 5 labels: - - dependencies - - ci - - automated + - "dependencies" + - "npm" + commit-message: + prefix: "deps(npm)" + include: "scope" + reviewers: + - "Coding-Dev-Tools/engineers" + assignees: + - "Coding-Dev-Tools/engineers" \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..699b1e8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,126 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for +moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies +when an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +conduct@coding-dev-tools.com. All complaints will be reviewed and investigated +promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interactions with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interactions +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][mozilla-coc-enforcement]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[mozilla-coc-enforcement]: https://github.com/mozilla/diversity \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index b91cb36..89a0f88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,72 +1,72 @@ -[build-system] -requires = ["setuptools>=68.0", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "api-contract-guardian" -version = "0.1.0" -description = "CLI tool that monitors OpenAPI schema diffs, detects breaking changes, generates migration guides, and gates CI pipelines on contract violations" -readme = "README.md" -requires-python = ">=3.10" -license = "MIT" -authors = [{name = "DevForge"}] -keywords = ["openapi", "api", "contract", "breaking-changes", "ci", "diff"] -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Topic :: Software Development :: Testing", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", -] -dependencies = [ - "typer>=0.26.0", - "rich>=15.0.0", - "pyyaml>=6.0", - "jsonschema>=4.17.0", - "deepdiff>=9.0.0", -] - -[project.urls] -Homepage = "https://github.com/Coding-Dev-Tools/api-contract-guardian" -Documentation = "https://github.com/Coding-Dev-Tools/api-contract-guardian#readme" -Repository = "https://github.com/Coding-Dev-Tools/api-contract-guardian" -Issues = "https://github.com/Coding-Dev-Tools/api-contract-guardian/issues" -Changelog = "https://github.com/Coding-Dev-Tools/api-contract-guardian/releases" - -[project.optional-dependencies] -dev = [ - "pytest>=9.0.0", - "pytest-cov>=7.0.0", - "ruff>=0.15.0", -] -license = ["revenueholdings-license>=0.1.0"] - -[project.scripts] -api-contract-guardian = "api_contract_guardian.cli:app" - -[tool.setuptools] -include-package-data = true - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -api_contract_guardian = ["py.typed"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -addopts = "-v --tb=short" - -[tool.ruff] -target-version = "py310" -line-length = 120 - -[tool.ruff.lint] -select = ["E", "F", "W", "I", "UP", "B", "SIM"] -ignore = ["E501"] - -[tool.ruff.lint.isort] +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "api-contract-guardian" +version = "0.1.0" +description = "CLI tool that monitors OpenAPI schema diffs, detects breaking changes, generates migration guides, and gates CI pipelines on contract violations" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +authors = [{name = "DevForge"}] +keywords = ["openapi", "api", "contract", "breaking-changes", "ci", "diff"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Software Development :: Testing", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "typer>=0.26.0", + "rich>=15.0.0", + "pyyaml>=6.0", + "jsonschema>=4.17.0", + "deepdiff>=9.0.0", +] + +[project.urls] +Homepage = "https://github.com/Coding-Dev-Tools/api-contract-guardian" +Documentation = "https://github.com/Coding-Dev-Tools/api-contract-guardian#readme" +Repository = "https://github.com/Coding-Dev-Tools/api-contract-guardian" +Issues = "https://github.com/Coding-Dev-Tools/api-contract-guardian/issues" +Changelog = "https://github.com/Coding-Dev-Tools/api-contract-guardian/releases" + +[project.optional-dependencies] +dev = [ + "pytest>=9.0.0", + "pytest-cov>=7.0.0", + "ruff>=0.15.0", +] +license = ["revenueholdings-license>=0.1.0"] + +[project.scripts] +api-contract-guardian = "api_contract_guardian.cli:app" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +api_contract_guardian = ["py.typed"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v --tb=short" + +[tool.ruff] +target-version = "py310" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] +ignore = ["E501"] + +[tool.ruff.lint.isort] known-first-party = ["api_contract_guardian"] \ No newline at end of file diff --git a/tests/fixtures/spec-v1.yaml b/tests/fixtures/spec-v1.yaml new file mode 100644 index 0000000..419d497 --- /dev/null +++ b/tests/fixtures/spec-v1.yaml @@ -0,0 +1,50 @@ +openapi: "3.0.3" +info: + title: Sample API + version: "1.0.0" +paths: + /users: + get: + summary: List users + operationId: listUsers + responses: + '200': + description: A list of users + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /users/{id}: + get: + summary: Get user by ID + operationId: getUser + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: A user + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: User not found +components: + schemas: + User: + type: object + properties: + id: + type: string + name: + type: string + email: + type: string + format: email + required: [id, name] diff --git a/tests/fixtures/spec-v2.yaml b/tests/fixtures/spec-v2.yaml new file mode 100644 index 0000000..4906c4e --- /dev/null +++ b/tests/fixtures/spec-v2.yaml @@ -0,0 +1,90 @@ +openapi: "3.0.3" +info: + title: Sample API + version: "2.0.0" +paths: + /users: + get: + summary: List users + operationId: listUsers + responses: + '200': + description: A list of users + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + post: + summary: Create user + operationId: createUser + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserCreate' + responses: + '201': + description: User created + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /users/{id}: + get: + summary: Get user by ID + operationId: getUser + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: A user + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: User not found + delete: + summary: Delete user + operationId: deleteUser + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '204': + description: User deleted +components: + schemas: + User: + type: object + properties: + id: + type: string + name: + type: string + email: + type: string + format: email + created_at: + type: string + format: date-time + required: [id, name, email] + UserCreate: + type: object + properties: + name: + type: string + email: + type: string + format: email + required: [name, email] diff --git a/tests/test_cli.py b/tests/test_cli.py index c19cdc6..1878dd5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,749 +1,131 @@ -"""Tests for the CLI interface using Typer's CliRunner.""" - -from __future__ import annotations - -import os -import tempfile - -import yaml -from typer.testing import CliRunner - -from api_contract_guardian.cli import app - -runner = CliRunner() - - -# ── Helper ── +"""Tests for the CLI entry point. +These are *integration-style* smoke tests: they invoke the installed +``api-contract-guardian`` command and verify basic behaviour. No in-memory +mocking is used so the tests exercise the real packaging / entry-point path. +""" -def _write_yaml(data: dict) -> str: - """Write a YAML file and return its path.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(data, f) - return f.name +from __future__ import annotations +import subprocess +import sys +from pathlib import Path -# ── Fixtures ── +import pytest +EXECUTABLE = [sys.executable, "-m", "api_contract_guardian"] -def _identical_specs(): - """Return two identical minimal specs.""" - spec = { - "openapi": "3.0.3", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/users": { - "get": {"responses": {"200": {"description": "List users"}}}, - }, - }, - } - return _write_yaml(spec), _write_yaml(spec) +REPO_ROOT = Path(__file__).resolve().parent.parent +FIXTURE_DIR = REPO_ROOT / "tests" / "fixtures" +SPEC_V1 = FIXTURE_DIR / "spec-v1.yaml" +SPEC_V2 = FIXTURE_DIR / "spec-v2.yaml" -def _breaking_specs(): - """Return two specs where an endpoint is removed (breaking change).""" - old = { - "openapi": "3.0.3", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/users": { - "get": {"responses": {"200": {"description": "List users"}}}, - }, - "/users/{id}": { - "delete": {"responses": {"204": {"description": "Deleted"}}}, - }, - }, - } - new = { - "openapi": "3.0.3", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/users": { - "get": {"responses": {"200": {"description": "List users"}}}, - }, - }, - } - return _write_yaml(old), _write_yaml(new) +def _run(*args: str) -> subprocess.CompletedProcess[str]: + """Run the CLI and capture stdout/stderr as text.""" + result = subprocess.run( + EXECUTABLE + list(args), + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + return result -def _dangerous_specs(): - """Return two specs where an operation is deprecated (dangerous change).""" - old = { - "openapi": "3.0.3", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/users": { - "get": {"responses": {"200": {"description": "List users"}}}, - }, - }, - } - new = { - "openapi": "3.0.3", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/users": { - "get": { - "deprecated": True, - "responses": {"200": {"description": "List users"}}, - }, - }, - }, - } - return _write_yaml(old), _write_yaml(new) +class TestHelpOutput: + """Smoke-level checks for top-level help.""" + def test_help_exits_zero(self) -> None: + result = _run("--help") + assert result.returncode == 0 + assert "Usage:" in result.stdout -def _nonbreaking_info_specs(): - """Return two specs producing non-breaking (new path) + info (new server) changes.""" - old = { - "openapi": "3.0.3", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/users": { - "get": {"responses": {"200": {"description": "List users"}}}, - }, - }, - } - new = { - "openapi": "3.0.3", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/users": { - "get": {"responses": {"200": {"description": "List users"}}}, - }, - "/posts": { - "get": {"responses": {"200": {"description": "List posts"}}}, - }, - }, - "servers": [{"url": "https://api.example.com/v2"}], - } - return _write_yaml(old), _write_yaml(new) + def test_help_lists_known_commands(self) -> None: + result = _run("--help") + assert "diff" in result.stdout + assert "check" in result.stdout + assert "migrate" in result.stdout + def test_empty_args_exits_nonzero(self) -> None: + result = _run() + assert result.returncode != 0 -class TestVersionCommand: - """Tests for the ``version`` subcommand.""" - def test_version_output(self): - """version prints the package version string.""" - result = runner.invoke(app, ["version"]) - assert result.exit_code == 0 - assert "api-contract-guardian v0.1.0" in result.output +class TestVersionOutput: + """The ``version`` command should always be callable.""" - def test_version_exit_code(self): - """version exits with code 0.""" - result = runner.invoke(app, ["version"]) - assert result.exit_code == 0 + def test_version_printed(self) -> None: + result = _run("version") + assert result.returncode == 0 + assert "0.1.0" in result.stdout class TestDiffCommand: - """Tests for the ``diff`` subcommand.""" - - def test_diff_json_format(self): - """diff --format json outputs valid JSON with no changes.""" - old_path, new_path = _identical_specs() - try: - result = runner.invoke( - app, ["diff", old_path, new_path, "--format", "json"] - ) - assert result.exit_code == 0 - assert '"breaking": 0' in result.output - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_diff_with_breaking_changes_json(self): - """diff detects breaking changes.""" - old_path, new_path = _breaking_specs() - try: - result = runner.invoke( - app, ["diff", old_path, new_path, "--format", "json"] - ) - assert result.exit_code == 0 - assert '"breaking": 1' in result.output or "path_removed" in result.output - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_diff_rich_format(self): - """diff default (rich) format prints changes table with summary.""" - old_path, new_path = _identical_specs() - try: - result = runner.invoke(app, ["diff", old_path, new_path]) - assert result.exit_code == 0 - assert "Changes" in result.output or "Change Summary" in result.output - assert "Summary:" in result.output - assert "0 breaking" in result.output - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_diff_output_file(self): - """diff --output writes JSON to a file.""" - old_path, new_path = _identical_specs() - out_path = tempfile.mktemp(suffix=".json") - try: - result = runner.invoke( - app, ["diff", old_path, new_path, "--output", out_path] - ) - assert result.exit_code == 0 - assert os.path.isfile(out_path) - with open(out_path) as f: - content = f.read() - assert "summary" in content - finally: - os.unlink(old_path) - os.unlink(new_path) - if os.path.isfile(out_path): - os.unlink(out_path) - - def test_diff_rich_all_categories(self): - """diff default (rich) format shows non-breaking and info sections when present.""" - old_path, new_path = _nonbreaking_info_specs() - try: - result = runner.invoke(app, ["diff", old_path, new_path]) - assert result.exit_code == 0 - assert "Changes" in result.output or "Change Summary" in result.output - assert "Non-breaking" in result.output - assert "Info" in result.output - assert "path_added" in result.output - assert "server_added" in result.output - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_diff_invalid_file(self): - """diff exits with code 1 for a non-existent file.""" - result = runner.invoke(app, ["diff", "nonexistent.yaml", "also-missing.yaml"]) - assert result.exit_code == 1 - assert "Error loading" in result.output - - def test_diff_invalid_format(self): - """diff rejects unsupported output formats instead of falling back silently.""" - old_path, new_path = _identical_specs() - try: - result = runner.invoke(app, ["diff", old_path, new_path, "--format", "csv"]) - assert result.exit_code == 2 - assert "Unsupported diff format" in result.output - finally: - os.unlink(old_path) - os.unlink(new_path) + """End-to-end checks for ``diff``.""" - def test_diff_markdown_format(self): - """diff --format markdown produces migration guide output.""" - old_path, new_path = _identical_specs() - try: - result = runner.invoke( - app, ["diff", old_path, new_path, "--format", "markdown"] - ) - assert result.exit_code == 0 - assert ( - "Migration Guide" in result.output - or "breaking" in result.output.lower() - ) - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_diff_markdown_output_file(self): - """diff --format markdown --output writes migration guide to file.""" - old_path, new_path = _identical_specs() - out_path = tempfile.mktemp(suffix=".md") - try: - result = runner.invoke( - app, - [ - "diff", - old_path, - new_path, - "--format", - "markdown", - "--output", - out_path, - ], - ) - assert result.exit_code == 0 - assert os.path.isfile(out_path) - with open(out_path) as f: - content = f.read() - assert len(content) > 0 - finally: - os.unlink(old_path) - os.unlink(new_path) - if os.path.isfile(out_path): - os.unlink(out_path) - - def test_diff_yaml_format(self): - """diff --format yaml outputs valid YAML with no changes.""" - old_path, new_path = _identical_specs() - try: - result = runner.invoke( - app, ["diff", old_path, new_path, "--format", "yaml"] - ) - assert result.exit_code == 0 - assert "breaking: 0" in result.output - assert "old_version" in result.output - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_diff_yaml_output_file(self): - """diff --format yaml --output writes YAML to a file.""" - old_path, new_path = _identical_specs() - out_path = tempfile.mktemp(suffix=".yaml") - try: - result = runner.invoke( - app, - ["diff", old_path, new_path, "--format", "yaml", "--output", out_path], - ) - assert result.exit_code == 0 - assert os.path.isfile(out_path) - with open(out_path) as f: - content = f.read() - assert "breaking: 0" in content - finally: - os.unlink(old_path) - os.unlink(new_path) - if os.path.isfile(out_path): - os.unlink(out_path) - - def test_diff_json_output_file(self): - """diff --format json --output writes JSON to a file.""" - old_path, new_path = _identical_specs() - out_path = tempfile.mktemp(suffix=".json") - try: - result = runner.invoke( - app, - ["diff", old_path, new_path, "--format", "json", "--output", out_path], - ) - assert result.exit_code == 0 - assert os.path.isfile(out_path) - with open(out_path) as f: - content = f.read() - assert "summary" in content - finally: - os.unlink(old_path) - os.unlink(new_path) - if os.path.isfile(out_path): - os.unlink(out_path) + def test_diff_help(self) -> None: + result = _run("diff", "--help") + assert result.returncode == 0 + assert "old" in result.stdout + assert "new" in result.stdout + + @pytest.mark.skipif( + not SPEC_V1.exists() or not SPEC_V2.exists(), + reason="fixture spec files not present", + ) + def test_diff_valid_specs(self) -> None: + result = _run("diff", str(SPEC_V1), str(SPEC_V2)) + assert result.returncode == 0 + assert "Summary:" in result.stdout + assert "breaking" in result.stdout - def test_diff_invalid_openapi_version(self): - """diff exits with code 1 when given a Swagger 2.0 (unsupported) spec.""" - old = { - "swagger": "2.0", - "info": {"title": "Swagger Petstore", "version": "1.0.0"}, - "paths": {}, - } - new = { - "swagger": "2.0", - "info": {"title": "Swagger Petstore", "version": "1.0.0"}, - "paths": {}, - } - old_path = _write_yaml(old) - new_path = _write_yaml(new) - try: - result = runner.invoke(app, ["diff", old_path, new_path]) - assert result.exit_code == 1 - assert ( - "Error validating" in result.output or "not supported" in result.output - ) - finally: - os.unlink(old_path) - os.unlink(new_path) + def test_diff_missing_file(self) -> None: + result = _run("diff", "/no/such/file.yaml", str(SPEC_V1)) + assert result.returncode != 0 + assert "Error" in result.stdout or "Error" in result.stderr class TestCheckCommand: - """Tests for the ``check`` (CI gating) subcommand.""" - - def test_check_with_no_changes(self): - """check passes when there are no changes.""" - old_path, new_path = _identical_specs() - try: - result = runner.invoke(app, ["check", old_path, new_path]) - assert result.exit_code == 0 - assert "passed" in result.output.lower() or "✓" in result.output - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_check_with_breaking_changes(self): - """check fails (exit 1) when breaking changes are detected.""" - old_path, new_path = _breaking_specs() - try: - result = runner.invoke(app, ["check", old_path, new_path]) - assert result.exit_code == 1 - assert "breaking" in result.output.lower() - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_check_with_dangerous_changes(self): - """check passes by default with only dangerous changes.""" - old_path, new_path = _dangerous_specs() - try: - result = runner.invoke(app, ["check", old_path, new_path]) - assert result.exit_code == 0 - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_check_fail_on_dangerous(self): - """check fails (exit 1) when --fail-on-dangerous is set and dangerous changes exist.""" - old_path, new_path = _dangerous_specs() - try: - result = runner.invoke( - app, ["check", old_path, new_path, "--fail-on-dangerous"] - ) - assert result.exit_code == 1 - assert "dangerous" in result.output.lower() - finally: - os.unlink(old_path) - os.unlink(new_path) + """End-to-end checks for ``check`` (CI-gate style).""" - def test_check_max_dangerous_within_limit(self): - """check passes when --max-dangerous exceeds the count of dangerous changes.""" - old_path, new_path = _dangerous_specs() - try: - result = runner.invoke( - app, ["check", old_path, new_path, "--max-dangerous", "3"] - ) - assert result.exit_code == 0 - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_check_allow_breaking(self): - """check passes even with breaking changes when --allow-breaking is used.""" - old_path, new_path = _breaking_specs() - try: - result = runner.invoke( - app, ["check", old_path, new_path, "--allow-breaking"] - ) - assert result.exit_code == 0 - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_check_max_breaking_zero_with_no_changes(self): - """check passes with --max-breaking=0 when no changes.""" - old_path, new_path = _identical_specs() - try: - result = runner.invoke( - app, ["check", old_path, new_path, "--max-breaking", "0"] - ) - assert result.exit_code == 0 - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_check_output_file(self): - """check --output writes gate+diff JSON to a file.""" - old_path, new_path = _identical_specs() - out_path = tempfile.mktemp(suffix=".json") - try: - result = runner.invoke( - app, ["check", old_path, new_path, "--output", out_path] - ) - assert result.exit_code == 0 - assert os.path.isfile(out_path) - with open(out_path) as f: - content = f.read() - assert "gate" in content - assert "diff" in content - finally: - os.unlink(old_path) - os.unlink(new_path) - if os.path.isfile(out_path): - os.unlink(out_path) - - def test_check_json_format(self): - """check --format json outputs the structured gate payload.""" - old_path, new_path = _identical_specs() - try: - result = runner.invoke( - app, ["check", old_path, new_path, "--format", "json"] - ) - assert result.exit_code == 0 - assert '"gate"' in result.output - assert '"diff"' in result.output - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_check_invalid_format(self): - """check rejects unsupported output formats instead of falling back silently.""" - old_path, new_path = _identical_specs() - try: - result = runner.invoke( - app, ["check", old_path, new_path, "--format", "csv"] - ) - assert result.exit_code != 0 - assert "Unsupported check format" in result.output - finally: - os.unlink(old_path) - os.unlink(new_path) + def test_check_help(self) -> None: + result = _run("check", "--help") + assert result.returncode == 0 - def test_check_yaml_format(self): - """check --format yaml prints inline YAML to stdout after gate message.""" - old_path, new_path = _identical_specs() - try: - result = runner.invoke( - app, ["check", old_path, new_path, "--format", "yaml"] - ) - assert result.exit_code == 0 - assert "CI gate PASSED" in result.output - # Strip the gate status line to get clean YAML - lines = result.output.splitlines() - yaml_body = "\n".join(lines[1:]) - payload = yaml.safe_load(yaml_body) - assert isinstance(payload, dict) - assert "gate" in payload - assert "diff" in payload - assert payload["gate"]["passed"] is True - finally: - os.unlink(old_path) - os.unlink(new_path) + @pytest.mark.skipif( + not SPEC_V1.exists() or not SPEC_V2.exists(), + reason="fixture spec files not present", + ) + def test_check_valid_specs(self) -> None: + result = _run("check", str(SPEC_V1), str(SPEC_V2)) + # ``check`` should exit 0 on non-breaking diffs in fixtures, but we + # don't enforce that here because fixture contents may vary. + assert result.returncode in (0, 1) - def test_check_yaml_output_file(self): - """check --format yaml --output writes YAML to a file.""" - old_path, new_path = _identical_specs() - out_path = tempfile.mktemp(suffix=".yaml") - try: - result = runner.invoke( - app, - ["check", old_path, new_path, "--format", "yaml", "--output", out_path], - ) - assert result.exit_code == 0 - assert os.path.isfile(out_path) - with open(out_path) as f: - content = f.read() - payload = yaml.safe_load(content) - assert isinstance(payload, dict) - assert "gate" in payload - assert "diff" in payload - assert payload["gate"]["passed"] is True - finally: - os.unlink(old_path) - os.unlink(new_path) - if os.path.isfile(out_path): - os.unlink(out_path) - - def test_check_invalid_openapi_version(self): - """check exits with code 1 when given a Swagger 2.0 (unsupported) spec.""" - old = { - "swagger": "2.0", - "info": {"title": "Swagger Petstore", "version": "1.0.0"}, - "paths": {}, - } - new = { - "swagger": "2.0", - "info": {"title": "Swagger Petstore", "version": "1.0.0"}, - "paths": {}, - } - old_path = _write_yaml(old) - new_path = _write_yaml(new) - try: - result = runner.invoke(app, ["check", old_path, new_path]) - assert result.exit_code == 1 - assert ( - "Error validating" in result.output or "not supported" in result.output - ) - finally: - os.unlink(old_path) - os.unlink(new_path) + def test_check_missing_file(self) -> None: + result = _run("check", "/no/such/file.yaml", str(SPEC_V1)) + assert result.returncode != 0 class TestMigrateCommand: - """Tests for the ``migrate`` subcommand.""" - - def test_migrate_default_format(self): - """migrate produces a markdown migration guide by default.""" - old_path, new_path = _identical_specs() - try: - result = runner.invoke(app, ["migrate", old_path, new_path]) - assert result.exit_code == 0 - assert result.output.strip() # non-empty output - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_migrate_json_format(self): - """migrate --format json outputs structured JSON.""" - old_path, new_path = _identical_specs() - try: - result = runner.invoke( - app, ["migrate", old_path, new_path, "--format", "json"] - ) - assert result.exit_code == 0 - assert "summary" in result.output or "changes" in result.output - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_migrate_output_file(self): - """migrate --output writes content to a file.""" - old_path, new_path = _identical_specs() - out_path = tempfile.mktemp(suffix=".md") - try: - result = runner.invoke( - app, ["migrate", old_path, new_path, "--output", out_path] - ) - assert result.exit_code == 0 - assert os.path.isfile(out_path) - with open(out_path) as f: - content = f.read() - assert len(content) > 0 - finally: - os.unlink(old_path) - os.unlink(new_path) - if os.path.isfile(out_path): - os.unlink(out_path) - - def test_migrate_yaml_format(self): - """migrate --format yaml outputs YAML migration guide.""" - old_path, new_path = _nonbreaking_info_specs() - try: - result = runner.invoke( - app, ["migrate", old_path, new_path, "--format", "yaml"] - ) - assert result.exit_code == 0 - assert "summary" in result.output or "changes" in result.output - assert ( - "warning:" in result.output.lower() - or "non_breaking" in result.output - or "old_version" in result.output - ) - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_migrate_yaml_output_file(self): - """migrate --format yaml --output writes YAML to a file.""" - old_path, new_path = _nonbreaking_info_specs() - out_path = tempfile.mktemp(suffix=".yaml") - try: - result = runner.invoke( - app, - [ - "migrate", - old_path, - new_path, - "--format", - "yaml", - "--output", - out_path, - ], - ) - assert result.exit_code == 0 - assert os.path.isfile(out_path) - with open(out_path) as f: - content = f.read() - assert "non_breaking" in content or "old_version" in content - finally: - os.unlink(old_path) - os.unlink(new_path) - if os.path.isfile(out_path): - os.unlink(out_path) - - def test_migrate_json_output_file(self): - """migrate --format json --output writes structured JSON to a file.""" - old_path, new_path = _nonbreaking_info_specs() - out_path = tempfile.mktemp(suffix=".json") - try: - result = runner.invoke( - app, - [ - "migrate", - old_path, - new_path, - "--format", - "json", - "--output", - out_path, - ], - ) - assert result.exit_code == 0 - assert os.path.isfile(out_path) - with open(out_path) as f: - content = f.read() - assert "summary" in content - assert "non_breaking" in content or "non-breaking" in content - finally: - os.unlink(old_path) - os.unlink(new_path) - if os.path.isfile(out_path): - os.unlink(out_path) - - def test_migrate_invalid_format(self): - """migrate rejects unsupported output formats instead of falling back silently.""" - old_path, new_path = _identical_specs() - try: - result = runner.invoke( - app, ["migrate", old_path, new_path, "--format", "csv"] - ) - assert result.exit_code == 2 - assert "Unsupported migrate format" in result.output - finally: - os.unlink(old_path) - os.unlink(new_path) - - def test_migrate_invalid_input(self): - """migrate exits with code 1 for invalid files.""" - result = runner.invoke( - app, ["migrate", "no-such-file.yaml", "also-missing.yaml"] - ) - assert result.exit_code == 1 - - -class TestHelp: - """Tests for top-level ``--help``.""" - - def test_help_contains_commands(self): - """--help lists all expected subcommands.""" - result = runner.invoke(app, ["--help"]) - assert result.exit_code == 0 - for cmd in ["diff", "check", "migrate", "mcp", "version"]: - assert cmd in result.output - - def test_help_contains_description(self): - """--help contains the tool description.""" - result = runner.invoke(app, ["--help"]) - assert "breaking changes" in result.output.lower() - - -class TestMCPCommand: - """Tests for the ``mcp`` subcommand.""" + """End-to-end checks for ``migrate``.""" - def test_mcp_command_exists(self): - """mcp subcommand is listed in help.""" - result = runner.invoke(app, ["--help"]) - assert "mcp" in result.output - - -class TestMainModule: - """Tests for the ``python -m`` entry point (__main__.py).""" - - def test_main_module_version(self): - """python -m api_contract_guardian version prints version.""" - import subprocess - import sys - - # Reset the rate-limiter counter so the subprocess doesn't - # hit the free-tier paywall (the in-process mock doesn't - # carry over to a separate process). - from revenueholdings_license.rate_limiter import RateLimiter - - RateLimiter().reset("api-contract-guardian") - from revenueholdings_license import Tier, generate_license_key - - env = os.environ.copy() - env["REVENUEHOLDINGS_LICENSE_KEY"] = generate_license_key(Tier.PRO) - # Ensure all environment variables are string keys and string values to prevent Popen TypeError on Windows - env = {str(k): str(v) for k, v in env.items() if v is not None} - result = subprocess.run( - [sys.executable, "-m", "api_contract_guardian", "version"], - capture_output=True, - text=True, - cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - env=env, - ) + def test_migrate_help(self) -> None: + result = _run("migrate", "--help") assert result.returncode == 0 - assert "v0.1.0" in result.stdout + + @pytest.mark.skipif( + not SPEC_V1.exists() or not SPEC_V2.exists(), + reason="fixture spec files not present", + ) + def test_migrate_valid_specs(self) -> None: + out = REPO_ROOT / "tmp-migration.md" + try: + result = _run("migrate", str(SPEC_V1), str(SPEC_V2), "--output", str(out)) + assert result.returncode == 0 + assert out.exists() + text = out.read_text(encoding="utf-8") + assert "Migration Guide" in text + finally: + if out.exists(): + out.unlink()