port-ocean 0.21.4__py3-none-any.whl → 0.22.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.

Potentially problematic release.


This version of port-ocean might be problematic. Click here for more details.

Files changed (32) hide show
  1. integrations/_infra/Makefile +2 -0
  2. port_ocean/cli/cookiecutter/cookiecutter.json +2 -2
  3. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/README.md +1 -1
  4. port_ocean/clients/port/mixins/entities.py +1 -2
  5. port_ocean/config/settings.py +22 -0
  6. port_ocean/context/event.py +1 -0
  7. port_ocean/context/ocean.py +5 -0
  8. port_ocean/context/resource.py +3 -4
  9. port_ocean/core/event_listener/base.py +6 -3
  10. port_ocean/core/handlers/entities_state_applier/port/applier.py +9 -1
  11. port_ocean/core/handlers/entity_processor/base.py +0 -2
  12. port_ocean/core/handlers/resync_state_updater/updater.py +9 -0
  13. port_ocean/core/handlers/webhook/processor_manager.py +5 -3
  14. port_ocean/core/handlers/webhook/webhook_event.py +0 -5
  15. port_ocean/core/integrations/mixins/sync_raw.py +61 -10
  16. port_ocean/core/ocean_types.py +1 -0
  17. port_ocean/helpers/metric/metric.py +238 -0
  18. port_ocean/helpers/metric/utils.py +30 -0
  19. port_ocean/helpers/retry.py +2 -1
  20. port_ocean/ocean.py +17 -4
  21. port_ocean/tests/clients/port/mixins/test_entities.py +11 -9
  22. port_ocean/tests/core/conftest.py +186 -0
  23. port_ocean/tests/core/handlers/entities_state_applier/test_applier.py +86 -6
  24. port_ocean/tests/core/handlers/mixins/test_sync_raw.py +5 -164
  25. port_ocean/tests/core/handlers/webhook/test_processor_manager.py +79 -44
  26. port_ocean/tests/test_metric.py +180 -0
  27. port_ocean/utils/async_http.py +4 -1
  28. {port_ocean-0.21.4.dist-info → port_ocean-0.22.0.dist-info}/METADATA +2 -1
  29. {port_ocean-0.21.4.dist-info → port_ocean-0.22.0.dist-info}/RECORD +32 -28
  30. {port_ocean-0.21.4.dist-info → port_ocean-0.22.0.dist-info}/LICENSE.md +0 -0
  31. {port_ocean-0.21.4.dist-info → port_ocean-0.22.0.dist-info}/WHEEL +0 -0
  32. {port_ocean-0.21.4.dist-info → port_ocean-0.22.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,238 @@
1
+ from typing import Any, TYPE_CHECKING, Optional, Dict, List, Tuple
2
+ from fastapi import APIRouter
3
+ from port_ocean.exceptions.context import ResourceContextNotFoundError
4
+ import prometheus_client
5
+ from httpx import AsyncClient
6
+
7
+ from loguru import logger
8
+ from port_ocean.context import resource
9
+ from prometheus_client import Gauge
10
+ import prometheus_client.openmetrics
11
+ import prometheus_client.openmetrics.exposition
12
+ import prometheus_client.parser
13
+
14
+ if TYPE_CHECKING:
15
+ from port_ocean.config.settings import MetricsSettings, IntegrationSettings
16
+
17
+
18
+ class MetricPhase:
19
+ EXTRACT = "extract"
20
+ TRANSFORM = "transform"
21
+ LOAD = "load"
22
+ RESYNC = "resync"
23
+ DELETE = "delete"
24
+
25
+
26
+ class MetricType:
27
+ # Define metric names as constants
28
+ DURATION_NAME = "duration_seconds"
29
+ OBJECT_COUNT_NAME = "object_count"
30
+ ERROR_COUNT_NAME = "error_count"
31
+ SUCCESS_NAME = "success"
32
+ RATE_LIMIT_WAIT_NAME = "rate_limit_wait_seconds"
33
+ DELETION_COUNT_NAME = "deletion_count"
34
+
35
+
36
+ # Registry for core and custom metrics
37
+ _metrics_registry: Dict[str, Tuple[str, str, List[str]]] = {
38
+ MetricType.DURATION_NAME: (
39
+ MetricType.DURATION_NAME,
40
+ "duration description",
41
+ ["kind", "phase"],
42
+ ),
43
+ MetricType.OBJECT_COUNT_NAME: (
44
+ MetricType.OBJECT_COUNT_NAME,
45
+ "object_count description",
46
+ ["kind", "phase"],
47
+ ),
48
+ MetricType.ERROR_COUNT_NAME: (
49
+ MetricType.ERROR_COUNT_NAME,
50
+ "error_count description",
51
+ ["kind", "phase"],
52
+ ),
53
+ MetricType.SUCCESS_NAME: (
54
+ MetricType.SUCCESS_NAME,
55
+ "success description",
56
+ ["kind", "phase"],
57
+ ),
58
+ MetricType.RATE_LIMIT_WAIT_NAME: (
59
+ MetricType.RATE_LIMIT_WAIT_NAME,
60
+ "rate_limit_wait description",
61
+ ["kind", "phase", "endpoint"],
62
+ ),
63
+ MetricType.DELETION_COUNT_NAME: (
64
+ MetricType.DELETION_COUNT_NAME,
65
+ "deletion_count description",
66
+ ["kind", "phase"],
67
+ ),
68
+ }
69
+
70
+
71
+ def register_metric(name: str, description: str, labels: List[str]) -> None:
72
+ """Register a custom metric that will be available for use.
73
+
74
+ Args:
75
+ name (str): The metric name to register
76
+ description (str): Description of what the metric measures
77
+ labels (list[str]): Labels to apply to the metric
78
+ """
79
+ _metrics_registry[name] = (name, description, labels)
80
+
81
+
82
+ class EmptyMetric:
83
+ def set(self, *args: Any) -> None:
84
+ return None
85
+
86
+ def labels(self, *args: Any) -> None:
87
+ return None
88
+
89
+
90
+ class Metrics:
91
+ def __init__(
92
+ self,
93
+ metrics_settings: "MetricsSettings",
94
+ integration_configuration: "IntegrationSettings",
95
+ ) -> None:
96
+ self.metrics_settings = metrics_settings
97
+ self.integration_configuration = integration_configuration
98
+ self.registry = prometheus_client.CollectorRegistry()
99
+ self.metrics: dict[str, Gauge] = {}
100
+ self.load_metrics()
101
+ self._integration_version: Optional[str] = None
102
+ self._ocean_version: Optional[str] = None
103
+
104
+ @property
105
+ def integration_version(self) -> str:
106
+ if self._integration_version is None:
107
+ from port_ocean.version import __integration_version__
108
+
109
+ self._integration_version = __integration_version__
110
+ return self._integration_version
111
+
112
+ @property
113
+ def ocean_version(self) -> str:
114
+ if self._ocean_version is None:
115
+ from port_ocean.version import __version__
116
+
117
+ self._ocean_version = __version__
118
+ return self._ocean_version
119
+
120
+ @property
121
+ def enabled(self) -> bool:
122
+ return self.metrics_settings.enabled
123
+
124
+ def load_metrics(self) -> None:
125
+ if not self.enabled:
126
+ return None
127
+
128
+ # Load all registered metrics
129
+ for name, (_, description, labels) in _metrics_registry.items():
130
+ self.metrics[name] = Gauge(
131
+ name, description, labels, registry=self.registry
132
+ )
133
+
134
+ def get_metric(self, name: str, labels: list[str]) -> Gauge | EmptyMetric:
135
+ if not self.enabled:
136
+ return EmptyMetric()
137
+ metrics = self.metrics.get(name)
138
+ if not metrics:
139
+ return EmptyMetric()
140
+ return metrics.labels(*labels)
141
+
142
+ def set_metric(self, name: str, labels: list[str], value: float) -> None:
143
+ """Set a metric value in a single method call.
144
+
145
+ Args:
146
+ name (str): The metric name to set.
147
+ labels (list[str]): The labels to apply to the metric.
148
+ value (float): The value to set.
149
+ """
150
+ if not self.enabled:
151
+ return None
152
+
153
+ self.get_metric(name, labels).set(value)
154
+
155
+ def create_mertic_router(self) -> APIRouter:
156
+ if not self.enabled:
157
+ return APIRouter()
158
+ router = APIRouter()
159
+
160
+ @router.get("/")
161
+ async def prom_metrics() -> str:
162
+ return self.generate_latest()
163
+
164
+ return router
165
+
166
+ def current_resource_kind(self) -> str:
167
+ try:
168
+ return f"{resource.resource.kind}-{resource.resource.index}"
169
+ except ResourceContextNotFoundError:
170
+ return "__runtime__"
171
+
172
+ def generate_latest(self) -> str:
173
+ return prometheus_client.openmetrics.exposition.generate_latest(
174
+ self.registry
175
+ ).decode()
176
+
177
+ async def flush(
178
+ self, metric_name: Optional[str] = None, kind: Optional[str] = None
179
+ ) -> None:
180
+ if not self.enabled:
181
+ return None
182
+
183
+ if not self.metrics_settings.webhook_url:
184
+ return None
185
+
186
+ try:
187
+ latest_raw = self.generate_latest()
188
+ metric_families = prometheus_client.parser.text_string_to_metric_families(
189
+ latest_raw
190
+ )
191
+ metrics_dict: dict[str, Any] = {}
192
+ for family in metric_families:
193
+ for sample in family.samples:
194
+ # Skip if a specific metric name was requested and this isn't it
195
+ if metric_name and sample.name != metric_name:
196
+ continue
197
+
198
+ current_level = metrics_dict
199
+ if sample.labels:
200
+ # Skip if a specific kind was requested and this isn't it
201
+ if kind and sample.labels.get("kind") != kind:
202
+ continue
203
+
204
+ # Create nested dictionary structure based on labels
205
+ for key, value in sample.labels.items():
206
+ if key not in current_level:
207
+ current_level[key] = {}
208
+ current_level = current_level[key]
209
+ if value not in current_level:
210
+ current_level[value] = {}
211
+ current_level = current_level[value]
212
+
213
+ current_level[sample.name] = sample.value
214
+
215
+ # If no metrics were filtered, exit early
216
+ if not metrics_dict.get("kind", {}):
217
+ return None
218
+
219
+ for kind_key, metrics in metrics_dict.get("kind", {}).items():
220
+ # Skip if we're filtering by kind and this isn't the requested kind
221
+ if kind and kind_key != kind:
222
+ continue
223
+
224
+ event = {
225
+ "integration_type": self.integration_configuration.type,
226
+ "integration_identifier": self.integration_configuration.identifier,
227
+ "integration_version": self.integration_version,
228
+ "ocean_version": self.ocean_version,
229
+ "kind_identifier": kind_key,
230
+ "kind": "-".join(kind_key.split("-")[:-1]),
231
+ "metrics": metrics,
232
+ }
233
+ logger.info(f"Sending metrics to webhook {kind_key}: {event}")
234
+ await AsyncClient().post(
235
+ url=self.metrics_settings.webhook_url, json=event
236
+ )
237
+ except Exception as e:
238
+ logger.error(f"Error sending metrics to webhook: {e}")
@@ -0,0 +1,30 @@
1
+ from functools import wraps
2
+ import time
3
+ from typing import Any, Callable
4
+
5
+ from port_ocean.context.ocean import ocean
6
+ from port_ocean.helpers.metric.metric import MetricType
7
+
8
+
9
+ def TimeMetric(phase: str) -> Any:
10
+ def decorator(func: Callable[..., Any]) -> Any:
11
+
12
+ @wraps(func)
13
+ async def wrapper(*args: Any, **kwargs: dict[Any, Any]) -> Any:
14
+ if not ocean.metrics.enabled:
15
+ return await func(*args, **kwargs)
16
+ start = time.monotonic()
17
+ res = await func(*args, **kwargs)
18
+ end = time.monotonic()
19
+ duration = end - start
20
+ ocean.metrics.set_metric(
21
+ name=MetricType.DURATION_NAME,
22
+ labels=[ocean.metrics.current_resource_kind(), phase],
23
+ value=duration,
24
+ )
25
+
26
+ return res
27
+
28
+ return wrapper
29
+
30
+ return decorator
@@ -5,7 +5,6 @@ from datetime import datetime
5
5
  from functools import partial
6
6
  from http import HTTPStatus
7
7
  from typing import Any, Callable, Coroutine, Iterable, Mapping, Union
8
-
9
8
  import httpx
10
9
  from dateutil.parser import isoparse
11
10
 
@@ -172,6 +171,7 @@ class RetryTransport(httpx.AsyncBaseTransport, httpx.BaseTransport):
172
171
  response = await self._retry_operation_async(request, send_method)
173
172
  else:
174
173
  response = await transport.handle_async_request(request)
174
+
175
175
  return response
176
176
  except Exception as e:
177
177
  # Retyable methods are logged via _log_error
@@ -340,6 +340,7 @@ class RetryTransport(httpx.AsyncBaseTransport, httpx.BaseTransport):
340
340
  attempts_made = 0
341
341
  response: httpx.Response | None = None
342
342
  error: Exception | None = None
343
+
343
344
  while True:
344
345
  if attempts_made > 0:
345
346
  sleep_time = self._calculate_sleep(attempts_made, {})
port_ocean/ocean.py CHANGED
@@ -4,7 +4,10 @@ from contextlib import asynccontextmanager
4
4
  import threading
5
5
  from typing import Any, AsyncIterator, Callable, Dict, Type
6
6
 
7
- from fastapi import APIRouter, FastAPI
7
+ import port_ocean.helpers.metric.metric
8
+
9
+ from fastapi import FastAPI, APIRouter
10
+
8
11
  from loguru import logger
9
12
  from pydantic import BaseModel
10
13
  from starlette.types import Receive, Scope, Send
@@ -49,12 +52,15 @@ class Ocean:
49
52
  _integration_config_model=config_factory,
50
53
  **(config_override or {}),
51
54
  )
52
-
53
55
  # add the integration sensitive configuration to the sensitive patterns to mask out
54
56
  sensitive_log_filter.hide_sensitive_strings(
55
57
  *self.config.get_sensitive_fields_data()
56
58
  )
57
59
  self.integration_router = integration_router or APIRouter()
60
+ self.metrics = port_ocean.helpers.metric.metric.Metrics(
61
+ metrics_settings=self.config.metrics,
62
+ integration_configuration=self.config.integration,
63
+ )
58
64
 
59
65
  self.webhook_manager = LiveEventsProcessorManager(
60
66
  self.integration_router,
@@ -91,8 +97,12 @@ class Ocean:
91
97
  await self.resync_state_updater.update_before_resync()
92
98
  logger.info("Starting a new scheduled resync")
93
99
  try:
94
- await self.integration.sync_raw_all()
95
- await self.resync_state_updater.update_after_resync()
100
+ successed = await self.integration.sync_raw_all()
101
+ await self.resync_state_updater.update_after_resync(
102
+ IntegrationStateStatus.Completed
103
+ if successed
104
+ else IntegrationStateStatus.Failed
105
+ )
96
106
  except asyncio.CancelledError:
97
107
  logger.warning(
98
108
  "resync was cancelled by the scheduled resync, skipping state update"
@@ -148,6 +158,9 @@ class Ocean:
148
158
 
149
159
  def initialize_app(self) -> None:
150
160
  self.fast_api_app.include_router(self.integration_router, prefix="/integration")
161
+ self.fast_api_app.include_router(
162
+ self.metrics.create_mertic_router(), prefix="/metrics"
163
+ )
151
164
 
152
165
  @asynccontextmanager
153
166
  async def lifecycle(_: FastAPI) -> AsyncIterator[None]:
@@ -1,5 +1,5 @@
1
1
  from typing import Any
2
- from unittest.mock import MagicMock
2
+ from unittest.mock import MagicMock, patch
3
3
 
4
4
  import pytest
5
5
 
@@ -37,17 +37,19 @@ async def entity_client(monkeypatch: Any) -> EntityClientMixin:
37
37
  async def test_batch_upsert_entities_read_timeout_should_raise_false(
38
38
  entity_client: EntityClientMixin,
39
39
  ) -> None:
40
- result_entities = await entity_client.batch_upsert_entities(
41
- entities=all_entities, request_options=MagicMock(), should_raise=False
42
- )
40
+ with patch("port_ocean.context.event.event", MagicMock()):
41
+ result_entities = await entity_client.batch_upsert_entities(
42
+ entities=all_entities, request_options=MagicMock(), should_raise=False
43
+ )
43
44
 
44
- assert result_entities == expected_result_entities
45
+ assert result_entities == expected_result_entities
45
46
 
46
47
 
47
48
  async def test_batch_upsert_entities_read_timeout_should_raise_true(
48
49
  entity_client: EntityClientMixin,
49
50
  ) -> None:
50
- with pytest.raises(ReadTimeout):
51
- await entity_client.batch_upsert_entities(
52
- entities=all_entities, request_options=MagicMock(), should_raise=True
53
- )
51
+ with patch("port_ocean.context.event.event", MagicMock()):
52
+ with pytest.raises(ReadTimeout):
53
+ await entity_client.batch_upsert_entities(
54
+ entities=all_entities, request_options=MagicMock(), should_raise=True
55
+ )
@@ -0,0 +1,186 @@
1
+ from contextlib import asynccontextmanager
2
+ from typing import Any, AsyncGenerator
3
+ from unittest.mock import MagicMock, AsyncMock, patch
4
+
5
+ import pytest
6
+ from httpx import Response
7
+
8
+ from port_ocean.clients.port.client import PortClient
9
+ from port_ocean.context.event import EventContext
10
+ from port_ocean.context.ocean import PortOceanContext, ocean
11
+ from port_ocean.core.handlers.entities_state_applier.port.applier import (
12
+ HttpEntitiesStateApplier,
13
+ )
14
+ from port_ocean.core.handlers.entity_processor.jq_entity_processor import (
15
+ JQEntityProcessor,
16
+ )
17
+ from port_ocean.core.handlers.port_app_config.models import (
18
+ EntityMapping,
19
+ MappingsConfig,
20
+ PortAppConfig,
21
+ PortResourceConfig,
22
+ ResourceConfig,
23
+ Selector,
24
+ )
25
+ from port_ocean.core.models import Entity
26
+ from port_ocean.ocean import Ocean
27
+
28
+
29
+ @pytest.fixture
30
+ def mock_http_client() -> MagicMock:
31
+ mock_http_client = MagicMock()
32
+ mock_upserted_entities = []
33
+
34
+ async def post(url: str, *args: Any, **kwargs: Any) -> Response:
35
+ entity = kwargs.get("json", {})
36
+ if entity.get("properties", {}).get("mock_is_to_fail", {}):
37
+ return Response(
38
+ 404, headers=MagicMock(), json={"ok": False, "error": "not_found"}
39
+ )
40
+
41
+ mock_upserted_entities.append(
42
+ f"{entity.get('identifier')}-{entity.get('blueprint')}"
43
+ )
44
+ return Response(
45
+ 200,
46
+ json={
47
+ "entity": {
48
+ "identifier": entity.get("identifier"),
49
+ "blueprint": entity.get("blueprint"),
50
+ }
51
+ },
52
+ )
53
+
54
+ mock_http_client.post = AsyncMock(side_effect=post)
55
+ return mock_http_client
56
+
57
+
58
+ @pytest.fixture
59
+ def mock_port_client(mock_http_client: MagicMock) -> PortClient:
60
+ mock_port_client = PortClient(
61
+ MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock()
62
+ )
63
+ mock_port_client.auth = AsyncMock()
64
+ mock_port_client.auth.headers = AsyncMock(
65
+ return_value={
66
+ "Authorization": "test",
67
+ "User-Agent": "test",
68
+ }
69
+ )
70
+
71
+ mock_port_client.search_entities = AsyncMock(return_value=[]) # type: ignore
72
+ mock_port_client.client = mock_http_client
73
+ return mock_port_client
74
+
75
+
76
+ @pytest.fixture
77
+ def mock_ocean(mock_port_client: PortClient) -> Ocean:
78
+ with patch("port_ocean.ocean.Ocean.__init__", return_value=None):
79
+ ocean_mock = Ocean(
80
+ MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock()
81
+ )
82
+ ocean_mock.config = MagicMock()
83
+ ocean_mock.config.port = MagicMock()
84
+ ocean_mock.config.port.port_app_config_cache_ttl = 60
85
+ ocean_mock.port_client = mock_port_client
86
+ ocean_mock.metrics = MagicMock()
87
+ ocean_mock.metrics.flush = AsyncMock()
88
+
89
+ return ocean_mock
90
+
91
+
92
+ @pytest.fixture
93
+ def mock_context(mock_ocean: Ocean) -> PortOceanContext:
94
+ context = PortOceanContext(mock_ocean)
95
+ ocean._app = context.app
96
+ return context
97
+
98
+
99
+ @pytest.fixture
100
+ def mock_port_app_config() -> PortAppConfig:
101
+ return PortAppConfig(
102
+ enable_merge_entity=True,
103
+ delete_dependent_entities=True,
104
+ create_missing_related_entities=False,
105
+ resources=[
106
+ ResourceConfig(
107
+ kind="project",
108
+ selector=Selector(query="true"),
109
+ port=PortResourceConfig(
110
+ entity=MappingsConfig(
111
+ mappings=EntityMapping(
112
+ identifier=".id | tostring",
113
+ title=".name",
114
+ blueprint='"service"',
115
+ properties={"url": ".web_url"},
116
+ relations={},
117
+ )
118
+ )
119
+ ),
120
+ )
121
+ ],
122
+ )
123
+
124
+
125
+ @pytest.fixture
126
+ def mock_port_app_config_handler(mock_port_app_config: PortAppConfig) -> MagicMock:
127
+ handler = MagicMock()
128
+
129
+ async def get_config(use_cache: bool = True) -> Any:
130
+ return mock_port_app_config
131
+
132
+ handler.get_port_app_config = get_config
133
+ return handler
134
+
135
+
136
+ @pytest.fixture
137
+ def mock_entity_processor(mock_context: PortOceanContext) -> JQEntityProcessor:
138
+ return JQEntityProcessor(mock_context)
139
+
140
+
141
+ @pytest.fixture
142
+ def mock_resource_config() -> ResourceConfig:
143
+ resource = ResourceConfig(
144
+ kind="service",
145
+ selector=Selector(query="true"),
146
+ port=PortResourceConfig(
147
+ entity=MappingsConfig(
148
+ mappings=EntityMapping(
149
+ identifier=".id",
150
+ title=".name",
151
+ blueprint='"service"',
152
+ properties={"url": ".web_url"},
153
+ relations={},
154
+ )
155
+ )
156
+ ),
157
+ )
158
+ return resource
159
+
160
+
161
+ @pytest.fixture
162
+ def mock_entities_state_applier(
163
+ mock_context: PortOceanContext,
164
+ ) -> HttpEntitiesStateApplier:
165
+ return HttpEntitiesStateApplier(mock_context)
166
+
167
+
168
+ @asynccontextmanager
169
+ async def no_op_event_context(
170
+ existing_event: EventContext,
171
+ ) -> AsyncGenerator[EventContext, None]:
172
+ yield existing_event
173
+
174
+
175
+ def create_entity(
176
+ id: str,
177
+ blueprint: str,
178
+ relation: dict[str, str] | None = None,
179
+ is_to_fail: bool = False,
180
+ ) -> Entity:
181
+ if relation is None:
182
+ relation = {}
183
+ entity = Entity(identifier=id, blueprint=blueprint)
184
+ entity.relations = relation
185
+ entity.properties = {"mock_is_to_fail": is_to_fail}
186
+ return entity
@@ -6,6 +6,11 @@ from port_ocean.core.handlers.entities_state_applier.port.applier import (
6
6
  from port_ocean.core.models import Entity
7
7
  from port_ocean.core.ocean_types import EntityDiff
8
8
  from port_ocean.clients.port.types import UserAgentType
9
+ from port_ocean.ocean import Ocean
10
+ from port_ocean.context.ocean import PortOceanContext
11
+ from port_ocean.tests.core.conftest import create_entity
12
+ from port_ocean.core.handlers.port_app_config.models import PortAppConfig
13
+ from port_ocean.context.event import event_context, EventType
9
14
 
10
15
 
11
16
  @pytest.mark.asyncio
@@ -23,8 +28,8 @@ async def test_delete_diff_no_deleted_entities() -> None:
23
28
 
24
29
 
25
30
  @pytest.mark.asyncio
26
- async def test_delete_diff_below_threshold() -> None:
27
- applier = HttpEntitiesStateApplier(Mock())
31
+ async def test_delete_diff_below_threshold(mock_context: PortOceanContext) -> None:
32
+ applier = HttpEntitiesStateApplier(mock_context)
28
33
  entities = EntityDiff(
29
34
  before=[
30
35
  Entity(identifier="1", blueprint="test"),
@@ -48,8 +53,10 @@ async def test_delete_diff_below_threshold() -> None:
48
53
 
49
54
 
50
55
  @pytest.mark.asyncio
51
- async def test_delete_diff_above_default_threshold() -> None:
52
- applier = HttpEntitiesStateApplier(Mock())
56
+ async def test_delete_diff_above_default_threshold(
57
+ mock_context: PortOceanContext,
58
+ ) -> None:
59
+ applier = HttpEntitiesStateApplier(mock_context)
53
60
  entities = EntityDiff(
54
61
  before=[
55
62
  Entity(identifier="1", blueprint="test"),
@@ -68,8 +75,10 @@ async def test_delete_diff_above_default_threshold() -> None:
68
75
 
69
76
 
70
77
  @pytest.mark.asyncio
71
- async def test_delete_diff_custom_threshold_above_threshold_not_deleted() -> None:
72
- applier = HttpEntitiesStateApplier(Mock())
78
+ async def test_delete_diff_custom_threshold_above_threshold_not_deleted(
79
+ mock_context: PortOceanContext,
80
+ ) -> None:
81
+ applier = HttpEntitiesStateApplier(mock_context)
73
82
  entities = EntityDiff(
74
83
  before=[
75
84
  Entity(identifier="1", blueprint="test"),
@@ -84,3 +93,74 @@ async def test_delete_diff_custom_threshold_above_threshold_not_deleted() -> Non
84
93
  )
85
94
 
86
95
  mock_safe_delete.assert_not_called()
96
+
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_applier_with_mock_context(
100
+ mock_ocean: Ocean,
101
+ mock_context: PortOceanContext,
102
+ mock_port_app_config: PortAppConfig,
103
+ ) -> None:
104
+ # Create an applier using the mock_context fixture
105
+ applier = HttpEntitiesStateApplier(mock_context)
106
+
107
+ # Create test entities
108
+ entity = Entity(identifier="test_entity", blueprint="test_blueprint")
109
+
110
+ async with event_context(EventType.RESYNC, trigger_type="machine") as event:
111
+ event.port_app_config = mock_port_app_config
112
+
113
+ # Test the upsert method with mocked client
114
+ with patch.object(mock_ocean.port_client.client, "post") as mock_post:
115
+ mock_post.return_value = Mock(
116
+ status_code=200,
117
+ json=lambda: {
118
+ "entity": {
119
+ "identifier": "test_entity",
120
+ "blueprint": "test_blueprint",
121
+ }
122
+ },
123
+ )
124
+
125
+ result = await applier.upsert([entity], UserAgentType.exporter)
126
+
127
+ # Assert that the post method was called
128
+ mock_post.assert_called_once()
129
+ assert len(result) == 1
130
+ assert result[0].identifier == "test_entity"
131
+
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_using_create_entity_helper(
135
+ mock_ocean: Ocean,
136
+ mock_context: PortOceanContext,
137
+ mock_port_app_config: PortAppConfig,
138
+ ) -> None:
139
+ # Create the applier with the mock context
140
+ applier = HttpEntitiesStateApplier(mock_context)
141
+
142
+ # Create test entities using the helper function
143
+ entity1 = create_entity("entity1", "service", {"related_to": "entity2"}, False)
144
+
145
+ # Test that entities were created correctly
146
+ assert entity1.identifier == "entity1"
147
+ assert entity1.blueprint == "service"
148
+ assert entity1.relations == {"related_to": "entity2"}
149
+ assert entity1.properties == {"mock_is_to_fail": False}
150
+
151
+ # Test the applier with these entities
152
+ async with event_context(EventType.RESYNC, trigger_type="machine") as event:
153
+ event.port_app_config = mock_port_app_config
154
+
155
+ with patch.object(mock_ocean.port_client.client, "post") as mock_post:
156
+ mock_post.return_value = Mock(
157
+ status_code=200,
158
+ json=lambda: {
159
+ "entity": {"identifier": "entity1", "blueprint": "service"}
160
+ },
161
+ )
162
+
163
+ result = await applier.upsert([entity1], UserAgentType.exporter)
164
+
165
+ mock_post.assert_called_once()
166
+ assert len(result) == 1