port-ocean 0.18.5__py3-none-any.whl → 0.18.7__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 (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