fix(ci): fix black/ruff failures, add CoC and PR template

- Fix Python formatting (sync no longer strips blank lines from .py files)
- Remove test_community_build.py (tests private sync module)
- Fix ruff warnings in test files
- Add CODE_OF_CONDUCT.md
- Add .github/PULL_REQUEST_TEMPLATE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-17 18:19:39 +03:30
parent c9083d86b1
commit c73e39f6e4
135 changed files with 1185 additions and 317 deletions

View File

@@ -1,8 +1,5 @@
"""Tests for Authentication System (core/auth.py)."""
import os
import secrets
import pytest
from core.auth import AuthManager

View File

@@ -1,270 +0,0 @@
"""Tests for Community Build Sync Script."""
import tempfile
from pathlib import Path
import pytest
# Import sync module from scripts
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts" / "community-build"))
from sync import (
SyncReport,
apply_branding_transform,
collect_files,
parse_communityignore,
scan_for_secrets,
should_exclude,
should_transform,
sync,
)
class TestParseCommunityignore:
"""Test .communityignore parsing."""
def test_parse_valid_file(self, tmp_path):
ignore_file = tmp_path / ".communityignore"
ignore_file.write_text("# comment\n\nfoo/\n*.pyc\nbar.txt\n")
patterns = parse_communityignore(ignore_file)
assert patterns == ["foo/", "*.pyc", "bar.txt"]
def test_parse_missing_file(self, tmp_path):
patterns = parse_communityignore(tmp_path / "nonexistent")
assert patterns == []
def test_skip_comments_and_empty(self, tmp_path):
ignore_file = tmp_path / ".communityignore"
ignore_file.write_text("# header\n\n# another\nreal_pattern\n\n")
patterns = parse_communityignore(ignore_file)
assert patterns == ["real_pattern"]
class TestShouldExclude:
"""Test file exclusion logic."""
def test_directory_pattern(self):
assert should_exclude("docs/plans/file.md", ["docs/plans/"]) is True
assert should_exclude("docs/other/file.md", ["docs/plans/"]) is False
def test_exact_match(self):
assert should_exclude("MASTER_CONTEXT.md", ["MASTER_CONTEXT.md"]) is True
assert should_exclude("README.md", ["MASTER_CONTEXT.md"]) is False
def test_glob_pattern(self):
assert should_exclude("module.pyc", ["*.pyc"]) is True
assert should_exclude("module.py", ["*.pyc"]) is False
def test_nested_directory(self):
assert should_exclude("__pycache__/module.cpython-311.pyc", ["__pycache__/"]) is True
def test_no_patterns(self):
assert should_exclude("any/file.txt", []) is False
def test_component_match(self):
"""Pattern matching against directory components."""
assert should_exclude("deep/__pycache__/file.pyc", ["__pycache__/"]) is True
class TestScanForSecrets:
"""Test secret detection."""
def test_no_secrets(self, tmp_path):
f = tmp_path / "clean.py"
f.write_text("x = 1\nprint('hello')\n")
assert scan_for_secrets(f, "clean.py") == []
def test_detects_private_key(self, tmp_path):
f = tmp_path / "bad.pem"
f.write_text("-----BEGIN PRIVATE KEY-----\ndata\n-----END PRIVATE KEY-----\n")
warnings = scan_for_secrets(f, "bad.pem")
assert len(warnings) > 0
def test_allowlisted_file_skipped(self, tmp_path):
f = tmp_path / "env.example"
f.write_text('password = "super_secret_value"\n')
assert scan_for_secrets(f, "env.example") == []
def test_detects_api_key_pattern(self, tmp_path):
f = tmp_path / "config.py"
f.write_text('api_key = "AKIAIOSFODNN7EXAMPLE12345"\n')
warnings = scan_for_secrets(f, "config.py")
assert len(warnings) > 0
class TestSync:
"""Test full sync operation."""
@pytest.fixture
def source_tree(self, tmp_path):
"""Create a mock source tree."""
src = tmp_path / "source"
src.mkdir()
# Create .communityignore
(src / ".communityignore").write_text("secret/\n*.log\nINTERNAL.md\n.communityignore\n")
# Create files that should be copied
(src / "README.md").write_text("# Public Readme")
(src / "core").mkdir()
(src / "core" / "auth.py").write_text("class AuthManager: pass")
# Create files that should be excluded
(src / "secret").mkdir()
(src / "secret" / "keys.json").write_text('{"key": "value"}')
(src / "INTERNAL.md").write_text("Internal docs")
(src / "app.log").write_text("log data")
return src
def test_dry_run(self, source_tree, tmp_path):
output = tmp_path / "output"
report = sync(source_tree, output, dry_run=True)
assert report.total_copied > 0
assert report.total_excluded > 0
assert not output.exists() # Nothing should be created
def test_actual_sync(self, source_tree, tmp_path):
output = tmp_path / "output"
report = sync(source_tree, output)
assert (output / "README.md").exists()
assert (output / "core" / "auth.py").exists()
assert not (output / "secret").exists()
assert not (output / "INTERNAL.md").exists()
assert not (output / "app.log").exists()
def test_communityignore_excluded(self, source_tree, tmp_path):
output = tmp_path / "output"
report = sync(source_tree, output)
# .communityignore itself should be excluded
assert not (output / ".communityignore").exists()
assert ".communityignore" in report.excluded
class TestSyncReport:
"""Test report generation."""
def test_summary_format(self):
report = SyncReport(
copied=["a.py", "b.py"],
excluded=["c.log"],
source_dir="/src",
output_dir="/out",
timestamp="2026-02-17 12:00:00 UTC",
)
summary = report.summary()
assert "Files copied: **2**" in summary
assert "Files excluded: **1**" in summary
def test_summary_with_warnings(self):
report = SyncReport(
copied=["a.py"],
excluded=[],
secret_warnings=["file.py: found secret"],
source_dir="/src",
output_dir="/out",
timestamp="now",
)
summary = report.summary()
assert "Secret Warnings" in summary
def test_summary_has_review_checklist(self):
report = SyncReport(
copied=["a.py"],
excluded=[],
source_dir="/src",
output_dir="/out",
timestamp="now",
)
summary = report.summary()
assert "Pre-Publish Review Checklist" in summary
assert "APPROVED FOR PUBLISH" in summary
class TestBrandingTransform:
"""Test branding transform (B.3)."""
def test_replaces_package_name(self):
content = 'name = "coolify-mcp-hub"'
transformed, changed = apply_branding_transform(content)
assert changed is True
assert 'name = "mcphub"' in transformed
def test_replaces_display_name(self):
content = "Welcome to Coolify MCP Hub"
transformed, changed = apply_branding_transform(content)
assert "MCP Hub" in transformed
assert "Coolify" not in transformed
def test_replaces_urls(self):
content = "Homepage: https://airano.ir"
transformed, changed = apply_branding_transform(content)
assert "mcphub.dev" in transformed
assert "airano.ir" not in transformed
def test_replaces_repo_urls(self):
content = "https://gitea.airano.ir/dev/coolify-mcp-hub"
transformed, changed = apply_branding_transform(content)
assert "github.com/airano-ir/mcphub" in transformed
def test_replaces_email(self):
content = "Contact: gitea@airano.ir"
transformed, changed = apply_branding_transform(content)
assert "hello@mcphub.dev" in transformed
def test_strips_private_comments(self):
content = "line1\n# PRIVATE: internal note\nline3\n"
transformed, changed = apply_branding_transform(content)
assert "PRIVATE" not in transformed
assert "line1" in transformed
assert "line3" in transformed
def test_no_changes_returns_false(self):
content = "clean content with no markers\n"
transformed, changed = apply_branding_transform(content)
assert changed is False
def test_should_transform_pyproject(self):
assert should_transform("pyproject.toml") is True
def test_should_transform_readme(self):
assert should_transform("README.md") is True
def test_should_transform_docs(self):
assert should_transform("docs/OAUTH_GUIDE.md") is True
def test_should_transform_python(self):
assert should_transform("core/auth.py") is True
assert should_transform("server.py") is True
assert should_transform("plugins/wordpress/plugin.py") is True
def test_should_not_transform_binary(self):
assert should_transform("core/data.bin") is False
assert should_transform("images/logo.png") is False
def test_transform_applied_in_sync(self, tmp_path):
"""Full sync should apply transforms to matching files."""
src = tmp_path / "source"
src.mkdir()
(src / ".communityignore").write_text(".communityignore\n")
(src / "README.md").write_text("Welcome to Coolify MCP Hub\nhttps://airano.ir\n")
(src / "core").mkdir()
(src / "core" / "auth.py").write_text("# coolify-mcp-hub internal")
output = tmp_path / "output"
report = sync(src, output)
# README.md should be transformed
readme = (output / "README.md").read_text()
assert "MCP Hub" in readme
assert "Coolify" not in readme
assert "mcphub.dev" in readme
assert "README.md" in report.transformed
# auth.py SHOULD be transformed (Python files now in TRANSFORM_GLOBS)
auth = (output / "core" / "auth.py").read_text()
assert "mcphub" in auth # transformed

View File

@@ -6,7 +6,6 @@ language detection, translations, and utility functions.
import time
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
import jwt
import pytest
@@ -20,7 +19,6 @@ from core.dashboard.routes import (
get_translations,
)
# --- Plugin Display Names ---
@@ -49,8 +47,15 @@ class TestPluginDisplayNames:
def test_all_nine_plugins_mapped(self):
"""All 9 plugin types should be in the display names map."""
expected = {
"wordpress", "woocommerce", "wordpress_advanced", "gitea",
"n8n", "supabase", "openpanel", "appwrite", "directus",
"wordpress",
"woocommerce",
"wordpress_advanced",
"gitea",
"n8n",
"supabase",
"openpanel",
"appwrite",
"directus",
}
assert expected == set(PLUGIN_DISPLAY_NAMES.keys())
@@ -119,7 +124,9 @@ class TestTranslations:
"""English and Farsi should have the same translation keys."""
en_keys = set(DASHBOARD_TRANSLATIONS["en"].keys())
fa_keys = set(DASHBOARD_TRANSLATIONS["fa"].keys())
assert en_keys == fa_keys, f"Missing keys in fa: {en_keys - fa_keys}, extra in fa: {fa_keys - en_keys}"
assert (
en_keys == fa_keys
), f"Missing keys in fa: {en_keys - fa_keys}, extra in fa: {fa_keys - en_keys}"
def test_no_empty_translations(self):
"""No translation value should be empty."""
@@ -197,7 +204,8 @@ class TestDashboardRateLimiting:
@pytest.fixture
def auth(self):
return DashboardAuth(
secret_key="test-secret", master_api_key="sk-master",
secret_key="test-secret",
master_api_key="sk-master",
)
def test_within_limit(self, auth):

View File

@@ -1,7 +1,5 @@
"""Tests for Rate Limiter (core/rate_limiter.py)."""
import time
import pytest
from core.rate_limiter import ClientRateLimitState, RateLimitConfig, RateLimiter, TokenBucket

View File

@@ -4,7 +4,6 @@ import pytest
from core.site_manager import SiteConfig, SiteManager
# --- SiteConfig Tests ---

View File

@@ -11,7 +11,6 @@ import pytest
from plugins.base import BasePlugin
from plugins.woocommerce.plugin import WooCommercePlugin
# --- WooCommercePlugin Initialization ---
@@ -102,7 +101,7 @@ class TestWooCommerceToolSpecs:
"""Each spec should have name, method_name, description, schema, scope."""
specs = WooCommercePlugin.get_tool_specifications()
for spec in specs:
assert "name" in spec, f"Missing 'name' in spec"
assert "name" in spec, "Missing 'name' in spec"
assert "method_name" in spec, f"Missing 'method_name' in {spec.get('name')}"
assert "description" in spec, f"Missing 'description' in {spec.get('name')}"
assert "schema" in spec, f"Missing 'schema' in {spec.get('name')}"
@@ -113,26 +112,42 @@ class TestWooCommerceToolSpecs:
specs = WooCommercePlugin.get_tool_specifications()
valid_scopes = {"read", "write", "admin"}
for spec in specs:
assert spec["scope"] in valid_scopes, f"Invalid scope '{spec['scope']}' in {spec['name']}"
assert (
spec["scope"] in valid_scopes
), f"Invalid scope '{spec['scope']}' in {spec['name']}"
def test_specs_unique_names(self):
"""All tool names should be unique."""
specs = WooCommercePlugin.get_tool_specifications()
names = [s["name"] for s in specs]
assert len(names) == len(set(names)), f"Duplicate names: {[n for n in names if names.count(n) > 1]}"
assert len(names) == len(
set(names)
), f"Duplicate names: {[n for n in names if names.count(n) > 1]}"
def test_product_tools_present(self):
"""Should include product management tools."""
specs = WooCommercePlugin.get_tool_specifications()
names = {s["name"] for s in specs}
expected = {"list_products", "get_product", "create_product", "update_product", "delete_product"}
expected = {
"list_products",
"get_product",
"create_product",
"update_product",
"delete_product",
}
assert expected.issubset(names), f"Missing product tools: {expected - names}"
def test_order_tools_present(self):
"""Should include order management tools."""
specs = WooCommercePlugin.get_tool_specifications()
names = {s["name"] for s in specs}
expected = {"list_orders", "get_order", "create_order", "update_order_status", "delete_order"}
expected = {
"list_orders",
"get_order",
"create_order",
"update_order_status",
"delete_order",
}
assert expected.issubset(names), f"Missing order tools: {expected - names}"
def test_customer_tools_present(self):
@@ -193,7 +208,7 @@ class TestWooCommerceHandlerDelegation:
async def test_create_product_delegates(self, plugin):
"""create_product should delegate to products handler."""
plugin.products.create_product = AsyncMock(return_value={"id": 99})
result = await plugin.create_product(name="Widget", regular_price="19.99")
await plugin.create_product(name="Widget", regular_price="19.99")
plugin.products.create_product.assert_called_once_with(name="Widget", regular_price="19.99")
@pytest.mark.asyncio
@@ -207,7 +222,7 @@ class TestWooCommerceHandlerDelegation:
async def test_get_order_delegates(self, plugin):
"""get_order should delegate to orders handler."""
plugin.orders.get_order = AsyncMock(return_value={"id": 1, "status": "completed"})
result = await plugin.get_order(order_id=1)
await plugin.get_order(order_id=1)
plugin.orders.get_order.assert_called_once_with(order_id=1)
@pytest.mark.asyncio
@@ -221,14 +236,14 @@ class TestWooCommerceHandlerDelegation:
async def test_create_coupon_delegates(self, plugin):
"""create_coupon should delegate to coupons handler."""
plugin.coupons.create_coupon = AsyncMock(return_value={"id": 5})
result = await plugin.create_coupon(code="SAVE10", discount_type="percent")
await plugin.create_coupon(code="SAVE10", discount_type="percent")
plugin.coupons.create_coupon.assert_called_once_with(code="SAVE10", discount_type="percent")
@pytest.mark.asyncio
async def test_get_sales_report_delegates(self, plugin):
"""get_sales_report should delegate to reports handler."""
plugin.reports.get_sales_report = AsyncMock(return_value={"total": 1000})
result = await plugin.get_sales_report(period="month")
await plugin.get_sales_report(period="month")
plugin.reports.get_sales_report.assert_called_once_with(period="month")

View File

@@ -14,7 +14,6 @@ from plugins.base import BasePlugin, PluginRegistry
from plugins.wordpress.client import AuthenticationError, ConfigurationError, WordPressClient
from plugins.wordpress.plugin import WordPressPlugin
# --- WordPressClient Tests ---
@@ -87,7 +86,9 @@ class TestWordPressClientErrorParsing:
def test_parse_json_error(self, client):
"""Should parse JSON error responses."""
error_text = json.dumps({"code": "rest_forbidden", "message": "Sorry, you are not allowed."})
error_text = json.dumps(
{"code": "rest_forbidden", "message": "Sorry, you are not allowed."}
)
result = client._parse_error_response(403, error_text)
assert result["error_code"] == "ACCESS_DENIED"
assert result["status_code"] == 403
@@ -159,10 +160,12 @@ class TestWordPressClientRequest:
mock_response.json = AsyncMock(return_value={"id": 1})
mock_session = AsyncMock()
mock_session.request = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
mock_session.request = MagicMock(
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
)
)
with patch("aiohttp.ClientSession") as mock_cls:
mock_cls.return_value = AsyncMock(
@@ -171,7 +174,8 @@ class TestWordPressClientRequest:
)
result = await client.request(
"GET", "posts",
"GET",
"posts",
params={"status": "publish", "search": None, "tags": "", "ids": []},
)
assert result == {"id": 1}
@@ -194,10 +198,12 @@ class TestWordPressClientRequest:
)
mock_session = AsyncMock()
mock_session.request = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
mock_session.request = MagicMock(
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
)
)
with patch("aiohttp.ClientSession") as mock_cls:
mock_cls.return_value = AsyncMock(
@@ -216,10 +222,12 @@ class TestWordPressClientRequest:
mock_response.text = AsyncMock(return_value="Internal Server Error")
mock_session = AsyncMock()
mock_session.request = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
mock_session.request = MagicMock(
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
)
)
with patch("aiohttp.ClientSession") as mock_cls:
mock_cls.return_value = AsyncMock(
@@ -236,8 +244,11 @@ class TestWordPressClientRequest:
client.request = AsyncMock(return_value={"posts": []})
result = await client.get("posts", params={"per_page": 10})
client.request.assert_called_once_with(
"GET", "posts", params={"per_page": 10},
use_custom_namespace=False, use_woocommerce=False,
"GET",
"posts",
params={"per_page": 10},
use_custom_namespace=False,
use_woocommerce=False,
)
assert result == {"posts": []}
@@ -263,8 +274,11 @@ class TestWordPressClientRequest:
client.request = AsyncMock(return_value={"available": True})
await client.get("products", use_woocommerce=True)
client.request.assert_called_once_with(
"GET", "products", params=None,
use_custom_namespace=False, use_woocommerce=True,
"GET",
"products",
params=None,
use_custom_namespace=False,
use_woocommerce=True,
)
@@ -361,7 +375,7 @@ class TestWordPressToolSpecifications:
"""Each spec should have name, method_name, description, schema, scope."""
specs = WordPressPlugin.get_tool_specifications()
for spec in specs:
assert "name" in spec, f"Missing 'name' in spec"
assert "name" in spec, "Missing 'name' in spec"
assert "method_name" in spec, f"Missing 'method_name' in {spec.get('name')}"
assert "description" in spec, f"Missing 'description' in {spec.get('name')}"
assert "schema" in spec, f"Missing 'schema' in {spec.get('name')}"
@@ -372,13 +386,17 @@ class TestWordPressToolSpecifications:
specs = WordPressPlugin.get_tool_specifications()
valid_scopes = {"read", "write", "admin"}
for spec in specs:
assert spec["scope"] in valid_scopes, f"Invalid scope '{spec['scope']}' in {spec['name']}"
assert (
spec["scope"] in valid_scopes
), f"Invalid scope '{spec['scope']}' in {spec['name']}"
def test_specs_unique_names(self):
"""All tool names should be unique."""
specs = WordPressPlugin.get_tool_specifications()
names = [s["name"] for s in specs]
assert len(names) == len(set(names)), f"Duplicate tool names found: {[n for n in names if names.count(n) > 1]}"
assert len(names) == len(
set(names)
), f"Duplicate tool names found: {[n for n in names if names.count(n) > 1]}"
def test_core_tools_present(self):
"""Should include key WordPress tools."""
@@ -442,21 +460,21 @@ class TestWordPressHandlerDelegation:
async def test_list_media_delegates(self, plugin):
"""list_media should delegate to media handler."""
plugin.media.list_media = AsyncMock(return_value=[])
result = await plugin.list_media()
await plugin.list_media()
plugin.media.list_media.assert_called_once()
@pytest.mark.asyncio
async def test_list_categories_delegates(self, plugin):
"""list_categories should delegate to taxonomy handler."""
plugin.taxonomy.list_categories = AsyncMock(return_value=[])
result = await plugin.list_categories()
await plugin.list_categories()
plugin.taxonomy.list_categories.assert_called_once()
@pytest.mark.asyncio
async def test_list_comments_delegates(self, plugin):
"""list_comments should delegate to comments handler."""
plugin.comments.list_comments = AsyncMock(return_value=[])
result = await plugin.list_comments()
await plugin.list_comments()
plugin.comments.list_comments.assert_called_once()
@pytest.mark.asyncio