port-ocean 0.18.6__py3-none-any.whl → 0.18.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- port_ocean/context/ocean.py +32 -0
- port_ocean/core/handlers/entities_state_applier/port/applier.py +18 -2
- port_ocean/core/handlers/port_app_config/models.py +3 -0
- port_ocean/core/handlers/queue/__init__.py +4 -0
- port_ocean/core/handlers/queue/abstract_queue.py +28 -0
- port_ocean/core/handlers/queue/local_queue.py +25 -0
- port_ocean/core/handlers/webhook/__init__.py +0 -0
- port_ocean/core/handlers/webhook/abstract_webhook_processor.py +101 -0
- port_ocean/core/handlers/webhook/processor_manager.py +237 -0
- port_ocean/core/handlers/webhook/webhook_event.py +77 -0
- port_ocean/core/integrations/mixins/sync.py +2 -1
- port_ocean/core/integrations/mixins/sync_raw.py +1 -1
- port_ocean/exceptions/webhook_processor.py +4 -0
- port_ocean/ocean.py +7 -1
- port_ocean/tests/core/handlers/entities_state_applier/test_applier.py +86 -0
- port_ocean/tests/core/handlers/port_app_config/test_base.py +8 -8
- port_ocean/tests/core/handlers/queue/test_local_queue.py +90 -0
- port_ocean/tests/core/handlers/webhook/test_abstract_webhook_processor.py +115 -0
- port_ocean/tests/core/handlers/webhook/test_processor_manager.py +391 -0
- port_ocean/tests/core/handlers/webhook/test_webhook_event.py +65 -0
- port_ocean/tests/helpers/ocean_app.py +2 -0
- port_ocean/utils/signal.py +6 -2
- {port_ocean-0.18.6.dist-info → port_ocean-0.18.8.dist-info}/METADATA +1 -1
- {port_ocean-0.18.6.dist-info → port_ocean-0.18.8.dist-info}/RECORD +27 -14
- {port_ocean-0.18.6.dist-info → port_ocean-0.18.8.dist-info}/LICENSE.md +0 -0
- {port_ocean-0.18.6.dist-info → port_ocean-0.18.8.dist-info}/WHEEL +0 -0
- {port_ocean-0.18.6.dist-info → port_ocean-0.18.8.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,86 @@
|
|
1
|
+
from unittest.mock import Mock, patch
|
2
|
+
import pytest
|
3
|
+
from port_ocean.core.handlers.entities_state_applier.port.applier import (
|
4
|
+
HttpEntitiesStateApplier,
|
5
|
+
)
|
6
|
+
from port_ocean.core.models import Entity
|
7
|
+
from port_ocean.core.ocean_types import EntityDiff
|
8
|
+
from port_ocean.clients.port.types import UserAgentType
|
9
|
+
|
10
|
+
|
11
|
+
@pytest.mark.asyncio
|
12
|
+
async def test_delete_diff_no_deleted_entities() -> None:
|
13
|
+
applier = HttpEntitiesStateApplier(Mock())
|
14
|
+
entities = EntityDiff(
|
15
|
+
before=[Entity(identifier="1", blueprint="test")],
|
16
|
+
after=[Entity(identifier="1", blueprint="test")],
|
17
|
+
)
|
18
|
+
|
19
|
+
with patch.object(applier, "_safe_delete") as mock_safe_delete:
|
20
|
+
await applier.delete_diff(entities, UserAgentType.exporter)
|
21
|
+
|
22
|
+
mock_safe_delete.assert_not_called()
|
23
|
+
|
24
|
+
|
25
|
+
@pytest.mark.asyncio
|
26
|
+
async def test_delete_diff_below_threshold() -> None:
|
27
|
+
applier = HttpEntitiesStateApplier(Mock())
|
28
|
+
entities = EntityDiff(
|
29
|
+
before=[
|
30
|
+
Entity(identifier="1", blueprint="test"),
|
31
|
+
Entity(identifier="2", blueprint="test"),
|
32
|
+
Entity(identifier="3", blueprint="test"),
|
33
|
+
],
|
34
|
+
after=[
|
35
|
+
Entity(identifier="1", blueprint="test"),
|
36
|
+
Entity(identifier="2", blueprint="test"),
|
37
|
+
],
|
38
|
+
)
|
39
|
+
|
40
|
+
with patch.object(applier, "_safe_delete") as mock_safe_delete:
|
41
|
+
await applier.delete_diff(
|
42
|
+
entities, UserAgentType.exporter, entity_deletion_threshold=0.9
|
43
|
+
)
|
44
|
+
|
45
|
+
mock_safe_delete.assert_called_once()
|
46
|
+
assert len(mock_safe_delete.call_args[0][0]) == 1
|
47
|
+
assert mock_safe_delete.call_args[0][0][0].identifier == "3"
|
48
|
+
|
49
|
+
|
50
|
+
@pytest.mark.asyncio
|
51
|
+
async def test_delete_diff_above_default_threshold() -> None:
|
52
|
+
applier = HttpEntitiesStateApplier(Mock())
|
53
|
+
entities = EntityDiff(
|
54
|
+
before=[
|
55
|
+
Entity(identifier="1", blueprint="test"),
|
56
|
+
Entity(identifier="2", blueprint="test"),
|
57
|
+
Entity(identifier="3", blueprint="test"),
|
58
|
+
],
|
59
|
+
after=[],
|
60
|
+
)
|
61
|
+
|
62
|
+
with patch.object(applier, "_safe_delete") as mock_safe_delete:
|
63
|
+
await applier.delete_diff(
|
64
|
+
entities, UserAgentType.exporter, entity_deletion_threshold=0.9
|
65
|
+
)
|
66
|
+
|
67
|
+
mock_safe_delete.assert_not_called()
|
68
|
+
|
69
|
+
|
70
|
+
@pytest.mark.asyncio
|
71
|
+
async def test_delete_diff_custom_threshold_above_threshold_not_deleted() -> None:
|
72
|
+
applier = HttpEntitiesStateApplier(Mock())
|
73
|
+
entities = EntityDiff(
|
74
|
+
before=[
|
75
|
+
Entity(identifier="1", blueprint="test"),
|
76
|
+
Entity(identifier="2", blueprint="test"),
|
77
|
+
],
|
78
|
+
after=[],
|
79
|
+
)
|
80
|
+
|
81
|
+
with patch.object(applier, "_safe_delete") as mock_safe_delete:
|
82
|
+
await applier.delete_diff(
|
83
|
+
entities, UserAgentType.exporter, entity_deletion_threshold=0.5
|
84
|
+
)
|
85
|
+
|
86
|
+
mock_safe_delete.assert_not_called()
|
@@ -10,7 +10,7 @@ from port_ocean.context.event import EventType, event_context
|
|
10
10
|
from port_ocean.exceptions.api import EmptyPortAppConfigError
|
11
11
|
|
12
12
|
|
13
|
-
class
|
13
|
+
class MockPortAppConfig(BasePortAppConfig):
|
14
14
|
mock_get_port_app_config: Any
|
15
15
|
|
16
16
|
async def _get_port_app_config(self) -> Dict[str, Any]:
|
@@ -25,15 +25,15 @@ def mock_context() -> PortOceanContext:
|
|
25
25
|
|
26
26
|
|
27
27
|
@pytest.fixture
|
28
|
-
def port_app_config_handler(mock_context: PortOceanContext) ->
|
29
|
-
handler =
|
28
|
+
def port_app_config_handler(mock_context: PortOceanContext) -> MockPortAppConfig:
|
29
|
+
handler = MockPortAppConfig(mock_context)
|
30
30
|
handler.mock_get_port_app_config = MagicMock()
|
31
31
|
return handler
|
32
32
|
|
33
33
|
|
34
34
|
@pytest.mark.asyncio
|
35
35
|
async def test_get_port_app_config_success(
|
36
|
-
port_app_config_handler:
|
36
|
+
port_app_config_handler: MockPortAppConfig,
|
37
37
|
) -> None:
|
38
38
|
# Arrange
|
39
39
|
valid_config = {
|
@@ -83,7 +83,7 @@ async def test_get_port_app_config_success(
|
|
83
83
|
|
84
84
|
@pytest.mark.asyncio
|
85
85
|
async def test_get_port_app_config_uses_cache(
|
86
|
-
port_app_config_handler:
|
86
|
+
port_app_config_handler: MockPortAppConfig,
|
87
87
|
) -> None:
|
88
88
|
# Arrange
|
89
89
|
valid_config = {
|
@@ -122,7 +122,7 @@ async def test_get_port_app_config_uses_cache(
|
|
122
122
|
|
123
123
|
@pytest.mark.asyncio
|
124
124
|
async def test_get_port_app_config_bypass_cache(
|
125
|
-
port_app_config_handler:
|
125
|
+
port_app_config_handler: MockPortAppConfig,
|
126
126
|
) -> None:
|
127
127
|
# Arrange
|
128
128
|
valid_config = {
|
@@ -163,7 +163,7 @@ async def test_get_port_app_config_bypass_cache(
|
|
163
163
|
|
164
164
|
@pytest.mark.asyncio
|
165
165
|
async def test_get_port_app_config_validation_error(
|
166
|
-
port_app_config_handler:
|
166
|
+
port_app_config_handler: MockPortAppConfig, monkeypatch: pytest.MonkeyPatch
|
167
167
|
) -> None:
|
168
168
|
# Arrange
|
169
169
|
invalid_config = {"invalid_field": "invalid_value"}
|
@@ -184,7 +184,7 @@ async def test_get_port_app_config_validation_error(
|
|
184
184
|
|
185
185
|
@pytest.mark.asyncio
|
186
186
|
async def test_get_port_app_config_fetch_error(
|
187
|
-
port_app_config_handler:
|
187
|
+
port_app_config_handler: MockPortAppConfig,
|
188
188
|
) -> None:
|
189
189
|
# Arrange
|
190
190
|
port_app_config_handler.mock_get_port_app_config.side_effect = (
|
@@ -0,0 +1,90 @@
|
|
1
|
+
import asyncio
|
2
|
+
import pytest
|
3
|
+
from dataclasses import dataclass
|
4
|
+
|
5
|
+
from port_ocean.core.handlers.queue.local_queue import LocalQueue
|
6
|
+
|
7
|
+
|
8
|
+
@dataclass
|
9
|
+
class MockMessage:
|
10
|
+
"""Example message type for testing"""
|
11
|
+
|
12
|
+
id: str
|
13
|
+
data: str
|
14
|
+
processed: bool = False
|
15
|
+
|
16
|
+
|
17
|
+
class TestLocalQueue:
|
18
|
+
"""
|
19
|
+
Test suite for LocalQueue implementation
|
20
|
+
This can serve as an example for testing other Queue implementations
|
21
|
+
"""
|
22
|
+
|
23
|
+
@pytest.fixture
|
24
|
+
def queue(self) -> LocalQueue[MockMessage]:
|
25
|
+
return LocalQueue[MockMessage]()
|
26
|
+
|
27
|
+
async def test_basic_queue_operations(self, queue: LocalQueue[MockMessage]) -> None:
|
28
|
+
"""Test basic put/get operations"""
|
29
|
+
message = MockMessage(id="1", data="test")
|
30
|
+
|
31
|
+
# Put item in queue
|
32
|
+
await queue.put(message)
|
33
|
+
|
34
|
+
# Get item from queue
|
35
|
+
received = await queue.get()
|
36
|
+
|
37
|
+
assert received.id == message.id
|
38
|
+
assert received.data == message.data
|
39
|
+
|
40
|
+
# Mark as processed
|
41
|
+
await queue.commit()
|
42
|
+
|
43
|
+
async def test_fifo_order(self, queue: LocalQueue[MockMessage]) -> None:
|
44
|
+
"""Demonstrate and test FIFO (First In, First Out) behavior"""
|
45
|
+
messages = [
|
46
|
+
MockMessage(id="1", data="first"),
|
47
|
+
MockMessage(id="2", data="second"),
|
48
|
+
MockMessage(id="3", data="third"),
|
49
|
+
]
|
50
|
+
|
51
|
+
# Put items in queue
|
52
|
+
for msg in messages:
|
53
|
+
await queue.put(msg)
|
54
|
+
|
55
|
+
# Verify order
|
56
|
+
for expected in messages:
|
57
|
+
received = await queue.get()
|
58
|
+
assert received.id == expected.id
|
59
|
+
await queue.commit()
|
60
|
+
|
61
|
+
async def test_wait_for_completion(self, queue: LocalQueue[MockMessage]) -> None:
|
62
|
+
"""Example of waiting for all messages to be processed"""
|
63
|
+
processed_count = 0
|
64
|
+
|
65
|
+
async def slow_processor() -> None:
|
66
|
+
nonlocal processed_count
|
67
|
+
while True:
|
68
|
+
try:
|
69
|
+
await asyncio.wait_for(queue.get(), timeout=0.1)
|
70
|
+
# Simulate processing time
|
71
|
+
await asyncio.sleep(0.1)
|
72
|
+
processed_count += 1
|
73
|
+
await queue.commit()
|
74
|
+
except asyncio.TimeoutError:
|
75
|
+
break
|
76
|
+
|
77
|
+
# Add messages
|
78
|
+
message_count = 5
|
79
|
+
for i in range(message_count):
|
80
|
+
await queue.put(MockMessage(id=str(i), data=f"test_{i}"))
|
81
|
+
|
82
|
+
# Start processor
|
83
|
+
processor = asyncio.create_task(slow_processor())
|
84
|
+
|
85
|
+
# Wait for completion
|
86
|
+
await queue.teardown()
|
87
|
+
|
88
|
+
await processor
|
89
|
+
|
90
|
+
assert processed_count == message_count
|
@@ -0,0 +1,115 @@
|
|
1
|
+
from fastapi import APIRouter
|
2
|
+
import pytest
|
3
|
+
|
4
|
+
from port_ocean.core.handlers.webhook.abstract_webhook_processor import (
|
5
|
+
AbstractWebhookProcessor,
|
6
|
+
)
|
7
|
+
from port_ocean.exceptions.webhook_processor import RetryableError
|
8
|
+
from port_ocean.core.handlers.webhook.webhook_event import (
|
9
|
+
EventHeaders,
|
10
|
+
EventPayload,
|
11
|
+
WebhookEvent,
|
12
|
+
)
|
13
|
+
from port_ocean.core.handlers.webhook.processor_manager import WebhookProcessorManager
|
14
|
+
from port_ocean.utils.signal import SignalHandler
|
15
|
+
|
16
|
+
|
17
|
+
class MockWebhookHandler(AbstractWebhookProcessor):
|
18
|
+
"""Concrete implementation for testing."""
|
19
|
+
|
20
|
+
def __init__(
|
21
|
+
self,
|
22
|
+
event: WebhookEvent,
|
23
|
+
should_fail: bool = False,
|
24
|
+
fail_count: int = 0,
|
25
|
+
max_retries: int = 3,
|
26
|
+
) -> None:
|
27
|
+
super().__init__(event)
|
28
|
+
self.authenticated = False
|
29
|
+
self.validated = False
|
30
|
+
self.handled = False
|
31
|
+
self.should_fail = should_fail
|
32
|
+
self.fail_count = fail_count
|
33
|
+
self.current_fails = 0
|
34
|
+
self.error_handler_called = False
|
35
|
+
self.cancelled = False
|
36
|
+
self.max_retries = max_retries
|
37
|
+
|
38
|
+
async def authenticate(self, payload: EventPayload, headers: EventHeaders) -> bool:
|
39
|
+
self.authenticated = True
|
40
|
+
return True
|
41
|
+
|
42
|
+
async def validate_payload(self, payload: EventPayload) -> bool:
|
43
|
+
self.validated = True
|
44
|
+
return True
|
45
|
+
|
46
|
+
async def handle_event(self, payload: EventPayload) -> None:
|
47
|
+
if self.should_fail and self.current_fails < self.fail_count:
|
48
|
+
self.current_fails += 1
|
49
|
+
raise RetryableError("Temporary failure")
|
50
|
+
self.handled = True
|
51
|
+
|
52
|
+
async def cancel(self) -> None:
|
53
|
+
self.cancelled = True
|
54
|
+
|
55
|
+
async def on_error(self, error: Exception) -> None:
|
56
|
+
self.error_handler_called = True
|
57
|
+
await super().on_error(error)
|
58
|
+
|
59
|
+
|
60
|
+
@pytest.mark.skip("Skipping until fixed")
|
61
|
+
class TestAbstractWebhookHandler:
|
62
|
+
@pytest.fixture
|
63
|
+
def webhook_event(self) -> WebhookEvent:
|
64
|
+
return WebhookEvent(
|
65
|
+
trace_id="test-trace",
|
66
|
+
payload={"test": "data"},
|
67
|
+
headers={"content-type": "application/json"},
|
68
|
+
)
|
69
|
+
|
70
|
+
@pytest.fixture
|
71
|
+
def processor_manager(self) -> WebhookProcessorManager:
|
72
|
+
return WebhookProcessorManager(APIRouter(), SignalHandler())
|
73
|
+
|
74
|
+
@pytest.fixture
|
75
|
+
def processor(self, webhook_event: WebhookEvent) -> MockWebhookHandler:
|
76
|
+
return MockWebhookHandler(webhook_event)
|
77
|
+
|
78
|
+
async def test_successful_processing(
|
79
|
+
self, processor: MockWebhookHandler, processor_manager: WebhookProcessorManager
|
80
|
+
) -> None:
|
81
|
+
"""Test successful webhook processing flow."""
|
82
|
+
await processor_manager._process_webhook_request(processor)
|
83
|
+
|
84
|
+
assert processor.authenticated
|
85
|
+
assert processor.validated
|
86
|
+
assert processor.handled
|
87
|
+
assert not processor.error_handler_called
|
88
|
+
|
89
|
+
async def test_retry_mechanism(
|
90
|
+
self, webhook_event: WebhookEvent, processor_manager: WebhookProcessorManager
|
91
|
+
) -> None:
|
92
|
+
"""Test retry mechanism with temporary failures."""
|
93
|
+
processor = MockWebhookHandler(webhook_event, should_fail=True, fail_count=2)
|
94
|
+
|
95
|
+
await processor_manager._process_webhook_request(processor)
|
96
|
+
|
97
|
+
assert processor.handled
|
98
|
+
assert processor.current_fails == 2
|
99
|
+
assert processor.retry_count == 2
|
100
|
+
assert processor.error_handler_called
|
101
|
+
|
102
|
+
async def test_max_retries_exceeded(
|
103
|
+
self, webhook_event: WebhookEvent, processor_manager: WebhookProcessorManager
|
104
|
+
) -> None:
|
105
|
+
"""Test behavior when max retries are exceeded."""
|
106
|
+
processor = MockWebhookHandler(
|
107
|
+
webhook_event, should_fail=True, fail_count=2, max_retries=1
|
108
|
+
)
|
109
|
+
|
110
|
+
with pytest.raises(RetryableError):
|
111
|
+
await processor_manager._process_webhook_request(processor)
|
112
|
+
|
113
|
+
assert processor.retry_count == processor.max_retries
|
114
|
+
assert processor.error_handler_called
|
115
|
+
assert not processor.handled
|