port-ocean 0.19.3__py3-none-any.whl → 0.20.1__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -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