Skip to content

Commit

Permalink
[Core] Raise error if integration gets empty config from port (#1353)
Browse files Browse the repository at this point in the history
# Description

What - raise error if integration gets empty config from port 

Why - to avoid integration to delete entities if resync gets 0 entities 

How - raise error

## Type of change

Please leave one option from the following and delete the rest:

- [ ] Bug fix (non-breaking change which fixes an issue)


### Core testing checklist

- [ ] Integration able to create all default resources from scratch
- [ ] Resync finishes successfully
- [ ] Resync able to create entities
- [ ] Resync able to update entities
- [ ] Resync able to detect and delete entities
- [ ] Scheduled resync able to abort existing resync and start a new one
- [ ] Tested with at least 2 integrations from scratch
- [ ] Tested with Kafka and Polling event listeners
- [ ] Tested deletion of entities that don't pass the selector
  • Loading branch information
yaelibarg authored Jan 28, 2025
1 parent d9c5a70 commit 364890d
Show file tree
Hide file tree
Showing 7 changed files with 290 additions and 3 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<!-- towncrier release notes start -->
## 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
Expand Down
9 changes: 8 additions & 1 deletion port_ocean/context/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion port_ocean/core/handlers/port_app_config/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
7 changes: 7 additions & 0 deletions port_ocean/exceptions/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
67 changes: 67 additions & 0 deletions port_ocean/tests/core/handlers/port_app_config/test_api.py
Original file line number Diff line number Diff line change
@@ -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()
197 changes: 197 additions & 0 deletions port_ocean/tests/core/handlers/port_app_config/test_base.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down

0 comments on commit 364890d

Please sign in to comment.