port-ocean 0.19.3__py3-none-any.whl → 0.20.0__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.
@@ -0,0 +1,404 @@
1
+ from typing import Any
2
+ from httpx import Response
3
+ import pytest
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+ from port_ocean.clients.port.client import PortClient
6
+ from port_ocean.clients.port.types import UserAgentType
7
+ from port_ocean.context.ocean import PortOceanContext
8
+ from port_ocean.core.handlers.entities_state_applier.port.applier import (
9
+ HttpEntitiesStateApplier,
10
+ )
11
+ from port_ocean.core.handlers.entity_processor.jq_entity_processor import (
12
+ JQEntityProcessor,
13
+ )
14
+ from port_ocean.core.handlers.webhook.webhook_event import WebhookEventRawResults
15
+ from port_ocean.core.integrations.mixins.live_events import LiveEventsMixin
16
+ from port_ocean.core.handlers.port_app_config.models import (
17
+ EntityMapping,
18
+ MappingsConfig,
19
+ PortAppConfig,
20
+ PortResourceConfig,
21
+ ResourceConfig,
22
+ Selector,
23
+ )
24
+ from port_ocean.core.models import Entity
25
+ from port_ocean.core.ocean_types import CalculationResult, EntitySelectorDiff
26
+ from port_ocean.ocean import Ocean
27
+
28
+ entity = Entity(
29
+ identifier="repo-one",
30
+ blueprint="service",
31
+ title="repo-one",
32
+ team=[],
33
+ properties={
34
+ "url": "https://example.com/repo-one",
35
+ "defaultBranch": "main",
36
+ },
37
+ relations={},
38
+ )
39
+
40
+ expected_entities = [
41
+ Entity(
42
+ identifier="repo-one",
43
+ blueprint="service",
44
+ title="repo-one",
45
+ team=[],
46
+ properties={
47
+ "url": "https://example.com/repo-one",
48
+ "defaultBranch": "main",
49
+ },
50
+ relations={},
51
+ ),
52
+ Entity(
53
+ identifier="repo-two",
54
+ blueprint="service",
55
+ title="repo-two",
56
+ team=[],
57
+ properties={
58
+ "url": "https://example.com/repo-two",
59
+ "defaultBranch": "develop",
60
+ },
61
+ relations={},
62
+ ),
63
+ Entity(
64
+ identifier="repo-three",
65
+ blueprint="service",
66
+ title="repo-three",
67
+ team=[],
68
+ properties={
69
+ "url": "https://example.com/repo-three",
70
+ "defaultBranch": "master",
71
+ },
72
+ relations={},
73
+ ),
74
+ ]
75
+
76
+ event_data_for_three_entities_for_repository_resource = [
77
+ {
78
+ "name": "repo-one",
79
+ "links": {"html": {"href": "https://example.com/repo-one"}},
80
+ "main_branch": "main",
81
+ },
82
+ {
83
+ "name": "repo-two",
84
+ "links": {"html": {"href": "https://example.com/repo-two"}},
85
+ "main_branch": "develop",
86
+ },
87
+ {
88
+ "name": "repo-three",
89
+ "links": {"html": {"href": "https://example.com/repo-three"}},
90
+ "main_branch": "master",
91
+ },
92
+ ]
93
+
94
+
95
+ one_webhook_event_raw_results_for_creation = WebhookEventRawResults(
96
+ updated_raw_results=[
97
+ {
98
+ "name": "repo-one",
99
+ "links": {"html": {"href": "https://example.com/repo-one"}},
100
+ "main_branch": "main",
101
+ }
102
+ ],
103
+ deleted_raw_results=[],
104
+ )
105
+ one_webhook_event_raw_results_for_creation.resource = ResourceConfig(
106
+ kind="repository",
107
+ selector=Selector(query="true"),
108
+ port=PortResourceConfig(
109
+ entity=MappingsConfig(
110
+ mappings=EntityMapping(
111
+ identifier=".name",
112
+ title=".name",
113
+ blueprint='"service"',
114
+ properties={
115
+ "url": ".links.html.href",
116
+ "defaultBranch": ".main_branch",
117
+ },
118
+ relations={},
119
+ )
120
+ )
121
+ ),
122
+ )
123
+ one_webhook_event_raw_results_for_deletion = WebhookEventRawResults(
124
+ deleted_raw_results=[
125
+ {
126
+ "name": "repo-one",
127
+ "links": {"html": {"href": "https://example.com/repo-one"}},
128
+ "main_branch": "main",
129
+ }
130
+ ],
131
+ updated_raw_results=[],
132
+ )
133
+ one_webhook_event_raw_results_for_deletion.resource = ResourceConfig(
134
+ kind="repository",
135
+ selector=Selector(query="true"),
136
+ port=PortResourceConfig(
137
+ entity=MappingsConfig(
138
+ mappings=EntityMapping(
139
+ identifier=".name",
140
+ title=".name",
141
+ blueprint='"service"',
142
+ properties={
143
+ "url": ".links.html.href",
144
+ "defaultBranch": ".main_branch",
145
+ },
146
+ relations={},
147
+ )
148
+ )
149
+ ),
150
+ )
151
+
152
+
153
+ @pytest.fixture
154
+ def mock_context(monkeypatch: Any) -> PortOceanContext:
155
+ mock_context = AsyncMock()
156
+ monkeypatch.setattr(PortOceanContext, "app", mock_context)
157
+ return mock_context
158
+
159
+
160
+ @pytest.fixture
161
+ def mock_entity_processor(mock_context: PortOceanContext) -> JQEntityProcessor:
162
+ return JQEntityProcessor(mock_context)
163
+
164
+
165
+ @pytest.fixture
166
+ def mock_entities_state_applier(
167
+ mock_context: PortOceanContext,
168
+ ) -> HttpEntitiesStateApplier:
169
+ return HttpEntitiesStateApplier(mock_context)
170
+
171
+
172
+ @pytest.fixture
173
+ def mock_repository_resource_config() -> ResourceConfig:
174
+ return ResourceConfig(
175
+ kind="repository",
176
+ selector=Selector(query="true"),
177
+ port=PortResourceConfig(
178
+ entity=MappingsConfig(
179
+ mappings=EntityMapping(
180
+ identifier=".name",
181
+ title=".name",
182
+ blueprint='"service"',
183
+ properties={
184
+ "url": ".links.html.href",
185
+ "defaultBranch": ".main_branch",
186
+ },
187
+ relations={},
188
+ )
189
+ )
190
+ ),
191
+ )
192
+
193
+
194
+ @pytest.fixture
195
+ def mock_repository_resource_config_not_passong_selector() -> ResourceConfig:
196
+ return ResourceConfig(
197
+ kind="repository",
198
+ selector=Selector(query="false"),
199
+ port=PortResourceConfig(
200
+ entity=MappingsConfig(
201
+ mappings=EntityMapping(
202
+ identifier=".name",
203
+ title=".name",
204
+ blueprint='"service"',
205
+ properties={
206
+ "url": ".links.html.href",
207
+ "defaultBranch": ".main_branch",
208
+ },
209
+ relations={},
210
+ )
211
+ )
212
+ ),
213
+ )
214
+
215
+
216
+ @pytest.fixture
217
+ def mock_port_app_config_with_repository_resource(
218
+ mock_repository_resource_config: ResourceConfig,
219
+ ) -> PortAppConfig:
220
+ return PortAppConfig(
221
+ enable_merge_entity=True,
222
+ delete_dependent_entities=True,
223
+ create_missing_related_entities=False,
224
+ resources=[mock_repository_resource_config],
225
+ entity_deletion_threshold=0.5,
226
+ )
227
+
228
+
229
+ @pytest.fixture
230
+ def mock_port_app_config_with_repository_resource_not_passing_selector(
231
+ mock_repository_resource_config_not_passong_selector: ResourceConfig,
232
+ ) -> PortAppConfig:
233
+ return PortAppConfig(
234
+ enable_merge_entity=True,
235
+ delete_dependent_entities=True,
236
+ create_missing_related_entities=False,
237
+ resources=[mock_repository_resource_config_not_passong_selector],
238
+ entity_deletion_threshold=0.5,
239
+ )
240
+
241
+
242
+ @pytest.fixture
243
+ def mock_port_app_config_handler(
244
+ mock_port_app_config_with_repository_resource: PortAppConfig,
245
+ ) -> MagicMock:
246
+ handler = MagicMock()
247
+ handler.get_port_app_config = AsyncMock(
248
+ return_value=mock_port_app_config_with_repository_resource
249
+ )
250
+ return handler
251
+
252
+
253
+ @pytest.fixture
254
+ def mock_port_client(mock_http_client: MagicMock) -> PortClient:
255
+ mock_port_client = PortClient(
256
+ MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock()
257
+ )
258
+ mock_port_client.auth = AsyncMock()
259
+ mock_port_client.auth.headers = AsyncMock(
260
+ return_value={
261
+ "Authorization": "test",
262
+ "User-Agent": "test",
263
+ }
264
+ )
265
+
266
+ mock_port_client.search_entities = AsyncMock(return_value=[]) # type: ignore
267
+ mock_port_client.client = mock_http_client
268
+ return mock_port_client
269
+
270
+
271
+ @pytest.fixture
272
+ def mock_ocean(mock_port_client: PortClient) -> Ocean:
273
+ with patch("port_ocean.ocean.Ocean.__init__", return_value=None):
274
+ ocean_mock = Ocean(
275
+ MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock()
276
+ )
277
+ ocean_mock.config = MagicMock()
278
+ ocean_mock.config.port = MagicMock()
279
+ ocean_mock.config.port.port_app_config_cache_ttl = 60
280
+ ocean_mock.port_client = mock_port_client
281
+
282
+ return ocean_mock
283
+
284
+
285
+ @pytest.fixture
286
+ def mock_live_events_mixin(
287
+ mock_entity_processor: JQEntityProcessor,
288
+ mock_entities_state_applier: HttpEntitiesStateApplier,
289
+ mock_port_app_config_handler: MagicMock,
290
+ ) -> LiveEventsMixin:
291
+ mixin = LiveEventsMixin()
292
+ mixin._entity_processor = mock_entity_processor
293
+ mixin._entities_state_applier = mock_entities_state_applier
294
+ mixin._port_app_config_handler = mock_port_app_config_handler
295
+ return mixin
296
+
297
+
298
+ @pytest.fixture
299
+ def mock_http_client() -> MagicMock:
300
+ mock_http_client = MagicMock()
301
+ mock_upserted_entities = []
302
+
303
+ async def post(url: str, *args: Any, **kwargs: Any) -> Response:
304
+ entity = kwargs.get("json", {})
305
+ if entity.get("properties", {}).get("mock_is_to_fail", {}):
306
+ return Response(
307
+ 404, headers=MagicMock(), json={"ok": False, "error": "not_found"}
308
+ )
309
+
310
+ mock_upserted_entities.append(
311
+ f"{entity.get('identifier')}-{entity.get('blueprint')}"
312
+ )
313
+ return Response(
314
+ 200,
315
+ json={
316
+ "entity": {
317
+ "identifier": entity.get("identifier"),
318
+ "blueprint": entity.get("blueprint"),
319
+ }
320
+ },
321
+ )
322
+
323
+ mock_http_client.post = AsyncMock(side_effect=post)
324
+ return mock_http_client
325
+
326
+
327
+ @pytest.mark.asyncio
328
+ async def test_parse_raw_event_results_to_entities_creation(
329
+ mock_live_events_mixin: LiveEventsMixin,
330
+ ) -> None:
331
+ """Test parsing raw event results for entity creation"""
332
+ mock_live_events_mixin.entity_processor.parse_items = AsyncMock() # type: ignore
333
+
334
+ calculation_result = CalculationResult(
335
+ entity_selector_diff=EntitySelectorDiff(passed=[entity], failed=[]),
336
+ errors=[],
337
+ misonfigured_entity_keys={},
338
+ )
339
+ mock_live_events_mixin.entity_processor.parse_items.return_value = (
340
+ calculation_result
341
+ )
342
+
343
+ entities_to_create, entities_to_delete = (
344
+ await mock_live_events_mixin._parse_raw_event_results_to_entities(
345
+ [one_webhook_event_raw_results_for_creation]
346
+ )
347
+ )
348
+
349
+ assert entities_to_create == [entity]
350
+ assert entities_to_delete == []
351
+ mock_live_events_mixin.entity_processor.parse_items.assert_called_once()
352
+
353
+
354
+ @pytest.mark.asyncio
355
+ async def test_parse_raw_event_results_to_entities_deletion(
356
+ mock_live_events_mixin: LiveEventsMixin,
357
+ ) -> None:
358
+ """Test parsing raw event results for entity deletion"""
359
+ mock_live_events_mixin.entity_processor.parse_items = AsyncMock() # type: ignore
360
+
361
+ calculation_result = CalculationResult(
362
+ entity_selector_diff=EntitySelectorDiff(passed=[entity], failed=[]),
363
+ errors=[],
364
+ misonfigured_entity_keys={},
365
+ )
366
+ mock_live_events_mixin.entity_processor.parse_items.return_value = (
367
+ calculation_result
368
+ )
369
+
370
+ entities_to_create, entities_to_delete = (
371
+ await mock_live_events_mixin._parse_raw_event_results_to_entities(
372
+ [one_webhook_event_raw_results_for_deletion]
373
+ )
374
+ )
375
+
376
+ assert entities_to_create == []
377
+ assert entities_to_delete == [entity]
378
+ mock_live_events_mixin.entity_processor.parse_items.assert_called_once()
379
+
380
+
381
+ @pytest.mark.asyncio
382
+ async def test_sync_raw_results_one_raw_result_entity_upserted(
383
+ mock_live_events_mixin: LiveEventsMixin,
384
+ ) -> None:
385
+ """Test synchronizing raw webhook event results"""
386
+ # Setup mocks
387
+ mock_live_events_mixin._parse_raw_event_results_to_entities = AsyncMock(return_value=([entity], [])) # type: ignore
388
+ mock_live_events_mixin.entities_state_applier.upsert = AsyncMock() # type: ignore
389
+ mock_live_events_mixin.entities_state_applier.delete = AsyncMock() # type: ignore
390
+ mock_live_events_mixin._delete_entities = AsyncMock() # type: ignore
391
+
392
+ # Call the method
393
+ await mock_live_events_mixin.sync_raw_results(
394
+ [one_webhook_event_raw_results_for_creation]
395
+ )
396
+
397
+ # Verify the method calls
398
+ mock_live_events_mixin._parse_raw_event_results_to_entities.assert_called_once_with(
399
+ [one_webhook_event_raw_results_for_creation]
400
+ )
401
+ mock_live_events_mixin.entities_state_applier.upsert.assert_called_once_with(
402
+ [entity], UserAgentType.exporter
403
+ )
404
+ mock_live_events_mixin.entities_state_applier.delete.assert_not_called()
@@ -1,115 +1,106 @@
1
- from fastapi import APIRouter
2
1
  import pytest
3
-
2
+ from port_ocean.core.handlers.port_app_config.models import ResourceConfig
4
3
  from port_ocean.core.handlers.webhook.abstract_webhook_processor import (
5
4
  AbstractWebhookProcessor,
6
5
  )
7
- from port_ocean.exceptions.webhook_processor import RetryableError
8
6
  from port_ocean.core.handlers.webhook.webhook_event import (
9
7
  EventHeaders,
10
8
  EventPayload,
11
9
  WebhookEvent,
10
+ WebhookEventRawResults,
12
11
  )
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
12
+ from port_ocean.exceptions.webhook_processor import RetryableError
13
+
14
+
15
+ class ConcreteWebhookProcessor(AbstractWebhookProcessor):
16
+ """Concrete implementation for testing the abstract class"""
17
+
18
+ def __init__(self, webhook_event: WebhookEvent) -> None:
19
+ super().__init__(webhook_event)
20
+ self.before_processing_called = False
21
+ self.after_processing_called = False
22
+ self.cancel_called = False
37
23
 
38
24
  async def authenticate(self, payload: EventPayload, headers: EventHeaders) -> bool:
39
- self.authenticated = True
40
25
  return True
41
26
 
42
27
  async def validate_payload(self, payload: EventPayload) -> bool:
43
- self.validated = True
44
28
  return True
45
29
 
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
30
+ async def handle_event(
31
+ self, payload: EventPayload, resource: ResourceConfig
32
+ ) -> WebhookEventRawResults:
33
+ return WebhookEventRawResults(updated_raw_results=[{}], deleted_raw_results=[])
34
+
35
+ def should_process_event(self, webhook_event: WebhookEvent) -> bool:
36
+ return True
37
+
38
+ async def before_processing(self) -> None:
39
+ await super().before_processing()
40
+ self.before_processing_called = True
41
+
42
+ async def after_processing(self) -> None:
43
+ await super().after_processing()
44
+ self.after_processing_called = True
51
45
 
52
46
  async def cancel(self) -> None:
53
- self.cancelled = True
54
-
55
- async def on_error(self, error: Exception) -> None:
56
- self.error_handler_called = True
57
- await super().on_error(error)
58
-
59
-
60
- @pytest.mark.skip("Skipping until fixed")
61
- class TestAbstractWebhookHandler:
62
- @pytest.fixture
63
- def webhook_event(self) -> WebhookEvent:
64
- return WebhookEvent(
65
- trace_id="test-trace",
66
- payload={"test": "data"},
67
- headers={"content-type": "application/json"},
68
- )
69
-
70
- @pytest.fixture
71
- def processor_manager(self) -> WebhookProcessorManager:
72
- return WebhookProcessorManager(APIRouter(), SignalHandler())
73
-
74
- @pytest.fixture
75
- def processor(self, webhook_event: WebhookEvent) -> MockWebhookHandler:
76
- return MockWebhookHandler(webhook_event)
77
-
78
- async def test_successful_processing(
79
- self, processor: MockWebhookHandler, processor_manager: WebhookProcessorManager
80
- ) -> None:
81
- """Test successful webhook processing flow."""
82
- await processor_manager._process_webhook_request(processor)
83
-
84
- assert processor.authenticated
85
- assert processor.validated
86
- assert processor.handled
87
- assert not processor.error_handler_called
88
-
89
- async def test_retry_mechanism(
90
- self, webhook_event: WebhookEvent, processor_manager: WebhookProcessorManager
91
- ) -> None:
92
- """Test retry mechanism with temporary failures."""
93
- processor = MockWebhookHandler(webhook_event, should_fail=True, fail_count=2)
94
-
95
- await processor_manager._process_webhook_request(processor)
96
-
97
- assert processor.handled
98
- assert processor.current_fails == 2
99
- assert processor.retry_count == 2
100
- assert processor.error_handler_called
101
-
102
- async def test_max_retries_exceeded(
103
- self, webhook_event: WebhookEvent, processor_manager: WebhookProcessorManager
104
- ) -> None:
105
- """Test behavior when max retries are exceeded."""
106
- processor = MockWebhookHandler(
107
- webhook_event, should_fail=True, fail_count=2, max_retries=1
108
- )
109
-
110
- with pytest.raises(RetryableError):
111
- await processor_manager._process_webhook_request(processor)
112
-
113
- assert processor.retry_count == processor.max_retries
114
- assert processor.error_handler_called
115
- assert not processor.handled
47
+ await super().cancel()
48
+ self.cancel_called = True
49
+
50
+ def get_matching_kinds(self, event: WebhookEvent) -> list[str]:
51
+ return ["test"]
52
+
53
+
54
+ @pytest.fixture
55
+ def webhook_event() -> WebhookEvent:
56
+ return WebhookEvent(payload={}, headers={}, trace_id="test-trace-id")
57
+
58
+
59
+ @pytest.fixture
60
+ def processor(webhook_event: WebhookEvent) -> ConcreteWebhookProcessor:
61
+ return ConcreteWebhookProcessor(webhook_event)
62
+
63
+
64
+ async def test_init_finishedSuccessfully(webhook_event: WebhookEvent) -> None:
65
+ processor = ConcreteWebhookProcessor(webhook_event)
66
+ assert processor.event == webhook_event
67
+ assert processor.retry_count == 0
68
+
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_calculateRetryDelay_delayCalculatedCorrectly(
72
+ processor: ConcreteWebhookProcessor,
73
+ ) -> None:
74
+ assert processor.calculate_retry_delay() == 1.0
75
+
76
+ processor.retry_count = 1
77
+ assert processor.calculate_retry_delay() == 2.0
78
+
79
+ processor.retry_count = 10
80
+ assert processor.calculate_retry_delay() == 30.0
81
+
82
+
83
+ @pytest.mark.asyncio
84
+ async def test_shouldRetry_returnsTrueOnRetryableError(
85
+ processor: ConcreteWebhookProcessor,
86
+ ) -> None:
87
+ assert processor.should_retry(RetryableError("test")) is True
88
+ assert processor.should_retry(ValueError("test")) is False
89
+
90
+
91
+ @pytest.mark.asyncio
92
+ async def test_lifecycleHooks_callsCorrectly(
93
+ processor: ConcreteWebhookProcessor,
94
+ ) -> None:
95
+ assert not processor.before_processing_called
96
+ assert not processor.after_processing_called
97
+ assert not processor.cancel_called
98
+
99
+ await processor.before_processing()
100
+ assert processor.before_processing_called
101
+
102
+ await processor.after_processing()
103
+ assert processor.after_processing_called
104
+
105
+ await processor.cancel()
106
+ assert processor.cancel_called