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.
- 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 +26 -20
- 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/mixins/test_sync_raw.py +149 -124
- port_ocean/tests/core/handlers/queue/test_local_queue.py +90 -0
- port_ocean/tests/core/handlers/webhook/test_abstract_webhook_processor.py +114 -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/utils/signal.py +6 -2
- {port_ocean-0.18.5.dist-info → port_ocean-0.18.7.dist-info}/METADATA +1 -1
- {port_ocean-0.18.5.dist-info → port_ocean-0.18.7.dist-info}/RECORD +26 -13
- {port_ocean-0.18.5.dist-info → port_ocean-0.18.7.dist-info}/LICENSE.md +0 -0
- {port_ocean-0.18.5.dist-info → port_ocean-0.18.7.dist-info}/WHEEL +0 -0
- {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
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
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.
|
460
|
-
"
|
461
|
-
|
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
|
-
#
|
469
|
-
|
470
|
-
|
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
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
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
|
-
|
693
|
-
|
694
|
-
|
695
|
-
|
696
|
-
|
697
|
-
|
698
|
-
|
699
|
-
|
700
|
-
|
701
|
-
|
702
|
-
#
|
703
|
-
|
704
|
-
|
705
|
-
|
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
|
-
|
712
|
-
|
713
|
-
|
714
|
-
|
715
|
-
|
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
|
-
|
724
|
-
|
725
|
-
|
726
|
-
|
727
|
-
|
728
|
-
|
729
|
-
|
730
|
-
|
731
|
-
|
732
|
-
|
733
|
-
|
734
|
-
|
735
|
-
|
736
|
-
|
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
|
-
|
741
|
-
|
742
|
-
|
743
|
-
|
744
|
-
|
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
|
-
|
752
|
-
|
753
|
-
|
754
|
-
|
755
|
-
|
756
|
-
|
757
|
-
|
758
|
-
|
759
|
-
|
760
|
-
|
761
|
-
|
762
|
-
|
763
|
-
|
764
|
-
|
765
|
-
|
766
|
-
|
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
|
-
|
770
|
-
|
771
|
-
|
772
|
-
|
773
|
-
|
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
|