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.
Files changed (27) hide show
  1. port_ocean/context/ocean.py +32 -0
  2. port_ocean/core/handlers/entities_state_applier/port/applier.py +18 -2
  3. port_ocean/core/handlers/port_app_config/models.py +3 -0
  4. port_ocean/core/handlers/queue/__init__.py +4 -0
  5. port_ocean/core/handlers/queue/abstract_queue.py +28 -0
  6. port_ocean/core/handlers/queue/local_queue.py +25 -0
  7. port_ocean/core/handlers/webhook/__init__.py +0 -0
  8. port_ocean/core/handlers/webhook/abstract_webhook_processor.py +101 -0
  9. port_ocean/core/handlers/webhook/processor_manager.py +237 -0
  10. port_ocean/core/handlers/webhook/webhook_event.py +77 -0
  11. port_ocean/core/integrations/mixins/sync.py +2 -1
  12. port_ocean/core/integrations/mixins/sync_raw.py +1 -1
  13. port_ocean/exceptions/webhook_processor.py +4 -0
  14. port_ocean/ocean.py +7 -1
  15. port_ocean/tests/core/handlers/entities_state_applier/test_applier.py +86 -0
  16. port_ocean/tests/core/handlers/port_app_config/test_base.py +8 -8
  17. port_ocean/tests/core/handlers/queue/test_local_queue.py +90 -0
  18. port_ocean/tests/core/handlers/webhook/test_abstract_webhook_processor.py +115 -0
  19. port_ocean/tests/core/handlers/webhook/test_processor_manager.py +391 -0
  20. port_ocean/tests/core/handlers/webhook/test_webhook_event.py +65 -0
  21. port_ocean/tests/helpers/ocean_app.py +2 -0
  22. port_ocean/utils/signal.py +6 -2
  23. {port_ocean-0.18.6.dist-info → port_ocean-0.18.8.dist-info}/METADATA +1 -1
  24. {port_ocean-0.18.6.dist-info → port_ocean-0.18.8.dist-info}/RECORD +27 -14
  25. {port_ocean-0.18.6.dist-info → port_ocean-0.18.8.dist-info}/LICENSE.md +0 -0
  26. {port_ocean-0.18.6.dist-info → port_ocean-0.18.8.dist-info}/WHEEL +0 -0
  27. {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 TestPortAppConfig(BasePortAppConfig):
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) -> TestPortAppConfig:
29
- handler = TestPortAppConfig(mock_context)
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: TestPortAppConfig,
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: TestPortAppConfig,
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: TestPortAppConfig,
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: TestPortAppConfig, monkeypatch: pytest.MonkeyPatch
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: TestPortAppConfig,
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