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.
- port_ocean/context/ocean.py +2 -7
- port_ocean/core/handlers/webhook/abstract_webhook_processor.py +18 -2
- port_ocean/core/handlers/webhook/processor_manager.py +107 -65
- port_ocean/core/handlers/webhook/webhook_event.py +71 -8
- port_ocean/core/integrations/mixins/live_events.py +88 -0
- port_ocean/ocean.py +4 -2
- port_ocean/tests/core/handlers/mixins/test_live_events.py +404 -0
- port_ocean/tests/core/handlers/webhook/test_abstract_webhook_processor.py +88 -97
- port_ocean/tests/core/handlers/webhook/test_processor_manager.py +1161 -288
- port_ocean/tests/core/handlers/webhook/test_webhook_event.py +97 -56
- {port_ocean-0.19.3.dist-info → port_ocean-0.20.0.dist-info}/METADATA +1 -1
- {port_ocean-0.19.3.dist-info → port_ocean-0.20.0.dist-info}/RECORD +15 -13
- {port_ocean-0.19.3.dist-info → port_ocean-0.20.0.dist-info}/LICENSE.md +0 -0
- {port_ocean-0.19.3.dist-info → port_ocean-0.20.0.dist-info}/WHEEL +0 -0
- {port_ocean-0.19.3.dist-info → port_ocean-0.20.0.dist-info}/entry_points.txt +0 -0
@@ -1,18 +1,85 @@
|
|
1
|
-
import asyncio
|
2
1
|
import pytest
|
3
|
-
from
|
4
|
-
|
5
|
-
|
6
|
-
from port_ocean.exceptions.webhook_processor import RetryableError
|
7
|
-
from port_ocean.core.handlers.webhook.processor_manager import WebhookProcessorManager
|
2
|
+
from port_ocean.core.handlers.webhook.processor_manager import (
|
3
|
+
LiveEventsProcessorManager,
|
4
|
+
)
|
8
5
|
from port_ocean.core.handlers.webhook.abstract_webhook_processor import (
|
9
6
|
AbstractWebhookProcessor,
|
10
7
|
)
|
11
8
|
from port_ocean.core.handlers.webhook.webhook_event import (
|
9
|
+
EventHeaders,
|
12
10
|
WebhookEvent,
|
11
|
+
WebhookEventRawResults,
|
12
|
+
EventPayload,
|
13
13
|
)
|
14
|
-
from
|
14
|
+
from fastapi import APIRouter
|
15
15
|
from port_ocean.utils.signal import SignalHandler
|
16
|
+
from typing import Dict, Any
|
17
|
+
import asyncio
|
18
|
+
from fastapi.testclient import TestClient
|
19
|
+
from fastapi import FastAPI
|
20
|
+
from port_ocean.context.ocean import PortOceanContext
|
21
|
+
from unittest.mock import AsyncMock
|
22
|
+
from port_ocean.context.event import event_context, EventType
|
23
|
+
from port_ocean.context.ocean import ocean
|
24
|
+
from unittest.mock import MagicMock, patch
|
25
|
+
from httpx import Response
|
26
|
+
from port_ocean.clients.port.client import PortClient
|
27
|
+
from port_ocean import Ocean
|
28
|
+
from port_ocean.core.integrations.base import BaseIntegration
|
29
|
+
from port_ocean.core.handlers.port_app_config.models import (
|
30
|
+
EntityMapping,
|
31
|
+
MappingsConfig,
|
32
|
+
PortAppConfig,
|
33
|
+
PortResourceConfig,
|
34
|
+
ResourceConfig,
|
35
|
+
Selector,
|
36
|
+
)
|
37
|
+
from port_ocean.core.integrations.mixins.live_events import LiveEventsMixin
|
38
|
+
from port_ocean.core.models import Entity
|
39
|
+
from port_ocean.exceptions.webhook_processor import RetryableError
|
40
|
+
from port_ocean.core.handlers.queue import LocalQueue
|
41
|
+
|
42
|
+
|
43
|
+
class MockProcessor(AbstractWebhookProcessor):
|
44
|
+
async def authenticate(
|
45
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
46
|
+
) -> bool:
|
47
|
+
return True
|
48
|
+
|
49
|
+
async def validate_payload(self, payload: Dict[str, Any]) -> bool:
|
50
|
+
return True
|
51
|
+
|
52
|
+
async def handle_event(
|
53
|
+
self, payload: EventPayload, resource: ResourceConfig
|
54
|
+
) -> WebhookEventRawResults:
|
55
|
+
return WebhookEventRawResults(updated_raw_results=[], deleted_raw_results=[])
|
56
|
+
|
57
|
+
def should_process_event(self, event: WebhookEvent) -> bool:
|
58
|
+
return True
|
59
|
+
|
60
|
+
def get_matching_kinds(self, event: WebhookEvent) -> list[str]:
|
61
|
+
return ["repository"]
|
62
|
+
|
63
|
+
|
64
|
+
class MockProcessorFalse(AbstractWebhookProcessor):
|
65
|
+
async def authenticate(
|
66
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
67
|
+
) -> bool:
|
68
|
+
return True
|
69
|
+
|
70
|
+
async def validate_payload(self, payload: Dict[str, Any]) -> bool:
|
71
|
+
return True
|
72
|
+
|
73
|
+
async def handle_event(
|
74
|
+
self, payload: EventPayload, resource: ResourceConfig
|
75
|
+
) -> WebhookEventRawResults:
|
76
|
+
return WebhookEventRawResults(updated_raw_results=[], deleted_raw_results=[])
|
77
|
+
|
78
|
+
def should_process_event(self, event: WebhookEvent) -> bool:
|
79
|
+
return False
|
80
|
+
|
81
|
+
def get_matching_kinds(self, event: WebhookEvent) -> list[str]:
|
82
|
+
return ["repository"]
|
16
83
|
|
17
84
|
|
18
85
|
class MockWebhookProcessor(AbstractWebhookProcessor):
|
@@ -32,360 +99,1166 @@ class MockWebhookProcessor(AbstractWebhookProcessor):
|
|
32
99
|
async def validate_payload(self, payload: Dict[str, Any]) -> bool:
|
33
100
|
return True
|
34
101
|
|
35
|
-
async def handle_event(
|
102
|
+
async def handle_event(
|
103
|
+
self, payload: EventPayload, resource: ResourceConfig
|
104
|
+
) -> WebhookEventRawResults:
|
36
105
|
if self.error_to_raise:
|
37
106
|
raise self.error_to_raise
|
38
107
|
self.processed = True
|
108
|
+
return WebhookEventRawResults(updated_raw_results=[], deleted_raw_results=[])
|
39
109
|
|
40
110
|
async def cancel(self) -> None:
|
41
111
|
self.cancel_called = True
|
42
112
|
|
113
|
+
def should_process_event(self, event: WebhookEvent) -> bool:
|
114
|
+
return True
|
43
115
|
|
44
|
-
|
45
|
-
|
46
|
-
super().__init__(event)
|
47
|
-
self.attempt_count = 0
|
48
|
-
|
49
|
-
async def handle_event(self, payload: Dict[str, Any]) -> None:
|
50
|
-
self.attempt_count += 1
|
51
|
-
if self.attempt_count < 3: # Succeed on third attempt
|
52
|
-
raise RetryableError("Temporary failure")
|
53
|
-
self.processed = True
|
54
|
-
|
55
|
-
|
56
|
-
class TestableWebhookProcessorManager(WebhookProcessorManager):
|
57
|
-
__test__ = False
|
58
|
-
|
59
|
-
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
60
|
-
super().__init__(*args, **kwargs)
|
61
|
-
self.running_processors: list[AbstractWebhookProcessor] = []
|
62
|
-
self.no_matching_processors: bool = False
|
63
|
-
|
64
|
-
def _extract_matching_processors(
|
65
|
-
self, event: WebhookEvent, path: str
|
66
|
-
) -> list[AbstractWebhookProcessor]:
|
67
|
-
try:
|
68
|
-
return super()._extract_matching_processors(event, path)
|
69
|
-
except ValueError:
|
70
|
-
self.no_matching_processors = True
|
71
|
-
raise
|
72
|
-
|
73
|
-
async def _process_single_event(
|
74
|
-
self, processor: AbstractWebhookProcessor, path: str
|
75
|
-
) -> None:
|
76
|
-
self.running_processors.append(processor)
|
77
|
-
await super()._process_single_event(processor, path)
|
78
|
-
|
79
|
-
|
80
|
-
class TestWebhookProcessorManager:
|
81
|
-
@pytest.fixture
|
82
|
-
def router(self) -> APIRouter:
|
83
|
-
return APIRouter()
|
84
|
-
|
85
|
-
@pytest.fixture
|
86
|
-
def signal_handler(self) -> SignalHandler:
|
87
|
-
return SignalHandler()
|
88
|
-
|
89
|
-
@pytest.fixture
|
90
|
-
def processor_manager(
|
91
|
-
self, router: APIRouter, signal_handler: SignalHandler
|
92
|
-
) -> TestableWebhookProcessorManager:
|
93
|
-
return TestableWebhookProcessorManager(router, signal_handler)
|
94
|
-
|
95
|
-
@pytest.fixture
|
96
|
-
def mock_event(self) -> WebhookEvent:
|
97
|
-
return WebhookEvent.from_dict(
|
98
|
-
{
|
99
|
-
"payload": {"test": "data"},
|
100
|
-
"headers": {"content-type": "application/json"},
|
101
|
-
"trace_id": "test-trace",
|
102
|
-
}
|
103
|
-
)
|
116
|
+
def get_matching_kinds(self, event: WebhookEvent) -> list[str]:
|
117
|
+
return ["test"]
|
104
118
|
|
105
|
-
@staticmethod
|
106
|
-
def assert_event_processed_successfully(
|
107
|
-
processor: MockWebhookProcessor,
|
108
|
-
) -> None:
|
109
|
-
"""Assert that a processor's event was processed successfully"""
|
110
|
-
assert processor.processed, "Event was not processed successfully"
|
111
119
|
|
112
|
-
|
113
|
-
|
114
|
-
"""Assert that an event was processed with an error"""
|
115
|
-
assert not processor.processed, "Event did not fail as expected"
|
120
|
+
class MockWebhookHandlerForProcessWebhookRequest(AbstractWebhookProcessor):
|
121
|
+
"""Concrete implementation for testing."""
|
116
122
|
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
assert "/test" in processor_manager._processors
|
124
|
-
assert len(processor_manager._processors["/test"]) == 1
|
125
|
-
assert isinstance(processor_manager._event_queues["/test"], LocalQueue)
|
126
|
-
|
127
|
-
@pytest.mark.skip(reason="Temporarily ignoring this test")
|
128
|
-
async def test_register_multiple_handlers_with_filters(
|
129
|
-
self, processor_manager: TestableWebhookProcessorManager
|
123
|
+
def __init__(
|
124
|
+
self,
|
125
|
+
event: WebhookEvent,
|
126
|
+
should_fail: bool = False,
|
127
|
+
fail_count: int = 0,
|
128
|
+
max_retries: int = 3,
|
130
129
|
) -> None:
|
131
|
-
|
130
|
+
super().__init__(event)
|
131
|
+
self.authenticated = False
|
132
|
+
self.validated = False
|
133
|
+
self.handled = False
|
134
|
+
self.should_fail = should_fail
|
135
|
+
self.fail_count = fail_count
|
136
|
+
self.current_fails = 0
|
137
|
+
self.error_handler_called = False
|
138
|
+
self.cancelled = False
|
139
|
+
self.max_retries = max_retries
|
140
|
+
|
141
|
+
async def authenticate(self, payload: EventPayload, headers: EventHeaders) -> bool:
|
142
|
+
self.authenticated = True
|
143
|
+
return True
|
132
144
|
|
133
|
-
|
134
|
-
|
145
|
+
async def validate_payload(self, payload: EventPayload) -> bool:
|
146
|
+
self.validated = True
|
147
|
+
return True
|
135
148
|
|
136
|
-
|
137
|
-
|
149
|
+
async def handle_event(
|
150
|
+
self, payload: EventPayload, resource: ResourceConfig
|
151
|
+
) -> WebhookEventRawResults:
|
152
|
+
if self.should_fail and self.current_fails < self.fail_count:
|
153
|
+
self.current_fails += 1
|
154
|
+
raise RetryableError("Temporary failure")
|
155
|
+
self.handled = True
|
156
|
+
return WebhookEventRawResults(updated_raw_results=[], deleted_raw_results=[])
|
138
157
|
|
139
|
-
|
140
|
-
|
158
|
+
def get_matching_kinds(self, event: WebhookEvent) -> list[str]:
|
159
|
+
return ["repository"]
|
141
160
|
|
142
|
-
|
161
|
+
def should_process_event(self, event: WebhookEvent) -> bool:
|
162
|
+
"""Filter the event data before processing."""
|
163
|
+
return True
|
143
164
|
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
165
|
+
async def cancel(self) -> None:
|
166
|
+
self.cancelled = True
|
167
|
+
|
168
|
+
async def on_error(self, error: Exception) -> None:
|
169
|
+
self.error_handler_called = True
|
170
|
+
await super().on_error(error)
|
171
|
+
|
172
|
+
|
173
|
+
@pytest.fixture
|
174
|
+
def processor_manager() -> LiveEventsProcessorManager:
|
175
|
+
router = APIRouter()
|
176
|
+
signal_handler = SignalHandler()
|
177
|
+
return LiveEventsProcessorManager(
|
178
|
+
router, signal_handler, max_event_processing_seconds=3
|
179
|
+
)
|
180
|
+
|
181
|
+
|
182
|
+
@pytest.fixture
|
183
|
+
def webhook_event() -> WebhookEvent:
|
184
|
+
return WebhookEvent(payload={}, headers={}, trace_id="test-trace")
|
185
|
+
|
186
|
+
|
187
|
+
@pytest.fixture
|
188
|
+
def webhook_event_for_process_webhook_request() -> WebhookEvent:
|
189
|
+
return WebhookEvent(
|
190
|
+
trace_id="test-trace",
|
191
|
+
payload={"test": "data"},
|
192
|
+
headers={"content-type": "application/json"},
|
193
|
+
)
|
194
|
+
|
195
|
+
|
196
|
+
@pytest.fixture
|
197
|
+
def processor_manager_for_process_webhook_request() -> LiveEventsProcessorManager:
|
198
|
+
return LiveEventsProcessorManager(APIRouter(), SignalHandler())
|
199
|
+
|
200
|
+
|
201
|
+
@pytest.fixture
|
202
|
+
def processor(
|
203
|
+
webhook_event_for_process_webhook_request: WebhookEvent,
|
204
|
+
) -> MockWebhookHandlerForProcessWebhookRequest:
|
205
|
+
return MockWebhookHandlerForProcessWebhookRequest(
|
206
|
+
webhook_event_for_process_webhook_request
|
207
|
+
)
|
208
|
+
|
209
|
+
|
210
|
+
@pytest.fixture
|
211
|
+
def mock_port_app_config() -> PortAppConfig:
|
212
|
+
return PortAppConfig(
|
213
|
+
enable_merge_entity=True,
|
214
|
+
delete_dependent_entities=True,
|
215
|
+
create_missing_related_entities=False,
|
216
|
+
resources=[
|
217
|
+
ResourceConfig(
|
218
|
+
kind="repository",
|
219
|
+
selector=Selector(query="true"),
|
220
|
+
port=PortResourceConfig(
|
221
|
+
entity=MappingsConfig(
|
222
|
+
mappings=EntityMapping(
|
223
|
+
identifier=".name",
|
224
|
+
title=".name",
|
225
|
+
blueprint='"service"',
|
226
|
+
properties={
|
227
|
+
"url": ".links.html.href",
|
228
|
+
"defaultBranch": ".main_branch",
|
229
|
+
},
|
230
|
+
relations={},
|
231
|
+
)
|
232
|
+
)
|
233
|
+
),
|
234
|
+
)
|
235
|
+
],
|
236
|
+
)
|
237
|
+
|
238
|
+
|
239
|
+
@pytest.fixture
|
240
|
+
def mock_http_client() -> MagicMock:
|
241
|
+
mock_http_client = MagicMock()
|
242
|
+
mock_upserted_entities = []
|
243
|
+
|
244
|
+
async def post(url: str, *args: Any, **kwargs: Any) -> Response:
|
245
|
+
entity = kwargs.get("json", {})
|
246
|
+
if entity.get("properties", {}).get("mock_is_to_fail", {}):
|
247
|
+
return Response(
|
248
|
+
404, headers=MagicMock(), json={"ok": False, "error": "not_found"}
|
249
|
+
)
|
250
|
+
|
251
|
+
mock_upserted_entities.append(
|
252
|
+
f"{entity.get('identifier')}-{entity.get('blueprint')}"
|
253
|
+
)
|
254
|
+
return Response(
|
255
|
+
200,
|
256
|
+
json={
|
257
|
+
"entity": {
|
258
|
+
"identifier": entity.get("identifier"),
|
259
|
+
"blueprint": entity.get("blueprint"),
|
260
|
+
}
|
261
|
+
},
|
262
|
+
)
|
152
263
|
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
264
|
+
mock_http_client.post = AsyncMock(side_effect=post)
|
265
|
+
return mock_http_client
|
266
|
+
|
267
|
+
|
268
|
+
@pytest.fixture
|
269
|
+
def mock_port_client(mock_http_client: MagicMock) -> PortClient:
|
270
|
+
mock_port_client = PortClient(
|
271
|
+
MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock()
|
272
|
+
)
|
273
|
+
mock_port_client.auth = AsyncMock()
|
274
|
+
mock_port_client.auth.headers = AsyncMock(
|
275
|
+
return_value={
|
276
|
+
"Authorization": "test",
|
277
|
+
"User-Agent": "test",
|
278
|
+
}
|
279
|
+
)
|
280
|
+
|
281
|
+
mock_port_client.search_entities = AsyncMock(return_value=[]) # type: ignore
|
282
|
+
mock_port_client.client = mock_http_client
|
283
|
+
return mock_port_client
|
284
|
+
|
285
|
+
|
286
|
+
@pytest.fixture
|
287
|
+
def mock_ocean(mock_port_client: PortClient) -> Ocean:
|
288
|
+
with patch("port_ocean.ocean.Ocean.__init__", return_value=None):
|
289
|
+
ocean_mock = Ocean(
|
290
|
+
MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock()
|
291
|
+
)
|
292
|
+
ocean_mock.config = MagicMock()
|
293
|
+
ocean_mock.config.port = MagicMock()
|
294
|
+
ocean_mock.config.port.port_app_config_cache_ttl = 60
|
295
|
+
ocean_mock.port_client = mock_port_client
|
296
|
+
ocean_mock.integration_router = APIRouter()
|
297
|
+
ocean_mock.fast_api_app = FastAPI()
|
298
|
+
return ocean_mock
|
299
|
+
|
300
|
+
|
301
|
+
@pytest.fixture
|
302
|
+
def mock_context(mock_ocean: Ocean) -> PortOceanContext:
|
303
|
+
context = PortOceanContext(mock_ocean)
|
304
|
+
ocean._app = context.app
|
305
|
+
return context
|
306
|
+
|
307
|
+
|
308
|
+
entity = Entity(
|
309
|
+
identifier="repo-one",
|
310
|
+
blueprint="service",
|
311
|
+
title="repo-one",
|
312
|
+
team=[],
|
313
|
+
properties={
|
314
|
+
"url": "https://example.com/repo-one",
|
315
|
+
"defaultBranch": "main",
|
316
|
+
},
|
317
|
+
relations={},
|
318
|
+
)
|
157
319
|
|
158
|
-
processor_manager.register_processor("/test", SuccessProcessor)
|
159
320
|
|
160
|
-
|
161
|
-
|
321
|
+
@pytest.mark.asyncio
|
322
|
+
async def test_extractMatchingProcessors_processorMatch(
|
323
|
+
processor_manager: LiveEventsProcessorManager,
|
324
|
+
webhook_event: WebhookEvent,
|
325
|
+
mock_port_app_config: PortAppConfig,
|
326
|
+
) -> None:
|
327
|
+
test_path = "/test"
|
328
|
+
processor_manager.register_processor(test_path, MockProcessor)
|
162
329
|
|
163
|
-
|
164
|
-
|
330
|
+
async with event_context(EventType.HTTP_REQUEST, trigger_type="request") as event:
|
331
|
+
event.port_app_config = mock_port_app_config
|
332
|
+
processors = processor_manager._extract_matching_processors(
|
333
|
+
webhook_event, test_path
|
334
|
+
)
|
165
335
|
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
336
|
+
assert len(processors) == 1
|
337
|
+
config, processor = processors[0]
|
338
|
+
assert isinstance(processor, MockProcessor)
|
339
|
+
assert config.kind == "repository"
|
340
|
+
assert processor.event != webhook_event
|
341
|
+
assert processor.event.payload == webhook_event.payload
|
342
|
+
|
343
|
+
|
344
|
+
@pytest.mark.asyncio
|
345
|
+
async def test_extractMatchingProcessors_noMatch(
|
346
|
+
processor_manager: LiveEventsProcessorManager,
|
347
|
+
webhook_event: WebhookEvent,
|
348
|
+
mock_port_app_config: PortAppConfig,
|
349
|
+
) -> None:
|
350
|
+
test_path = "/test"
|
351
|
+
processor_manager.register_processor(test_path, MockProcessorFalse)
|
352
|
+
|
353
|
+
with pytest.raises(ValueError, match="No matching processors found"):
|
354
|
+
async with event_context(
|
355
|
+
EventType.HTTP_REQUEST, trigger_type="request"
|
356
|
+
) as event:
|
357
|
+
event.port_app_config = mock_port_app_config
|
358
|
+
processor_manager._extract_matching_processors(webhook_event, test_path)
|
359
|
+
|
360
|
+
|
361
|
+
@pytest.mark.asyncio
|
362
|
+
async def test_extractMatchingProcessors_multipleMatches(
|
363
|
+
processor_manager: LiveEventsProcessorManager,
|
364
|
+
webhook_event: WebhookEvent,
|
365
|
+
mock_port_app_config: PortAppConfig,
|
366
|
+
) -> None:
|
367
|
+
test_path = "/test"
|
368
|
+
processor_manager.register_processor(test_path, MockProcessor)
|
369
|
+
processor_manager.register_processor(test_path, MockProcessor)
|
370
|
+
|
371
|
+
async with event_context(EventType.HTTP_REQUEST, trigger_type="request") as event:
|
372
|
+
event.port_app_config = mock_port_app_config
|
373
|
+
processors = processor_manager._extract_matching_processors(
|
374
|
+
webhook_event, test_path
|
375
|
+
)
|
170
376
|
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
377
|
+
assert len(processors) == 2
|
378
|
+
assert all(isinstance(p, MockProcessor) for _, p in processors)
|
379
|
+
assert all(p.event != webhook_event for _, p in processors)
|
380
|
+
|
381
|
+
|
382
|
+
@pytest.mark.asyncio
|
383
|
+
async def test_extractMatchingProcessors_onlyOneMatches(
|
384
|
+
processor_manager: LiveEventsProcessorManager,
|
385
|
+
webhook_event: WebhookEvent,
|
386
|
+
mock_port_app_config: PortAppConfig,
|
387
|
+
) -> None:
|
388
|
+
test_path = "/test"
|
389
|
+
processor_manager.register_processor(test_path, MockProcessor)
|
390
|
+
processor_manager.register_processor(test_path, MockProcessorFalse)
|
391
|
+
|
392
|
+
async with event_context(EventType.HTTP_REQUEST, trigger_type="request") as event:
|
393
|
+
event.port_app_config = mock_port_app_config
|
394
|
+
processors = processor_manager._extract_matching_processors(
|
395
|
+
webhook_event, test_path
|
396
|
+
)
|
179
397
|
|
180
|
-
|
181
|
-
|
398
|
+
assert len(processors) == 1
|
399
|
+
config, processor = processors[0]
|
400
|
+
assert isinstance(processor, MockProcessor)
|
401
|
+
assert config.kind == "repository"
|
402
|
+
assert processor.event != webhook_event
|
403
|
+
assert processor.event.payload == webhook_event.payload
|
404
|
+
|
405
|
+
|
406
|
+
def test_registerProcessor_registrationWorks(
|
407
|
+
processor_manager: LiveEventsProcessorManager,
|
408
|
+
) -> None:
|
409
|
+
processor_manager.register_processor("/test", MockWebhookProcessor)
|
410
|
+
assert "/test" in processor_manager._processors_classes
|
411
|
+
assert len(processor_manager._processors_classes["/test"]) == 1
|
412
|
+
assert isinstance(processor_manager._event_queues["/test"], LocalQueue)
|
413
|
+
|
414
|
+
|
415
|
+
def test_registerProcessor_multipleHandlers_allRegistered(
|
416
|
+
processor_manager: LiveEventsProcessorManager,
|
417
|
+
) -> None:
|
418
|
+
processor_manager.register_processor("/test", MockWebhookProcessor)
|
419
|
+
processor_manager.register_processor("/test", MockWebhookProcessor)
|
420
|
+
|
421
|
+
assert len(processor_manager._processors_classes["/test"]) == 2
|
422
|
+
|
423
|
+
|
424
|
+
def test_registerProcessor_invalidHandlerRegistration_throwsError(
|
425
|
+
processor_manager: LiveEventsProcessorManager,
|
426
|
+
) -> None:
|
427
|
+
"""Test registration of invalid processor type."""
|
428
|
+
|
429
|
+
with pytest.raises(ValueError):
|
430
|
+
processor_manager.register_processor("/test", object) # type: ignore
|
431
|
+
|
432
|
+
|
433
|
+
@pytest.mark.asyncio
|
434
|
+
async def test_processWebhookRequest_successfulProcessing(
|
435
|
+
processor: MockWebhookHandlerForProcessWebhookRequest,
|
436
|
+
processor_manager_for_process_webhook_request: LiveEventsProcessorManager,
|
437
|
+
mock_port_app_config: PortAppConfig,
|
438
|
+
) -> None:
|
439
|
+
"""Test successful webhook processing flow."""
|
440
|
+
await processor_manager_for_process_webhook_request._process_webhook_request(
|
441
|
+
processor, mock_port_app_config.resources[0]
|
442
|
+
)
|
443
|
+
|
444
|
+
assert processor.authenticated
|
445
|
+
assert processor.validated
|
446
|
+
assert processor.handled
|
447
|
+
assert not processor.error_handler_called
|
448
|
+
|
449
|
+
|
450
|
+
@pytest.mark.asyncio
|
451
|
+
async def test_processWebhookRequest_retryTwoTimesThenSuccessfulProcessing(
|
452
|
+
webhook_event_for_process_webhook_request: WebhookEvent,
|
453
|
+
processor_manager_for_process_webhook_request: LiveEventsProcessorManager,
|
454
|
+
mock_port_app_config: PortAppConfig,
|
455
|
+
) -> None:
|
456
|
+
"""Test retry mechanism with temporary failures."""
|
457
|
+
processor = MockWebhookHandlerForProcessWebhookRequest(
|
458
|
+
webhook_event_for_process_webhook_request, should_fail=True, fail_count=2
|
459
|
+
)
|
460
|
+
|
461
|
+
await processor_manager_for_process_webhook_request._process_webhook_request(
|
462
|
+
processor, mock_port_app_config.resources[0]
|
463
|
+
)
|
464
|
+
|
465
|
+
assert processor.handled
|
466
|
+
assert processor.current_fails == 2
|
467
|
+
assert processor.retry_count == 2
|
468
|
+
assert processor.error_handler_called
|
469
|
+
|
470
|
+
|
471
|
+
@pytest.mark.asyncio
|
472
|
+
async def test_processWebhookRequest_maxRetriesExceeded_exceptionRaised(
|
473
|
+
webhook_event: WebhookEvent,
|
474
|
+
processor_manager_for_process_webhook_request: LiveEventsProcessorManager,
|
475
|
+
mock_port_app_config: PortAppConfig,
|
476
|
+
) -> None:
|
477
|
+
"""Test behavior when max retries are exceeded."""
|
478
|
+
processor = MockWebhookHandlerForProcessWebhookRequest(
|
479
|
+
webhook_event, should_fail=True, fail_count=2, max_retries=1
|
480
|
+
)
|
481
|
+
|
482
|
+
with pytest.raises(RetryableError):
|
483
|
+
await processor_manager_for_process_webhook_request._process_webhook_request(
|
484
|
+
processor, mock_port_app_config.resources[0]
|
485
|
+
)
|
182
486
|
|
183
|
-
|
184
|
-
|
487
|
+
assert processor.retry_count == processor.max_retries
|
488
|
+
assert processor.error_handler_called
|
489
|
+
assert not processor.handled
|
185
490
|
|
186
|
-
# Verify all tasks are cleaned up
|
187
|
-
assert len(processor_manager._webhook_processor_tasks) == 0
|
188
|
-
self.assert_event_processed_successfully(
|
189
|
-
processor_manager.running_processors[0] # type: ignore
|
190
|
-
)
|
191
491
|
|
192
|
-
|
193
|
-
|
194
|
-
|
492
|
+
@pytest.mark.asyncio
|
493
|
+
@patch(
|
494
|
+
"port_ocean.core.handlers.entities_state_applier.port.applier.HttpEntitiesStateApplier.upsert"
|
495
|
+
)
|
496
|
+
@patch(
|
497
|
+
"port_ocean.core.handlers.entities_state_applier.port.applier.HttpEntitiesStateApplier.delete"
|
498
|
+
)
|
499
|
+
async def test_integrationTest_postRequestSent_webhookEventRawResultProcessed_entityUpserted(
|
500
|
+
mock_delete: AsyncMock,
|
501
|
+
mock_upsert: AsyncMock,
|
502
|
+
mock_context: PortOceanContext,
|
503
|
+
mock_port_app_config: PortAppConfig,
|
504
|
+
monkeypatch: pytest.MonkeyPatch,
|
505
|
+
) -> None:
|
506
|
+
"""Integration test for the complete webhook processing flow"""
|
507
|
+
|
508
|
+
monkeypatch.setattr(
|
509
|
+
"port_ocean.core.integrations.mixins.handler.ocean", mock_context
|
510
|
+
)
|
511
|
+
monkeypatch.setattr(
|
512
|
+
"port_ocean.core.integrations.mixins.live_events.ocean", mock_context
|
513
|
+
)
|
514
|
+
processed_events: list[WebhookEventRawResults] = []
|
515
|
+
mock_upsert.return_value = [entity]
|
516
|
+
|
517
|
+
class TestProcessor(AbstractWebhookProcessor):
|
518
|
+
def __init__(self, event: WebhookEvent) -> None:
|
519
|
+
super().__init__(event)
|
520
|
+
|
521
|
+
async def authenticate(
|
522
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
523
|
+
) -> bool:
|
524
|
+
return True
|
525
|
+
|
526
|
+
async def validate_payload(self, payload: Dict[str, Any]) -> bool:
|
527
|
+
return True
|
528
|
+
|
529
|
+
async def handle_event(
|
530
|
+
self, payload: EventPayload, resource: ResourceConfig
|
531
|
+
) -> WebhookEventRawResults:
|
532
|
+
event_data = WebhookEventRawResults(
|
533
|
+
updated_raw_results=[
|
534
|
+
{
|
535
|
+
"name": "repo-one",
|
536
|
+
"links": {"html": {"href": "https://example.com/repo-one"}},
|
537
|
+
"main_branch": "main",
|
538
|
+
}
|
539
|
+
],
|
540
|
+
deleted_raw_results=[],
|
541
|
+
)
|
542
|
+
processed_events.append(event_data)
|
543
|
+
return event_data
|
544
|
+
|
545
|
+
def should_process_event(self, event: WebhookEvent) -> bool:
|
546
|
+
return True
|
547
|
+
|
548
|
+
def get_matching_kinds(self, event: WebhookEvent) -> list[str]:
|
549
|
+
return ["repository"]
|
550
|
+
|
551
|
+
processing_complete = asyncio.Event()
|
552
|
+
original_process_data = LiveEventsMixin.sync_raw_results
|
553
|
+
|
554
|
+
async def patched_export_single_resource(
|
555
|
+
self: LiveEventsMixin, webhookEventRawResults: list[WebhookEventRawResults]
|
195
556
|
) -> None:
|
196
|
-
|
197
|
-
|
198
|
-
|
557
|
+
try:
|
558
|
+
await original_process_data(self, webhookEventRawResults)
|
559
|
+
except Exception as e:
|
560
|
+
raise e
|
561
|
+
finally:
|
562
|
+
processing_complete.set()
|
563
|
+
|
564
|
+
monkeypatch.setattr(
|
565
|
+
LiveEventsMixin,
|
566
|
+
"sync_raw_results",
|
567
|
+
patched_export_single_resource,
|
568
|
+
)
|
569
|
+
test_path = "/webhook-test"
|
570
|
+
mock_context.app.integration = BaseIntegration(ocean)
|
571
|
+
mock_context.app.webhook_manager = LiveEventsProcessorManager(
|
572
|
+
mock_context.app.integration_router, SignalHandler()
|
573
|
+
)
|
574
|
+
|
575
|
+
mock_context.app.webhook_manager.register_processor(test_path, TestProcessor)
|
576
|
+
await mock_context.app.webhook_manager.start_processing_event_messages()
|
577
|
+
mock_context.app.fast_api_app.include_router(
|
578
|
+
mock_context.app.webhook_manager._router
|
579
|
+
)
|
580
|
+
client = TestClient(mock_context.app.fast_api_app)
|
581
|
+
|
582
|
+
test_payload = {"test": "data"}
|
583
|
+
|
584
|
+
async with event_context(EventType.HTTP_REQUEST, trigger_type="request") as event:
|
585
|
+
mock_context.app.webhook_manager.port_app_config_handler.get_port_app_config = AsyncMock(return_value=mock_port_app_config) # type: ignore
|
586
|
+
event.port_app_config = (
|
587
|
+
await mock_context.app.webhook_manager.port_app_config_handler.get_port_app_config()
|
199
588
|
)
|
200
589
|
|
201
|
-
|
202
|
-
|
590
|
+
response = client.post(
|
591
|
+
test_path, json=test_payload, headers={"Content-Type": "application/json"}
|
203
592
|
)
|
204
593
|
|
205
|
-
|
206
|
-
|
594
|
+
assert response.status_code == 200
|
595
|
+
assert response.json() == {"status": "ok"}
|
207
596
|
|
208
|
-
|
209
|
-
|
597
|
+
try:
|
598
|
+
await asyncio.wait_for(processing_complete.wait(), timeout=10.0)
|
599
|
+
except asyncio.TimeoutError:
|
600
|
+
pytest.fail("Event processing timed out")
|
210
601
|
|
211
|
-
|
212
|
-
processor_manager.register_processor("/test", MockWebhookProcessor, filter2)
|
602
|
+
assert len(processed_events) == 1
|
213
603
|
|
214
|
-
|
604
|
+
mock_upsert.assert_called_once()
|
605
|
+
mock_delete.assert_not_called()
|
215
606
|
|
216
|
-
|
217
|
-
await processor_manager._event_queues["/test"].put(type1_event)
|
218
|
-
await processor_manager._event_queues["/test"].put(type2_event)
|
607
|
+
await mock_context.app.webhook_manager.shutdown()
|
219
608
|
|
220
|
-
await asyncio.sleep(0.1)
|
221
609
|
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
610
|
+
@pytest.mark.asyncio
|
611
|
+
@patch(
|
612
|
+
"port_ocean.core.handlers.entities_state_applier.port.applier.HttpEntitiesStateApplier.upsert"
|
613
|
+
)
|
614
|
+
@patch(
|
615
|
+
"port_ocean.core.handlers.entities_state_applier.port.applier.HttpEntitiesStateApplier.delete"
|
616
|
+
)
|
617
|
+
async def test_integrationTest_postRequestSent_reachedTimeout_entityNotUpserted(
|
618
|
+
mock_delete: AsyncMock,
|
619
|
+
mock_upsert: AsyncMock,
|
620
|
+
mock_context: PortOceanContext,
|
621
|
+
mock_port_app_config: PortAppConfig,
|
622
|
+
monkeypatch: pytest.MonkeyPatch,
|
623
|
+
) -> None:
|
624
|
+
"""Integration test for the complete webhook processing flow"""
|
625
|
+
|
626
|
+
monkeypatch.setattr(
|
627
|
+
"port_ocean.core.integrations.mixins.handler.ocean", mock_context
|
628
|
+
)
|
629
|
+
monkeypatch.setattr(
|
630
|
+
"port_ocean.core.integrations.mixins.live_events.ocean", mock_context
|
631
|
+
)
|
632
|
+
mock_upsert.return_value = [entity]
|
633
|
+
test_state = {"exception_thrown": None}
|
634
|
+
|
635
|
+
class TestProcessor(AbstractWebhookProcessor):
|
636
|
+
async def authenticate(
|
637
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
638
|
+
) -> bool:
|
639
|
+
return True
|
640
|
+
|
641
|
+
async def validate_payload(self, payload: Dict[str, Any]) -> bool:
|
642
|
+
return True
|
643
|
+
|
644
|
+
async def handle_event(
|
645
|
+
self, payload: EventPayload, resource: ResourceConfig
|
646
|
+
) -> WebhookEventRawResults:
|
647
|
+
await asyncio.sleep(3)
|
648
|
+
return WebhookEventRawResults(
|
649
|
+
updated_raw_results=[], deleted_raw_results=[]
|
650
|
+
)
|
651
|
+
|
652
|
+
def should_process_event(self, event: WebhookEvent) -> bool:
|
653
|
+
return True
|
654
|
+
|
655
|
+
def get_matching_kinds(self, event: WebhookEvent) -> list[str]:
|
656
|
+
return ["repository"]
|
657
|
+
|
658
|
+
processing_complete = asyncio.Event()
|
659
|
+
original_process_data = LiveEventsProcessorManager._process_single_event
|
660
|
+
|
661
|
+
async def patched_process_single_event(
|
662
|
+
self: LiveEventsProcessorManager,
|
663
|
+
processor: AbstractWebhookProcessor,
|
664
|
+
path: str,
|
665
|
+
resource: ResourceConfig,
|
666
|
+
) -> WebhookEventRawResults:
|
667
|
+
try:
|
668
|
+
return await original_process_data(self, processor, path, resource)
|
669
|
+
except Exception as e:
|
670
|
+
test_state["exception_thrown"] = e # type: ignore
|
671
|
+
raise e
|
672
|
+
finally:
|
673
|
+
processing_complete.set()
|
674
|
+
|
675
|
+
monkeypatch.setattr(
|
676
|
+
LiveEventsProcessorManager,
|
677
|
+
"_process_single_event",
|
678
|
+
patched_process_single_event,
|
679
|
+
)
|
680
|
+
test_path = "/webhook-test"
|
681
|
+
mock_context.app.integration = BaseIntegration(ocean)
|
682
|
+
mock_context.app.webhook_manager = LiveEventsProcessorManager(
|
683
|
+
mock_context.app.integration_router, SignalHandler(), 2
|
684
|
+
)
|
685
|
+
|
686
|
+
mock_context.app.webhook_manager.register_processor(test_path, TestProcessor)
|
687
|
+
await mock_context.app.webhook_manager.start_processing_event_messages()
|
688
|
+
mock_context.app.fast_api_app.include_router(
|
689
|
+
mock_context.app.webhook_manager._router
|
690
|
+
)
|
691
|
+
client = TestClient(mock_context.app.fast_api_app)
|
692
|
+
|
693
|
+
test_payload = {"test": "data"}
|
694
|
+
|
695
|
+
async with event_context(EventType.HTTP_REQUEST, trigger_type="request") as event:
|
696
|
+
mock_context.app.webhook_manager.port_app_config_handler.get_port_app_config = AsyncMock(return_value=mock_port_app_config) # type: ignore
|
697
|
+
event.port_app_config = (
|
698
|
+
await mock_context.app.webhook_manager.port_app_config_handler.get_port_app_config()
|
228
699
|
)
|
229
700
|
|
230
|
-
|
231
|
-
|
232
|
-
self, router: APIRouter, signal_handler: SignalHandler, mock_event: WebhookEvent
|
233
|
-
) -> None:
|
234
|
-
"""Test processor timeout behavior."""
|
235
|
-
|
236
|
-
# Set a short timeout for testing
|
237
|
-
processor_manager = TestableWebhookProcessorManager(
|
238
|
-
router, signal_handler, max_event_processing_seconds=0.1
|
701
|
+
response = client.post(
|
702
|
+
test_path, json=test_payload, headers={"Content-Type": "application/json"}
|
239
703
|
)
|
240
704
|
|
241
|
-
|
242
|
-
|
243
|
-
await asyncio.sleep(2) # Longer than max_handler_processing_seconds
|
705
|
+
assert response.status_code == 200
|
706
|
+
assert response.json() == {"status": "ok"}
|
244
707
|
|
245
|
-
|
246
|
-
await
|
247
|
-
|
708
|
+
try:
|
709
|
+
await asyncio.wait_for(processing_complete.wait(), timeout=100.0)
|
710
|
+
except asyncio.TimeoutError:
|
711
|
+
pytest.fail("Event processing timed out")
|
248
712
|
|
249
|
-
|
250
|
-
|
713
|
+
assert isinstance(test_state["exception_thrown"], asyncio.TimeoutError) is True
|
714
|
+
mock_upsert.assert_not_called()
|
715
|
+
mock_delete.assert_not_called()
|
251
716
|
|
252
|
-
|
253
|
-
processor_manager.running_processors[0] # type: ignore
|
254
|
-
)
|
717
|
+
await mock_context.app.webhook_manager.shutdown()
|
255
718
|
|
256
|
-
@pytest.mark.skip(reason="Temporarily ignoring this test")
|
257
|
-
async def test_handler_cancellation(
|
258
|
-
self,
|
259
|
-
processor_manager: TestableWebhookProcessorManager,
|
260
|
-
mock_event: WebhookEvent,
|
261
|
-
) -> None:
|
262
|
-
"""Test processor cancellation during shutdown."""
|
263
|
-
cancelled_events: list[WebhookEvent] = []
|
264
719
|
|
265
|
-
|
266
|
-
|
267
|
-
|
720
|
+
@pytest.mark.asyncio
|
721
|
+
@patch(
|
722
|
+
"port_ocean.core.handlers.entities_state_applier.port.applier.HttpEntitiesStateApplier.upsert"
|
723
|
+
)
|
724
|
+
@patch(
|
725
|
+
"port_ocean.core.handlers.entities_state_applier.port.applier.HttpEntitiesStateApplier.delete"
|
726
|
+
)
|
727
|
+
async def test_integrationTest_postRequestSent_noMatchingHandlers_entityNotUpserted(
|
728
|
+
mock_delete: AsyncMock,
|
729
|
+
mock_upsert: AsyncMock,
|
730
|
+
mock_context: PortOceanContext,
|
731
|
+
mock_port_app_config: PortAppConfig,
|
732
|
+
monkeypatch: pytest.MonkeyPatch,
|
733
|
+
) -> None:
|
734
|
+
"""Integration test for the complete webhook processing flow"""
|
735
|
+
|
736
|
+
monkeypatch.setattr(
|
737
|
+
"port_ocean.core.integrations.mixins.handler.ocean", mock_context
|
738
|
+
)
|
739
|
+
monkeypatch.setattr(
|
740
|
+
"port_ocean.core.integrations.mixins.live_events.ocean", mock_context
|
741
|
+
)
|
742
|
+
test_state = {"exception_thrown": None}
|
743
|
+
mock_upsert.return_value = [entity]
|
744
|
+
|
745
|
+
class TestProcessor(AbstractWebhookProcessor):
|
746
|
+
async def authenticate(
|
747
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
748
|
+
) -> bool:
|
749
|
+
return True
|
750
|
+
|
751
|
+
async def validate_payload(self, payload: Dict[str, Any]) -> bool:
|
752
|
+
return True
|
753
|
+
|
754
|
+
async def handle_event(
|
755
|
+
self, payload: EventPayload, resource: ResourceConfig
|
756
|
+
) -> WebhookEventRawResults:
|
757
|
+
event_data = WebhookEventRawResults(
|
758
|
+
updated_raw_results=[
|
759
|
+
{
|
760
|
+
"name": "repo-one",
|
761
|
+
"links": {"html": {"href": "https://example.com/repo-one"}},
|
762
|
+
"main_branch": "main",
|
763
|
+
}
|
764
|
+
],
|
765
|
+
deleted_raw_results=[],
|
766
|
+
)
|
767
|
+
return event_data
|
768
|
+
|
769
|
+
def should_process_event(self, event: WebhookEvent) -> bool:
|
770
|
+
return False
|
771
|
+
|
772
|
+
def get_matching_kinds(self, event: WebhookEvent) -> list[str]:
|
773
|
+
return ["repository"]
|
774
|
+
|
775
|
+
processing_complete = asyncio.Event()
|
776
|
+
original_process_data = LiveEventsProcessorManager._extract_matching_processors
|
777
|
+
|
778
|
+
def patched_extract_matching_processors(
|
779
|
+
self: LiveEventsProcessorManager, event: WebhookEvent, path: str
|
780
|
+
) -> list[tuple[ResourceConfig, AbstractWebhookProcessor]]:
|
781
|
+
try:
|
782
|
+
return original_process_data(self, event, path)
|
783
|
+
except Exception as e:
|
784
|
+
test_state["exception_thrown"] = e # type: ignore
|
785
|
+
return []
|
786
|
+
finally:
|
787
|
+
processing_complete.set()
|
788
|
+
|
789
|
+
monkeypatch.setattr(
|
790
|
+
LiveEventsProcessorManager,
|
791
|
+
"_extract_matching_processors",
|
792
|
+
patched_extract_matching_processors,
|
793
|
+
)
|
794
|
+
test_path = "/webhook-test"
|
795
|
+
mock_context.app.integration = BaseIntegration(ocean)
|
796
|
+
mock_context.app.webhook_manager = LiveEventsProcessorManager(
|
797
|
+
mock_context.app.integration_router, SignalHandler()
|
798
|
+
)
|
799
|
+
|
800
|
+
mock_context.app.webhook_manager.register_processor(test_path, TestProcessor)
|
801
|
+
await mock_context.app.webhook_manager.start_processing_event_messages()
|
802
|
+
mock_context.app.fast_api_app.include_router(
|
803
|
+
mock_context.app.webhook_manager._router
|
804
|
+
)
|
805
|
+
client = TestClient(mock_context.app.fast_api_app)
|
806
|
+
|
807
|
+
test_payload = {"test": "data"}
|
808
|
+
|
809
|
+
async with event_context(EventType.HTTP_REQUEST, trigger_type="request") as event:
|
810
|
+
mock_context.app.webhook_manager.port_app_config_handler.get_port_app_config = AsyncMock(return_value=mock_port_app_config) # type: ignore
|
811
|
+
event.port_app_config = (
|
812
|
+
await mock_context.app.webhook_manager.port_app_config_handler.get_port_app_config()
|
813
|
+
)
|
268
814
|
|
269
|
-
|
270
|
-
|
271
|
-
|
815
|
+
response = client.post(
|
816
|
+
test_path, json=test_payload, headers={"Content-Type": "application/json"}
|
817
|
+
)
|
272
818
|
|
273
|
-
|
274
|
-
|
275
|
-
await processor_manager._event_queues["/test"].put(mock_event)
|
819
|
+
assert response.status_code == 200
|
820
|
+
assert response.json() == {"status": "ok"}
|
276
821
|
|
277
|
-
|
822
|
+
try:
|
823
|
+
await asyncio.wait_for(processing_complete.wait(), timeout=10.0)
|
824
|
+
except asyncio.TimeoutError:
|
825
|
+
pytest.fail("Event processing timed out")
|
278
826
|
|
279
|
-
|
280
|
-
await processor_manager._cancel_all_tasks()
|
827
|
+
assert isinstance(test_state["exception_thrown"], ValueError) is True
|
281
828
|
|
282
|
-
|
283
|
-
|
284
|
-
assert any(event.payload.get("canceled") for event in cancelled_events)
|
829
|
+
mock_upsert.assert_not_called()
|
830
|
+
mock_delete.assert_not_called()
|
285
831
|
|
286
|
-
|
287
|
-
async def test_invalid_handler_registration(self) -> None:
|
288
|
-
"""Test registration of invalid processor type."""
|
289
|
-
handler_manager = WebhookProcessorManager(APIRouter(), SignalHandler())
|
832
|
+
await mock_context.app.webhook_manager.shutdown()
|
290
833
|
|
291
|
-
with pytest.raises(ValueError):
|
292
|
-
handler_manager.register_processor("/test", object) # type: ignore
|
293
834
|
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
835
|
+
@pytest.mark.asyncio
|
836
|
+
@patch(
|
837
|
+
"port_ocean.core.handlers.entities_state_applier.port.applier.HttpEntitiesStateApplier.upsert"
|
838
|
+
)
|
839
|
+
@patch(
|
840
|
+
"port_ocean.core.handlers.entities_state_applier.port.applier.HttpEntitiesStateApplier.delete"
|
841
|
+
)
|
842
|
+
async def test_integrationTest_postRequestSent_webhookEventRawResultProcessedForMultipleProcessors_entitiesUpserted(
|
843
|
+
mock_delete: AsyncMock,
|
844
|
+
mock_upsert: AsyncMock,
|
845
|
+
mock_context: PortOceanContext,
|
846
|
+
mock_port_app_config: PortAppConfig,
|
847
|
+
monkeypatch: pytest.MonkeyPatch,
|
848
|
+
) -> None:
|
849
|
+
"""Integration test for the complete webhook processing flow"""
|
850
|
+
|
851
|
+
monkeypatch.setattr(
|
852
|
+
"port_ocean.core.integrations.mixins.handler.ocean", mock_context
|
853
|
+
)
|
854
|
+
monkeypatch.setattr(
|
855
|
+
"port_ocean.core.integrations.mixins.live_events.ocean", mock_context
|
856
|
+
)
|
857
|
+
processed_events: list[WebhookEventRawResults] = []
|
858
|
+
mock_upsert.return_value = [entity]
|
859
|
+
|
860
|
+
class TestProcessorA(AbstractWebhookProcessor):
|
861
|
+
async def authenticate(
|
862
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
863
|
+
) -> bool:
|
864
|
+
return True
|
865
|
+
|
866
|
+
async def validate_payload(self, payload: Dict[str, Any]) -> bool:
|
867
|
+
return True
|
868
|
+
|
869
|
+
async def handle_event(
|
870
|
+
self, payload: EventPayload, resource: ResourceConfig
|
871
|
+
) -> WebhookEventRawResults:
|
872
|
+
event_data = WebhookEventRawResults(
|
873
|
+
updated_raw_results=[
|
874
|
+
{
|
875
|
+
"name": "repo-one",
|
876
|
+
"links": {"html": {"href": "https://example.com/repo-one"}},
|
877
|
+
"main_branch": "main",
|
878
|
+
}
|
879
|
+
],
|
880
|
+
deleted_raw_results=[],
|
881
|
+
)
|
882
|
+
processed_events.append(event_data)
|
883
|
+
return event_data
|
884
|
+
|
885
|
+
def should_process_event(self, event: WebhookEvent) -> bool:
|
886
|
+
return True
|
887
|
+
|
888
|
+
def get_matching_kinds(self, event: WebhookEvent) -> list[str]:
|
889
|
+
return ["repository"]
|
890
|
+
|
891
|
+
class TestProcessorB(AbstractWebhookProcessor):
|
892
|
+
async def authenticate(
|
893
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
894
|
+
) -> bool:
|
895
|
+
return True
|
896
|
+
|
897
|
+
async def validate_payload(self, payload: Dict[str, Any]) -> bool:
|
898
|
+
return True
|
899
|
+
|
900
|
+
async def handle_event(
|
901
|
+
self, payload: EventPayload, resource: ResourceConfig
|
902
|
+
) -> WebhookEventRawResults:
|
903
|
+
event_data = WebhookEventRawResults(
|
904
|
+
updated_raw_results=[
|
905
|
+
{
|
906
|
+
"name": "repo-two",
|
907
|
+
"links": {"html": {"href": "https://example.com/repo-two"}},
|
908
|
+
"main_branch": "main",
|
909
|
+
}
|
910
|
+
],
|
911
|
+
deleted_raw_results=[],
|
912
|
+
)
|
913
|
+
processed_events.append(event_data)
|
914
|
+
return event_data
|
915
|
+
|
916
|
+
def should_process_event(self, event: WebhookEvent) -> bool:
|
917
|
+
return True
|
918
|
+
|
919
|
+
def get_matching_kinds(self, event: WebhookEvent) -> list[str]:
|
920
|
+
return ["repository"]
|
921
|
+
|
922
|
+
class TestProcessorFiltersOut(AbstractWebhookProcessor):
|
923
|
+
async def authenticate(
|
924
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
925
|
+
) -> bool:
|
926
|
+
return True
|
927
|
+
|
928
|
+
async def validate_payload(self, payload: Dict[str, Any]) -> bool:
|
929
|
+
return True
|
930
|
+
|
931
|
+
async def handle_event(
|
932
|
+
self, payload: EventPayload, resource: ResourceConfig
|
933
|
+
) -> WebhookEventRawResults:
|
934
|
+
event_data = WebhookEventRawResults(
|
935
|
+
updated_raw_results=[
|
936
|
+
{
|
937
|
+
"name": "repo-one",
|
938
|
+
"links": {"html": {"href": "https://example.com/repo-one"}},
|
939
|
+
"main_branch": "main",
|
940
|
+
}
|
941
|
+
],
|
942
|
+
deleted_raw_results=[],
|
943
|
+
)
|
944
|
+
processed_events.append(event_data)
|
945
|
+
return event_data
|
946
|
+
|
947
|
+
def should_process_event(self, event: WebhookEvent) -> bool:
|
948
|
+
return False
|
949
|
+
|
950
|
+
def get_matching_kinds(self, event: WebhookEvent) -> list[str]:
|
951
|
+
return ["repository"]
|
952
|
+
|
953
|
+
processing_complete = asyncio.Event()
|
954
|
+
original_process_data = LiveEventsMixin.sync_raw_results
|
955
|
+
|
956
|
+
async def patched_export_single_resource(
|
957
|
+
self: LiveEventsMixin, webhookEventRawResults: list[WebhookEventRawResults]
|
298
958
|
) -> None:
|
299
|
-
|
300
|
-
|
301
|
-
|
959
|
+
try:
|
960
|
+
await original_process_data(self, webhookEventRawResults)
|
961
|
+
except Exception as e:
|
962
|
+
raise e
|
963
|
+
finally:
|
964
|
+
processing_complete.set()
|
965
|
+
|
966
|
+
monkeypatch.setattr(
|
967
|
+
LiveEventsMixin,
|
968
|
+
"sync_raw_results",
|
969
|
+
patched_export_single_resource,
|
970
|
+
)
|
971
|
+
test_path = "/webhook-test"
|
972
|
+
mock_context.app.integration = BaseIntegration(ocean)
|
973
|
+
mock_context.app.webhook_manager = LiveEventsProcessorManager(
|
974
|
+
mock_context.app.integration_router, SignalHandler()
|
975
|
+
)
|
976
|
+
|
977
|
+
mock_context.app.webhook_manager.register_processor(test_path, TestProcessorA)
|
978
|
+
mock_context.app.webhook_manager.register_processor(test_path, TestProcessorB)
|
979
|
+
mock_context.app.webhook_manager.register_processor(
|
980
|
+
test_path, TestProcessorFiltersOut
|
981
|
+
)
|
982
|
+
await mock_context.app.webhook_manager.start_processing_event_messages()
|
983
|
+
mock_context.app.fast_api_app.include_router(
|
984
|
+
mock_context.app.webhook_manager._router
|
985
|
+
)
|
986
|
+
client = TestClient(mock_context.app.fast_api_app)
|
987
|
+
|
988
|
+
test_payload = {"test": "data"}
|
989
|
+
|
990
|
+
async with event_context(EventType.HTTP_REQUEST, trigger_type="request") as event:
|
991
|
+
mock_context.app.webhook_manager.port_app_config_handler.get_port_app_config = AsyncMock(return_value=mock_port_app_config) # type: ignore
|
992
|
+
event.port_app_config = (
|
993
|
+
await mock_context.app.webhook_manager.port_app_config_handler.get_port_app_config()
|
302
994
|
)
|
303
995
|
|
304
|
-
|
305
|
-
|
996
|
+
response = client.post(
|
997
|
+
test_path, json=test_payload, headers={"Content-Type": "application/json"}
|
998
|
+
)
|
306
999
|
|
307
|
-
|
1000
|
+
assert response.status_code == 200
|
1001
|
+
assert response.json() == {"status": "ok"}
|
308
1002
|
|
309
|
-
|
310
|
-
|
1003
|
+
try:
|
1004
|
+
await asyncio.wait_for(processing_complete.wait(), timeout=10.0)
|
1005
|
+
except asyncio.TimeoutError:
|
1006
|
+
pytest.fail("Event processing timed out")
|
311
1007
|
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
) -> None:
|
316
|
-
# Test multiple processors for same path
|
317
|
-
processor_manager.register_processor("/test", MockWebhookProcessor)
|
318
|
-
processor_manager.register_processor("/test", MockWebhookProcessor)
|
319
|
-
assert len(processor_manager._processors["/test"]) == 2
|
1008
|
+
assert len(processed_events) == 2
|
1009
|
+
assert mock_upsert.call_count == 1
|
1010
|
+
mock_delete.assert_not_called()
|
320
1011
|
|
321
|
-
|
322
|
-
async def test_all_matching_processors_execute(
|
323
|
-
self,
|
324
|
-
processor_manager: TestableWebhookProcessorManager,
|
325
|
-
mock_event: WebhookEvent,
|
326
|
-
) -> None:
|
327
|
-
"""Test that all matching processors are executed even if some fail."""
|
328
|
-
processed_count = 0
|
1012
|
+
await mock_context.app.webhook_manager.shutdown()
|
329
1013
|
|
330
|
-
class SuccessProcessor(MockWebhookProcessor):
|
331
|
-
async def handle_event(self, payload: Dict[str, Any]) -> None:
|
332
|
-
nonlocal processed_count
|
333
|
-
processed_count += 1
|
334
|
-
self.processed = True
|
335
1014
|
|
336
|
-
|
337
|
-
|
338
|
-
|
1015
|
+
@pytest.mark.asyncio
|
1016
|
+
@patch(
|
1017
|
+
"port_ocean.core.handlers.entities_state_applier.port.applier.HttpEntitiesStateApplier.upsert"
|
1018
|
+
)
|
1019
|
+
@patch(
|
1020
|
+
"port_ocean.core.handlers.entities_state_applier.port.applier.HttpEntitiesStateApplier.delete"
|
1021
|
+
)
|
1022
|
+
async def test_integrationTest_postRequestSent_webhookEventRawResultProcessedwithRetry_entityUpserted(
|
1023
|
+
mock_delete: AsyncMock,
|
1024
|
+
mock_upsert: AsyncMock,
|
1025
|
+
mock_context: PortOceanContext,
|
1026
|
+
mock_port_app_config: PortAppConfig,
|
1027
|
+
monkeypatch: pytest.MonkeyPatch,
|
1028
|
+
) -> None:
|
1029
|
+
"""Integration test for the complete webhook processing flow"""
|
1030
|
+
|
1031
|
+
monkeypatch.setattr(
|
1032
|
+
"port_ocean.core.integrations.mixins.handler.ocean", mock_context
|
1033
|
+
)
|
1034
|
+
monkeypatch.setattr(
|
1035
|
+
"port_ocean.core.integrations.mixins.live_events.ocean", mock_context
|
1036
|
+
)
|
1037
|
+
processed_events: list[WebhookEventRawResults] = []
|
1038
|
+
mock_upsert.return_value = [entity]
|
1039
|
+
test_state = {"retry": False}
|
1040
|
+
|
1041
|
+
class TestProcessor(AbstractWebhookProcessor):
|
1042
|
+
def __init__(self, event: WebhookEvent) -> None:
|
1043
|
+
super().__init__(event)
|
1044
|
+
self.tries = 0
|
1045
|
+
|
1046
|
+
async def authenticate(
|
1047
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
1048
|
+
) -> bool:
|
1049
|
+
return True
|
1050
|
+
|
1051
|
+
async def validate_payload(self, payload: Dict[str, Any]) -> bool:
|
1052
|
+
return True
|
1053
|
+
|
1054
|
+
async def handle_event(
|
1055
|
+
self, payload: EventPayload, resource: ResourceConfig
|
1056
|
+
) -> WebhookEventRawResults:
|
1057
|
+
self.tries += 1
|
1058
|
+
if self.tries < 2:
|
1059
|
+
test_state["retry"] = True
|
1060
|
+
raise RetryableError("Test error")
|
1061
|
+
event_data = WebhookEventRawResults(
|
1062
|
+
updated_raw_results=[
|
1063
|
+
{
|
1064
|
+
"name": "repo-one",
|
1065
|
+
"links": {"html": {"href": "https://example.com/repo-one"}},
|
1066
|
+
"main_branch": "main",
|
1067
|
+
}
|
1068
|
+
],
|
1069
|
+
deleted_raw_results=[],
|
1070
|
+
)
|
1071
|
+
processed_events.append(event_data)
|
1072
|
+
return event_data
|
1073
|
+
|
1074
|
+
def should_process_event(self, event: WebhookEvent) -> bool:
|
1075
|
+
return True
|
1076
|
+
|
1077
|
+
def get_matching_kinds(self, event: WebhookEvent) -> list[str]:
|
1078
|
+
return ["repository"]
|
1079
|
+
|
1080
|
+
processing_complete = asyncio.Event()
|
1081
|
+
original_process_data = LiveEventsMixin.sync_raw_results
|
1082
|
+
|
1083
|
+
async def patched_export_single_resource(
|
1084
|
+
self: LiveEventsMixin, webhookEventRawResults: list[WebhookEventRawResults]
|
1085
|
+
) -> None:
|
1086
|
+
try:
|
1087
|
+
await original_process_data(self, webhookEventRawResults)
|
1088
|
+
except Exception as e:
|
1089
|
+
raise e
|
1090
|
+
finally:
|
1091
|
+
processing_complete.set()
|
1092
|
+
|
1093
|
+
monkeypatch.setattr(
|
1094
|
+
LiveEventsMixin,
|
1095
|
+
"sync_raw_results",
|
1096
|
+
patched_export_single_resource,
|
1097
|
+
)
|
1098
|
+
test_path = "/webhook-test"
|
1099
|
+
mock_context.app.integration = BaseIntegration(ocean)
|
1100
|
+
mock_context.app.webhook_manager = LiveEventsProcessorManager(
|
1101
|
+
mock_context.app.integration_router, SignalHandler()
|
1102
|
+
)
|
1103
|
+
|
1104
|
+
mock_context.app.webhook_manager.register_processor(test_path, TestProcessor)
|
1105
|
+
await mock_context.app.webhook_manager.start_processing_event_messages()
|
1106
|
+
mock_context.app.fast_api_app.include_router(
|
1107
|
+
mock_context.app.webhook_manager._router
|
1108
|
+
)
|
1109
|
+
client = TestClient(mock_context.app.fast_api_app)
|
1110
|
+
|
1111
|
+
test_payload = {"test": "data"}
|
1112
|
+
|
1113
|
+
async with event_context(EventType.HTTP_REQUEST, trigger_type="request") as event:
|
1114
|
+
mock_context.app.webhook_manager.port_app_config_handler.get_port_app_config = AsyncMock(return_value=mock_port_app_config) # type: ignore
|
1115
|
+
event.port_app_config = (
|
1116
|
+
await mock_context.app.webhook_manager.port_app_config_handler.get_port_app_config()
|
1117
|
+
)
|
339
1118
|
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
processor_manager.register_processor("/test", SuccessProcessor)
|
1119
|
+
response = client.post(
|
1120
|
+
test_path, json=test_payload, headers={"Content-Type": "application/json"}
|
1121
|
+
)
|
344
1122
|
|
345
|
-
|
346
|
-
|
1123
|
+
assert response.status_code == 200
|
1124
|
+
assert response.json() == {"status": "ok"}
|
347
1125
|
|
348
|
-
|
349
|
-
await asyncio.
|
1126
|
+
try:
|
1127
|
+
await asyncio.wait_for(processing_complete.wait(), timeout=10.0)
|
1128
|
+
except asyncio.TimeoutError:
|
1129
|
+
pytest.fail("Event processing timed out")
|
350
1130
|
|
351
|
-
|
352
|
-
|
1131
|
+
assert len(processed_events) == 1
|
1132
|
+
assert test_state["retry"] is True
|
1133
|
+
mock_upsert.assert_called_once()
|
1134
|
+
mock_delete.assert_not_called()
|
353
1135
|
|
354
|
-
|
355
|
-
async def test_retry_mechanism(
|
356
|
-
self,
|
357
|
-
processor_manager: TestableWebhookProcessorManager,
|
358
|
-
mock_event: WebhookEvent,
|
359
|
-
) -> None:
|
360
|
-
"""Test retry mechanism with temporary failures."""
|
361
|
-
processor = MockWebhookProcessor(mock_event)
|
362
|
-
processor.error_to_raise = RetryableError("Temporary failure")
|
1136
|
+
await mock_context.app.webhook_manager.shutdown()
|
363
1137
|
|
364
|
-
# Simulate 2 failures before success
|
365
|
-
async def handle_event(payload: Dict[str, Any]) -> None:
|
366
|
-
if processor.retry_count < 2:
|
367
|
-
raise RetryableError("Temporary failure")
|
368
|
-
processor.processed = True
|
369
1138
|
|
370
|
-
|
1139
|
+
@pytest.mark.asyncio
|
1140
|
+
@patch(
|
1141
|
+
"port_ocean.core.handlers.entities_state_applier.port.applier.HttpEntitiesStateApplier.upsert"
|
1142
|
+
)
|
1143
|
+
@patch(
|
1144
|
+
"port_ocean.core.handlers.entities_state_applier.port.applier.HttpEntitiesStateApplier.delete"
|
1145
|
+
)
|
1146
|
+
async def test_integrationTest_postRequestSent_webhookEventRawResultProcessedwithRetry_exceededMaxRetries_entityNotUpserted(
|
1147
|
+
mock_delete: AsyncMock,
|
1148
|
+
mock_upsert: AsyncMock,
|
1149
|
+
mock_context: PortOceanContext,
|
1150
|
+
mock_port_app_config: PortAppConfig,
|
1151
|
+
monkeypatch: pytest.MonkeyPatch,
|
1152
|
+
) -> None:
|
1153
|
+
"""Integration test for the complete webhook processing flow"""
|
1154
|
+
|
1155
|
+
monkeypatch.setattr(
|
1156
|
+
"port_ocean.core.integrations.mixins.handler.ocean", mock_context
|
1157
|
+
)
|
1158
|
+
monkeypatch.setattr(
|
1159
|
+
"port_ocean.core.integrations.mixins.live_events.ocean", mock_context
|
1160
|
+
)
|
1161
|
+
processed_events: list[WebhookEventRawResults] = []
|
1162
|
+
mock_upsert.return_value = [entity]
|
1163
|
+
test_state = {"retry": False, "exception": False}
|
1164
|
+
|
1165
|
+
class TestProcessor(AbstractWebhookProcessor):
|
1166
|
+
def __init__(self, event: WebhookEvent) -> None:
|
1167
|
+
super().__init__(event)
|
1168
|
+
self.tries = 0
|
1169
|
+
|
1170
|
+
async def authenticate(
|
1171
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
1172
|
+
) -> bool:
|
1173
|
+
return True
|
1174
|
+
|
1175
|
+
async def validate_payload(self, payload: Dict[str, Any]) -> bool:
|
1176
|
+
return True
|
1177
|
+
|
1178
|
+
async def handle_event(
|
1179
|
+
self, payload: EventPayload, resource: ResourceConfig
|
1180
|
+
) -> WebhookEventRawResults:
|
1181
|
+
self.tries += 1
|
1182
|
+
if self.tries < 5:
|
1183
|
+
test_state["retry"] = True
|
1184
|
+
raise RetryableError("Test error")
|
1185
|
+
event_data = WebhookEventRawResults(
|
1186
|
+
updated_raw_results=[
|
1187
|
+
{
|
1188
|
+
"name": "repo-one",
|
1189
|
+
"links": {"html": {"href": "https://example.com/repo-one"}},
|
1190
|
+
"main_branch": "main",
|
1191
|
+
}
|
1192
|
+
],
|
1193
|
+
deleted_raw_results=[],
|
1194
|
+
)
|
1195
|
+
processed_events.append(event_data)
|
1196
|
+
return event_data
|
1197
|
+
|
1198
|
+
def should_process_event(self, event: WebhookEvent) -> bool:
|
1199
|
+
return True
|
1200
|
+
|
1201
|
+
def get_matching_kinds(self, event: WebhookEvent) -> list[str]:
|
1202
|
+
return ["repository"]
|
1203
|
+
|
1204
|
+
processing_complete = asyncio.Event()
|
1205
|
+
original_process_data = LiveEventsProcessorManager._process_webhook_request
|
1206
|
+
|
1207
|
+
async def patched_process_webhook_request(
|
1208
|
+
self: LiveEventsProcessorManager,
|
1209
|
+
processor: AbstractWebhookProcessor,
|
1210
|
+
resource: ResourceConfig,
|
1211
|
+
) -> WebhookEventRawResults:
|
1212
|
+
try:
|
1213
|
+
return await original_process_data(self, processor, resource)
|
1214
|
+
except Exception as e:
|
1215
|
+
test_state["exception"] = True
|
1216
|
+
raise e
|
1217
|
+
finally:
|
1218
|
+
processing_complete.set()
|
1219
|
+
|
1220
|
+
monkeypatch.setattr(
|
1221
|
+
LiveEventsProcessorManager,
|
1222
|
+
"_process_webhook_request",
|
1223
|
+
patched_process_webhook_request,
|
1224
|
+
)
|
1225
|
+
test_path = "/webhook-test"
|
1226
|
+
mock_context.app.integration = BaseIntegration(ocean)
|
1227
|
+
mock_context.app.webhook_manager = LiveEventsProcessorManager(
|
1228
|
+
mock_context.app.integration_router, SignalHandler()
|
1229
|
+
)
|
1230
|
+
|
1231
|
+
mock_context.app.webhook_manager.register_processor(test_path, TestProcessor)
|
1232
|
+
await mock_context.app.webhook_manager.start_processing_event_messages()
|
1233
|
+
mock_context.app.fast_api_app.include_router(
|
1234
|
+
mock_context.app.webhook_manager._router
|
1235
|
+
)
|
1236
|
+
client = TestClient(mock_context.app.fast_api_app)
|
1237
|
+
|
1238
|
+
test_payload = {"test": "data"}
|
1239
|
+
|
1240
|
+
async with event_context(EventType.HTTP_REQUEST, trigger_type="request") as event:
|
1241
|
+
mock_context.app.webhook_manager.port_app_config_handler.get_port_app_config = AsyncMock(return_value=mock_port_app_config) # type: ignore
|
1242
|
+
event.port_app_config = (
|
1243
|
+
await mock_context.app.webhook_manager.port_app_config_handler.get_port_app_config()
|
1244
|
+
)
|
371
1245
|
|
372
|
-
|
1246
|
+
response = client.post(
|
1247
|
+
test_path, json=test_payload, headers={"Content-Type": "application/json"}
|
1248
|
+
)
|
373
1249
|
|
374
|
-
|
375
|
-
|
1250
|
+
assert response.status_code == 200
|
1251
|
+
assert response.json() == {"status": "ok"}
|
376
1252
|
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
mock_event: WebhookEvent,
|
382
|
-
) -> None:
|
383
|
-
"""Test behavior when max retries are exceeded."""
|
384
|
-
processor = MockWebhookProcessor(mock_event)
|
385
|
-
processor.max_retries = 1
|
386
|
-
processor.error_to_raise = RetryableError("Temporary failure")
|
1253
|
+
try:
|
1254
|
+
await asyncio.wait_for(processing_complete.wait(), timeout=30.0)
|
1255
|
+
except asyncio.TimeoutError:
|
1256
|
+
pytest.fail("Event processing timed out")
|
387
1257
|
|
388
|
-
|
389
|
-
|
1258
|
+
assert len(processed_events) == 0
|
1259
|
+
assert test_state["retry"] is True
|
1260
|
+
assert test_state["exception"] is True
|
1261
|
+
mock_upsert.assert_not_called()
|
1262
|
+
mock_delete.assert_not_called()
|
390
1263
|
|
391
|
-
|
1264
|
+
await mock_context.app.webhook_manager.shutdown()
|