diff --git a/app/startup/composition/network.py b/app/startup/composition/network.py new file mode 100644 index 000000000..8fffaeb37 --- /dev/null +++ b/app/startup/composition/network.py @@ -0,0 +1,157 @@ +"""网络应用端口的宿主组合根。""" + +from collections.abc import Mapping +from typing import Any, Callable, Optional, cast + +from app.adapters.network.http import AsyncRequestUtils, RequestUtils +from app.adapters.network.ip import IpUtils +from app.application.configuration import get_runtime_settings +from app.application.image import ( + ImageResponsePort, + ImageTransport, + InternalAddressPort, + configure_image_ports, +) +from app.application.messaging.ingress import ( + MessageIngressPort, + configure_message_ingress_port, +) +from app.application.network import ( + NetworkTestResponse, + NetworkTestService, + NetworkTestTransport, + configure_network_test_service, +) +from app.runtime.log import logger + + +class _NetworkTestTransportAdapter: + """把通用异步 HTTP Adapter 收窄为网络探测 GET 端口。""" + + async def get( + self, + url: str, + *, + proxy: Any = None, + headers: Optional[Mapping[str, str]] = None, + user_agent: Optional[str] = None, + ) -> Optional[NetworkTestResponse]: + """使用固定超时、证书校验和手动重定向策略请求目标。""" + response = await AsyncRequestUtils( + proxies=proxy, + headers=dict(headers) if headers else None, + timeout=10, + ua=user_agent or "", + verify=True, + follow_redirects=False, + ).get_res(url, allow_redirects=False) + return cast(Optional[NetworkTestResponse], response) + + +def _read_network_test_setting(key: str, default: Any = None) -> Any: + """延迟读取已由组合根发布的部署设置,避免装配阶段提前取值。""" + return get_runtime_settings().get(key, default) + + +class _ImageTransportAdapter: + """把通用 HTTP Adapter 收窄为图片应用服务的 GET 端口。""" + + def get( + self, + url: str, + *, + options: Mapping[str, Any], + ) -> Optional[ImageResponsePort]: + """同步创建短生命周期请求对象并返回响应。""" + response = RequestUtils(**dict(options)).get_res(url=url) + return cast(Optional[ImageResponsePort], response) + + async def async_get( + self, + url: str, + *, + options: Mapping[str, Any], + ) -> Optional[ImageResponsePort]: + """异步创建短生命周期请求对象并返回响应。""" + response = await AsyncRequestUtils(**dict(options)).get_res(url=url) + return cast(Optional[ImageResponsePort], response) + + +class _InternalAddressAdapter: + """把通用地址判断收窄为图片代理决策端口。""" + + @staticmethod + def is_internal(url: str) -> bool: + """委托通用 IP 工具判断 URL 是否指向内部地址。""" + probe = cast(Callable[[str], bool], IpUtils.is_internal) + return bool(probe(url)) + + +class _MessageIngressAdapter: + """通过通用 HTTP Adapter 投递本地消息并负责释放响应。""" + + def post( + self, + url: str, + payload: Mapping[str, Any], + *, + timeout: float, + ) -> Optional[int]: + """同步投递消息,关闭响应后返回状态码。""" + response = RequestUtils(timeout=timeout).post_res( # type: ignore[arg-type] + url, + json=dict(payload), + ) + if response is None: + return None + try: + return int(response.status_code) + finally: + try: + response.close() + except Exception as error: # noqa: BLE001 - 释放失败不改变投递结果 + logger.debug(f"释放本地消息入口响应失败:{error}") + + async def async_post( + self, + url: str, + payload: Mapping[str, Any], + *, + timeout: float, + ) -> Optional[int]: + """异步投递消息,关闭响应后返回状态码。""" + response = await AsyncRequestUtils( + timeout=timeout # type: ignore[arg-type] + ).post_res( + url, + json=dict(payload), + ) + if response is None: + return None + try: + return int(response.status_code) + finally: + try: + await response.aclose() + except Exception as error: # noqa: BLE001 - 释放失败不改变投递结果 + logger.debug(f"释放本地消息入口响应失败:{error}") + + +def configure_application_network_ports() -> None: + """装配网络探测、图片读取、内部地址判断和消息回环传输端口。""" + network_test_transport: NetworkTestTransport = _NetworkTestTransportAdapter() + image_transport: ImageTransport = _ImageTransportAdapter() + internal_address: InternalAddressPort = _InternalAddressAdapter() + message_ingress: MessageIngressPort = _MessageIngressAdapter() + configure_network_test_service( + NetworkTestService( + transport=network_test_transport, + settings=_read_network_test_setting, + logger=logger, + ) + ) + configure_image_ports( + transport=image_transport, + internal_address=internal_address, + ) + configure_message_ingress_port(message_ingress) diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index f23bab1b5..d3aadf06e 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -1,13 +1,11 @@ import asyncio import inspect import sys -from collections.abc import Awaitable, Mapping +from collections.abc import Awaitable from functools import partial -from typing import Any, Callable, Optional, cast +from typing import Any, Callable, cast from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper -from app.adapters.network.http import AsyncRequestUtils, RequestUtils -from app.adapters.network.ip import IpUtils from app.chain.mediaserver import MediaServerChain from app.chain.tmdb import TmdbChain @@ -47,16 +45,9 @@ from app.application.configuration import ( TransferRetryConfig, configure_transfer_retry_config, get_configured_system_config, - get_runtime_settings, ) from app.application.history import configure_transfer_history_repository -from app.application.image import ( - ImageResponsePort, - ImageTransport, - InternalAddressPort, - configure_image_ports, - configure_wallpaper_providers, -) +from app.application.image import configure_wallpaper_providers from app.application.messaging.agent import ( dispatch_web_agent_message_event, shutdown_web_agent_background_tasks, @@ -69,22 +60,12 @@ from app.application.messaging.chat import ( configure_agent_chat_service, get_configured_agent_chat_persistence, ) -from app.application.messaging.ingress import ( - MessageIngressPort, - configure_message_ingress_port, -) from app.application.messaging.message import ( MessageHelper, MessageQueueManager, stop_message, ) from app.application.module import configure_module_runtime -from app.application.network import ( - NetworkTestResponse, - NetworkTestService, - NetworkTestTransport, - configure_network_test_service, -) from app.application.outbox import ( ClaimedOutboxMessage, OutboxDispatcher, @@ -203,6 +184,7 @@ from app.startup.composition.database import ( start_database_runtime, stop_database_runtime, ) +from app.startup.composition.network import configure_application_network_ports from app.startup.composition.security import ( configure_security_access, configure_security_services, @@ -470,138 +452,6 @@ def configure_wallpaper_services() -> None: ) -class _NetworkTestTransportAdapter: - """把通用异步 HTTP Adapter 收窄为网络探测 GET 端口。""" - - async def get( - self, - url: str, - *, - proxy: Any = None, - headers: Optional[Mapping[str, str]] = None, - user_agent: Optional[str] = None, - ) -> Optional[NetworkTestResponse]: - """使用固定超时、证书校验和手动重定向策略请求目标。""" - response = await AsyncRequestUtils( - proxies=proxy, - headers=dict(headers) if headers else None, - timeout=10, - ua=user_agent or "", - verify=True, - follow_redirects=False, - ).get_res(url, allow_redirects=False) - return cast(Optional[NetworkTestResponse], response) - - -def _read_network_test_setting(key: str, default: Any = None) -> Any: - """延迟读取已由组合根发布的部署设置,避免应用服务持有全局对象。""" - return get_runtime_settings().get(key, default) - - -class _ImageTransportAdapter: - """把通用 HTTP Adapter 收窄为图片应用服务的 GET 端口。""" - - def get( - self, - url: str, - *, - options: Mapping[str, Any], - ) -> Optional[ImageResponsePort]: - """同步创建短生命周期请求对象并返回响应。""" - response = RequestUtils(**dict(options)).get_res(url=url) - return cast(Optional[ImageResponsePort], response) - - async def async_get( - self, - url: str, - *, - options: Mapping[str, Any], - ) -> Optional[ImageResponsePort]: - """异步创建短生命周期请求对象并返回响应。""" - response = await AsyncRequestUtils(**dict(options)).get_res(url=url) - return cast(Optional[ImageResponsePort], response) - - -class _InternalAddressAdapter: - """把通用地址判断收窄为图片代理决策端口。""" - - @staticmethod - def is_internal(url: str) -> bool: - """返回 URL 是否指向内部地址。""" - probe = cast(Callable[[str], bool], IpUtils.is_internal) - return bool(probe(url)) - - -class _MessageIngressAdapter: - """通过通用 HTTP Adapter 投递本地消息并负责释放响应。""" - - def post( - self, - url: str, - payload: Mapping[str, Any], - *, - timeout: float, - ) -> Optional[int]: - """同步投递消息,关闭响应后返回状态码。""" - response = RequestUtils(timeout=timeout).post_res( # type: ignore[arg-type] - url, - json=dict(payload), - ) - if response is None: - return None - try: - return int(response.status_code) - finally: - try: - response.close() - except Exception as error: # noqa: BLE001 - 释放失败不改变投递结果 - logger.debug(f"释放本地消息入口响应失败:{error}") - - async def async_post( - self, - url: str, - payload: Mapping[str, Any], - *, - timeout: float, - ) -> Optional[int]: - """异步投递消息,关闭响应后返回状态码。""" - response = await AsyncRequestUtils( - timeout=timeout # type: ignore[arg-type] - ).post_res( - url, - json=dict(payload), - ) - if response is None: - return None - try: - return int(response.status_code) - finally: - try: - await response.aclose() - except Exception as error: # noqa: BLE001 - 释放失败不改变投递结果 - logger.debug(f"释放本地消息入口响应失败:{error}") - - -def configure_application_network_ports() -> None: - """装配网络探测、图片读取、内部地址判断和消息回环传输端口。""" - network_test_transport: NetworkTestTransport = _NetworkTestTransportAdapter() - image_transport: ImageTransport = _ImageTransportAdapter() - internal_address: InternalAddressPort = _InternalAddressAdapter() - message_ingress: MessageIngressPort = _MessageIngressAdapter() - configure_network_test_service( - NetworkTestService( - transport=network_test_transport, - settings=_read_network_test_setting, - logger=logger, - ) - ) - configure_image_ports( - transport=image_transport, - internal_address=internal_address, - ) - configure_message_ingress_port(message_ingress) - - def notify_event_error(title: str, message: str) -> None: """将事件总线错误转发到系统消息通道。""" MessageHelper().put( diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index 39a7a6305..187ef9b1f 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -70,7 +70,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 965 / 8,207 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 966 / 8,216 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 1fa240b64..ca4e7daa1 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -745,8 +745,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 965 | -| 内部导入边 | 8,207 | +| Python 模块 | 966 | +| 内部导入边 | 8,216 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 2464a54e9..cf31103f7 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -143,7 +143,11 @@ plugin persistence and legacy API data-port wiring. Initializers call these owne startup order and must not recreate their concrete construction. `startup/composition/security.py` owns authentication, user lookup, PassKey and Web access provider assembly; `initializers/modules.py` only invokes that owner and places -its returned `AuthenticationRuntime` into the host runtime. Database runtime +its returned `AuthenticationRuntime` into the host runtime. +`startup/composition/network.py` +owns concrete network-test, image, internal-address and message-ingress Adapter wiring +while retaining lazy RuntimeSettings reads. Initializers call these owners in +startup order and must not recreate their concrete construction. Database runtime ownership rejects overlapping lifespans; after a successful worker shutdown, the configuration/database composition owners must revoke every provider they published before releasing database connections. Other domain providers remain owned by their diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 32fd91bc7..5964731ba 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1089,8 +1089,8 @@ "runtime_only": true } }, - "edge_count": 8207, - "edge_sha256": "726a95dd9ad2caadc87bbed1395f56e01f0b323217e466380112326fe994e04d", + "edge_count": 8216, + "edge_sha256": "4a7246f19b3fc9ec6e71558d2da17b186c5feb273a44e3160206b3517754e8eb", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -8702,6 +8702,18 @@ "app.startup.composition.database -> app.db.worker", "app.startup.composition.database -> app.runtime", "app.startup.composition.database -> app.runtime.settings", + "app.startup.composition.network -> app.adapters", + "app.startup.composition.network -> app.adapters.network", + "app.startup.composition.network -> app.adapters.network.http", + "app.startup.composition.network -> app.adapters.network.ip", + "app.startup.composition.network -> app.application", + "app.startup.composition.network -> app.application.configuration", + "app.startup.composition.network -> app.application.image", + "app.startup.composition.network -> app.application.messaging", + "app.startup.composition.network -> app.application.messaging.ingress", + "app.startup.composition.network -> app.application.network", + "app.startup.composition.network -> app.runtime", + "app.startup.composition.network -> app.runtime.log", "app.startup.composition.subscription -> app.adapters", "app.startup.composition.subscription -> app.adapters.external", "app.startup.composition.subscription -> app.adapters.external.server", @@ -8824,8 +8836,6 @@ "app.startup.initializers.modules -> app.adapters.network", "app.startup.initializers.modules -> app.adapters.network.browser", "app.startup.initializers.modules -> app.adapters.network.doh", - "app.startup.initializers.modules -> app.adapters.network.http", - "app.startup.initializers.modules -> app.adapters.network.ip", "app.startup.initializers.modules -> app.adapters.system", "app.startup.initializers.modules -> app.adapters.system.host", "app.startup.initializers.modules -> app.adapters.system.resource", @@ -8847,10 +8857,8 @@ "app.startup.initializers.modules -> app.application.messaging", "app.startup.initializers.modules -> app.application.messaging.agent", "app.startup.initializers.modules -> app.application.messaging.chat", - "app.startup.initializers.modules -> app.application.messaging.ingress", "app.startup.initializers.modules -> app.application.messaging.message", "app.startup.initializers.modules -> app.application.module", - "app.startup.initializers.modules -> app.application.network", "app.startup.initializers.modules -> app.application.outbox", "app.startup.initializers.modules -> app.application.security", "app.startup.initializers.modules -> app.application.security.auth", @@ -8934,6 +8942,7 @@ "app.startup.initializers.modules -> app.startup.composition.configuration", "app.startup.initializers.modules -> app.startup.composition.context", "app.startup.initializers.modules -> app.startup.composition.database", + "app.startup.initializers.modules -> app.startup.composition.network", "app.startup.initializers.modules -> app.startup.composition.subscription", "app.startup.initializers.modules -> app.startup.initializers", "app.startup.initializers.modules -> app.startup.initializers.agent", @@ -9300,7 +9309,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 965, + "module_count": 966, "modules": [ "app", "app.adapters", @@ -10226,6 +10235,7 @@ "app.startup.composition.configuration", "app.startup.composition.context", "app.startup.composition.database", + "app.startup.composition.network", "app.startup.composition.subscription", "app.startup.initializers", "app.startup.initializers.agent", diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index c328d876b..c29f14e88 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -799,6 +799,29 @@ def test_modules_initializer_delegates_configuration_and_database_composition(): assert "configure_api_data_runtime" in database_source +def test_modules_initializer_delegates_network_composition(): + """模块初始化器只调用网络组合 API,不再内联具体 Adapter 组装。""" + initializer_source = ( + APP_ROOT / "startup" / "initializers" / "modules.py" + ).read_text(encoding="utf-8") + network_source = ( + APP_ROOT / "startup" / "composition" / "network.py" + ).read_text(encoding="utf-8") + + assert "configure_application_network_ports()" in initializer_source + for detail in ( + "_NetworkTestTransportAdapter", + "_ImageTransportAdapter", + "_InternalAddressAdapter", + "_MessageIngressAdapter", + "NetworkTestService(", + "configure_image_ports(", + "configure_message_ingress_port(", + ): + assert detail not in initializer_source + assert detail in network_source + + def test_canonical_workflow_oper_has_no_legacy_writer_or_duplicate_exports(): """工作流旧写入口只能存在于 SDK Legacy facade。""" oper_path = APP_ROOT / "db" / "oper" / "workflow.py" diff --git a/tests/test_configuration_initializer.py b/tests/test_configuration_initializer.py index 9aca505e7..5e42fc797 100644 --- a/tests/test_configuration_initializer.py +++ b/tests/test_configuration_initializer.py @@ -48,49 +48,6 @@ class _MutableSettings: return True, "" -@pytest.mark.asyncio -async def test_network_test_transport_enforces_safe_http_options(monkeypatch) -> None: - """组合根适配器必须固定证书校验、超时和手动重定向策略。""" - captured = {} - response = SimpleNamespace(status_code=200, headers={}, text="ok") - - class _RequestUtils: - """捕获组合根网络探测适配器使用的 HTTP 参数。""" - - def __init__(self, **kwargs) -> None: - """记录通用请求工具的构造参数。""" - captured["options"] = kwargs - - async def get_res(self, url, allow_redirects=True): - """记录请求地址和重定向开关并返回固定响应。""" - captured["url"] = url - captured["allow_redirects"] = allow_redirects - return response - - monkeypatch.setattr(modules_initializer, "AsyncRequestUtils", _RequestUtils) - - result = await modules_initializer._NetworkTestTransportAdapter().get( - "https://example.com/health", - proxy="http://proxy.example:7890", - headers={"Authorization": "Bearer test"}, - user_agent="MoviePilot-Test", - ) - - assert result is response - assert captured == { - "url": "https://example.com/health", - "allow_redirects": False, - "options": { - "proxies": "http://proxy.example:7890", - "headers": {"Authorization": "Bearer test"}, - "timeout": 10, - "ua": "MoviePilot-Test", - "verify": True, - "follow_redirects": False, - }, - } - - @pytest.mark.asyncio async def test_runtime_settings_service_uses_legacy_settings_from_startup_root( monkeypatch, diff --git a/tests/test_message_ingress.py b/tests/test_message_ingress.py index 612fe8af1..48b4cece0 100644 --- a/tests/test_message_ingress.py +++ b/tests/test_message_ingress.py @@ -125,18 +125,18 @@ async def test_async_forward_message_to_host_uses_same_contract(monkeypatch): def test_startup_message_ingress_adapter_closes_sync_response(monkeypatch): """生产同步注入适配层必须负责关闭 HTTP 响应。""" - from app.startup.initializers import modules as module_initializer + from app.startup.composition import network as network_composition response = SimpleNamespace(status_code=202, close=MagicMock()) request = MagicMock() request.post_res.return_value = response monkeypatch.setattr( - module_initializer, + network_composition, "RequestUtils", MagicMock(return_value=request), ) - status_code = module_initializer._MessageIngressAdapter().post( + status_code = network_composition._MessageIngressAdapter().post( "http://127.0.0.1/message", {"text": "hello"}, timeout=9, @@ -149,18 +149,18 @@ def test_startup_message_ingress_adapter_closes_sync_response(monkeypatch): @pytest.mark.asyncio async def test_startup_message_ingress_adapter_closes_async_response(monkeypatch): """生产异步注入适配层必须负责关闭 HTTP 响应。""" - from app.startup.initializers import modules as module_initializer + from app.startup.composition import network as network_composition response = SimpleNamespace(status_code=202, aclose=AsyncMock()) request = MagicMock() request.post_res = AsyncMock(return_value=response) monkeypatch.setattr( - module_initializer, + network_composition, "AsyncRequestUtils", MagicMock(return_value=request), ) - status_code = await module_initializer._MessageIngressAdapter().async_post( + status_code = await network_composition._MessageIngressAdapter().async_post( "http://127.0.0.1/message", {"text": "hello"}, timeout=10, diff --git a/tests/test_network_composition.py b/tests/test_network_composition.py new file mode 100644 index 000000000..05ac58df0 --- /dev/null +++ b/tests/test_network_composition.py @@ -0,0 +1,148 @@ +"""网络应用端口组合根契约测试。""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.startup.composition import network as network_composition + + +@pytest.mark.asyncio +async def test_network_test_transport_enforces_safe_http_options(monkeypatch) -> None: + """网络探测适配器固定证书校验、超时和手动重定向策略。""" + captured = {} + response = SimpleNamespace(status_code=200, headers={}, text="ok") + + class _RequestUtils: + """捕获网络探测适配器使用的 HTTP 参数。""" + + def __init__(self, **kwargs) -> None: + """记录通用请求工具的构造参数。""" + captured["options"] = kwargs + + async def get_res(self, url, allow_redirects=True): + """记录请求地址和重定向开关并返回固定响应。""" + captured["url"] = url + captured["allow_redirects"] = allow_redirects + return response + + monkeypatch.setattr(network_composition, "AsyncRequestUtils", _RequestUtils) + + result = await network_composition._NetworkTestTransportAdapter().get( + "https://example.com/health", + proxy="http://proxy.example:7890", + headers={"Authorization": "Bearer test"}, + user_agent="MoviePilot-Test", + ) + + assert result is response + assert captured == { + "url": "https://example.com/health", + "allow_redirects": False, + "options": { + "proxies": "http://proxy.example:7890", + "headers": {"Authorization": "Bearer test"}, + "timeout": 10, + "ua": "MoviePilot-Test", + "verify": True, + "follow_redirects": False, + }, + } + + +def test_image_transport_forwards_sync_request_options(monkeypatch) -> None: + """同步图片适配器完整透传请求参数,并单独传递目标 URL。""" + response = object() + request = MagicMock() + request.get_res.return_value = response + request_factory = MagicMock(return_value=request) + monkeypatch.setattr(network_composition, "RequestUtils", request_factory) + options = { + "timeout": 15, + "proxies": "http://proxy.example:7890", + "headers": {"Accept": "image/*"}, + } + + result = network_composition._ImageTransportAdapter().get( + "https://images.example/poster.jpg", + options=options, + ) + + assert result is response + request_factory.assert_called_once_with(**options) + request.get_res.assert_called_once_with( + url="https://images.example/poster.jpg", + ) + + +@pytest.mark.asyncio +async def test_image_transport_forwards_async_request_options(monkeypatch) -> None: + """异步图片适配器完整透传请求参数,并单独传递目标 URL。""" + response = object() + request = MagicMock() + request.get_res = AsyncMock(return_value=response) + request_factory = MagicMock(return_value=request) + monkeypatch.setattr(network_composition, "AsyncRequestUtils", request_factory) + options = { + "timeout": 20, + "proxies": None, + "verify": True, + } + + result = await network_composition._ImageTransportAdapter().async_get( + "https://images.example/backdrop.webp", + options=options, + ) + + assert result is response + request_factory.assert_called_once_with(**options) + request.get_res.assert_awaited_once_with( + url="https://images.example/backdrop.webp", + ) + + +def test_internal_address_adapter_delegates_to_ip_utility(monkeypatch) -> None: + """内部地址判断必须原样委托通用 IP 工具。""" + probe = MagicMock(return_value=True) + monkeypatch.setattr(network_composition.IpUtils, "is_internal", probe) + + assert network_composition._InternalAddressAdapter().is_internal("http://192.168.1.10/image.jpg") is True + probe.assert_called_once_with("http://192.168.1.10/image.jpg") + + +def test_network_port_composition_does_not_read_settings_early(monkeypatch) -> None: + """端口装配只登记延迟 reader,不得在配置发布前读取 RuntimeSettings。""" + get_runtime_settings = MagicMock(side_effect=AssertionError("装配阶段不得读取 RuntimeSettings")) + configured = {} + monkeypatch.setattr( + network_composition, + "get_runtime_settings", + get_runtime_settings, + ) + monkeypatch.setattr( + network_composition, + "configure_network_test_service", + lambda service: configured.update(network=service), + ) + monkeypatch.setattr( + network_composition, + "configure_image_ports", + lambda **ports: configured.update(image=ports), + ) + monkeypatch.setattr( + network_composition, + "configure_message_ingress_port", + lambda port: configured.update(message=port), + ) + + network_composition.configure_application_network_ports() + + get_runtime_settings.assert_not_called() + assert set(configured) == {"network", "image", "message"} + runtime_settings = MagicMock() + runtime_settings.get.return_value = "configured" + get_runtime_settings.side_effect = None + get_runtime_settings.return_value = runtime_settings + assert configured["network"]._settings("PROXY", None) == "configured" + runtime_settings.get.assert_called_once_with("PROXY", None)