port-ocean 0.18.5__py3-none-any.whl → 0.18.7__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (26) 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 +26 -20
  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/mixins/test_sync_raw.py +149 -124
  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 +114 -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/utils/signal.py +6 -2
  22. {port_ocean-0.18.5.dist-info → port_ocean-0.18.7.dist-info}/METADATA +1 -1
  23. {port_ocean-0.18.5.dist-info → port_ocean-0.18.7.dist-info}/RECORD +26 -13
  24. {port_ocean-0.18.5.dist-info → port_ocean-0.18.7.dist-info}/LICENSE.md +0 -0
  25. {port_ocean-0.18.5.dist-info → port_ocean-0.18.7.dist-info}/WHEEL +0 -0
  26. {port_ocean-0.18.5.dist-info → port_ocean-0.18.7.dist-info}/entry_points.txt +0 -0
port_ocean/ocean.py CHANGED
@@ -26,6 +26,7 @@ from port_ocean.utils.repeat import repeat_every
26
26
  from port_ocean.utils.signal import signal_handler
27
27
  from port_ocean.version import __integration_version__
28
28
  from port_ocean.utils.misc import IntegrationStateStatus
29
+ from port_ocean.core.handlers.webhook.processor_manager import WebhookProcessorManager
29
30
 
30
31
 
31
32
  class Ocean:
@@ -53,6 +54,10 @@ class Ocean:
53
54
  )
54
55
  self.integration_router = integration_router or APIRouter()
55
56
 
57
+ self.webhook_manager = WebhookProcessorManager(
58
+ self.integration_router, signal_handler
59
+ )
60
+
56
61
  self.port_client = PortClient(
57
62
  base_url=self.config.port.base_url,
58
63
  client_id=self.config.port.client_id,
@@ -120,6 +125,7 @@ class Ocean:
120
125
  async def lifecycle(_: FastAPI) -> AsyncIterator[None]:
121
126
  try:
122
127
  await self.integration.start()
128
+ await self.webhook_manager.start_processing_event_messages()
123
129
  await self._setup_scheduled_resync()
124
130
  yield None
125
131
  except Exception:
@@ -127,7 +133,7 @@ class Ocean:
127
133
  logger.complete()
128
134
  sys.exit("Server stopped")
129
135
  finally:
130
- signal_handler.exit()
136
+ await signal_handler.exit()
131
137
 
132
138
  self.fast_api_app.router.lifespan_context = lifecycle
133
139
  self.app_initialized = True
@@ -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()
@@ -435,66 +435,63 @@ async def test_register_raw(
435
435
  mock_sync_raw_mixin_with_jq_processor: SyncRawMixin,
436
436
  mock_resource_config: ResourceConfig,
437
437
  ) -> None:
438
- # Mock the integration settings with skip_check_diff
439
- with patch.object(ocean.config.integration, "skip_check_diff", False):
440
- kind = "service"
441
- user_agent_type = UserAgentType.exporter
442
- raw_entity = [
443
- {"id": "entity_1", "name": "entity_1", "web_url": "https://example.com"},
444
- ]
445
- expected_result = [
446
- {
447
- "identifier": "entity_1",
448
- "blueprint": "service",
449
- "name": "entity_1",
450
- "properties": {},
451
- },
452
- ]
453
438
 
454
- async with event_context(
455
- EventType.HTTP_REQUEST, trigger_type="machine"
456
- ) as event:
457
- # Use patch to mock the method instead of direct assignment
439
+ kind = "service"
440
+ user_agent_type = UserAgentType.exporter
441
+ raw_entity = [
442
+ {"id": "entity_1", "name": "entity_1", "web_url": "https://example.com"},
443
+ ]
444
+ expected_result = [
445
+ {
446
+ "identifier": "entity_1",
447
+ "blueprint": "service",
448
+ "name": "entity_1",
449
+ "properties": {"url": "https://example.com"},
450
+ },
451
+ ]
452
+
453
+ async with event_context(EventType.HTTP_REQUEST, trigger_type="machine") as event:
454
+ # Use patch to mock the method instead of direct assignment
455
+ with patch.object(
456
+ mock_sync_raw_mixin_with_jq_processor.port_app_config_handler,
457
+ "get_port_app_config",
458
+ return_value=PortAppConfig(
459
+ enable_merge_entity=True,
460
+ delete_dependent_entities=True,
461
+ create_missing_related_entities=False,
462
+ resources=[mock_resource_config],
463
+ ),
464
+ ):
465
+ # Ensure the event.port_app_config is set correctly
466
+ event.port_app_config = await mock_sync_raw_mixin_with_jq_processor.port_app_config_handler.get_port_app_config(
467
+ use_cache=False
468
+ )
469
+
470
+ def upsert_side_effect(
471
+ entities: list[Entity], user_agent_type: UserAgentType
472
+ ) -> list[Entity]:
473
+ # Simulate returning the passed entities
474
+ return entities
475
+
476
+ # Patch the upsert method with the side effect
458
477
  with patch.object(
459
- mock_sync_raw_mixin_with_jq_processor.port_app_config_handler,
460
- "get_port_app_config",
461
- return_value=PortAppConfig(
462
- enable_merge_entity=True,
463
- delete_dependent_entities=True,
464
- create_missing_related_entities=False,
465
- resources=[mock_resource_config],
466
- ),
478
+ mock_sync_raw_mixin_with_jq_processor.entities_state_applier,
479
+ "upsert",
480
+ side_effect=upsert_side_effect,
467
481
  ):
468
- # Ensure the event.port_app_config is set correctly
469
- event.port_app_config = await mock_sync_raw_mixin_with_jq_processor.port_app_config_handler.get_port_app_config(
470
- use_cache=False
471
- )
472
-
473
- def upsert_side_effect(
474
- entities: list[Entity], user_agent_type: UserAgentType
475
- ) -> list[Entity]:
476
- # Simulate returning the passed entities
477
- return entities
478
-
479
- # Patch the upsert method with the side effect
480
- with patch.object(
481
- mock_sync_raw_mixin_with_jq_processor.entities_state_applier,
482
- "upsert",
483
- side_effect=upsert_side_effect,
484
- ):
485
- # Call the register_raw method
486
- registered_entities = (
487
- await mock_sync_raw_mixin_with_jq_processor.register_raw(
488
- kind, raw_entity, user_agent_type
489
- )
482
+ # Call the register_raw method
483
+ registered_entities = (
484
+ await mock_sync_raw_mixin_with_jq_processor.register_raw(
485
+ kind, raw_entity, user_agent_type
490
486
  )
487
+ )
491
488
 
492
- # Assert that the registered entities match the expected results
493
- assert len(registered_entities) == len(expected_result)
494
- for entity, result in zip(registered_entities, expected_result):
495
- assert entity.identifier == result["identifier"]
496
- assert entity.blueprint == result["blueprint"]
497
- assert entity.properties == result["properties"]
489
+ # Assert that the registered entities match the expected results
490
+ assert len(registered_entities) == len(expected_result)
491
+ for entity, result in zip(registered_entities, expected_result):
492
+ assert entity.identifier == result["identifier"]
493
+ assert entity.blueprint == result["blueprint"]
494
+ assert entity.properties == result["properties"]
498
495
 
499
496
 
500
497
  @pytest.mark.asyncio
@@ -689,30 +686,26 @@ async def test_register_resource_raw_no_changes_upsert_not_called_entitiy_is_ret
689
686
  mock_sync_raw_mixin: SyncRawMixin,
690
687
  mock_port_app_config: PortAppConfig,
691
688
  ) -> None:
692
- # Mock the integration settings with skip_check_diff
693
- with patch.object(ocean.config.integration, "skip_check_diff", False):
694
- entity = Entity(identifier="1", blueprint="service")
695
- mock_sync_raw_mixin._calculate_raw = AsyncMock(return_value=[CalculationResult(entity_selector_diff=EntitySelectorDiff(passed=[entity], failed=[]), errors=[], misconfigurations=[], misonfigured_entity_keys=[])]) # type: ignore
696
- mock_sync_raw_mixin._map_entities_compared_with_port = AsyncMock(return_value=([])) # type: ignore
697
- mock_sync_raw_mixin.entities_state_applier.upsert = AsyncMock() # type: ignore
698
-
699
- async with event_context(EventType.RESYNC, trigger_type="machine") as event:
700
- event.port_app_config = mock_port_app_config
701
-
702
- # Test execution
703
- result = await mock_sync_raw_mixin._register_resource_raw(
704
- mock_port_app_config.resources[
705
- 0
706
- ], # Use the first resource from the config
707
- [{"some": "data"}],
708
- UserAgentType.exporter,
709
- )
689
+ entity = Entity(identifier="1", blueprint="service")
690
+ mock_sync_raw_mixin._calculate_raw = AsyncMock(return_value=[CalculationResult(entity_selector_diff=EntitySelectorDiff(passed=[entity], failed=[]), errors=[], misconfigurations=[], misonfigured_entity_keys=[])]) # type: ignore
691
+ mock_sync_raw_mixin._map_entities_compared_with_port = AsyncMock(return_value=([])) # type: ignore
692
+ mock_sync_raw_mixin.entities_state_applier.upsert = AsyncMock() # type: ignore
693
+
694
+ async with event_context(EventType.RESYNC, trigger_type="machine") as event:
695
+ event.port_app_config = mock_port_app_config
696
+
697
+ # Test execution
698
+ result = await mock_sync_raw_mixin._register_resource_raw(
699
+ mock_port_app_config.resources[0], # Use the first resource from the config
700
+ [{"some": "data"}],
701
+ UserAgentType.exporter,
702
+ )
710
703
 
711
- # Assertions
712
- assert len(result.entity_selector_diff.passed) == 1
713
- mock_sync_raw_mixin._calculate_raw.assert_called_once()
714
- mock_sync_raw_mixin.entities_state_applier.upsert.assert_not_called()
715
- mock_sync_raw_mixin._map_entities_compared_with_port.assert_called_once()
704
+ # Assertions
705
+ assert len(result.entity_selector_diff.passed) == 1
706
+ mock_sync_raw_mixin._calculate_raw.assert_called_once()
707
+ mock_sync_raw_mixin.entities_state_applier.upsert.assert_not_called()
708
+ mock_sync_raw_mixin._map_entities_compared_with_port.assert_called_once()
716
709
 
717
710
 
718
711
  @pytest.mark.asyncio
@@ -720,57 +713,89 @@ async def test_register_resource_raw_with_changes_upsert_called_and_entities_are
720
713
  mock_sync_raw_mixin: SyncRawMixin,
721
714
  mock_port_app_config: PortAppConfig,
722
715
  ) -> None:
723
- # Mock the integration settings with skip_check_diff
724
- with patch.object(ocean.config.integration, "skip_check_diff", False):
725
- entity = Entity(identifier="1", blueprint="service")
726
- mock_sync_raw_mixin._calculate_raw = AsyncMock(return_value=[CalculationResult(entity_selector_diff=EntitySelectorDiff(passed=[entity], failed=[]), errors=[], misconfigurations=[], misonfigured_entity_keys=[])]) # type: ignore
727
- mock_sync_raw_mixin._map_entities_compared_with_port = AsyncMock(return_value=([entity])) # type: ignore
728
- mock_sync_raw_mixin.entities_state_applier.upsert = AsyncMock(return_value=[entity]) # type: ignore
729
-
730
- async with event_context(EventType.RESYNC, trigger_type="machine") as event:
731
- event.port_app_config = mock_port_app_config
732
-
733
- # Test execution
734
- result = await mock_sync_raw_mixin._register_resource_raw(
735
- mock_port_app_config.resources[0],
736
- [{"some": "data"}],
737
- UserAgentType.exporter,
738
- )
716
+ entity = Entity(identifier="1", blueprint="service")
717
+ mock_sync_raw_mixin._calculate_raw = AsyncMock(return_value=[CalculationResult(entity_selector_diff=EntitySelectorDiff(passed=[entity], failed=[]), errors=[], misconfigurations=[], misonfigured_entity_keys=[])]) # type: ignore
718
+ mock_sync_raw_mixin._map_entities_compared_with_port = AsyncMock(return_value=([entity])) # type: ignore
719
+ mock_sync_raw_mixin.entities_state_applier.upsert = AsyncMock(return_value=[entity]) # type: ignore
720
+
721
+ async with event_context(EventType.RESYNC, trigger_type="machine") as event:
722
+ event.port_app_config = mock_port_app_config
723
+
724
+ # Test execution
725
+ result = await mock_sync_raw_mixin._register_resource_raw(
726
+ mock_port_app_config.resources[0],
727
+ [{"some": "data"}],
728
+ UserAgentType.exporter,
729
+ )
739
730
 
740
- # Assertions
741
- assert len(result.entity_selector_diff.passed) == 1
742
- mock_sync_raw_mixin._calculate_raw.assert_called_once()
743
- mock_sync_raw_mixin.entities_state_applier.upsert.assert_called_once()
744
- mock_sync_raw_mixin._map_entities_compared_with_port.assert_called_once()
731
+ # Assertions
732
+ assert len(result.entity_selector_diff.passed) == 1
733
+ mock_sync_raw_mixin._calculate_raw.assert_called_once()
734
+ mock_sync_raw_mixin.entities_state_applier.upsert.assert_called_once()
735
+ mock_sync_raw_mixin._map_entities_compared_with_port.assert_called_once()
745
736
 
746
737
 
747
738
  @pytest.mark.asyncio
748
739
  async def test_register_resource_raw_with_errors(
749
740
  mock_sync_raw_mixin: SyncRawMixin, mock_port_app_config: PortAppConfig
750
741
  ) -> None:
751
- # Mock the integration settings with skip_check_diff
752
- with patch.object(ocean.config.integration, "skip_check_diff", False):
753
- failed_entity = Entity(identifier="1", blueprint="service")
754
- error = Exception("Test error")
755
- mock_sync_raw_mixin._calculate_raw = AsyncMock(return_value=[CalculationResult(entity_selector_diff=EntitySelectorDiff(passed=[], failed=[failed_entity]), errors=[error], misconfigurations=[], misonfigured_entity_keys=[])]) # type: ignore
756
- mock_sync_raw_mixin._map_entities_compared_with_port = AsyncMock(return_value=([])) # type: ignore
757
- mock_sync_raw_mixin.entities_state_applier.upsert = AsyncMock() # type: ignore
758
-
759
- async with event_context(EventType.RESYNC, trigger_type="machine") as event:
760
- event.port_app_config = mock_port_app_config
761
-
762
- # Test execution
763
- result = await mock_sync_raw_mixin._register_resource_raw(
764
- mock_port_app_config.resources[0],
765
- [{"some": "data"}],
766
- UserAgentType.exporter,
767
- )
742
+ failed_entity = Entity(identifier="1", blueprint="service")
743
+ error = Exception("Test error")
744
+ mock_sync_raw_mixin._calculate_raw = AsyncMock(return_value=[CalculationResult(entity_selector_diff=EntitySelectorDiff(passed=[], failed=[failed_entity]), errors=[error], misconfigurations=[], misonfigured_entity_keys=[])]) # type: ignore
745
+ mock_sync_raw_mixin._map_entities_compared_with_port = AsyncMock(return_value=([])) # type: ignore
746
+ mock_sync_raw_mixin.entities_state_applier.upsert = AsyncMock() # type: ignore
747
+
748
+ async with event_context(EventType.RESYNC, trigger_type="machine") as event:
749
+ event.port_app_config = mock_port_app_config
750
+
751
+ # Test execution
752
+ result = await mock_sync_raw_mixin._register_resource_raw(
753
+ mock_port_app_config.resources[0],
754
+ [{"some": "data"}],
755
+ UserAgentType.exporter,
756
+ )
757
+
758
+ # Assertions
759
+ assert len(result.entity_selector_diff.passed) == 0
760
+ assert len(result.entity_selector_diff.failed) == 1
761
+ assert len(result.errors) == 1
762
+ assert result.errors[0] == error
763
+ mock_sync_raw_mixin._calculate_raw.assert_called_once()
764
+ mock_sync_raw_mixin._map_entities_compared_with_port.assert_called_once()
765
+ mock_sync_raw_mixin.entities_state_applier.upsert.assert_not_called()
766
+
767
+
768
+ @pytest.mark.asyncio
769
+ async def test_register_resource_raw_skip_event_type_http_request_upsert_called_and_no_entitites_diff_calculation(
770
+ mock_sync_raw_mixin: SyncRawMixin,
771
+ mock_port_app_config: PortAppConfig,
772
+ mock_context: PortOceanContext,
773
+ monkeypatch: pytest.MonkeyPatch,
774
+ ) -> None:
775
+ # Mock dependencies
776
+ entity = Entity(identifier="1", blueprint="service")
777
+ calculation_result = CalculationResult(
778
+ entity_selector_diff=EntitySelectorDiff(passed=[entity], failed=[]),
779
+ errors=[],
780
+ misconfigurations=[],
781
+ misonfigured_entity_keys=[],
782
+ )
783
+ mock_sync_raw_mixin._calculate_raw = AsyncMock(return_value=[calculation_result]) # type: ignore
784
+ mock_sync_raw_mixin._map_entities_compared_with_port = AsyncMock() # type: ignore
785
+ mock_sync_raw_mixin.entities_state_applier.upsert = AsyncMock(return_value=[entity]) # type: ignore
786
+
787
+ async with event_context(EventType.HTTP_REQUEST, trigger_type="machine") as event:
788
+ event.port_app_config = mock_port_app_config
789
+
790
+ # Test execution
791
+ result = await mock_sync_raw_mixin._register_resource_raw(
792
+ mock_port_app_config.resources[0],
793
+ [{"some": "data"}],
794
+ UserAgentType.exporter,
795
+ )
768
796
 
769
- # Assertions
770
- assert len(result.entity_selector_diff.passed) == 0
771
- assert len(result.entity_selector_diff.failed) == 1
772
- assert len(result.errors) == 1
773
- assert result.errors[0] == error
774
- mock_sync_raw_mixin._calculate_raw.assert_called_once()
775
- mock_sync_raw_mixin._map_entities_compared_with_port.assert_called_once()
776
- mock_sync_raw_mixin.entities_state_applier.upsert.assert_not_called()
797
+ # Assertions
798
+ assert len(result.entity_selector_diff.passed) == 1
799
+ mock_sync_raw_mixin._calculate_raw.assert_called_once()
800
+ mock_sync_raw_mixin._map_entities_compared_with_port.assert_not_called()
801
+ mock_sync_raw_mixin.entities_state_applier.upsert.assert_called_once()
@@ -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,114 @@
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
+ class TestAbstractWebhookHandler:
61
+ @pytest.fixture
62
+ def webhook_event(self) -> WebhookEvent:
63
+ return WebhookEvent(
64
+ trace_id="test-trace",
65
+ payload={"test": "data"},
66
+ headers={"content-type": "application/json"},
67
+ )
68
+
69
+ @pytest.fixture
70
+ def processor_manager(self) -> WebhookProcessorManager:
71
+ return WebhookProcessorManager(APIRouter(), SignalHandler())
72
+
73
+ @pytest.fixture
74
+ def processor(self, webhook_event: WebhookEvent) -> MockWebhookHandler:
75
+ return MockWebhookHandler(webhook_event)
76
+
77
+ async def test_successful_processing(
78
+ self, processor: MockWebhookHandler, processor_manager: WebhookProcessorManager
79
+ ) -> None:
80
+ """Test successful webhook processing flow."""
81
+ await processor_manager._process_webhook_request(processor)
82
+
83
+ assert processor.authenticated
84
+ assert processor.validated
85
+ assert processor.handled
86
+ assert not processor.error_handler_called
87
+
88
+ async def test_retry_mechanism(
89
+ self, webhook_event: WebhookEvent, processor_manager: WebhookProcessorManager
90
+ ) -> None:
91
+ """Test retry mechanism with temporary failures."""
92
+ processor = MockWebhookHandler(webhook_event, should_fail=True, fail_count=2)
93
+
94
+ await processor_manager._process_webhook_request(processor)
95
+
96
+ assert processor.handled
97
+ assert processor.current_fails == 2
98
+ assert processor.retry_count == 2
99
+ assert processor.error_handler_called
100
+
101
+ async def test_max_retries_exceeded(
102
+ self, webhook_event: WebhookEvent, processor_manager: WebhookProcessorManager
103
+ ) -> None:
104
+ """Test behavior when max retries are exceeded."""
105
+ processor = MockWebhookHandler(
106
+ webhook_event, should_fail=True, fail_count=2, max_retries=1
107
+ )
108
+
109
+ with pytest.raises(RetryableError):
110
+ await processor_manager._process_webhook_request(processor)
111
+
112
+ assert processor.retry_count == processor.max_retries
113
+ assert processor.error_handler_called
114
+ assert not processor.handled