- Add new metadata.py module with 4 core functions: * update_package_metadata: Update description, keywords, classifiers * manage_package_urls: Update homepage, documentation, repository URLs * set_package_visibility: Make packages private/public (for organizations) * manage_package_keywords: Update search keywords and tags - Add PyPIMetadataClient with comprehensive async/await patterns - Include robust error handling and validation for all metadata formats - Provide implementation guidance for metadata updates via package uploads - Add MCP server endpoints for all 4 metadata management functions - Update tools/__init__.py with proper imports and exports - Create comprehensive test suite with 50+ test cases covering: * Client initialization and validation * All metadata management functions * Error handling and edge cases * URL validation and accessibility checking * Keyword quality analysis and scoring * Integration workflows Features: - Production-ready code following existing patterns - Comprehensive docstrings and type hints - Authentication with API tokens - Dry-run mode for safe validation - URL quality scoring and accessibility validation - Keyword quality analysis with recommendations - Organization detection for visibility management - Detailed validation errors and recommendations
721 lines
29 KiB
Python
721 lines
29 KiB
Python
"""Tests for PyPI metadata management tools."""
|
|
|
|
import asyncio
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from pypi_query_mcp.core.exceptions import (
|
|
InvalidPackageNameError,
|
|
NetworkError,
|
|
PackageNotFoundError,
|
|
PyPIAuthenticationError,
|
|
PyPIPermissionError,
|
|
PyPIServerError,
|
|
)
|
|
from pypi_query_mcp.tools.metadata import (
|
|
PyPIMetadataClient,
|
|
manage_package_keywords,
|
|
manage_package_urls,
|
|
set_package_visibility,
|
|
update_package_metadata,
|
|
)
|
|
|
|
|
|
class TestPyPIMetadataClient:
|
|
"""Test cases for PyPIMetadataClient."""
|
|
|
|
def test_init_default(self):
|
|
"""Test client initialization with default values."""
|
|
client = PyPIMetadataClient()
|
|
|
|
assert client.api_token is None
|
|
assert client.test_pypi is False
|
|
assert client.timeout == 60.0
|
|
assert client.max_retries == 3
|
|
assert client.retry_delay == 2.0
|
|
assert "pypi.org" in client.api_url
|
|
assert "pypi.org" in client.manage_url
|
|
|
|
def test_init_test_pypi(self):
|
|
"""Test client initialization for TestPyPI."""
|
|
client = PyPIMetadataClient(test_pypi=True)
|
|
|
|
assert client.test_pypi is True
|
|
assert "test.pypi.org" in client.api_url
|
|
assert "test.pypi.org" in client.manage_url
|
|
|
|
def test_init_with_token(self):
|
|
"""Test client initialization with API token."""
|
|
token = "pypi-test-token"
|
|
client = PyPIMetadataClient(api_token=token)
|
|
|
|
assert client.api_token == token
|
|
assert "Authorization" in client._client.headers
|
|
assert client._client.headers["Authorization"] == f"token {token}"
|
|
|
|
def test_validate_package_name_valid(self):
|
|
"""Test package name validation with valid names."""
|
|
client = PyPIMetadataClient()
|
|
|
|
valid_names = [
|
|
"requests",
|
|
"django-rest-framework",
|
|
"numpy",
|
|
"package_name",
|
|
"package.name",
|
|
"a",
|
|
"a1",
|
|
"package-1.0",
|
|
]
|
|
|
|
for name in valid_names:
|
|
result = client._validate_package_name(name)
|
|
assert result == name.strip()
|
|
|
|
def test_validate_package_name_invalid(self):
|
|
"""Test package name validation with invalid names."""
|
|
client = PyPIMetadataClient()
|
|
|
|
invalid_names = [
|
|
"",
|
|
" ",
|
|
"-invalid",
|
|
"invalid-",
|
|
".invalid",
|
|
"invalid.",
|
|
"in..valid",
|
|
"in--valid",
|
|
]
|
|
|
|
for name in invalid_names:
|
|
with pytest.raises(InvalidPackageNameError):
|
|
client._validate_package_name(name)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_make_request_success(self):
|
|
"""Test successful HTTP request."""
|
|
with patch.object(httpx.AsyncClient, 'request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_request.return_value = mock_response
|
|
|
|
client = PyPIMetadataClient()
|
|
response = await client._make_request("GET", "https://example.com")
|
|
|
|
assert response == mock_response
|
|
mock_request.assert_called_once_with("GET", "https://example.com")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_make_request_authentication_error(self):
|
|
"""Test HTTP request with authentication error."""
|
|
with patch.object(httpx.AsyncClient, 'request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 401
|
|
mock_request.return_value = mock_response
|
|
|
|
client = PyPIMetadataClient()
|
|
|
|
with pytest.raises(PyPIAuthenticationError):
|
|
await client._make_request("GET", "https://example.com")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_package_ownership_no_token(self):
|
|
"""Test package ownership verification without token."""
|
|
client = PyPIMetadataClient()
|
|
result = await client._verify_package_ownership("test-package")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_package_ownership_with_token(self):
|
|
"""Test package ownership verification with token."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_request.return_value = mock_response
|
|
|
|
client = PyPIMetadataClient(api_token="test-token")
|
|
result = await client._verify_package_ownership("test-package")
|
|
assert result is True
|
|
|
|
|
|
class TestUpdatePackageMetadata:
|
|
"""Test cases for update_package_metadata function."""
|
|
|
|
@pytest.fixture
|
|
def mock_package_data(self):
|
|
"""Mock package data from PyPI API."""
|
|
return {
|
|
"info": {
|
|
"name": "test-package",
|
|
"version": "1.0.0",
|
|
"summary": "Test package description",
|
|
"keywords": "test,python,package",
|
|
"classifiers": [
|
|
"Development Status :: 4 - Beta",
|
|
"Programming Language :: Python :: 3",
|
|
"License :: OSI Approved :: MIT License",
|
|
],
|
|
"author": "Test Author",
|
|
"license": "MIT",
|
|
}
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_metadata_dry_run_success(self, mock_package_data):
|
|
"""Test metadata update in dry run mode."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
result = await update_package_metadata(
|
|
package_name="test-package",
|
|
description="New description",
|
|
keywords=["test", "python", "new"],
|
|
dry_run=True,
|
|
)
|
|
|
|
assert result["dry_run"] is True
|
|
assert result["package_name"] == "test-package"
|
|
assert "metadata_updates" in result
|
|
assert result["metadata_updates"]["description"] == "New description"
|
|
assert "test" in result["metadata_updates"]["keywords"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_metadata_invalid_package(self):
|
|
"""Test metadata update with invalid package name."""
|
|
with pytest.raises(InvalidPackageNameError):
|
|
await update_package_metadata(
|
|
package_name="",
|
|
description="Test description",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_metadata_package_not_found(self):
|
|
"""Test metadata update with non-existent package."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 404
|
|
mock_request.return_value = mock_response
|
|
|
|
with pytest.raises(PackageNotFoundError):
|
|
await update_package_metadata(
|
|
package_name="non-existent-package",
|
|
description="Test description",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_metadata_validation_errors(self, mock_package_data):
|
|
"""Test metadata update with validation errors."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
# Test description too long
|
|
long_description = "x" * 3000
|
|
result = await update_package_metadata(
|
|
package_name="test-package",
|
|
description=long_description,
|
|
dry_run=True,
|
|
)
|
|
|
|
assert len(result["validation_errors"]) > 0
|
|
assert any("Description exceeds" in error for error in result["validation_errors"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_metadata_invalid_keywords(self, mock_package_data):
|
|
"""Test metadata update with invalid keywords."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
# Test invalid keyword type
|
|
result = await update_package_metadata(
|
|
package_name="test-package",
|
|
keywords="not-a-list", # Should be a list
|
|
dry_run=True,
|
|
)
|
|
|
|
assert len(result["validation_errors"]) > 0
|
|
assert any("must be a list" in error for error in result["validation_errors"])
|
|
|
|
|
|
class TestManagePackageUrls:
|
|
"""Test cases for manage_package_urls function."""
|
|
|
|
@pytest.fixture
|
|
def mock_package_data(self):
|
|
"""Mock package data from PyPI API."""
|
|
return {
|
|
"info": {
|
|
"name": "test-package",
|
|
"version": "1.0.0",
|
|
"home_page": "https://example.com",
|
|
"download_url": "",
|
|
"project_urls": {
|
|
"Documentation": "https://docs.example.com",
|
|
"Repository": "https://github.com/user/repo",
|
|
},
|
|
}
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manage_urls_dry_run_success(self, mock_package_data):
|
|
"""Test URL management in dry run mode."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
result = await manage_package_urls(
|
|
package_name="test-package",
|
|
homepage="https://new-homepage.com",
|
|
documentation="https://new-docs.com",
|
|
validate_urls=False, # Skip URL validation for test
|
|
dry_run=True,
|
|
)
|
|
|
|
assert result["dry_run"] is True
|
|
assert result["package_name"] == "test-package"
|
|
assert "url_updates" in result
|
|
assert result["url_updates"]["homepage"] == "https://new-homepage.com"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manage_urls_invalid_format(self, mock_package_data):
|
|
"""Test URL management with invalid URL formats."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
result = await manage_package_urls(
|
|
package_name="test-package",
|
|
homepage="not-a-url",
|
|
dry_run=True,
|
|
)
|
|
|
|
assert len(result["validation_errors"]) > 0
|
|
assert any("Invalid" in error and "URL format" in error for error in result["validation_errors"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manage_urls_with_validation(self, mock_package_data):
|
|
"""Test URL management with URL validation."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
# Mock package data request
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
|
|
# Mock URL validation request
|
|
mock_head_response = Mock()
|
|
mock_head_response.status_code = 200
|
|
|
|
mock_request.side_effect = [mock_response, mock_head_response]
|
|
|
|
with patch.object(httpx.AsyncClient, 'head', return_value=mock_head_response):
|
|
result = await manage_package_urls(
|
|
package_name="test-package",
|
|
homepage="https://valid-url.com",
|
|
validate_urls=True,
|
|
dry_run=True,
|
|
)
|
|
|
|
assert "validation_results" in result
|
|
assert "homepage" in result["validation_results"]
|
|
assert result["validation_results"]["homepage"]["accessible"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manage_urls_quality_score(self, mock_package_data):
|
|
"""Test URL quality score calculation."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
result = await manage_package_urls(
|
|
package_name="test-package",
|
|
homepage="https://secure-url.com", # HTTPS URL
|
|
documentation="http://insecure-url.com", # HTTP URL
|
|
validate_urls=False,
|
|
dry_run=True,
|
|
)
|
|
|
|
assert "url_quality_score" in result
|
|
assert isinstance(result["url_quality_score"], (int, float))
|
|
|
|
|
|
class TestSetPackageVisibility:
|
|
"""Test cases for set_package_visibility function."""
|
|
|
|
@pytest.fixture
|
|
def mock_package_data(self):
|
|
"""Mock package data from PyPI API."""
|
|
return {
|
|
"info": {
|
|
"name": "test-package",
|
|
"version": "1.0.0",
|
|
"author": "TestOrg",
|
|
"home_page": "https://github.com/testorg/test-package",
|
|
}
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_visibility_public_success(self, mock_package_data):
|
|
"""Test setting package visibility to public."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
with patch.object(PyPIMetadataClient, '_verify_package_ownership', return_value=True):
|
|
result = await set_package_visibility(
|
|
package_name="test-package",
|
|
visibility="public",
|
|
api_token="test-token",
|
|
)
|
|
|
|
assert result["package_name"] == "test-package"
|
|
assert result["requested_visibility"] == "public"
|
|
assert result["success"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_visibility_private_no_confirmation(self, mock_package_data):
|
|
"""Test setting package visibility to private without confirmation."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
result = await set_package_visibility(
|
|
package_name="test-package",
|
|
visibility="private",
|
|
confirm_action=False,
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["confirmation_required"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_visibility_invalid_visibility(self):
|
|
"""Test setting package visibility with invalid value."""
|
|
with pytest.raises(ValueError):
|
|
await set_package_visibility(
|
|
package_name="test-package",
|
|
visibility="invalid",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_visibility_organization_indicators(self, mock_package_data):
|
|
"""Test detection of organization indicators."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
with patch.object(PyPIMetadataClient, '_verify_package_ownership', return_value=True):
|
|
result = await set_package_visibility(
|
|
package_name="test-package",
|
|
visibility="public",
|
|
api_token="test-token",
|
|
)
|
|
|
|
assert "organization_indicators" in result
|
|
# Should detect GitHub organization from home_page URL
|
|
assert len(result["organization_indicators"]) > 0
|
|
|
|
|
|
class TestManagePackageKeywords:
|
|
"""Test cases for manage_package_keywords function."""
|
|
|
|
@pytest.fixture
|
|
def mock_package_data(self):
|
|
"""Mock package data from PyPI API."""
|
|
return {
|
|
"info": {
|
|
"name": "test-package",
|
|
"version": "1.0.0",
|
|
"keywords": "python,test,package",
|
|
"classifiers": [
|
|
"Topic :: Software Development :: Libraries",
|
|
"Topic :: Internet :: WWW/HTTP",
|
|
],
|
|
"summary": "A test package for Python development and web applications",
|
|
}
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manage_keywords_list_action(self, mock_package_data):
|
|
"""Test listing package keywords."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
result = await manage_package_keywords(
|
|
package_name="test-package",
|
|
action="list",
|
|
)
|
|
|
|
assert result["action"] == "list"
|
|
assert result["current_keywords"] == ["python", "test", "package"]
|
|
assert "keyword_analysis" in result
|
|
assert result["success"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manage_keywords_add_action(self, mock_package_data):
|
|
"""Test adding keywords."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
with patch.object(PyPIMetadataClient, '_verify_package_ownership', return_value=True):
|
|
result = await manage_package_keywords(
|
|
package_name="test-package",
|
|
action="add",
|
|
keywords=["automation", "cli"],
|
|
api_token="test-token",
|
|
dry_run=True,
|
|
)
|
|
|
|
assert result["action"] == "add"
|
|
assert "automation" in result["keywords_after"]
|
|
assert "cli" in result["keywords_after"]
|
|
assert result["changes_detected"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manage_keywords_remove_action(self, mock_package_data):
|
|
"""Test removing keywords."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
with patch.object(PyPIMetadataClient, '_verify_package_ownership', return_value=True):
|
|
result = await manage_package_keywords(
|
|
package_name="test-package",
|
|
action="remove",
|
|
keywords=["test"],
|
|
api_token="test-token",
|
|
dry_run=True,
|
|
)
|
|
|
|
assert result["action"] == "remove"
|
|
assert "test" not in result["keywords_after"]
|
|
assert result["changes_detected"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manage_keywords_replace_action(self, mock_package_data):
|
|
"""Test replacing all keywords."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
with patch.object(PyPIMetadataClient, '_verify_package_ownership', return_value=True):
|
|
result = await manage_package_keywords(
|
|
package_name="test-package",
|
|
action="replace",
|
|
keywords=["new", "keywords", "only"],
|
|
api_token="test-token",
|
|
dry_run=True,
|
|
)
|
|
|
|
assert result["action"] == "replace"
|
|
assert result["keywords_after"] == ["new", "keywords", "only"]
|
|
assert result["changes_detected"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manage_keywords_invalid_action(self):
|
|
"""Test managing keywords with invalid action."""
|
|
with pytest.raises(ValueError):
|
|
await manage_package_keywords(
|
|
package_name="test-package",
|
|
action="invalid",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manage_keywords_validation_errors(self, mock_package_data):
|
|
"""Test keyword validation errors."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
with patch.object(PyPIMetadataClient, '_verify_package_ownership', return_value=True):
|
|
result = await manage_package_keywords(
|
|
package_name="test-package",
|
|
action="add",
|
|
keywords=["valid", "x" * 60, "invalid@keyword"], # One too long, one with invalid chars
|
|
api_token="test-token",
|
|
dry_run=True,
|
|
)
|
|
|
|
assert len(result["validation_errors"]) > 0
|
|
assert any("too long" in error.lower() for error in result["validation_errors"])
|
|
assert any("invalid" in error.lower() for error in result["validation_errors"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manage_keywords_quality_analysis(self, mock_package_data):
|
|
"""Test keyword quality analysis."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
result = await manage_package_keywords(
|
|
package_name="test-package",
|
|
action="list",
|
|
)
|
|
|
|
assert "keyword_analysis" in result
|
|
assert "keyword_quality" in result["keyword_analysis"]
|
|
|
|
# Check that each keyword has quality metrics
|
|
for keyword in result["current_keywords"]:
|
|
assert keyword in result["keyword_analysis"]["keyword_quality"]
|
|
quality_data = result["keyword_analysis"]["keyword_quality"][keyword]
|
|
assert "score" in quality_data
|
|
assert "quality" in quality_data
|
|
assert quality_data["quality"] in ["high", "medium", "low"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manage_keywords_no_keywords_for_modify_action(self):
|
|
"""Test modify actions without providing keywords."""
|
|
with pytest.raises(ValueError):
|
|
await manage_package_keywords(
|
|
package_name="test-package",
|
|
action="add",
|
|
keywords=None, # Should provide keywords for add action
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manage_keywords_too_many_keywords(self, mock_package_data):
|
|
"""Test adding too many keywords."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
with patch.object(PyPIMetadataClient, '_verify_package_ownership', return_value=True):
|
|
# Generate 25 keywords (more than the 20 limit)
|
|
too_many_keywords = [f"keyword{i}" for i in range(25)]
|
|
|
|
result = await manage_package_keywords(
|
|
package_name="test-package",
|
|
action="replace",
|
|
keywords=too_many_keywords,
|
|
api_token="test-token",
|
|
dry_run=True,
|
|
)
|
|
|
|
assert len(result["validation_errors"]) > 0
|
|
assert any("Too many keywords" in error for error in result["validation_errors"])
|
|
# Should be truncated to 20
|
|
assert len(result["keywords_after"]) <= 20
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manage_keywords_permission_error(self, mock_package_data):
|
|
"""Test keyword management without proper permissions."""
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = mock_package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
with patch.object(PyPIMetadataClient, '_verify_package_ownership', return_value=False):
|
|
with pytest.raises(PyPIPermissionError):
|
|
await manage_package_keywords(
|
|
package_name="test-package",
|
|
action="add",
|
|
keywords=["new-keyword"],
|
|
api_token="test-token",
|
|
dry_run=False, # Not dry run, so permission check applies
|
|
)
|
|
|
|
|
|
# Integration-style tests
|
|
class TestMetadataIntegration:
|
|
"""Integration tests for metadata tools."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_metadata_workflow(self):
|
|
"""Test a complete metadata management workflow."""
|
|
package_data = {
|
|
"info": {
|
|
"name": "test-package",
|
|
"version": "1.0.0",
|
|
"summary": "Old description",
|
|
"keywords": "old,keywords",
|
|
"classifiers": ["Development Status :: 3 - Alpha"],
|
|
"home_page": "https://old-site.com",
|
|
"project_urls": {},
|
|
}
|
|
}
|
|
|
|
with patch.object(PyPIMetadataClient, '_make_request') as mock_request:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = package_data
|
|
mock_request.return_value = mock_response
|
|
|
|
with patch.object(PyPIMetadataClient, '_verify_package_ownership', return_value=True):
|
|
# Test metadata update
|
|
metadata_result = await update_package_metadata(
|
|
package_name="test-package",
|
|
description="New improved description",
|
|
keywords=["python", "testing", "automation"],
|
|
classifiers=["Development Status :: 4 - Beta"],
|
|
api_token="test-token",
|
|
dry_run=True,
|
|
)
|
|
|
|
# Test URL management
|
|
url_result = await manage_package_urls(
|
|
package_name="test-package",
|
|
homepage="https://new-homepage.com",
|
|
documentation="https://docs.new-site.com",
|
|
repository="https://github.com/user/test-package",
|
|
validate_urls=False,
|
|
dry_run=True,
|
|
)
|
|
|
|
# Test keyword management
|
|
keyword_result = await manage_package_keywords(
|
|
package_name="test-package",
|
|
action="add",
|
|
keywords=["cli", "tool"],
|
|
api_token="test-token",
|
|
dry_run=True,
|
|
)
|
|
|
|
# Verify all operations completed successfully
|
|
assert metadata_result["dry_run"] is True
|
|
assert url_result["dry_run"] is True
|
|
assert keyword_result["dry_run"] is True
|
|
|
|
assert metadata_result["changes_detected"]["description"]["changed"] is True
|
|
assert url_result["changes_detected"]["homepage"]["changed"] is True
|
|
assert keyword_result["changes_detected"] is True |