port-ocean 0.18.6__py3-none-any.whl → 0.18.8__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- 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
|