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.
- 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
|