diff --git a/CHANGELOG.md b/CHANGELOG.md index d2d2cdf8fc..f1e2fa3303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.18.5 (2025-01-28) + +### Bug Fixes + +- Fixed an issue where the integration would delete all entities if the Port app configuration was empty + ## 0.18.4 (2025-01-22) ### Improvements diff --git a/port_ocean/context/event.py b/port_ocean/context/event.py index 5f8b2b37d5..1fa0c2c4b4 100644 --- a/port_ocean/context/event.py +++ b/port_ocean/context/event.py @@ -19,6 +19,7 @@ from werkzeug.local import LocalStack, LocalProxy from port_ocean.context.resource import resource +from port_ocean.exceptions.api import EmptyPortAppConfigError from port_ocean.exceptions.context import ( EventContextNotFoundError, ResourceContextNotFoundError, @@ -176,8 +177,14 @@ def _handle_event(triggering_event_id: int) -> None: logger.info("Event started") try: yield event - except: + except EmptyPortAppConfigError as e: + logger.error( + f"Skipping resync due to empty mapping: {str(e)}", exc_info=True + ) + raise + except Exception as e: success = False + logger.error(f"Event failed with error: {str(e)}", exc_info=True) raise else: success = True diff --git a/port_ocean/core/handlers/port_app_config/api.py b/port_ocean/core/handlers/port_app_config/api.py index 59d20abb7a..596bf95362 100644 --- a/port_ocean/core/handlers/port_app_config/api.py +++ b/port_ocean/core/handlers/port_app_config/api.py @@ -3,6 +3,7 @@ from loguru import logger from port_ocean.core.handlers.port_app_config.base import BasePortAppConfig +from port_ocean.exceptions.api import EmptyPortAppConfigError class APIPortAppConfig(BasePortAppConfig): @@ -20,7 +21,9 @@ async def _get_port_app_config(self) -> dict[str, Any]: if not config: logger.error( "The integration port app config is empty. " + f"Integration: {integration}, " + f"Config: {config}. " "Please make sure to configure your port app config using Port's API." ) - + raise EmptyPortAppConfigError() return config diff --git a/port_ocean/exceptions/api.py b/port_ocean/exceptions/api.py index 0f7801cd60..a16e91b280 100644 --- a/port_ocean/exceptions/api.py +++ b/port_ocean/exceptions/api.py @@ -13,3 +13,10 @@ def response(self) -> Response: class InternalServerException(BaseAPIException): def response(self) -> Response: return PlainTextResponse(content="Internal server error", status_code=500) + + +class EmptyPortAppConfigError(Exception): + """Exception raised when the Port app configuration is empty.""" + + def __init__(self, message: str = "Port app config is empty") -> None: + super().__init__(message) diff --git a/port_ocean/tests/core/handlers/port_app_config/test_api.py b/port_ocean/tests/core/handlers/port_app_config/test_api.py new file mode 100644 index 0000000000..a6b6742207 --- /dev/null +++ b/port_ocean/tests/core/handlers/port_app_config/test_api.py @@ -0,0 +1,67 @@ +import pytest +from unittest.mock import AsyncMock + +from port_ocean.core.handlers.port_app_config.api import APIPortAppConfig +from port_ocean.exceptions.api import EmptyPortAppConfigError + + +@pytest.fixture +def mock_context() -> AsyncMock: + context = AsyncMock() + context.port_client.get_current_integration = AsyncMock() + return context + + +@pytest.fixture +def api_config(mock_context: AsyncMock) -> APIPortAppConfig: + return APIPortAppConfig(mock_context) + + +async def test_get_port_app_config_valid_config_returns_config( + api_config: APIPortAppConfig, mock_context: AsyncMock +) -> None: + # Arrange + expected_config = {"key": "value"} + mock_context.port_client.get_current_integration.return_value = { + "config": expected_config + } + + # Act + result = await api_config._get_port_app_config() + + # Assert + assert result == expected_config + mock_context.port_client.get_current_integration.assert_called_once() + + +async def test_get_port_app_config_empty_config_raises_value_error( + api_config: APIPortAppConfig, mock_context: AsyncMock +) -> None: + # Arrange + mock_context.port_client.get_current_integration.return_value = {"config": {}} + + # Act & Assert + with pytest.raises(EmptyPortAppConfigError, match="Port app config is empty"): + await api_config._get_port_app_config() + + +async def test_get_port_app_config_missing_config_key_raises_key_error( + api_config: APIPortAppConfig, mock_context: AsyncMock +) -> None: + # Arrange + mock_context.port_client.get_current_integration.return_value = {} + + # Act & Assert + with pytest.raises(KeyError): + await api_config._get_port_app_config() + + +async def test_get_port_app_config_empty_integration_raises_key_error( + api_config: APIPortAppConfig, mock_context: AsyncMock +) -> None: + # Arrange + mock_context.port_client.get_current_integration.return_value = {} + + # Act & Assert + with pytest.raises(KeyError): + await api_config._get_port_app_config() diff --git a/port_ocean/tests/core/handlers/port_app_config/test_base.py b/port_ocean/tests/core/handlers/port_app_config/test_base.py new file mode 100644 index 0000000000..0b37c56fa9 --- /dev/null +++ b/port_ocean/tests/core/handlers/port_app_config/test_base.py @@ -0,0 +1,197 @@ +import pytest +from unittest.mock import MagicMock +from pydantic import ValidationError +from typing import Any, Dict + +from port_ocean.context.ocean import PortOceanContext +from port_ocean.core.handlers.port_app_config.base import BasePortAppConfig +from port_ocean.core.handlers.port_app_config.models import PortAppConfig +from port_ocean.context.event import EventType, event_context +from port_ocean.exceptions.api import EmptyPortAppConfigError + + +class TestPortAppConfig(BasePortAppConfig): + mock_get_port_app_config: Any + + async def _get_port_app_config(self) -> Dict[str, Any]: + return self.mock_get_port_app_config() + + +@pytest.fixture +def mock_context() -> PortOceanContext: + context = MagicMock(spec=PortOceanContext) + context.config.port.port_app_config_cache_ttl = 300 # 5 minutes + return context + + +@pytest.fixture +def port_app_config_handler(mock_context: PortOceanContext) -> TestPortAppConfig: + handler = TestPortAppConfig(mock_context) + handler.mock_get_port_app_config = MagicMock() + return handler + + +@pytest.mark.asyncio +async def test_get_port_app_config_success( + port_app_config_handler: TestPortAppConfig, +) -> None: + # Arrange + valid_config = { + "resources": [ + { + "kind": "repository", + "selector": {"query": "true"}, + "port": { + "entity": { + "mappings": { + "identifier": ".name", + "title": ".name", + "blueprint": '"service"', + "properties": { + "description": ".description", + "url": ".html_url", + "defaultBranch": ".default_branch", + }, + } + } + }, + } + ] + } + port_app_config_handler.mock_get_port_app_config.return_value = valid_config + + # Act + async with event_context(EventType.RESYNC, trigger_type="machine"): + result = await port_app_config_handler.get_port_app_config() + + # Assert + assert isinstance(result, PortAppConfig) + assert result.resources[0].port.entity.mappings.title == ".name" + assert result.resources[0].port.entity.mappings.identifier == ".name" + assert result.resources[0].port.entity.mappings.blueprint == '"service"' + assert ( + result.resources[0].port.entity.mappings.properties["description"] + == ".description" + ) + assert result.resources[0].port.entity.mappings.properties["url"] == ".html_url" + assert ( + result.resources[0].port.entity.mappings.properties["defaultBranch"] + == ".default_branch" + ) + port_app_config_handler.mock_get_port_app_config.assert_called_once() + + +@pytest.mark.asyncio +async def test_get_port_app_config_uses_cache( + port_app_config_handler: TestPortAppConfig, +) -> None: + # Arrange + valid_config = { + "resources": [ + { + "kind": "repository", + "selector": {"query": "true"}, + "port": { + "entity": { + "mappings": { + "identifier": ".name", + "title": ".name", + "blueprint": '"service"', + "properties": { + "description": ".description", + "url": ".html_url", + "defaultBranch": ".default_branch", + }, + } + } + }, + } + ] + } + port_app_config_handler.mock_get_port_app_config.return_value = valid_config + + # Act + async with event_context(EventType.RESYNC, trigger_type="machine"): + result1 = await port_app_config_handler.get_port_app_config() + result2 = await port_app_config_handler.get_port_app_config() + + # Assert + assert result1 == result2 + port_app_config_handler.mock_get_port_app_config.assert_called_once() # Called only once due to caching + + +@pytest.mark.asyncio +async def test_get_port_app_config_bypass_cache( + port_app_config_handler: TestPortAppConfig, +) -> None: + # Arrange + valid_config = { + "resources": [ + { + "kind": "repository", + "selector": {"query": "true"}, + "port": { + "entity": { + "mappings": { + "identifier": ".name", + "title": ".name", + "blueprint": '"service"', + "properties": { + "description": ".description", + "url": ".html_url", + "defaultBranch": ".default_branch", + }, + } + } + }, + } + ] + } + port_app_config_handler.mock_get_port_app_config.return_value = valid_config + + # Act + async with event_context(EventType.RESYNC, trigger_type="machine"): + result1 = await port_app_config_handler.get_port_app_config() + result2 = await port_app_config_handler.get_port_app_config(use_cache=False) + + # Assert + assert result1 == result2 + assert ( + port_app_config_handler.mock_get_port_app_config.call_count == 2 + ) # Called twice due to cache bypass + + +@pytest.mark.asyncio +async def test_get_port_app_config_validation_error( + port_app_config_handler: TestPortAppConfig, monkeypatch: pytest.MonkeyPatch +) -> None: + # Arrange + invalid_config = {"invalid_field": "invalid_value"} + port_app_config_handler.mock_get_port_app_config.return_value = invalid_config + + def mock_parse_obj(*args: Any, **kwargs: Any) -> None: + raise ValidationError(errors=[], model=PortAppConfig) + + monkeypatch.setattr( + port_app_config_handler.CONFIG_CLASS, "parse_obj", mock_parse_obj + ) + + # Act & Assert + with pytest.raises(ValidationError): + async with event_context(EventType.RESYNC, trigger_type="machine"): + await port_app_config_handler.get_port_app_config() + + +@pytest.mark.asyncio +async def test_get_port_app_config_fetch_error( + port_app_config_handler: TestPortAppConfig, +) -> None: + # Arrange + port_app_config_handler.mock_get_port_app_config.side_effect = ( + EmptyPortAppConfigError("Port app config is empty") + ) + + # Act & Assert + async with event_context(EventType.RESYNC, trigger_type="machine"): + with pytest.raises(EmptyPortAppConfigError, match="Port app config is empty"): + await port_app_config_handler.get_port_app_config() diff --git a/pyproject.toml b/pyproject.toml index ea38b0fed0..b895f6699a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "port-ocean" -version = "0.18.4" +version = "0.18.5" description = "Port Ocean is a CLI tool for managing your Port projects." readme = "README.md" homepage = "https://app.getport.io"