From 769fea643d67fa22b6c1439c98765bfdb94f1833 Mon Sep 17 00:00:00 2001 From: "spyroot@gmail.com" Date: Fri, 24 Jul 2026 10:29:41 +0400 Subject: [PATCH] Serve the Dell corpus through the vendor-faithful mock in dualmode tests Fourteen dualmode suites under the XR8620t corpus hand-rolled a POST callback answering 202 with a DMTF-generic task token, so a confirmed Dell action asserted an id shape Dell never returns. Replace those scaffolds with MockRedfishService, which realizes an Action POST as a JID_ OEM job id, and assert task_id against service.JOB_ID. - convert GET-override seams (monkeypatched discovery, factory bodies, remove-action flags) to overlay writes under both request-path casings - drop the per-file fixture index and JID literal duplicates - tighten the ism-installer Managers-discovery assertion, which compared a lowercased request path against a mixed-case literal --- ...test_dell_bios_device_recovery_dualmode.py | 181 ++++++++++-------- tests/dell_lc/test_dell_lc_export_dualmode.py | 129 ++++++------- .../test_dell_lc_ism_installer_dualmode.py | 131 +++++++------ .../test_dell_lc_log_comment_dualmode.py | 111 +++++------ ...dell_lc_supportassist_schedule_dualmode.py | 131 ++++++------- .../test_dell_license_actions_dualmode.py | 155 +++++++-------- .../licenses/test_license_install_dualmode.py | 139 ++++++-------- .../test_dell_metric_actions_dualmode.py | 122 +++++------- .../test_dell_raid_config_actions_dualmode.py | 150 +++++++-------- .../test_dell_raid_patrol_read_dualmode.py | 159 +++++++-------- .../raid/test_dell_raid_rename_vd_dualmode.py | 154 +++++++-------- tests/raid/test_dell_raid_spare_dualmode.py | 135 ++++++------- .../test_dell_system_lcd_errors_dualmode.py | 176 +++++++++-------- tests/test_telemetry_submit_test_dualmode.py | 108 +++++------ 14 files changed, 907 insertions(+), 1074 deletions(-) diff --git a/tests/bios/test_dell_bios_device_recovery_dualmode.py b/tests/bios/test_dell_bios_device_recovery_dualmode.py index 7833a884..24183fc8 100644 --- a/tests/bios/test_dell_bios_device_recovery_dualmode.py +++ b/tests/bios/test_dell_bios_device_recovery_dualmode.py @@ -1,11 +1,11 @@ """Dual-mode-style coverage for DellBIOSService.DeviceRecovery.""" from __future__ import annotations -import json -from contextlib import contextmanager +import copy from pathlib import Path import pytest +from conftest import MockRedfishService, _build_fixture_index from vendor_corpus import corpus_dir from redfish_ctl.idrac_manager import IDracManager @@ -16,68 +16,73 @@ Path(__file__).parent.parent / "dell_xr8620t_corpus.tar.gz", "10.252.252.209", ) -DELL_INDEX = {path.name.lower(): path for path in DELL_CORPUS.glob("*.json")} SYSTEM = "/redfish/v1/Systems/System.Embedded.1" BIOS_SERVICE = f"{SYSTEM}/Oem/Dell/DellBIOSService" ACTION = "#DellBIOSService.DeviceRecovery" TARGET = f"{BIOS_SERVICE}/Actions/DellBIOSService.DeviceRecovery" -TASK_ID = "JID_000000000001" -def _fixture_for_path(path): - name = "_" + path.strip("/").replace("/", "_") + ".json" - return DELL_INDEX.get(name.lower()) +@pytest.fixture +def dell_bios_mock(): + """Return a manager and mock service backed by the Dell XR8620t corpus. + The vendor-faithful service realizes an Action POST the Dell way: 202 plus + a ``JID_`` OEM job id in the Location header, never a DMTF-generic token. -def _post_requests(requests): - return [request for request in requests if request.method == "POST"] - - -@contextmanager -def _mock_dell_corpus(remove_action=False): + :return: tuple of IDracManager and the recording MockRedfishService. + """ requests_mock = pytest.importorskip("requests_mock") - requests = [] - - def get_cb(request, context): - requests.append(request) - fixture = _fixture_for_path(request.path) - if fixture is None: - context.status_code = 404 - return json.dumps({"error": f"no fixture for {request.path}"}) - body = json.loads(fixture.read_text()) - if remove_action and request.path.lower() == BIOS_SERVICE.lower(): - body["Actions"].pop(ACTION, None) - context.status_code = 200 - return json.dumps(body) - - def post_cb(request, context): - requests.append(request) - context.status_code = 202 - context.headers["Location"] = f"/redfish/v1/TaskService/Tasks/{TASK_ID}" - return "" - + service = MockRedfishService( + DELL_CORPUS, + index=_build_fixture_index(DELL_CORPUS), + ) with requests_mock.Mocker() as mocker: - mocker.get(requests_mock.ANY, text=get_cb) - mocker.post(requests_mock.ANY, text=post_cb) - manager = IDracManager( - idrac_ip="mock-dell", - idrac_username="root", - idrac_password="mock", - insecure=True, - is_debug=False, + mocker.get(requests_mock.ANY, text=service.get_cb) + mocker.patch(requests_mock.ANY, text=service.patch_cb) + mocker.post(requests_mock.ANY, text=service.post_cb) + mocker.delete(requests_mock.ANY, text=service.delete_cb) + service.mocker = mocker + yield ( + IDracManager( + idrac_ip="mock-dell", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ), + service, ) - yield manager, requests -def test_dell_bios_device_recovery_lists_corpus_target(): +def _post_requests(service): + """Return POST requests recorded by the mock Redfish service. + + :param service: the recording MockRedfishService. + :return: list of POST requests. + """ + return [request for request in service.requests if request.method == "POST"] + + +def _overlay_bios_service(service, body): + """Overlay DellBIOSService under both common request casings. + + :param service: the recording MockRedfishService. + :param body: replacement BIOS-service body. + """ + service._overlay[BIOS_SERVICE] = body + service._overlay[BIOS_SERVICE.lower()] = body + + +def test_dell_bios_device_recovery_lists_corpus_target(dell_bios_mock): """The command lists the DellBIOSService target advertised by the corpus.""" - with _mock_dell_corpus() as (manager, requests): - result = manager.sync_invoke( - ApiRequestType.DellBiosDeviceRecovery, - "dell-bios-device-recovery", - list_only=True, - ) + manager, service = dell_bios_mock + + result = manager.sync_invoke( + ApiRequestType.DellBiosDeviceRecovery, + "dell-bios-device-recovery", + list_only=True, + ) assert isinstance(result, CommandResult) assert result.error is None @@ -90,16 +95,17 @@ def test_dell_bios_device_recovery_lists_corpus_target(): "devices": ["BIOS"], } ] - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_bios_device_recovery_previews_by_default(): +def test_dell_bios_device_recovery_previews_by_default(dell_bios_mock): """DeviceRecovery defaults to a destructive-action dry-run.""" - with _mock_dell_corpus() as (manager, requests): - result = manager.sync_invoke( - ApiRequestType.DellBiosDeviceRecovery, - "dell-bios-device-recovery", - ) + manager, service = dell_bios_mock + + result = manager.sync_invoke( + ApiRequestType.DellBiosDeviceRecovery, + "dell-bios-device-recovery", + ) assert result.error is None assert result.data == { @@ -113,22 +119,24 @@ def test_dell_bios_device_recovery_previews_by_default(): "bios_service": BIOS_SERVICE, "device": "BIOS", } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_bios_device_recovery_confirm_posts_device_payload(): - """With --confirm the command POSTs the advertised DeviceRecovery payload.""" - with _mock_dell_corpus() as (manager, requests): - result = manager.sync_invoke( - ApiRequestType.DellBiosDeviceRecovery, - "dell-bios-device-recovery", - confirm=True, - ) +def test_dell_bios_device_recovery_confirm_posts_device_payload(dell_bios_mock): + """--confirm POSTs DeviceRecovery; the Dell lens realizes a ``JID_`` job id.""" + manager, service = dell_bios_mock + + result = manager.sync_invoke( + ApiRequestType.DellBiosDeviceRecovery, + "dell-bios-device-recovery", + confirm=True, + ) - posts = _post_requests(requests) + posts = _post_requests(service) assert result.error is None assert result.data["executed"] is True - assert result.data["task_id"] == TASK_ID + assert result.data["task_id"] == service.JOB_ID + assert service.JOB_ID.startswith("JID_") assert result.data["action"] == ACTION assert result.data["target"] == TARGET assert result.data["level"] == "destructive" @@ -137,15 +145,16 @@ def test_dell_bios_device_recovery_confirm_posts_device_payload(): assert posts[0].json() == {"Device": "BIOS"} -def test_dell_bios_device_recovery_rejects_unadvertised_device(): +def test_dell_bios_device_recovery_rejects_unadvertised_device(dell_bios_mock): """Payload validation rejects Device values outside the service metadata.""" - with _mock_dell_corpus() as (manager, requests): - result = manager.sync_invoke( - ApiRequestType.DellBiosDeviceRecovery, - "dell-bios-device-recovery", - device="BMC", - confirm=True, - ) + manager, service = dell_bios_mock + + result = manager.sync_invoke( + ApiRequestType.DellBiosDeviceRecovery, + "dell-bios-device-recovery", + device="BMC", + confirm=True, + ) assert result.error == ( "invalid value for DellBIOSService.DeviceRecovery Device: BMC; " @@ -154,17 +163,21 @@ def test_dell_bios_device_recovery_rejects_unadvertised_device(): assert result.data["action"] == ACTION assert result.data["target"] == TARGET assert result.data["payload"] == {"Device": "BMC"} - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_bios_device_recovery_missing_action_reports_available(): +def test_dell_bios_device_recovery_missing_action_reports_available(dell_bios_mock): """A service without DeviceRecovery reports the missing action and never POSTs.""" - with _mock_dell_corpus(remove_action=True) as (manager, requests): - result = manager.sync_invoke( - ApiRequestType.DellBiosDeviceRecovery, - "dell-bios-device-recovery", - confirm=True, - ) + manager, service = dell_bios_mock + body = copy.deepcopy(service._state(BIOS_SERVICE)) + body["Actions"].pop(ACTION, None) + _overlay_bios_service(service, body) + + result = manager.sync_invoke( + ApiRequestType.DellBiosDeviceRecovery, + "dell-bios-device-recovery", + confirm=True, + ) assert result.error == ( f"action '{ACTION}' not found on DellBIOSService" @@ -172,4 +185,4 @@ def test_dell_bios_device_recovery_missing_action_reports_available(): assert result.data["action"] == ACTION assert result.data["available"] == [] assert result.data["attempted"] == [BIOS_SERVICE] - assert _post_requests(requests) == [] + assert _post_requests(service) == [] diff --git a/tests/dell_lc/test_dell_lc_export_dualmode.py b/tests/dell_lc/test_dell_lc_export_dualmode.py index 4f2fb4cc..74c782e9 100644 --- a/tests/dell_lc/test_dell_lc_export_dualmode.py +++ b/tests/dell_lc/test_dell_lc_export_dualmode.py @@ -1,9 +1,9 @@ """Dual-mode-style coverage for DellLCService export actions.""" -import json from pathlib import Path import pytest +from conftest import MockRedfishService, _build_fixture_index from vendor_corpus import corpus_dir from redfish_ctl.cmd_exceptions import InvalidArgument @@ -15,67 +15,49 @@ DELL_CORPUS = corpus_dir( Path(__file__).parent.parent / "dell_xr8620t_corpus.tar.gz", "10.252.252.209" ) -DELL_INDEX = {path.name.lower(): path for path in DELL_CORPUS.glob("*.json")} LC_SERVICE = "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/DellLCService" LC_ACTIONS = f"{LC_SERVICE}/Actions/DellLCService" -def _fixture_for_path(path): - """Return the extracted Dell fixture matching a Redfish path. - - :param path: request path from requests-mock. - :return: fixture path, or None when the corpus lacks the resource. - """ - name = "_" + path.strip("/").replace("/", "_") + ".json" - return DELL_INDEX.get(name.lower()) - - @pytest.fixture -def dell_lc_export_manager(): - """Serve the committed Dell corpus over requests-mock. +def dell_lc_export_mock(): + """Return a manager and mock service backed by the Dell XR8620t corpus. - :return: tuple of IDracManager and recorded requests list. + The vendor-faithful service realizes an Action POST the Dell way: 202 plus + a ``JID_`` OEM job id in the Location header, never a DMTF-generic token. + + :return: tuple of IDracManager and the recording MockRedfishService. """ requests_mock = pytest.importorskip("requests_mock") - requests = [] - - def get_cb(request, context): - requests.append(request) - fixture = _fixture_for_path(request.path) - if fixture is None: - context.status_code = 404 - return json.dumps({"error": f"no fixture for {request.path}"}) - context.status_code = 200 - return fixture.read_text() - - def post_cb(request, context): - requests.append(request) - context.status_code = 202 - context.headers["Location"] = "/redfish/v1/TaskService/Tasks/lc-export-1" - return json.dumps({ - "Task": {"@odata.id": "/redfish/v1/TaskService/Tasks/lc-export-1"} - }) - + service = MockRedfishService( + DELL_CORPUS, + index=_build_fixture_index(DELL_CORPUS), + ) with requests_mock.Mocker() as mocker: - mocker.get(requests_mock.ANY, text=get_cb) - mocker.post(requests_mock.ANY, text=post_cb) - manager = IDracManager( - idrac_ip="mock-dell-lc-export", - idrac_username="root", - idrac_password="mock", - insecure=True, - is_debug=False, + mocker.get(requests_mock.ANY, text=service.get_cb) + mocker.patch(requests_mock.ANY, text=service.patch_cb) + mocker.post(requests_mock.ANY, text=service.post_cb) + mocker.delete(requests_mock.ANY, text=service.delete_cb) + service.mocker = mocker + yield ( + IDracManager( + idrac_ip="mock-dell-lc-export", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ), + service, ) - yield manager, requests -def _post_requests(requests): - """Return POST requests recorded by the mock Redfish transport. +def _post_requests(service): + """Return POST requests recorded by the mock Redfish service. - :param requests: recorded requests-mock request objects. + :param service: the recording MockRedfishService. :return: list of POST requests. """ - return [request for request in requests if request.method == "POST"] + return [request for request in service.requests if request.method == "POST"] def _export_rows(result): @@ -88,10 +70,10 @@ def _export_rows(result): def test_dell_lc_export_lists_corpus_targets_without_mutating( - dell_lc_export_manager, + dell_lc_export_mock, ): """No export choice lists corpus-advertised LC export actions and never POSTs.""" - manager, requests = dell_lc_export_manager + manager, service = dell_lc_export_mock result = manager.sync_invoke(ApiRequestType.DellLcExport, "dell-lc-export") @@ -113,14 +95,14 @@ def test_dell_lc_export_lists_corpus_targets_without_mutating( "OSAppDataWithoutPII", "TTYLogs", ] - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_lc_export_without_confirm_previews_payload_only( - dell_lc_export_manager, + dell_lc_export_mock, ): """DellLCService.ExportLCLog resolves the target but does not POST by default.""" - manager, requests = dell_lc_export_manager + manager, service = dell_lc_export_mock result = manager.sync_invoke( ApiRequestType.DellLcExport, @@ -149,14 +131,14 @@ def test_dell_lc_export_without_confirm_previews_payload_only( "IgnoreCertWarning": "On", "ProxySupport": "Off", } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_lc_export_confirm_posts_hw_inventory_payload( - dell_lc_export_manager, + dell_lc_export_mock, ): - """--confirm POSTs the selected export payload to the discovered target.""" - manager, requests = dell_lc_export_manager + """--confirm POSTs the export; the Dell lens realizes a ``JID_`` job id.""" + manager, service = dell_lc_export_mock result = manager.sync_invoke( ApiRequestType.DellLcExport, @@ -170,13 +152,14 @@ def test_dell_lc_export_confirm_posts_hw_inventory_payload( confirm=True, ) - posts = _post_requests(requests) + posts = _post_requests(service) assert isinstance(result, CommandResult) assert result.error is None assert result.data["executed"] is True assert result.data["action"] == "#DellLCService.ExportHWInventory" assert result.data["target"] == f"{LC_ACTIONS}.ExportHWInventory" - assert result.data["task_id"] == "lc-export-1" + assert result.data["task_id"] == service.JOB_ID + assert service.JOB_ID.startswith("JID_") assert len(posts) == 1 assert posts[0].path.lower() == f"{LC_ACTIONS}.ExportHWInventory".lower() assert posts[0].json() == { @@ -188,9 +171,9 @@ def test_dell_lc_export_confirm_posts_hw_inventory_payload( } -def test_dell_lc_export_dry_run_overrides_confirm(dell_lc_export_manager): +def test_dell_lc_export_dry_run_overrides_confirm(dell_lc_export_mock): """--dry_run remains a no-POST preview even when --confirm is also supplied.""" - manager, requests = dell_lc_export_manager + manager, service = dell_lc_export_mock result = manager.sync_invoke( ApiRequestType.DellLcExport, @@ -206,12 +189,12 @@ def test_dell_lc_export_dry_run_overrides_confirm(dell_lc_export_manager): assert result.data["dry_run"] is True assert result.data["blocked"] is None assert result.data["payload"] == {"ShareType": "Local"} - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_lc_export_rejects_invalid_share_type(dell_lc_export_manager): +def test_dell_lc_export_rejects_invalid_share_type(dell_lc_export_mock): """Inline allowable values reject an unsupported ShareType before POST.""" - manager, requests = dell_lc_export_manager + manager, service = dell_lc_export_mock result = manager.sync_invoke( ApiRequestType.DellLcExport, @@ -233,12 +216,12 @@ def test_dell_lc_export_rejects_invalid_share_type(dell_lc_export_manager): "allowed": ["CIFS", "HTTP", "HTTPS", "Local", "NFS"], } ] - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_lc_export_rejects_invalid_data_selector(dell_lc_export_manager): +def test_dell_lc_export_rejects_invalid_data_selector(dell_lc_export_mock): """Inline allowable values reject unsupported support-report selectors.""" - manager, requests = dell_lc_export_manager + manager, service = dell_lc_export_mock result = manager.sync_invoke( ApiRequestType.DellLcExport, @@ -254,15 +237,15 @@ def test_dell_lc_export_rejects_invalid_data_selector(dell_lc_export_manager): "DataSelectorArrayIn: DebugLogs; allowed: HWData, OSAppData, " "OSAppDataWithoutPII, TTYLogs" ) - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_lc_export_redacts_password_from_env( - dell_lc_export_manager, + dell_lc_export_mock, monkeypatch, ): """Dry-run output does not echo a share password read from env.""" - manager, requests = dell_lc_export_manager + manager, service = dell_lc_export_mock monkeypatch.setenv("LC_EXPORT_PASSWORD", "placeholder-value") result = manager.sync_invoke( @@ -279,12 +262,12 @@ def test_dell_lc_export_redacts_password_from_env( assert result.error is None assert result.data["payload"]["UserName"] == "share-user" assert result.data["payload"]["Password"] == "********" - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_lc_export_rejects_missing_password_env(dell_lc_export_manager): +def test_dell_lc_export_rejects_missing_password_env(dell_lc_export_mock): """Missing password environment variables fail before any POST.""" - manager, requests = dell_lc_export_manager + manager, service = dell_lc_export_mock with pytest.raises( InvalidArgument, @@ -298,7 +281,7 @@ def test_dell_lc_export_rejects_missing_password_env(dell_lc_export_manager): confirm=True, ) - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_lc_export_legacy_fixture_reports_missing_export(redfish_api): diff --git a/tests/dell_lc/test_dell_lc_ism_installer_dualmode.py b/tests/dell_lc/test_dell_lc_ism_installer_dualmode.py index 21c48e90..9e8e9fc9 100644 --- a/tests/dell_lc/test_dell_lc_ism_installer_dualmode.py +++ b/tests/dell_lc/test_dell_lc_ism_installer_dualmode.py @@ -1,9 +1,10 @@ """Dual-mode-style coverage for DellLCService.ExposeiSMInstallerToHostOS.""" -import json +import copy from pathlib import Path import pytest +from conftest import MockRedfishService, _build_fixture_index from vendor_corpus import corpus_dir from redfish_ctl.idrac_manager import IDracManager @@ -13,67 +14,66 @@ DELL_CORPUS = corpus_dir( Path(__file__).parent.parent / "dell_xr8620t_corpus.tar.gz", "10.252.252.209" ) -DELL_INDEX = {path.name.lower(): path for path in DELL_CORPUS.glob("*.json")} LC_SERVICE = "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/DellLCService" ISM_ACTION = "#DellLCService.ExposeiSMInstallerToHostOS" ISM_TARGET = f"{LC_SERVICE}/Actions/DellLCService.ExposeiSMInstallerToHostOS" -def _fixture_for_path(path): - """Return the extracted Dell fixture matching a Redfish path.""" - name = "_" + path.strip("/").replace("/", "_") + ".json" - return DELL_INDEX.get(name.lower()) +@pytest.fixture +def dell_lc_mock(): + """Return a manager and mock service backed by the Dell XR8620t corpus. + The vendor-faithful service realizes an Action POST the Dell way: 202 plus + a ``JID_`` OEM job id in the Location header, never a DMTF-generic token. -@pytest.fixture -def dell_lc_manager(): - """Serve the committed Dell corpus over requests-mock.""" + :return: tuple of IDracManager and the recording MockRedfishService. + """ requests_mock = pytest.importorskip("requests_mock") - requests = [] - remove_action = {"enabled": False} - - def get_cb(request, context): - requests.append(request) - fixture = _fixture_for_path(request.path) - if fixture is None: - context.status_code = 404 - return json.dumps({"error": f"no fixture for {request.path}"}) - context.status_code = 200 - data = json.loads(fixture.read_text(encoding="utf-8")) - if remove_action["enabled"] and request.path.lower() == LC_SERVICE.lower(): - data = dict(data) - actions = dict(data.get("Actions") or {}) - actions.pop(ISM_ACTION, None) - data["Actions"] = actions - return json.dumps(data) - - def post_cb(request, context): - requests.append(request) - context.status_code = 202 - context.headers["Location"] = "/redfish/v1/TaskService/Tasks/ism-1" - return json.dumps({"Task": {"@odata.id": "/redfish/v1/TaskService/Tasks/ism-1"}}) - + service = MockRedfishService( + DELL_CORPUS, + index=_build_fixture_index(DELL_CORPUS), + ) with requests_mock.Mocker() as mocker: - mocker.get(requests_mock.ANY, text=get_cb) - mocker.post(requests_mock.ANY, text=post_cb) - manager = IDracManager( - idrac_ip="mock-dell-ism", - idrac_username="root", - idrac_password="mock", - insecure=True, - is_debug=False, + mocker.get(requests_mock.ANY, text=service.get_cb) + mocker.patch(requests_mock.ANY, text=service.patch_cb) + mocker.post(requests_mock.ANY, text=service.post_cb) + mocker.delete(requests_mock.ANY, text=service.delete_cb) + service.mocker = mocker + yield ( + IDracManager( + idrac_ip="mock-dell-ism", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ), + service, ) - yield manager, requests, remove_action -def _post_requests(requests): - """Return POST requests recorded by the mock Redfish transport.""" - return [request for request in requests if request.method == "POST"] +def _post_requests(service): + """Return POST requests recorded by the mock Redfish service. + + :param service: the recording MockRedfishService. + :return: list of POST requests. + """ + return [request for request in service.requests if request.method == "POST"] -def test_ism_installer_without_confirm_is_preview_only(dell_lc_manager): +def _overlay_lc_service_without_ism(service): + """Overlay DellLCService with the iSM action removed, under both casings. + + :param service: the recording MockRedfishService. + """ + body = copy.deepcopy(service._state(LC_SERVICE)) + body["Actions"].pop(ISM_ACTION, None) + service._overlay[LC_SERVICE] = body + service._overlay[LC_SERVICE.lower()] = body + + +def test_ism_installer_without_confirm_is_preview_only(dell_lc_mock): """The iSM installer action previews by default and does not POST.""" - manager, requests, _remove_action = dell_lc_manager + manager, service = dell_lc_mock result = manager.sync_invoke( ApiRequestType.DellLcIsmInstaller, @@ -90,12 +90,12 @@ def test_ism_installer_without_confirm_is_preview_only(dell_lc_manager): "level": "destructive", "blocked": "destructive action requires --confirm", } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_ism_installer_confirm_posts_to_discovered_target(dell_lc_manager): - """With --confirm the command POSTs to the target from DellLCService.""" - manager, requests, _remove_action = dell_lc_manager +def test_ism_installer_confirm_posts_to_discovered_target(dell_lc_mock): + """With --confirm the POST fires; the Dell lens realizes a ``JID_`` job id.""" + manager, service = dell_lc_mock result = manager.sync_invoke( ApiRequestType.DellLcIsmInstaller, @@ -103,21 +103,23 @@ def test_ism_installer_confirm_posts_to_discovered_target(dell_lc_manager): confirm=True, ) - posts = _post_requests(requests) + posts = _post_requests(service) assert isinstance(result, CommandResult) assert result.error is None assert result.data["executed"] is True assert result.data["action"] == ISM_ACTION assert result.data["target"] == ISM_TARGET assert result.data["level"] == "destructive" + assert result.data["task_id"] == service.JOB_ID + assert service.JOB_ID.startswith("JID_") assert len(posts) == 1 assert posts[0].path.lower() == ISM_TARGET.lower() assert posts[0].json() == {} -def test_ism_installer_confirm_with_dry_run_still_does_not_post(dell_lc_manager): +def test_ism_installer_confirm_with_dry_run_still_does_not_post(dell_lc_mock): """--dry_run keeps the command in preview mode even with --confirm.""" - manager, requests, _remove_action = dell_lc_manager + manager, service = dell_lc_mock result = manager.sync_invoke( ApiRequestType.DellLcIsmInstaller, @@ -131,12 +133,12 @@ def test_ism_installer_confirm_with_dry_run_still_does_not_post(dell_lc_manager) assert result.data["dry_run"] is True assert result.data["blocked"] is None assert result.data["target"] == ISM_TARGET - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_ism_installer_resource_uri_override_skips_manager_discovery(dell_lc_manager): +def test_ism_installer_resource_uri_override_skips_manager_discovery(dell_lc_mock): """A direct DellLCService URI can be used when Manager discovery is ambiguous.""" - manager, requests, _remove_action = dell_lc_manager + manager, service = dell_lc_mock result = manager.sync_invoke( ApiRequestType.DellLcIsmInstaller, @@ -147,14 +149,17 @@ def test_ism_installer_resource_uri_override_skips_manager_discovery(dell_lc_man assert isinstance(result, CommandResult) assert result.error is None assert result.data["target"] == ISM_TARGET - assert not any(request.path == "/redfish/v1/Managers" for request in requests) - assert _post_requests(requests) == [] + assert not any( + request.path.lower() == "/redfish/v1/managers" + for request in service.requests + ) + assert _post_requests(service) == [] -def test_ism_installer_missing_action_reports_no_post(dell_lc_manager): +def test_ism_installer_missing_action_reports_no_post(dell_lc_mock): """A BMC without the action returns a structured error and never POSTs.""" - manager, requests, remove_action = dell_lc_manager - remove_action["enabled"] = True + manager, service = dell_lc_mock + _overlay_lc_service_without_ism(service) result = manager.sync_invoke( ApiRequestType.DellLcIsmInstaller, @@ -165,4 +170,4 @@ def test_ism_installer_missing_action_reports_no_post(dell_lc_manager): assert isinstance(result, CommandResult) assert result.error == f"action '{ISM_ACTION}' not found on DellLCService" assert result.data == {"action": ISM_ACTION, "available": []} - assert _post_requests(requests) == [] + assert _post_requests(service) == [] diff --git a/tests/dell_lc/test_dell_lc_log_comment_dualmode.py b/tests/dell_lc/test_dell_lc_log_comment_dualmode.py index 584597ff..73495f30 100644 --- a/tests/dell_lc/test_dell_lc_log_comment_dualmode.py +++ b/tests/dell_lc/test_dell_lc_log_comment_dualmode.py @@ -1,8 +1,8 @@ """Dual-mode-style coverage for DellLCService.InsertCommentInLCLog.""" -import json from pathlib import Path import pytest +from conftest import MockRedfishService, _build_fixture_index from vendor_corpus import corpus_dir from redfish_ctl.cmd_exceptions import InvalidArgument @@ -14,73 +14,55 @@ DELL_CORPUS = corpus_dir( Path(__file__).parent.parent / "dell_xr8620t_corpus.tar.gz", "10.252.252.209" ) -DELL_INDEX = {path.name.lower(): path for path in DELL_CORPUS.glob("*.json")} LC_SERVICE = "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/DellLCService" INSERT_TARGET = f"{LC_SERVICE}/Actions/DellLCService.InsertCommentInLCLog" INSERT_ACTION = "#DellLCService.InsertCommentInLCLog" -def _fixture_for_path(path): - """Return the extracted Dell fixture matching a Redfish path. - - :param path: request path from requests-mock. - :return: fixture path, or None when the corpus lacks the resource. - """ - name = "_" + path.strip("/").replace("/", "_") + ".json" - return DELL_INDEX.get(name.lower()) - - @pytest.fixture -def dell_lc_manager(): - """Serve the committed Dell corpus over requests-mock. +def dell_lc_mock(): + """Return a manager and mock service backed by the Dell XR8620t corpus. - :return: tuple of IDracManager and recorded requests list. + The vendor-faithful service realizes an Action POST the Dell way: 202 plus + a ``JID_`` OEM job id in the Location header, never a DMTF-generic token. + + :return: tuple of IDracManager and the recording MockRedfishService. """ requests_mock = pytest.importorskip("requests_mock") - requests = [] - - def get_cb(request, context): - requests.append(request) - fixture = _fixture_for_path(request.path) - if fixture is None: - context.status_code = 404 - return json.dumps({"error": f"no fixture for {request.path}"}) - context.status_code = 200 - return fixture.read_text() - - def post_cb(request, context): - requests.append(request) - context.status_code = 202 - context.headers["Location"] = "/redfish/v1/TaskService/Tasks/lclog-comment-1" - return json.dumps({ - "Task": {"@odata.id": "/redfish/v1/TaskService/Tasks/lclog-comment-1"} - }) - + service = MockRedfishService( + DELL_CORPUS, + index=_build_fixture_index(DELL_CORPUS), + ) with requests_mock.Mocker() as mocker: - mocker.get(requests_mock.ANY, text=get_cb) - mocker.post(requests_mock.ANY, text=post_cb) - manager = IDracManager( - idrac_ip="mock-dell-lc", - idrac_username="root", - idrac_password="mock", - insecure=True, - is_debug=False, + mocker.get(requests_mock.ANY, text=service.get_cb) + mocker.patch(requests_mock.ANY, text=service.patch_cb) + mocker.post(requests_mock.ANY, text=service.post_cb) + mocker.delete(requests_mock.ANY, text=service.delete_cb) + service.mocker = mocker + yield ( + IDracManager( + idrac_ip="mock-dell-lc", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ), + service, ) - yield manager, requests -def _post_requests(requests): - """Return POST requests recorded by the mock Redfish transport. +def _post_requests(service): + """Return POST requests recorded by the mock Redfish service. - :param requests: recorded requests-mock request objects. + :param service: the recording MockRedfishService. :return: list of POST requests. """ - return [request for request in requests if request.method == "POST"] + return [request for request in service.requests if request.method == "POST"] -def test_lc_log_comment_lists_target_without_mutating(dell_lc_manager): +def test_lc_log_comment_lists_target_without_mutating(dell_lc_mock): """Without a comment, the command lists the target and never POSTs.""" - manager, requests = dell_lc_manager + manager, service = dell_lc_mock result = manager.sync_invoke( ApiRequestType.DellLcLogComment, @@ -94,12 +76,12 @@ def test_lc_log_comment_lists_target_without_mutating(dell_lc_manager): "action": INSERT_ACTION, "target": INSERT_TARGET, } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_lc_log_comment_without_confirm_is_preview_only(dell_lc_manager): +def test_lc_log_comment_without_confirm_is_preview_only(dell_lc_mock): """InsertCommentInLCLog resolves the target but does not POST by default.""" - manager, requests = dell_lc_manager + manager, service = dell_lc_mock result = manager.sync_invoke( ApiRequestType.DellLcLogComment, @@ -115,12 +97,12 @@ def test_lc_log_comment_without_confirm_is_preview_only(dell_lc_manager): assert result.data["level"] == "destructive" assert result.data["blocked"] == "destructive action requires --confirm" assert result.data["payload"] == {"Comment": "maintenance note"} - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_lc_log_comment_confirm_posts_payload(dell_lc_manager): - """--confirm POSTs the comment payload to the discovered action target.""" - manager, requests = dell_lc_manager +def test_lc_log_comment_confirm_posts_payload(dell_lc_mock): + """--confirm POSTs the comment; the Dell lens realizes a ``JID_`` job id.""" + manager, service = dell_lc_mock result = manager.sync_invoke( ApiRequestType.DellLcLogComment, @@ -130,14 +112,15 @@ def test_lc_log_comment_confirm_posts_payload(dell_lc_manager): confirm=True, ) - posts = _post_requests(requests) + posts = _post_requests(service) assert isinstance(result, CommandResult) assert result.error is None assert result.data["executed"] is True assert result.data["action"] == INSERT_ACTION assert result.data["target"] == INSERT_TARGET assert result.data["level"] == "destructive" - assert result.data["task_id"] == "lclog-comment-1" + assert result.data["task_id"] == service.JOB_ID + assert service.JOB_ID.startswith("JID_") assert len(posts) == 1 assert posts[0].path.lower() == INSERT_TARGET.lower() assert posts[0].json() == { @@ -146,9 +129,9 @@ def test_lc_log_comment_confirm_posts_payload(dell_lc_manager): } -def test_lc_log_comment_confirm_dry_run_still_does_not_post(dell_lc_manager): +def test_lc_log_comment_confirm_dry_run_still_does_not_post(dell_lc_mock): """--dry_run remains a no-POST preview even when --confirm is also present.""" - manager, requests = dell_lc_manager + manager, service = dell_lc_mock result = manager.sync_invoke( ApiRequestType.DellLcLogComment, @@ -163,12 +146,12 @@ def test_lc_log_comment_confirm_dry_run_still_does_not_post(dell_lc_manager): assert result.data["dry_run"] is True assert result.data["blocked"] is None assert result.data["payload"] == {"Comment": "do not send"} - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_lc_log_comment_rejects_empty_comment(dell_lc_manager): +def test_lc_log_comment_rejects_empty_comment(dell_lc_mock): """A blank comment is rejected before any action POST can fire.""" - manager, requests = dell_lc_manager + manager, service = dell_lc_mock with pytest.raises(InvalidArgument, match="comment cannot be empty"): manager.sync_invoke( @@ -178,7 +161,7 @@ def test_lc_log_comment_rejects_empty_comment(dell_lc_manager): confirm=True, ) - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_lc_log_comment_reports_missing_action_without_post(redfish_mock): diff --git a/tests/dell_lc/test_dell_lc_supportassist_schedule_dualmode.py b/tests/dell_lc/test_dell_lc_supportassist_schedule_dualmode.py index 9f20cc09..f8c1f1a9 100644 --- a/tests/dell_lc/test_dell_lc_supportassist_schedule_dualmode.py +++ b/tests/dell_lc/test_dell_lc_supportassist_schedule_dualmode.py @@ -1,9 +1,9 @@ """Dual-mode-style coverage for DellLCService SupportAssist schedules.""" -import json from pathlib import Path import pytest +from conftest import MockRedfishService, _build_fixture_index from vendor_corpus import corpus_dir from redfish_ctl.actions.action_policy import Destructiveness, classify @@ -17,7 +17,6 @@ DELL_CORPUS = corpus_dir( Path(__file__).parent.parent / "dell_xr8620t_corpus.tar.gz", "10.252.252.209" ) -DELL_INDEX = {path.name.lower(): path for path in DELL_CORPUS.glob("*.json")} DELL_LC_SERVICE = ( "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/DellLCService" ) @@ -31,62 +30,45 @@ ) -def _fixture_for_path(path): - """Return the extracted Dell fixture matching a Redfish path. - - :param path: request path from requests-mock. - :return: fixture path, or None when the corpus lacks the resource. - """ - name = "_" + path.strip("/").replace("/", "_") + ".json" - return DELL_INDEX.get(name.lower()) - - @pytest.fixture -def dell_lc_manager(): - """Serve the committed Dell corpus over requests-mock. +def dell_lc_supportassist_mock(): + """Return a manager and mock service backed by the Dell XR8620t corpus. - :return: tuple of IDracManager and recorded requests list. + The vendor-faithful service realizes an Action POST the Dell way: 202 plus + a ``JID_`` OEM job id in the Location header, never a DMTF-generic token. + + :return: tuple of IDracManager and the recording MockRedfishService. """ requests_mock = pytest.importorskip("requests_mock") - requests = [] - - def get_cb(request, context): - requests.append(request) - fixture = _fixture_for_path(request.path) - if fixture is None: - context.status_code = 404 - return json.dumps({"error": f"no fixture for {request.path}"}) - context.status_code = 200 - return fixture.read_text() - - def post_cb(request, context): - requests.append(request) - context.status_code = 202 - context.headers["Location"] = "/redfish/v1/TaskService/Tasks/supportassist-1" - return json.dumps( - {"Task": {"@odata.id": "/redfish/v1/TaskService/Tasks/supportassist-1"}} - ) - + service = MockRedfishService( + DELL_CORPUS, + index=_build_fixture_index(DELL_CORPUS), + ) with requests_mock.Mocker() as mocker: - mocker.get(requests_mock.ANY, text=get_cb) - mocker.post(requests_mock.ANY, text=post_cb) - manager = IDracManager( - idrac_ip="mock-dell-lc-supportassist", - idrac_username="root", - idrac_password="mock", - insecure=True, - is_debug=False, + mocker.get(requests_mock.ANY, text=service.get_cb) + mocker.patch(requests_mock.ANY, text=service.patch_cb) + mocker.post(requests_mock.ANY, text=service.post_cb) + mocker.delete(requests_mock.ANY, text=service.delete_cb) + service.mocker = mocker + yield ( + IDracManager( + idrac_ip="mock-dell-lc-supportassist", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ), + service, ) - yield manager, requests -def _post_requests(requests): - """Return POST requests recorded by the mock Redfish transport. +def _post_requests(service): + """Return POST requests recorded by the mock Redfish service. - :param requests: recorded requests-mock request objects. + :param service: the recording MockRedfishService. :return: list of POST requests. """ - return [request for request in requests if request.method == "POST"] + return [request for request in service.requests if request.method == "POST"] def test_dell_lc_supportassist_schedule_policy_is_reversible(): @@ -102,10 +84,10 @@ def test_dell_lc_supportassist_schedule_policy_is_reversible(): def test_dell_lc_supportassist_schedule_lists_targets_without_post( - dell_lc_manager, + dell_lc_supportassist_mock, ): """Listing discovers schedule actions and does not POST.""" - manager, requests = dell_lc_manager + manager, service = dell_lc_supportassist_mock result = manager.sync_invoke( ApiRequestType.DellLcSupportAssistSchedule, @@ -129,14 +111,14 @@ def test_dell_lc_supportassist_schedule_lists_targets_without_post( "AllowedRecurrences": ["Monthly", "Quarterly", "Weekly"], }, ] - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_lc_supportassist_schedule_set_previews_by_default( - dell_lc_manager, + dell_lc_supportassist_mock, ): """Setting a recurrence does not POST unless --confirm is present.""" - manager, requests = dell_lc_manager + manager, service = dell_lc_supportassist_mock result = manager.sync_invoke( ApiRequestType.DellLcSupportAssistSchedule, @@ -153,14 +135,14 @@ def test_dell_lc_supportassist_schedule_set_previews_by_default( assert result.data["level"] == "reversible" assert result.data["blocked"] is None assert result.data["payload"] == {"Recurrence": "Weekly"} - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_lc_supportassist_schedule_set_confirm_posts_payload( - dell_lc_manager, + dell_lc_supportassist_mock, ): - """--confirm POSTs the recurrence payload to the set action target.""" - manager, requests = dell_lc_manager + """--confirm POSTs the recurrence; the Dell lens realizes a ``JID_`` job id.""" + manager, service = dell_lc_supportassist_mock result = manager.sync_invoke( ApiRequestType.DellLcSupportAssistSchedule, @@ -170,24 +152,25 @@ def test_dell_lc_supportassist_schedule_set_confirm_posts_payload( confirm=True, ) - posts = _post_requests(requests) + posts = _post_requests(service) assert isinstance(result, CommandResult) assert result.error is None assert result.data["executed"] is True assert result.data["action"] == "#DellLCService.SupportAssistSetAutoCollectSchedule" assert result.data["target"] == SET_TARGET assert result.data["level"] == "reversible" - assert result.data["task_id"] == "supportassist-1" + assert result.data["task_id"] == service.JOB_ID + assert service.JOB_ID.startswith("JID_") assert len(posts) == 1 assert posts[0].path.lower() == SET_TARGET.lower() assert posts[0].json() == {"Recurrence": "Weekly"} def test_dell_lc_supportassist_schedule_clear_previews_by_default( - dell_lc_manager, + dell_lc_supportassist_mock, ): """Clearing the schedule resolves the target but does not POST by default.""" - manager, requests = dell_lc_manager + manager, service = dell_lc_supportassist_mock result = manager.sync_invoke( ApiRequestType.DellLcSupportAssistSchedule, @@ -201,14 +184,14 @@ def test_dell_lc_supportassist_schedule_clear_previews_by_default( assert result.data["action"] == "#DellLCService.SupportAssistClearAutoCollectSchedule" assert result.data["target"] == CLEAR_TARGET assert result.data["payload"] == {} - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_lc_supportassist_schedule_clear_confirm_posts_empty_payload( - dell_lc_manager, + dell_lc_supportassist_mock, ): """--confirm POSTs an empty payload to the clear action target.""" - manager, requests = dell_lc_manager + manager, service = dell_lc_supportassist_mock result = manager.sync_invoke( ApiRequestType.DellLcSupportAssistSchedule, @@ -217,7 +200,7 @@ def test_dell_lc_supportassist_schedule_clear_confirm_posts_empty_payload( confirm=True, ) - posts = _post_requests(requests) + posts = _post_requests(service) assert isinstance(result, CommandResult) assert result.error is None assert result.data["executed"] is True @@ -228,10 +211,10 @@ def test_dell_lc_supportassist_schedule_clear_confirm_posts_empty_payload( def test_dell_lc_supportassist_schedule_dry_run_overrides_confirm( - dell_lc_manager, + dell_lc_supportassist_mock, ): """--dry_run keeps the command preview-only even with --confirm.""" - manager, requests = dell_lc_manager + manager, service = dell_lc_supportassist_mock result = manager.sync_invoke( ApiRequestType.DellLcSupportAssistSchedule, @@ -247,14 +230,14 @@ def test_dell_lc_supportassist_schedule_dry_run_overrides_confirm( assert result.data["dry_run"] is True assert result.data["blocked"] is None assert result.data["payload"] == {"Recurrence": "Monthly"} - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_lc_supportassist_schedule_rejects_invalid_recurrence( - dell_lc_manager, + dell_lc_supportassist_mock, ): """Inline allowable values reject unsupported recurrences before POST.""" - manager, requests = dell_lc_manager + manager, service = dell_lc_supportassist_mock result = manager.sync_invoke( ApiRequestType.DellLcSupportAssistSchedule, @@ -276,14 +259,14 @@ def test_dell_lc_supportassist_schedule_rejects_invalid_recurrence( "allowed": ["Monthly", "Quarterly", "Weekly"], } ] - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_lc_supportassist_schedule_set_requires_recurrence( - dell_lc_manager, + dell_lc_supportassist_mock, ): """The set action fails closed when Recurrence is omitted.""" - manager, requests = dell_lc_manager + manager, service = dell_lc_supportassist_mock result = manager.sync_invoke( ApiRequestType.DellLcSupportAssistSchedule, @@ -298,7 +281,7 @@ def test_dell_lc_supportassist_schedule_set_requires_recurrence( "required": ["Recurrence"], "action": "#DellLCService.SupportAssistSetAutoCollectSchedule", } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_lc_supportassist_schedule_missing_action_does_not_post( @@ -319,7 +302,7 @@ def test_dell_lc_supportassist_schedule_missing_action_does_not_post( "action": "#DellLCService.SupportAssistClearAutoCollectSchedule", "available": [], } - assert _post_requests(service.requests) == [] + assert _post_requests(service) == [] def test_dell_lc_supportassist_schedule_exposes_cli_entrypoint(): diff --git a/tests/licenses/test_dell_license_actions_dualmode.py b/tests/licenses/test_dell_license_actions_dualmode.py index a988bd53..e6252f41 100644 --- a/tests/licenses/test_dell_license_actions_dualmode.py +++ b/tests/licenses/test_dell_license_actions_dualmode.py @@ -1,22 +1,21 @@ """Dual-mode-style coverage for Dell OEM license-management actions.""" -import json +import copy from pathlib import Path import pytest +from conftest import MockRedfishService, _build_fixture_index from vendor_corpus import corpus_dir from redfish_ctl.actions.action_policy import Destructiveness, classify from redfish_ctl.cmd_exceptions import InvalidArgument from redfish_ctl.idrac_manager import IDracManager from redfish_ctl.idrac_shared import ApiRequestType -from redfish_ctl.licenses.cmd_dell_license_actions import DellLicenseActions from redfish_ctl.redfish_manager import CommandResult DELL_CORPUS = corpus_dir( Path(__file__).parent.parent / "dell_xr8620t_corpus.tar.gz", "10.252.252.209" ) -DELL_INDEX = {path.name.lower(): path for path in DELL_CORPUS.glob("*.json")} SERVICE_URI = ( "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/" "DellLicenseManagementService" @@ -31,69 +30,62 @@ IMPORT_TARGET = f"{SERVICE_URI}/Actions/DellLicenseManagementService.ImportLicense" -def _fixture_for_path(path): - """Return the extracted Dell fixture matching a Redfish path. - - :param path: request path from requests-mock. - :return: fixture path, or None when the corpus lacks the resource. - """ - name = "_" + path.strip("/").replace("/", "_") + ".json" - return DELL_INDEX.get(name.lower()) - - @pytest.fixture -def dell_license_action_manager(): - """Serve the committed Dell corpus over requests-mock. +def dell_license_actions_mock(): + """Return a manager and mock service backed by the Dell XR8620t corpus. - :return: tuple of IDracManager and recorded requests list. + The vendor-faithful service realizes an Action POST the Dell way: 202 plus + a ``JID_`` OEM job id in the Location header, never a DMTF-generic token. + + :return: tuple of IDracManager and the recording MockRedfishService. """ requests_mock = pytest.importorskip("requests_mock") - requests = [] - - def get_cb(request, context): - requests.append(request) - fixture = _fixture_for_path(request.path) - if fixture is None: - context.status_code = 404 - return json.dumps({"error": f"no fixture for {request.path}"}) - context.status_code = 200 - return fixture.read_text() - - def post_cb(request, context): - requests.append(request) - context.status_code = 202 - context.headers["Location"] = "/redfish/v1/TaskService/Tasks/license-action-1" - return json.dumps({ - "Task": {"@odata.id": "/redfish/v1/TaskService/Tasks/license-action-1"} - }) - + service = MockRedfishService( + DELL_CORPUS, + index=_build_fixture_index(DELL_CORPUS), + ) with requests_mock.Mocker() as mocker: - mocker.get(requests_mock.ANY, text=get_cb) - mocker.post(requests_mock.ANY, text=post_cb) - manager = IDracManager( - idrac_ip="mock-dell-license-actions", - idrac_username="root", - idrac_password="mock", - insecure=True, - is_debug=False, + mocker.get(requests_mock.ANY, text=service.get_cb) + mocker.patch(requests_mock.ANY, text=service.patch_cb) + mocker.post(requests_mock.ANY, text=service.post_cb) + mocker.delete(requests_mock.ANY, text=service.delete_cb) + service.mocker = mocker + yield ( + IDracManager( + idrac_ip="mock-dell-license-actions", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ), + service, ) - yield manager, requests -def _post_requests(requests): - """Return POST requests recorded by the mock Redfish transport. +def _post_requests(service): + """Return POST requests recorded by the mock Redfish service. - :param requests: recorded requests-mock request objects. + :param service: the recording MockRedfishService. :return: list of POST requests. """ - return [request for request in requests if request.method == "POST"] + return [request for request in service.requests if request.method == "POST"] + + +def _overlay_license_service(service, body): + """Overlay DellLicenseManagementService under both common request casings. + + :param service: the recording MockRedfishService. + :param body: replacement license-management-service body. + """ + service._overlay[SERVICE_URI] = body + service._overlay[SERVICE_URI.lower()] = body def test_dell_license_actions_lists_targets_without_mutating( - dell_license_action_manager, + dell_license_actions_mock, ): """With no selected action, the command lists targets and never POSTs.""" - manager, requests = dell_license_action_manager + manager, service = dell_license_actions_mock result = manager.sync_invoke( ApiRequestType.DellLicenseActions, @@ -129,14 +121,14 @@ def test_dell_license_actions_lists_targets_without_mutating( "import", "import-from-share", } <= supported - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_license_delete_previews_without_confirm( - dell_license_action_manager, + dell_license_actions_mock, ): """DeleteLicense resolves the Dell target but does not POST by default.""" - manager, requests = dell_license_action_manager + manager, service = dell_license_actions_mock result = manager.sync_invoke( ApiRequestType.DellLicenseActions, @@ -157,15 +149,15 @@ def test_dell_license_delete_previews_without_confirm( "EntitlementID": "49195PA", "DeleteOptions": "Force", } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_license_export_to_share_confirm_posts_payload( - dell_license_action_manager, + dell_license_actions_mock, monkeypatch, ): """--confirm POSTs the network-share export payload to the Dell target.""" - manager, requests = dell_license_action_manager + manager, service = dell_license_actions_mock monkeypatch.setenv("LICENSE_SHARE_PASSWORD", "placeholder-value") result = manager.sync_invoke( @@ -181,14 +173,15 @@ def test_dell_license_export_to_share_confirm_posts_payload( confirm=True, ) - posts = _post_requests(requests) + posts = _post_requests(service) assert isinstance(result, CommandResult) assert result.error is None assert result.data["executed"] is True assert result.data["action"] == EXPORT_SHARE_ACTION assert result.data["target"] == EXPORT_SHARE_TARGET assert result.data["level"] == "destructive" - assert result.data["task_id"] == "license-action-1" + assert result.data["task_id"] == service.JOB_ID + assert service.JOB_ID.startswith("JID_") assert len(posts) == 1 assert posts[0].path.lower() == EXPORT_SHARE_TARGET.lower() assert posts[0].json() == { @@ -202,11 +195,11 @@ def test_dell_license_export_to_share_confirm_posts_payload( def test_dell_license_share_password_is_redacted_in_preview( - dell_license_action_manager, + dell_license_actions_mock, monkeypatch, ): """Dry-run output does not echo share or proxy passwords.""" - manager, requests = dell_license_action_manager + manager, service = dell_license_actions_mock monkeypatch.setenv("LICENSE_SHARE_PASSWORD", "placeholder-value") monkeypatch.setenv("LICENSE_PROXY_PASSWORD", "proxy-placeholder") @@ -232,14 +225,14 @@ def test_dell_license_share_password_is_redacted_in_preview( assert result.data["payload"]["Password"] == "********" assert result.data["payload"]["ProxyPassword"] == "********" assert result.data["payload"]["ProxyPort"] == 8080 - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_license_export_rejects_invalid_share_type( - dell_license_action_manager, + dell_license_actions_mock, ): """Inline allowable values reject an unsupported ShareType before POST.""" - manager, requests = dell_license_action_manager + manager, service = dell_license_actions_mock result = manager.sync_invoke( ApiRequestType.DellLicenseActions, @@ -262,14 +255,14 @@ def test_dell_license_export_rejects_invalid_share_type( "allowed": ["CIFS", "HTTP", "HTTPS", "NFS"], } ] - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_license_by_device_requires_device( - dell_license_action_manager, + dell_license_actions_mock, ): """By-device export selectors fail closed without a device identifier.""" - manager, requests = dell_license_action_manager + manager, service = dell_license_actions_mock with pytest.raises(InvalidArgument, match="requires --device"): manager.sync_invoke( @@ -279,15 +272,15 @@ def test_dell_license_by_device_requires_device( confirm=True, ) - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_license_direct_import_reads_file_and_redacts( - dell_license_action_manager, + dell_license_actions_mock, tmp_path, ): """ImportLicense can read local license content without echoing it.""" - manager, requests = dell_license_action_manager + manager, service = dell_license_actions_mock license_file = tmp_path / "license.xml" license_file.write_text("placeholder\n", encoding="utf-8") @@ -307,27 +300,17 @@ def test_dell_license_direct_import_reads_file_and_redacts( "ImportOptions": "Force", "LicenseFile": "********", } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_license_missing_action_reports_available( - dell_license_action_manager, - monkeypatch, + dell_license_actions_mock, ): """A service without DeleteLicense reports available actions and does not POST.""" - manager, requests = dell_license_action_manager - - def service_without_delete(self, do_async): - fixture = _fixture_for_path(SERVICE_URI) - data = json.loads(fixture.read_text()) - data["Actions"].pop(DELETE_ACTION) - return SERVICE_URI, data - - monkeypatch.setattr( - DellLicenseActions, - "_license_management_service", - service_without_delete, - ) + manager, service = dell_license_actions_mock + body = copy.deepcopy(service._state(SERVICE_URI)) + body["Actions"].pop(DELETE_ACTION) + _overlay_license_service(service, body) result = manager.sync_invoke( ApiRequestType.DellLicenseActions, @@ -340,7 +323,7 @@ def service_without_delete(self, do_async): assert result.error == f"action '{DELETE_ACTION}' not found on {SERVICE_URI}" assert DELETE_ACTION not in result.data["available"] assert EXPORT_SHARE_ACTION in result.data["available"] - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_license_actions_policy_and_registry(): diff --git a/tests/licenses/test_license_install_dualmode.py b/tests/licenses/test_license_install_dualmode.py index 2dbe0755..423a9a35 100644 --- a/tests/licenses/test_license_install_dualmode.py +++ b/tests/licenses/test_license_install_dualmode.py @@ -1,9 +1,9 @@ """Dual-mode-style coverage for LicenseService.Install.""" -import json from pathlib import Path import pytest +from conftest import MockRedfishService, _build_fixture_index from vendor_corpus import corpus_dir from redfish_ctl.cmd_exceptions import InvalidArgument @@ -14,70 +14,54 @@ DELL_CORPUS = corpus_dir( Path(__file__).parent.parent / "dell_xr8620t_corpus.tar.gz", "10.252.252.209" ) -DELL_INDEX = {path.name.lower(): path for path in DELL_CORPUS.glob("*.json")} LICENSE_SERVICE = "/redfish/v1/LicenseService" INSTALL_TARGET = f"{LICENSE_SERVICE}/Actions/LicenseService.Install" -def _fixture_for_path(path): - """Return the extracted Dell fixture matching a Redfish path. - - :param path: request path from requests-mock. - :return: fixture path, or None when the corpus lacks the resource. - """ - name = "_" + path.strip("/").replace("/", "_") + ".json" - return DELL_INDEX.get(name.lower()) - - @pytest.fixture -def dell_license_manager(): - """Serve the committed Dell corpus over requests-mock. +def dell_license_install_mock(): + """Return a manager and mock service backed by the Dell XR8620t corpus. - :return: tuple of IDracManager and recorded requests list. + The vendor-faithful service realizes an Action POST the Dell way: 202 plus + a ``JID_`` OEM job id in the Location header, never a DMTF-generic token. + + :return: tuple of IDracManager and the recording MockRedfishService. """ requests_mock = pytest.importorskip("requests_mock") - requests = [] - - def get_cb(request, context): - requests.append(request) - fixture = _fixture_for_path(request.path) - if fixture is None: - context.status_code = 404 - return json.dumps({"error": f"no fixture for {request.path}"}) - context.status_code = 200 - return fixture.read_text() - - def post_cb(request, context): - requests.append(request) - context.status_code = 202 - context.headers["Location"] = "/redfish/v1/TaskService/Tasks/license-1" - return json.dumps({"Task": {"@odata.id": "/redfish/v1/TaskService/Tasks/license-1"}}) - + service = MockRedfishService( + DELL_CORPUS, + index=_build_fixture_index(DELL_CORPUS), + ) with requests_mock.Mocker() as mocker: - mocker.get(requests_mock.ANY, text=get_cb) - mocker.post(requests_mock.ANY, text=post_cb) - manager = IDracManager( - idrac_ip="mock-dell-license", - idrac_username="root", - idrac_password="mock", - insecure=True, - is_debug=False, + mocker.get(requests_mock.ANY, text=service.get_cb) + mocker.patch(requests_mock.ANY, text=service.patch_cb) + mocker.post(requests_mock.ANY, text=service.post_cb) + mocker.delete(requests_mock.ANY, text=service.delete_cb) + service.mocker = mocker + yield ( + IDracManager( + idrac_ip="mock-dell-license", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ), + service, ) - yield manager, requests -def _post_requests(requests): - """Return POST requests recorded by the mock Redfish transport. +def _post_requests(service): + """Return POST requests recorded by the mock Redfish service. - :param requests: recorded requests-mock request objects. + :param service: the recording MockRedfishService. :return: list of POST requests. """ - return [request for request in requests if request.method == "POST"] + return [request for request in service.requests if request.method == "POST"] -def test_license_install_lists_target_without_mutating(dell_license_manager): +def test_license_install_lists_target_without_mutating(dell_license_install_mock): """With no license URI, the command lists the Install target and never POSTs.""" - manager, requests = dell_license_manager + manager, service = dell_license_install_mock result = manager.sync_invoke(ApiRequestType.LicenseInstall, "license-install") @@ -89,12 +73,12 @@ def test_license_install_lists_target_without_mutating(dell_license_manager): "target": INSTALL_TARGET, "transfer_protocols": ["CIFS", "HTTP", "HTTPS", "NFS"], } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_license_install_without_confirm_is_preview_only(dell_license_manager): +def test_license_install_without_confirm_is_preview_only(dell_license_install_mock): """LicenseService.Install resolves the target but does not POST without --confirm.""" - manager, requests = dell_license_manager + manager, service = dell_license_install_mock result = manager.sync_invoke( ApiRequestType.LicenseInstall, @@ -114,12 +98,12 @@ def test_license_install_without_confirm_is_preview_only(dell_license_manager): "LicenseFileURI": "https://repo.example.test/license.xml", "TransferProtocol": "HTTPS", } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_license_install_confirm_posts_payload(dell_license_manager): +def test_license_install_confirm_posts_payload(dell_license_install_mock): """--confirm POSTs the license URI payload to the discovered action target.""" - manager, requests = dell_license_manager + manager, service = dell_license_install_mock result = manager.sync_invoke( ApiRequestType.LicenseInstall, @@ -129,14 +113,15 @@ def test_license_install_confirm_posts_payload(dell_license_manager): confirm=True, ) - posts = _post_requests(requests) + posts = _post_requests(service) assert isinstance(result, CommandResult) assert result.error is None assert result.data["executed"] is True assert result.data["action"] == "#LicenseService.Install" assert result.data["target"] == INSTALL_TARGET assert result.data["level"] == "destructive" - assert result.data["task_id"] == "license-1" + assert result.data["task_id"] == service.JOB_ID + assert service.JOB_ID.startswith("JID_") assert len(posts) == 1 assert posts[0].path.lower() == INSTALL_TARGET.lower() assert posts[0].json() == { @@ -145,9 +130,9 @@ def test_license_install_confirm_posts_payload(dell_license_manager): } -def test_license_install_confirm_dry_run_still_does_not_post(dell_license_manager): +def test_license_install_confirm_dry_run_still_does_not_post(dell_license_install_mock): """--dry_run remains a no-POST preview even when --confirm is also present.""" - manager, requests = dell_license_manager + manager, service = dell_license_install_mock result = manager.sync_invoke( ApiRequestType.LicenseInstall, @@ -163,12 +148,12 @@ def test_license_install_confirm_dry_run_still_does_not_post(dell_license_manage assert result.data["dry_run"] is True assert result.data["blocked"] is None assert result.data["target"] == INSTALL_TARGET - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_license_install_rejects_invalid_transfer_protocol(dell_license_manager): +def test_license_install_rejects_invalid_transfer_protocol(dell_license_install_mock): """Inline allowable values reject an unsupported TransferProtocol before POST.""" - manager, requests = dell_license_manager + manager, service = dell_license_install_mock result = manager.sync_invoke( ApiRequestType.LicenseInstall, @@ -190,12 +175,12 @@ def test_license_install_rejects_invalid_transfer_protocol(dell_license_manager) "allowed": ["CIFS", "HTTP", "HTTPS", "NFS"], } ] - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_license_install_strips_and_omits_empty_optional_fields(dell_license_manager): +def test_license_install_strips_and_omits_empty_optional_fields(dell_license_install_mock): """Optional strings are stripped, and blank values are omitted from payloads.""" - manager, requests = dell_license_manager + manager, service = dell_license_install_mock result = manager.sync_invoke( ApiRequestType.LicenseInstall, @@ -212,15 +197,15 @@ def test_license_install_strips_and_omits_empty_optional_fields(dell_license_man "LicenseFileURI": "https://repo.example.test/license.xml", "TransferProtocol": "HTTPS", } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_license_install_masks_password_from_env_in_dry_run( - dell_license_manager, + dell_license_install_mock, monkeypatch, ): """Dry-run output does not echo a URI credential password read from env.""" - manager, requests = dell_license_manager + manager, service = dell_license_install_mock monkeypatch.setenv("LICENSE_INSTALL_PASSWORD", "placeholder-value") result = manager.sync_invoke( @@ -236,15 +221,15 @@ def test_license_install_masks_password_from_env_in_dry_run( assert result.error is None assert result.data["payload"]["Username"] == "license-reader" assert result.data["payload"]["Password"] == "********" - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_license_install_reads_password_file_and_redacts_dry_run( - dell_license_manager, + dell_license_install_mock, tmp_path, ): """A password file source is supported without echoing the file content.""" - manager, requests = dell_license_manager + manager, service = dell_license_install_mock password_file = tmp_path / "license-password" password_file.write_text("placeholder-value\n", encoding="utf-8") @@ -259,12 +244,12 @@ def test_license_install_reads_password_file_and_redacts_dry_run( assert isinstance(result, CommandResult) assert result.error is None assert result.data["payload"]["Password"] == "********" - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_license_install_rejects_missing_password_env(dell_license_manager): +def test_license_install_rejects_missing_password_env(dell_license_install_mock): """Missing password environment variables fail before any POST.""" - manager, requests = dell_license_manager + manager, service = dell_license_install_mock with pytest.raises( InvalidArgument, @@ -278,12 +263,12 @@ def test_license_install_rejects_missing_password_env(dell_license_manager): confirm=True, ) - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_license_install_rejects_empty_license_uri(dell_license_manager): +def test_license_install_rejects_empty_license_uri(dell_license_install_mock): """A blank URI is rejected before any action POST can fire.""" - manager, requests = dell_license_manager + manager, service = dell_license_install_mock with pytest.raises(InvalidArgument, match="license file URI cannot be empty"): manager.sync_invoke( @@ -293,7 +278,7 @@ def test_license_install_rejects_empty_license_uri(dell_license_manager): confirm=True, ) - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_license_install_reports_missing_action_without_post(redfish_mock_factory): @@ -314,4 +299,4 @@ def test_license_install_reports_missing_action_without_post(redfish_mock_factor "action": "#LicenseService.Install", "available": [], } - assert _post_requests(service.requests) == [] + assert _post_requests(service) == [] diff --git a/tests/metrics/test_dell_metric_actions_dualmode.py b/tests/metrics/test_dell_metric_actions_dualmode.py index 2e08cb73..abc36505 100644 --- a/tests/metrics/test_dell_metric_actions_dualmode.py +++ b/tests/metrics/test_dell_metric_actions_dualmode.py @@ -1,8 +1,8 @@ """Dual-mode-style coverage for Dell MetricService action commands.""" -import json from pathlib import Path import pytest +from conftest import MockRedfishService, _build_fixture_index from vendor_corpus import corpus_dir from redfish_ctl.cmd_exceptions import InvalidArgument @@ -14,7 +14,6 @@ DELL_CORPUS = corpus_dir( Path(__file__).parent.parent / "dell_xr8620t_corpus.tar.gz", "10.252.252.209" ) -DELL_INDEX = {path.name.lower(): path for path in DELL_CORPUS.glob("*.json")} METRIC_SERVICE = "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellMetricService" CONTROL_ACTION = "#DellMetricService.ControlMetrics" CONTROL_TARGET = f"{METRIC_SERVICE}/Actions/DellMetricService.ControlMetrics" @@ -22,67 +21,50 @@ EXPORT_TARGET = f"{METRIC_SERVICE}/Actions/DellMetricService.ExportThermalHistory" -def _fixture_for_path(path): - """Return the extracted Dell fixture matching a Redfish path. - - :param path: request path from requests-mock. - :return: fixture path, or None when the corpus lacks the resource. - """ - name = "_" + path.strip("/").replace("/", "_") + ".json" - return DELL_INDEX.get(name.lower()) - - @pytest.fixture -def dell_metric_manager(): - """Serve the committed Dell corpus over requests-mock. +def dell_metric_mock(): + """Return a manager and mock service backed by the Dell XR8620t corpus. - :return: tuple of IDracManager and recorded requests list. + The vendor-faithful service realizes an Action POST the Dell way: 202 plus + a ``JID_`` OEM job id in the Location header, never a DMTF-generic token. + + :return: tuple of IDracManager and the recording MockRedfishService. """ requests_mock = pytest.importorskip("requests_mock") - requests = [] - - def get_cb(request, context): - requests.append(request) - fixture = _fixture_for_path(request.path) - if fixture is None: - context.status_code = 404 - return json.dumps({"error": f"no fixture for {request.path}"}) - context.status_code = 200 - return fixture.read_text() - - def post_cb(request, context): - requests.append(request) - context.status_code = 202 - context.headers["Location"] = "/redfish/v1/TaskService/Tasks/dell-metric-1" - return json.dumps({ - "Task": {"@odata.id": "/redfish/v1/TaskService/Tasks/dell-metric-1"} - }) - + service = MockRedfishService( + DELL_CORPUS, + index=_build_fixture_index(DELL_CORPUS), + ) with requests_mock.Mocker() as mocker: - mocker.get(requests_mock.ANY, text=get_cb) - mocker.post(requests_mock.ANY, text=post_cb) - manager = IDracManager( - idrac_ip="mock-dell-metric", - idrac_username="root", - idrac_password="mock", - insecure=True, - is_debug=False, + mocker.get(requests_mock.ANY, text=service.get_cb) + mocker.patch(requests_mock.ANY, text=service.patch_cb) + mocker.post(requests_mock.ANY, text=service.post_cb) + mocker.delete(requests_mock.ANY, text=service.delete_cb) + service.mocker = mocker + yield ( + IDracManager( + idrac_ip="mock-dell-metric", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ), + service, ) - yield manager, requests -def _post_requests(requests): - """Return POST requests recorded by the mock Redfish transport. +def _post_requests(service): + """Return POST requests recorded by the mock Redfish service. - :param requests: recorded requests-mock request objects. + :param service: the recording MockRedfishService. :return: list of POST requests. """ - return [request for request in requests if request.method == "POST"] + return [request for request in service.requests if request.method == "POST"] -def test_dell_metric_actions_lists_corpus_targets(dell_metric_manager): +def test_dell_metric_actions_lists_corpus_targets(dell_metric_mock): """Without an action, Dell MetricService targets are listed without POSTs.""" - manager, requests = dell_metric_manager + manager, service = dell_metric_mock result = manager.sync_invoke( ApiRequestType.DellMetricActions, @@ -101,12 +83,12 @@ def test_dell_metric_actions_lists_corpus_targets(dell_metric_manager): "FileType": ["CSV", "XML"], "ShareType": ["CIFS", "NFS"], } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_control_metrics_defaults_to_dry_run(dell_metric_manager): +def test_control_metrics_defaults_to_dry_run(dell_metric_mock): """ControlMetrics previews the Reset payload by default.""" - manager, requests = dell_metric_manager + manager, service = dell_metric_mock result = manager.sync_invoke( ApiRequestType.DellMetricActions, @@ -122,12 +104,12 @@ def test_control_metrics_defaults_to_dry_run(dell_metric_manager): assert result.data["payload"] == {"MetricCollectionEnabled": "Reset"} assert result.data["level"] == "destructive" assert result.data["blocked"] == "destructive action requires --confirm" - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_control_metrics_confirm_posts_payload(dell_metric_manager): - """ControlMetrics --confirm POSTs the advertised Reset payload.""" - manager, requests = dell_metric_manager +def test_control_metrics_confirm_posts_payload(dell_metric_mock): + """ControlMetrics --confirm POSTs; the Dell lens realizes a ``JID_`` job id.""" + manager, service = dell_metric_mock result = manager.sync_invoke( ApiRequestType.DellMetricActions, @@ -136,23 +118,25 @@ def test_control_metrics_confirm_posts_payload(dell_metric_manager): confirm=True, ) - posts = _post_requests(requests) + posts = _post_requests(service) assert isinstance(result, CommandResult) assert result.error is None assert result.data["executed"] is True assert result.data["action"] == CONTROL_ACTION assert result.data["target"] == CONTROL_TARGET + assert result.data["task_id"] == service.JOB_ID + assert service.JOB_ID.startswith("JID_") assert len(posts) == 1 assert posts[0].path.lower() == CONTROL_TARGET.lower() assert posts[0].json() == {"MetricCollectionEnabled": "Reset"} def test_export_thermal_history_preview_masks_share_password( - dell_metric_manager, + dell_metric_mock, monkeypatch, ): """ExportThermalHistory dry-run masks the share password and avoids POST.""" - manager, requests = dell_metric_manager + manager, service = dell_metric_mock monkeypatch.setenv("THERMAL_SHARE_PASSWORD", "secret-value") result = manager.sync_invoke( @@ -180,15 +164,15 @@ def test_export_thermal_history_preview_masks_share_password( "UserName": "exporter", "Password": "********", } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_export_thermal_history_confirm_posts_unmasked_password( - dell_metric_manager, + dell_metric_mock, monkeypatch, ): """ExportThermalHistory --confirm uses the real password only in the POST.""" - manager, requests = dell_metric_manager + manager, service = dell_metric_mock monkeypatch.setenv("THERMAL_SHARE_PASSWORD", "secret-value") result = manager.sync_invoke( @@ -201,7 +185,7 @@ def test_export_thermal_history_confirm_posts_unmasked_password( confirm=True, ) - posts = _post_requests(requests) + posts = _post_requests(service) assert isinstance(result, CommandResult) assert result.error is None assert result.data["executed"] is True @@ -212,9 +196,9 @@ def test_export_thermal_history_confirm_posts_unmasked_password( assert posts[0].json()["Password"] == "secret-value" -def test_export_thermal_history_requires_share_target(dell_metric_manager): +def test_export_thermal_history_requires_share_target(dell_metric_mock): """ExportThermalHistory rejects missing share fields before any POST.""" - manager, requests = dell_metric_manager + manager, service = dell_metric_mock with pytest.raises(InvalidArgument, match="IPAddress, ShareName"): manager.sync_invoke( @@ -224,14 +208,14 @@ def test_export_thermal_history_requires_share_target(dell_metric_manager): confirm=True, ) - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_export_thermal_history_validates_inline_allowable_values( - dell_metric_manager, + dell_metric_mock, ): """ExportThermalHistory rejects FileType values outside the action metadata.""" - manager, requests = dell_metric_manager + manager, service = dell_metric_mock result = manager.sync_invoke( ApiRequestType.DellMetricActions, @@ -252,7 +236,7 @@ def test_export_thermal_history_validates_inline_allowable_values( "invalid value for DellMetricService.ExportThermalHistory FileType: " "TXT; allowed: CSV, XML" ) - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_metric_actions_is_registered(): diff --git a/tests/raid/test_dell_raid_config_actions_dualmode.py b/tests/raid/test_dell_raid_config_actions_dualmode.py index 760fa25c..808b1823 100644 --- a/tests/raid/test_dell_raid_config_actions_dualmode.py +++ b/tests/raid/test_dell_raid_config_actions_dualmode.py @@ -1,9 +1,10 @@ """Dual-mode-style coverage for DellRaidService configuration actions.""" -import json +import copy from pathlib import Path import pytest +from conftest import MockRedfishService, _build_fixture_index from vendor_corpus import corpus_dir from redfish_ctl.actions.action_policy import Destructiveness, classify @@ -16,85 +17,67 @@ DELL_CORPUS = corpus_dir( Path(__file__).parent.parent / "dell_xr8620t_corpus.tar.gz", "10.252.252.209" ) -DELL_INDEX = {path.name.lower(): path for path in DELL_CORPUS.glob("*.json")} RAID_SERVICE = "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellRaidService" BOOT_TARGET = f"{RAID_SERVICE}/Actions/DellRaidService.SetBootVD" ASSET_TARGET = f"{RAID_SERVICE}/Actions/DellRaidService.SetAssetName" -def _fixture_for_path(path): - """Return the extracted Dell fixture matching a Redfish path.""" - name = "_" + path.strip("/").replace("/", "_") + ".json" - return DELL_INDEX.get(name.lower()) - - -def _corpus_body(path): - """Return one Dell corpus fixture body as JSON.""" - fixture = _fixture_for_path(path) - if fixture is None: - raise AssertionError(f"missing Dell fixture for {path}") - return json.loads(fixture.read_text()) +@pytest.fixture +def dell_raid_mock(): + """Return a manager and mock service backed by the Dell XR8620t corpus. + The vendor-faithful service realizes an Action POST the Dell way: 202 plus + a ``JID_`` OEM job id in the Location header, never a DMTF-generic token. -@pytest.fixture -def dell_raid_manager_factory(): - """Serve the committed Dell corpus over requests-mock.""" + :return: tuple of IDracManager and the recording MockRedfishService. + """ requests_mock = pytest.importorskip("requests_mock") - started = [] - - def factory(service_body=None): - requests = [] - - def get_cb(request, context): - requests.append(request) - if request.path.lower() == RAID_SERVICE.lower() and service_body is not None: - context.status_code = 200 - return json.dumps(service_body) - fixture = _fixture_for_path(request.path) - if fixture is None: - context.status_code = 404 - return json.dumps({"error": f"no fixture for {request.path}"}) - context.status_code = 200 - return fixture.read_text() - - def post_cb(request, context): - requests.append(request) - context.status_code = 202 - context.headers["Location"] = "/redfish/v1/TaskService/Tasks/raid-config-1" - return json.dumps({ - "Task": {"@odata.id": "/redfish/v1/TaskService/Tasks/raid-config-1"} - }) - - mocker = requests_mock.Mocker() - mocker.start() - started.append(mocker) - mocker.get(requests_mock.ANY, text=get_cb) - mocker.post(requests_mock.ANY, text=post_cb) - manager = IDracManager( - idrac_ip="mock-dell-raid", - idrac_username="root", - idrac_password="mock", - insecure=True, - is_debug=False, + service = MockRedfishService( + DELL_CORPUS, + index=_build_fixture_index(DELL_CORPUS), + ) + with requests_mock.Mocker() as mocker: + mocker.get(requests_mock.ANY, text=service.get_cb) + mocker.patch(requests_mock.ANY, text=service.patch_cb) + mocker.post(requests_mock.ANY, text=service.post_cb) + mocker.delete(requests_mock.ANY, text=service.delete_cb) + service.mocker = mocker + yield ( + IDracManager( + idrac_ip="mock-dell-raid", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ), + service, ) - return manager, requests - yield factory - for mocker in reversed(started): - mocker.stop() +def _post_requests(service): + """Return POST requests recorded by the mock Redfish service. + + :param service: the recording MockRedfishService. + :return: list of POST requests. + """ + return [request for request in service.requests if request.method == "POST"] + +def _overlay_raid_service(service, body): + """Overlay DellRaidService under both common request casings. -def _post_requests(requests): - """Return POST requests recorded by the mock Redfish transport.""" - return [request for request in requests if request.method == "POST"] + :param service: the recording MockRedfishService. + :param body: replacement RAID-service body. + """ + service._overlay[RAID_SERVICE] = body + service._overlay[RAID_SERVICE.lower()] = body def test_dell_raid_config_actions_list_targets_without_posting( - dell_raid_manager_factory, + dell_raid_mock, ): """With no action, the command lists supported targets and never POSTs.""" - manager, requests = dell_raid_manager_factory() + manager, service = dell_raid_mock result = manager.sync_invoke( ApiRequestType.DellRaidConfigActions, @@ -108,14 +91,14 @@ def test_dell_raid_config_actions_list_targets_without_posting( assert rows["set-boot-vd"]["RequiredPayload"] == ["TargetFQDD"] assert rows["set-asset-name"]["Target"] == ASSET_TARGET assert rows["set-asset-name"]["RequiredPayload"] == ["AssetName"] - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_raid_config_actions_previews_boot_vd_by_default( - dell_raid_manager_factory, + dell_raid_mock, ): """SetBootVD previews by default and does not POST.""" - manager, requests = dell_raid_manager_factory() + manager, service = dell_raid_mock result = manager.sync_invoke( ApiRequestType.DellRaidConfigActions, @@ -133,14 +116,14 @@ def test_dell_raid_config_actions_previews_boot_vd_by_default( "level": "destructive", "blocked": "destructive action requires --confirm", } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_raid_config_actions_confirm_posts_asset_name( - dell_raid_manager_factory, + dell_raid_mock, ): - """--confirm POSTs SetAssetName to the corpus-advertised target.""" - manager, requests = dell_raid_manager_factory() + """--confirm POSTs SetAssetName; the Dell lens realizes a ``JID_`` job id.""" + manager, service = dell_raid_mock result = manager.sync_invoke( ApiRequestType.DellRaidConfigActions, @@ -150,23 +133,24 @@ def test_dell_raid_config_actions_confirm_posts_asset_name( confirm=True, ) - posts = _post_requests(requests) + posts = _post_requests(service) assert result.error is None assert result.data["executed"] is True assert result.data["action"] == "#DellRaidService.SetAssetName" assert result.data["target"] == ASSET_TARGET assert result.data["level"] == "destructive" - assert result.data["task_id"] == "raid-config-1" + assert result.data["task_id"] == service.JOB_ID + assert service.JOB_ID.startswith("JID_") assert len(posts) == 1 assert posts[0].path.lower() == ASSET_TARGET.lower() assert posts[0].json() == {"AssetName": "rack-a-drawer-2"} def test_dell_raid_config_actions_dry_run_overrides_confirm( - dell_raid_manager_factory, + dell_raid_mock, ): """--dry_run remains a no-POST preview even when --confirm is also present.""" - manager, requests = dell_raid_manager_factory() + manager, service = dell_raid_mock result = manager.sync_invoke( ApiRequestType.DellRaidConfigActions, @@ -181,14 +165,14 @@ def test_dell_raid_config_actions_dry_run_overrides_confirm( assert result.data["dry_run"] is True assert result.data["blocked"] is None assert result.data["target"] == BOOT_TARGET - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_raid_config_actions_missing_payload_is_rejected( - dell_raid_manager_factory, + dell_raid_mock, ): """The command rejects a selected action before POST when required payload is missing.""" - manager, requests = dell_raid_manager_factory() + manager, service = dell_raid_mock with pytest.raises(InvalidArgument, match="set-boot-vd requires: TargetFQDD"): manager.sync_invoke( @@ -198,17 +182,17 @@ def test_dell_raid_config_actions_missing_payload_is_rejected( confirm=True, ) - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_raid_config_actions_missing_action_reports_available( - dell_raid_manager_factory, + dell_raid_mock, ): """A DellRaidService without SetBootVD reports the missing action.""" - service_body = _corpus_body(RAID_SERVICE) - service_body["Actions"] = dict(service_body["Actions"]) - service_body["Actions"].pop("#DellRaidService.SetBootVD") - manager, requests = dell_raid_manager_factory(service_body=service_body) + manager, service = dell_raid_mock + body = copy.deepcopy(service._state(RAID_SERVICE)) + body["Actions"].pop("#DellRaidService.SetBootVD") + _overlay_raid_service(service, body) result = manager.sync_invoke( ApiRequestType.DellRaidConfigActions, @@ -221,7 +205,7 @@ def test_dell_raid_config_actions_missing_action_reports_available( assert result.error == "Dell RAID configuration action not found: set-boot-vd" assert result.data["action"] == "#DellRaidService.SetBootVD" assert result.data["available"][0]["Action"] == "set-asset-name" - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_raid_config_actions_policy_and_registry(): diff --git a/tests/raid/test_dell_raid_patrol_read_dualmode.py b/tests/raid/test_dell_raid_patrol_read_dualmode.py index a42dcbd8..d3c34119 100644 --- a/tests/raid/test_dell_raid_patrol_read_dualmode.py +++ b/tests/raid/test_dell_raid_patrol_read_dualmode.py @@ -1,9 +1,10 @@ """Dual-mode-style coverage for DellRaidService patrol-read actions.""" -import json +import copy from pathlib import Path import pytest +from conftest import MockRedfishService, _build_fixture_index from vendor_corpus import corpus_dir from redfish_ctl.actions.action_policy import Destructiveness, classify @@ -15,98 +16,65 @@ DELL_CORPUS = corpus_dir( Path(__file__).parent.parent / "dell_xr8620t_corpus.tar.gz", "10.252.252.209" ) -DELL_INDEX = {path.name.lower(): path for path in DELL_CORPUS.glob("*.json")} RAID_SERVICE = "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellRaidService" START_TARGET = f"{RAID_SERVICE}/Actions/DellRaidService.StartPatrolRead" STOP_TARGET = f"{RAID_SERVICE}/Actions/DellRaidService.StopPatrolRead" -def _fixture_for_path(path): - """Return the extracted Dell fixture matching a Redfish path. - - :param path: request path from requests-mock. - :return: fixture path, or None when the corpus lacks the resource. - """ - name = "_" + path.strip("/").replace("/", "_") + ".json" - return DELL_INDEX.get(name.lower()) - - -def _corpus_body(path): - """Return one Dell corpus fixture body as JSON. - - :param path: Redfish resource path to read from the extracted corpus. - :return: parsed fixture payload. - """ - fixture = _fixture_for_path(path) - if fixture is None: - raise AssertionError(f"missing Dell fixture for {path}") - return json.loads(fixture.read_text()) - - @pytest.fixture -def dell_raid_manager_factory(): - """Serve the committed Dell corpus over requests-mock. +def dell_raid_mock(): + """Return a manager and mock service backed by the Dell XR8620t corpus. + + The vendor-faithful service realizes an Action POST the Dell way: 202 plus + a ``JID_`` OEM job id in the Location header, never a DMTF-generic token. - :return: factory producing a manager and recorded requests list. + :return: tuple of IDracManager and the recording MockRedfishService. """ requests_mock = pytest.importorskip("requests_mock") - started = [] - - def factory(service_body=None): - requests = [] - - def get_cb(request, context): - requests.append(request) - if request.path.lower() == RAID_SERVICE.lower() and service_body is not None: - context.status_code = 200 - return json.dumps(service_body) - fixture = _fixture_for_path(request.path) - if fixture is None: - context.status_code = 404 - return json.dumps({"error": f"no fixture for {request.path}"}) - context.status_code = 200 - return fixture.read_text() - - def post_cb(request, context): - requests.append(request) - context.status_code = 202 - context.headers["Location"] = "/redfish/v1/TaskService/Tasks/raid-patrol-1" - return json.dumps({ - "Task": {"@odata.id": "/redfish/v1/TaskService/Tasks/raid-patrol-1"} - }) - - mocker = requests_mock.Mocker() - mocker.start() - started.append(mocker) - mocker.get(requests_mock.ANY, text=get_cb) - mocker.post(requests_mock.ANY, text=post_cb) - manager = IDracManager( - idrac_ip="mock-dell-raid", - idrac_username="root", - idrac_password="mock", - insecure=True, - is_debug=False, + service = MockRedfishService( + DELL_CORPUS, + index=_build_fixture_index(DELL_CORPUS), + ) + with requests_mock.Mocker() as mocker: + mocker.get(requests_mock.ANY, text=service.get_cb) + mocker.patch(requests_mock.ANY, text=service.patch_cb) + mocker.post(requests_mock.ANY, text=service.post_cb) + mocker.delete(requests_mock.ANY, text=service.delete_cb) + service.mocker = mocker + yield ( + IDracManager( + idrac_ip="mock-dell-raid", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ), + service, ) - return manager, requests - yield factory - for mocker in reversed(started): - mocker.stop() +def _post_requests(service): + """Return POST requests recorded by the mock Redfish service. + :param service: the recording MockRedfishService. + :return: list of POST requests. + """ + return [request for request in service.requests if request.method == "POST"] -def _post_requests(requests): - """Return POST requests recorded by the mock Redfish transport. - :param requests: recorded requests-mock request objects. - :return: list of POST requests. +def _overlay_raid_service(service, body): + """Overlay DellRaidService under both common request casings. + + :param service: the recording MockRedfishService. + :param body: replacement RAID-service body. """ - return [request for request in requests if request.method == "POST"] + service._overlay[RAID_SERVICE] = body + service._overlay[RAID_SERVICE.lower()] = body -def test_dell_raid_patrol_read_lists_targets_without_posting(dell_raid_manager_factory): +def test_dell_raid_patrol_read_lists_targets_without_posting(dell_raid_mock): """With no action, the command lists patrol-read targets and never POSTs.""" - manager, requests = dell_raid_manager_factory() + manager, service = dell_raid_mock result = manager.sync_invoke( ApiRequestType.DellRaidPatrolRead, @@ -122,12 +90,12 @@ def test_dell_raid_patrol_read_lists_targets_without_posting(dell_raid_manager_f } assert "#DellRaidService.StartPatrolRead" in result.data["available"] assert "#DellRaidService.StopPatrolRead" in result.data["available"] - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_raid_patrol_read_previews_start_by_default(dell_raid_manager_factory): +def test_dell_raid_patrol_read_previews_start_by_default(dell_raid_mock): """A selected patrol-read action previews by default and does not POST.""" - manager, requests = dell_raid_manager_factory() + manager, service = dell_raid_mock result = manager.sync_invoke( ApiRequestType.DellRaidPatrolRead, @@ -145,12 +113,12 @@ def test_dell_raid_patrol_read_previews_start_by_default(dell_raid_manager_facto "level": "reversible", "blocked": None, } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_raid_patrol_read_confirm_posts_start(dell_raid_manager_factory): - """--confirm POSTs StartPatrolRead to the corpus-advertised target.""" - manager, requests = dell_raid_manager_factory() +def test_dell_raid_patrol_read_confirm_posts_start(dell_raid_mock): + """--confirm POSTs StartPatrolRead; the Dell lens realizes a ``JID_`` job id.""" + manager, service = dell_raid_mock result = manager.sync_invoke( ApiRequestType.DellRaidPatrolRead, @@ -159,22 +127,23 @@ def test_dell_raid_patrol_read_confirm_posts_start(dell_raid_manager_factory): confirm=True, ) - posts = _post_requests(requests) + posts = _post_requests(service) assert isinstance(result, CommandResult) assert result.error is None assert result.data["executed"] is True assert result.data["action"] == "#DellRaidService.StartPatrolRead" assert result.data["target"] == START_TARGET assert result.data["level"] == "reversible" - assert result.data["task_id"] == "raid-patrol-1" + assert result.data["task_id"] == service.JOB_ID + assert service.JOB_ID.startswith("JID_") assert len(posts) == 1 assert posts[0].path.lower() == START_TARGET.lower() assert posts[0].json() == {} -def test_dell_raid_patrol_read_confirm_posts_stop(dell_raid_manager_factory): +def test_dell_raid_patrol_read_confirm_posts_stop(dell_raid_mock): """--confirm POSTs StopPatrolRead to the corpus-advertised target.""" - manager, requests = dell_raid_manager_factory() + manager, service = dell_raid_mock result = manager.sync_invoke( ApiRequestType.DellRaidPatrolRead, @@ -183,7 +152,7 @@ def test_dell_raid_patrol_read_confirm_posts_stop(dell_raid_manager_factory): confirm=True, ) - posts = _post_requests(requests) + posts = _post_requests(service) assert isinstance(result, CommandResult) assert result.error is None assert result.data["executed"] is True @@ -195,9 +164,9 @@ def test_dell_raid_patrol_read_confirm_posts_stop(dell_raid_manager_factory): assert posts[0].json() == {} -def test_dell_raid_patrol_read_dry_run_overrides_confirm(dell_raid_manager_factory): +def test_dell_raid_patrol_read_dry_run_overrides_confirm(dell_raid_mock): """--dry_run remains a no-POST preview even when --confirm is also present.""" - manager, requests = dell_raid_manager_factory() + manager, service = dell_raid_mock result = manager.sync_invoke( ApiRequestType.DellRaidPatrolRead, @@ -212,17 +181,17 @@ def test_dell_raid_patrol_read_dry_run_overrides_confirm(dell_raid_manager_facto assert result.data["dry_run"] is True assert result.data["blocked"] is None assert result.data["target"] == STOP_TARGET - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_raid_patrol_read_missing_action_reports_available( - dell_raid_manager_factory, + dell_raid_mock, ): """A DellRaidService without StopPatrolRead reports the missing action.""" - service_body = _corpus_body(RAID_SERVICE) - service_body["Actions"] = dict(service_body["Actions"]) - service_body["Actions"].pop("#DellRaidService.StopPatrolRead") - manager, requests = dell_raid_manager_factory(service_body=service_body) + manager, service = dell_raid_mock + body = copy.deepcopy(service._state(RAID_SERVICE)) + body["Actions"].pop("#DellRaidService.StopPatrolRead") + _overlay_raid_service(service, body) result = manager.sync_invoke( ApiRequestType.DellRaidPatrolRead, @@ -239,7 +208,7 @@ def test_dell_raid_patrol_read_missing_action_reports_available( "action '#DellRaidService.StopPatrolRead' not found on " + RAID_SERVICE ) - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_raid_patrol_read_policy_and_registry(): diff --git a/tests/raid/test_dell_raid_rename_vd_dualmode.py b/tests/raid/test_dell_raid_rename_vd_dualmode.py index fd221bf3..b929b692 100644 --- a/tests/raid/test_dell_raid_rename_vd_dualmode.py +++ b/tests/raid/test_dell_raid_rename_vd_dualmode.py @@ -1,9 +1,10 @@ """Dual-mode-style coverage for DellRaidService RenameVD.""" -import json +import copy from pathlib import Path import pytest +from conftest import MockRedfishService, _build_fixture_index from vendor_corpus import corpus_dir from redfish_ctl.actions.action_policy import Destructiveness, classify @@ -16,88 +17,66 @@ DELL_CORPUS = corpus_dir( Path(__file__).parent.parent / "dell_xr8620t_corpus.tar.gz", "10.252.252.209" ) -DELL_INDEX = {path.name.lower(): path for path in DELL_CORPUS.glob("*.json")} RAID_SERVICE = "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellRaidService" RENAME_TARGET = f"{RAID_SERVICE}/Actions/DellRaidService.RenameVD" -def _fixture_for_path(path): - """Return the extracted Dell fixture matching a Redfish path.""" - name = "_" + path.strip("/").replace("/", "_") + ".json" - return DELL_INDEX.get(name.lower()) - - -def _corpus_body(path): - """Return one Dell corpus fixture body as JSON.""" - fixture = _fixture_for_path(path) - if fixture is None: - raise AssertionError(f"missing Dell fixture for {path}") - return json.loads(fixture.read_text()) +@pytest.fixture +def dell_raid_mock(): + """Return a manager and mock service backed by the Dell XR8620t corpus. + The vendor-faithful service realizes an Action POST the Dell way: 202 plus + a ``JID_`` OEM job id in the Location header, never a DMTF-generic token. -@pytest.fixture -def dell_raid_manager_factory(): - """Serve the committed Dell corpus over requests-mock.""" + :return: tuple of IDracManager and the recording MockRedfishService. + """ requests_mock = pytest.importorskip("requests_mock") - started = [] - - def factory(service_body=None): - requests = [] - - def get_cb(request, context): - requests.append(request) - is_override = ( - request.path.lower() == RAID_SERVICE.lower() - and service_body is not None - ) - if is_override: - context.status_code = 200 - return json.dumps(service_body) - fixture = _fixture_for_path(request.path) - if fixture is None: - context.status_code = 404 - return json.dumps({"error": f"no fixture for {request.path}"}) - context.status_code = 200 - return fixture.read_text() - - def post_cb(request, context): - requests.append(request) - context.status_code = 202 - context.headers["Location"] = "/redfish/v1/TaskService/Tasks/rename-vd-1" - return json.dumps({ - "Task": {"@odata.id": "/redfish/v1/TaskService/Tasks/rename-vd-1"} - }) - - mocker = requests_mock.Mocker() - mocker.start() - started.append(mocker) - mocker.get(requests_mock.ANY, text=get_cb) - mocker.post(requests_mock.ANY, text=post_cb) - manager = IDracManager( - idrac_ip="mock-dell-raid", - idrac_username="root", - idrac_password="mock", - insecure=True, - is_debug=False, + service = MockRedfishService( + DELL_CORPUS, + index=_build_fixture_index(DELL_CORPUS), + ) + with requests_mock.Mocker() as mocker: + mocker.get(requests_mock.ANY, text=service.get_cb) + mocker.patch(requests_mock.ANY, text=service.patch_cb) + mocker.post(requests_mock.ANY, text=service.post_cb) + mocker.delete(requests_mock.ANY, text=service.delete_cb) + service.mocker = mocker + yield ( + IDracManager( + idrac_ip="mock-dell-raid", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ), + service, ) - return manager, requests - yield factory - for mocker in reversed(started): - mocker.stop() +def _post_requests(service): + """Return POST requests recorded by the mock Redfish service. + + :param service: the recording MockRedfishService. + :return: list of POST requests. + """ + return [request for request in service.requests if request.method == "POST"] + +def _overlay_raid_service(service, body): + """Overlay DellRaidService under both common request casings. -def _post_requests(requests): - """Return POST requests recorded by the mock Redfish transport.""" - return [request for request in requests if request.method == "POST"] + :param service: the recording MockRedfishService. + :param body: replacement RAID-service body. + """ + service._overlay[RAID_SERVICE] = body + service._overlay[RAID_SERVICE.lower()] = body def test_dell_raid_rename_vd_lists_target_without_posting( - dell_raid_manager_factory, + dell_raid_mock, ): """With no payload, the command lists RenameVD and never POSTs.""" - manager, requests = dell_raid_manager_factory() + manager, service = dell_raid_mock result = manager.sync_invoke( ApiRequestType.DellRaidRenameVD, @@ -113,12 +92,12 @@ def test_dell_raid_rename_vd_lists_target_without_posting( "Target": RENAME_TARGET, "RequiredPayload": ["TargetFQDD", "Name"], }] - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_raid_rename_vd_previews_by_default(dell_raid_manager_factory): +def test_dell_raid_rename_vd_previews_by_default(dell_raid_mock): """RenameVD previews by default and does not POST.""" - manager, requests = dell_raid_manager_factory() + manager, service = dell_raid_mock result = manager.sync_invoke( ApiRequestType.DellRaidRenameVD, @@ -136,12 +115,12 @@ def test_dell_raid_rename_vd_previews_by_default(dell_raid_manager_factory): "level": "destructive", "blocked": "destructive action requires --confirm", } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_raid_rename_vd_confirm_posts(dell_raid_manager_factory): - """--confirm POSTs RenameVD to the corpus-advertised target.""" - manager, requests = dell_raid_manager_factory() +def test_dell_raid_rename_vd_confirm_posts(dell_raid_mock): + """--confirm POSTs RenameVD; the Dell lens realizes a ``JID_`` job id.""" + manager, service = dell_raid_mock result = manager.sync_invoke( ApiRequestType.DellRaidRenameVD, @@ -151,23 +130,24 @@ def test_dell_raid_rename_vd_confirm_posts(dell_raid_manager_factory): confirm=True, ) - posts = _post_requests(requests) + posts = _post_requests(service) assert result.error is None assert result.data["executed"] is True assert result.data["action"] == "#DellRaidService.RenameVD" assert result.data["target"] == RENAME_TARGET assert result.data["level"] == "destructive" - assert result.data["task_id"] == "rename-vd-1" + assert result.data["task_id"] == service.JOB_ID + assert service.JOB_ID.startswith("JID_") assert len(posts) == 1 assert posts[0].path.lower() == RENAME_TARGET.lower() assert posts[0].json() == {"TargetFQDD": "Disk.Virtual.0", "Name": "data-vd"} def test_dell_raid_rename_vd_dry_run_overrides_confirm( - dell_raid_manager_factory, + dell_raid_mock, ): """--dry_run remains a no-POST preview even when --confirm is also present.""" - manager, requests = dell_raid_manager_factory() + manager, service = dell_raid_mock result = manager.sync_invoke( ApiRequestType.DellRaidRenameVD, @@ -182,14 +162,14 @@ def test_dell_raid_rename_vd_dry_run_overrides_confirm( assert result.data["dry_run"] is True assert result.data["blocked"] is None assert result.data["target"] == RENAME_TARGET - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_raid_rename_vd_missing_payload_is_rejected( - dell_raid_manager_factory, + dell_raid_mock, ): """The command rejects missing required payload before POST.""" - manager, requests = dell_raid_manager_factory() + manager, service = dell_raid_mock with pytest.raises(InvalidArgument, match="requires: Name"): manager.sync_invoke( @@ -199,17 +179,17 @@ def test_dell_raid_rename_vd_missing_payload_is_rejected( confirm=True, ) - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_raid_rename_vd_missing_action_reports_available( - dell_raid_manager_factory, + dell_raid_mock, ): """A DellRaidService without RenameVD reports the missing action.""" - service_body = _corpus_body(RAID_SERVICE) - service_body["Actions"] = dict(service_body["Actions"]) - service_body["Actions"].pop("#DellRaidService.RenameVD") - manager, requests = dell_raid_manager_factory(service_body=service_body) + manager, service = dell_raid_mock + body = copy.deepcopy(service._state(RAID_SERVICE)) + body["Actions"].pop("#DellRaidService.RenameVD") + _overlay_raid_service(service, body) result = manager.sync_invoke( ApiRequestType.DellRaidRenameVD, @@ -222,7 +202,7 @@ def test_dell_raid_rename_vd_missing_action_reports_available( assert result.error == "Dell RAID RenameVD action not found" assert result.data["action"] == "#DellRaidService.RenameVD" assert "#DellRaidService.SetBootVD" in result.data["available"] - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_raid_rename_vd_policy_and_registry(): diff --git a/tests/raid/test_dell_raid_spare_dualmode.py b/tests/raid/test_dell_raid_spare_dualmode.py index ceb684f7..79eb6173 100644 --- a/tests/raid/test_dell_raid_spare_dualmode.py +++ b/tests/raid/test_dell_raid_spare_dualmode.py @@ -1,9 +1,10 @@ """Dual-mode-style coverage for DellRaidService spare actions.""" -import json +import copy from pathlib import Path import pytest +from conftest import MockRedfishService, _build_fixture_index from vendor_corpus import corpus_dir from redfish_ctl.actions.action_policy import Destructiveness, classify @@ -16,7 +17,6 @@ DELL_CORPUS = corpus_dir( Path(__file__).parent.parent / "dell_xr8620t_corpus.tar.gz", "10.252.252.209" ) -DELL_INDEX = {path.name.lower(): path for path in DELL_CORPUS.glob("*.json")} RAID_SERVICE = "/redfish/v1/Systems/System.Embedded.1/Oem/Dell/DellRaidService" ASSIGN_TARGET = f"{RAID_SERVICE}/Actions/DellRaidService.AssignSpare" UNASSIGN_TARGET = f"{RAID_SERVICE}/Actions/DellRaidService.UnassignSpare" @@ -24,82 +24,62 @@ VD_FQDD = "Disk.Virtual.0:RAID.Integrated.1-1" -def _fixture_for_path(path): - """Return the extracted Dell fixture matching a Redfish path. - - :param path: request path from requests-mock. - :return: fixture path, or None when the corpus lacks the resource. - """ - name = "_" + path.strip("/").replace("/", "_") + ".json" - return DELL_INDEX.get(name.lower()) - - @pytest.fixture -def dell_raid_spare_manager(): - """Serve the committed Dell corpus over requests-mock. +def dell_raid_spare_mock(): + """Return a manager and mock service backed by the Dell XR8620t corpus. - :return: tuple of IDracManager, recorded requests, and response overlay. + The vendor-faithful service realizes an Action POST the Dell way: 202 plus + a ``JID_`` OEM job id in the Location header, never a DMTF-generic token. + + :return: tuple of IDracManager and the recording MockRedfishService. """ requests_mock = pytest.importorskip("requests_mock") - requests = [] - overlay = {} - - def get_cb(request, context): - requests.append(request) - if request.path.lower() in overlay: - context.status_code = 200 - return json.dumps(overlay[request.path.lower()]) - fixture = _fixture_for_path(request.path) - if fixture is None: - context.status_code = 404 - return json.dumps({"error": f"no fixture for {request.path}"}) - context.status_code = 200 - return fixture.read_text() - - def post_cb(request, context): - requests.append(request) - context.status_code = 202 - context.headers["Location"] = "/redfish/v1/TaskService/Tasks/raid-spare-1" - return json.dumps({"Task": {"@odata.id": "/redfish/v1/TaskService/Tasks/raid-spare-1"}}) - + service = MockRedfishService( + DELL_CORPUS, + index=_build_fixture_index(DELL_CORPUS), + ) with requests_mock.Mocker() as mocker: - mocker.get(requests_mock.ANY, text=get_cb) - mocker.post(requests_mock.ANY, text=post_cb) - manager = IDracManager( - idrac_ip="mock-dell-raid-spare", - idrac_username="root", - idrac_password="mock", - insecure=True, - is_debug=False, + mocker.get(requests_mock.ANY, text=service.get_cb) + mocker.patch(requests_mock.ANY, text=service.patch_cb) + mocker.post(requests_mock.ANY, text=service.post_cb) + mocker.delete(requests_mock.ANY, text=service.delete_cb) + service.mocker = mocker + yield ( + IDracManager( + idrac_ip="mock-dell-raid-spare", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ), + service, ) - yield manager, requests, overlay -def _post_requests(requests): - """Return POST requests recorded by the mock Redfish transport. +def _post_requests(service): + """Return POST requests recorded by the mock Redfish service. - :param requests: recorded requests-mock request objects. + :param service: the recording MockRedfishService. :return: list of POST requests. """ - return [request for request in requests if request.method == "POST"] + return [request for request in service.requests if request.method == "POST"] -def _without_action(action_name): - """Return the DellRaidService fixture body with one action removed. +def _overlay_without_action(service, action_name): + """Overlay DellRaidService with one action removed, under both casings. + :param service: the recording MockRedfishService. :param action_name: full ``#DellRaidService.*`` action name to remove. - :return: copied fixture body with the selected action removed. """ - fixture = _fixture_for_path(RAID_SERVICE) - body = json.loads(fixture.read_text()) - body["Actions"] = dict(body["Actions"]) + body = copy.deepcopy(service._state(RAID_SERVICE)) body["Actions"].pop(action_name, None) - return body + service._overlay[RAID_SERVICE] = body + service._overlay[RAID_SERVICE.lower()] = body -def test_dell_raid_spare_lists_targets_and_candidates(dell_raid_spare_manager): +def test_dell_raid_spare_lists_targets_and_candidates(dell_raid_spare_mock): """Calling dell-raid-spare without an action lists targets without POSTing.""" - manager, requests, _overlay = dell_raid_spare_manager + manager, service = dell_raid_spare_mock result = manager.sync_invoke( ApiRequestType.DellRaidSpareActions, @@ -125,12 +105,12 @@ def test_dell_raid_spare_lists_targets_and_candidates(dell_raid_spare_manager): volume["id"] == "PCIeSSD.Integrated.1-0" for volume in result.data["candidates"]["virtual_disks"] ) - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_raid_spare_assign_defaults_to_preview(dell_raid_spare_manager): +def test_dell_raid_spare_assign_defaults_to_preview(dell_raid_spare_mock): """AssignSpare is a guarded storage change and does not POST by default.""" - manager, requests, _overlay = dell_raid_spare_manager + manager, service = dell_raid_spare_mock result = manager.sync_invoke( ApiRequestType.DellRaidSpareActions, @@ -147,12 +127,12 @@ def test_dell_raid_spare_assign_defaults_to_preview(dell_raid_spare_manager): assert result.data["level"] == "destructive" assert result.data["blocked"] == "destructive action requires --confirm" assert result.data["payload"] == {"TargetFQDD": DISK_FQDD} - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_raid_spare_assign_dedicated_posts_with_confirm(dell_raid_spare_manager): - """--confirm POSTs a dedicated hot-spare payload to AssignSpare.""" - manager, requests, _overlay = dell_raid_spare_manager +def test_dell_raid_spare_assign_dedicated_posts_with_confirm(dell_raid_spare_mock): + """--confirm POSTs AssignSpare; the Dell lens realizes a ``JID_`` job id.""" + manager, service = dell_raid_spare_mock result = manager.sync_invoke( ApiRequestType.DellRaidSpareActions, @@ -163,13 +143,14 @@ def test_dell_raid_spare_assign_dedicated_posts_with_confirm(dell_raid_spare_man confirm=True, ) - posts = _post_requests(requests) + posts = _post_requests(service) assert isinstance(result, CommandResult) assert result.error is None assert result.data["executed"] is True assert result.data["action"] == "#DellRaidService.AssignSpare" assert result.data["target"] == ASSIGN_TARGET - assert result.data["task_id"] == "raid-spare-1" + assert result.data["task_id"] == service.JOB_ID + assert service.JOB_ID.startswith("JID_") assert len(posts) == 1 assert posts[0].path.lower() == ASSIGN_TARGET.lower() assert posts[0].json() == { @@ -178,9 +159,9 @@ def test_dell_raid_spare_assign_dedicated_posts_with_confirm(dell_raid_spare_man } -def test_dell_raid_spare_unassign_dry_run_overrides_confirm(dell_raid_spare_manager): +def test_dell_raid_spare_unassign_dry_run_overrides_confirm(dell_raid_spare_mock): """--dry_run keeps UnassignSpare as a no-POST preview even with --confirm.""" - manager, requests, _overlay = dell_raid_spare_manager + manager, service = dell_raid_spare_mock result = manager.sync_invoke( ApiRequestType.DellRaidSpareActions, @@ -198,12 +179,12 @@ def test_dell_raid_spare_unassign_dry_run_overrides_confirm(dell_raid_spare_mana assert result.data["action"] == "#DellRaidService.UnassignSpare" assert result.data["target"] == UNASSIGN_TARGET assert result.data["payload"] == {"TargetFQDD": DISK_FQDD} - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_raid_spare_unassign_rejects_virtual_disk(dell_raid_spare_manager): +def test_dell_raid_spare_unassign_rejects_virtual_disk(dell_raid_spare_mock): """UnassignSpare accepts only the physical disk target.""" - manager, requests, _overlay = dell_raid_spare_manager + manager, service = dell_raid_spare_mock with pytest.raises(InvalidArgument, match="only valid with --action assign"): manager.sync_invoke( @@ -214,13 +195,13 @@ def test_dell_raid_spare_unassign_rejects_virtual_disk(dell_raid_spare_manager): virtual_disk=[VD_FQDD], confirm=True, ) - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_raid_spare_reports_missing_action(dell_raid_spare_manager): +def test_dell_raid_spare_reports_missing_action(dell_raid_spare_mock): """A service without UnassignSpare returns an actionable no-POST error.""" - manager, requests, overlay = dell_raid_spare_manager - overlay[RAID_SERVICE.lower()] = _without_action("#DellRaidService.UnassignSpare") + manager, service = dell_raid_spare_mock + _overlay_without_action(service, "#DellRaidService.UnassignSpare") result = manager.sync_invoke( ApiRequestType.DellRaidSpareActions, @@ -237,7 +218,7 @@ def test_dell_raid_spare_reports_missing_action(dell_raid_spare_manager): "action '#DellRaidService.UnassignSpare' not found on " f"{RAID_SERVICE}" ) - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_raid_spare_policy_and_registry_are_wired(): diff --git a/tests/system/test_dell_system_lcd_errors_dualmode.py b/tests/system/test_dell_system_lcd_errors_dualmode.py index 4a7ffb4b..069d57d5 100644 --- a/tests/system/test_dell_system_lcd_errors_dualmode.py +++ b/tests/system/test_dell_system_lcd_errors_dualmode.py @@ -1,10 +1,10 @@ """Dual-mode-style coverage for DellSystemManagementService.ShowErrorsOnLCD.""" -import json -from contextlib import contextmanager +import copy from pathlib import Path import pytest +from conftest import MockRedfishService, _build_fixture_index from vendor_corpus import corpus_dir from redfish_ctl.actions.action_policy import Destructiveness, classify @@ -16,78 +16,73 @@ DELL_CORPUS = corpus_dir( Path(__file__).parent.parent / "dell_xr8620t_corpus.tar.gz", "10.252.252.209" ) -DELL_INDEX = {path.name.lower(): path for path in DELL_CORPUS.glob("*.json")} SYSTEM = "/redfish/v1/Systems/System.Embedded.1" SERVICE = f"{SYSTEM}/Oem/Dell/DellSystemManagementService" ACTION = "#DellSystemManagementService.ShowErrorsOnLCD" TARGET = f"{SERVICE}/Actions/DellSystemManagementService.ShowErrorsOnLCD" -def _fixture_for_path(path): - """Return the extracted Dell fixture matching a Redfish path. +@pytest.fixture +def dell_system_lcd_mock(): + """Return a manager and mock service backed by the Dell XR8620t corpus. - :param path: request path from requests-mock. - :return: fixture path, or None when the corpus lacks the resource. + The vendor-faithful service realizes an Action POST the Dell way: 202 plus + a ``JID_`` OEM job id in the Location header, never a DMTF-generic token. + + :return: tuple of IDracManager and the recording MockRedfishService. """ - name = "_" + path.strip("/").replace("/", "_") + ".json" - return DELL_INDEX.get(name.lower()) + requests_mock = pytest.importorskip("requests_mock") + service = MockRedfishService( + DELL_CORPUS, + index=_build_fixture_index(DELL_CORPUS), + ) + with requests_mock.Mocker() as mocker: + mocker.get(requests_mock.ANY, text=service.get_cb) + mocker.patch(requests_mock.ANY, text=service.patch_cb) + mocker.post(requests_mock.ANY, text=service.post_cb) + mocker.delete(requests_mock.ANY, text=service.delete_cb) + service.mocker = mocker + yield ( + IDracManager( + idrac_ip="mock-dell-system-lcd", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ), + service, + ) -@contextmanager -def _dell_system_lcd_manager(remove_action=False): - """Serve the Dell corpus over requests-mock. +def _post_requests(service): + """Return POST requests recorded by the mock Redfish service. - :param remove_action: drop ShowErrorsOnLCD from the service fixture. - :return: tuple of IDracManager and recorded requests. + :param service: the recording MockRedfishService. + :return: list of POST requests. """ - requests_mock = pytest.importorskip("requests_mock") - requests = [] - - def get_cb(request, context): - requests.append(request) - fixture = _fixture_for_path(request.path) - if fixture is None: - context.status_code = 404 - return json.dumps({"error": f"no fixture for {request.path}"}) - context.status_code = 200 - data = json.loads(fixture.read_text()) - if remove_action and request.path.lower() == SERVICE.lower(): - data["Actions"].pop(ACTION, None) - return json.dumps(data) - - def post_cb(request, context): - requests.append(request) - context.status_code = 202 - context.headers["Location"] = "/redfish/v1/TaskService/Tasks/lcd-1" - return json.dumps( - {"Task": {"@odata.id": "/redfish/v1/TaskService/Tasks/lcd-1"}} - ) + return [request for request in service.requests if request.method == "POST"] - with requests_mock.Mocker() as mocker: - mocker.get(requests_mock.ANY, text=get_cb) - mocker.post(requests_mock.ANY, text=post_cb) - manager = IDracManager( - idrac_ip="mock-dell-system-lcd", - idrac_username="root", - idrac_password="mock", - insecure=True, - is_debug=False, - ) - yield manager, requests +def _overlay_management_service(service, body): + """Overlay DellSystemManagementService under both common request casings. -def _post_requests(requests): - """Return POST requests recorded by the mock Redfish transport.""" - return [request for request in requests if request.method == "POST"] + :param service: the recording MockRedfishService. + :param body: replacement system-management-service body. + """ + service._overlay[SERVICE] = body + service._overlay[SERVICE.lower()] = body -def test_dell_system_lcd_errors_without_confirm_is_preview_only(): +def test_dell_system_lcd_errors_without_confirm_is_preview_only( + dell_system_lcd_mock, +): """ShowErrorsOnLCD resolves its target but does not POST without --confirm.""" - with _dell_system_lcd_manager() as (manager, requests): - result = manager.sync_invoke( - ApiRequestType.DellSystemLcdErrors, - "dell-system-lcd-errors", - ) + manager, service = dell_system_lcd_mock + + result = manager.sync_invoke( + ApiRequestType.DellSystemLcdErrors, + "dell-system-lcd-errors", + ) assert isinstance(result, CommandResult) assert result.error is None @@ -99,57 +94,70 @@ def test_dell_system_lcd_errors_without_confirm_is_preview_only(): assert result.data["level"] == "destructive" assert result.data["blocked"] == "destructive action requires --confirm" assert result.data["payload"] == {} - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_system_lcd_errors_confirm_posts_empty_payload(): - """--confirm POSTs the empty ShowErrorsOnLCD payload to the action target.""" - with _dell_system_lcd_manager() as (manager, requests): - result = manager.sync_invoke( - ApiRequestType.DellSystemLcdErrors, - "dell-system-lcd-errors", - confirm=True, - ) +def test_dell_system_lcd_errors_confirm_posts_empty_payload( + dell_system_lcd_mock, +): + """--confirm POSTs ShowErrorsOnLCD; the Dell lens realizes a ``JID_`` job id.""" + manager, service = dell_system_lcd_mock + + result = manager.sync_invoke( + ApiRequestType.DellSystemLcdErrors, + "dell-system-lcd-errors", + confirm=True, + ) - posts = _post_requests(requests) + posts = _post_requests(service) assert isinstance(result, CommandResult) assert result.error is None assert result.data["executed"] is True assert result.data["action"] == ACTION assert result.data["target"] == TARGET assert result.data["level"] == "destructive" - assert result.data["task_id"] == "lcd-1" + assert result.data["task_id"] == service.JOB_ID + assert service.JOB_ID.startswith("JID_") assert len(posts) == 1 assert posts[0].path.lower() == TARGET.lower() assert posts[0].json() == {} -def test_dell_system_lcd_errors_confirm_dry_run_still_does_not_post(): +def test_dell_system_lcd_errors_confirm_dry_run_still_does_not_post( + dell_system_lcd_mock, +): """--dry_run remains a no-POST preview even when --confirm is also present.""" - with _dell_system_lcd_manager() as (manager, requests): - result = manager.sync_invoke( - ApiRequestType.DellSystemLcdErrors, - "dell-system-lcd-errors", - confirm=True, - dry_run=True, - ) + manager, service = dell_system_lcd_mock + + result = manager.sync_invoke( + ApiRequestType.DellSystemLcdErrors, + "dell-system-lcd-errors", + confirm=True, + dry_run=True, + ) assert isinstance(result, CommandResult) assert result.error is None assert result.data["dry_run"] is True assert result.data["blocked"] is None assert result.data["target"] == TARGET - assert _post_requests(requests) == [] + assert _post_requests(service) == [] -def test_dell_system_lcd_errors_reports_missing_action_without_post(): +def test_dell_system_lcd_errors_reports_missing_action_without_post( + dell_system_lcd_mock, +): """A Dell service without ShowErrorsOnLCD reports the absent action.""" - with _dell_system_lcd_manager(remove_action=True) as (manager, requests): - result = manager.sync_invoke( - ApiRequestType.DellSystemLcdErrors, - "dell-system-lcd-errors", - system_uri=SYSTEM, - ) + manager, service = dell_system_lcd_mock + body = copy.deepcopy(service._state(SERVICE)) + body["Actions"].pop(ACTION, None) + _overlay_management_service(service, body) + + result = manager.sync_invoke( + ApiRequestType.DellSystemLcdErrors, + "dell-system-lcd-errors", + system_uri=SYSTEM, + ) assert isinstance(result, CommandResult) assert result.error == ( @@ -158,7 +166,7 @@ def test_dell_system_lcd_errors_reports_missing_action_without_post(): assert result.data["action"] == ACTION assert result.data["attempted"] == [SERVICE] assert "RebootChassisManager" in result.data["available"] - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_dell_system_lcd_errors_policy_is_destructive(): diff --git a/tests/test_telemetry_submit_test_dualmode.py b/tests/test_telemetry_submit_test_dualmode.py index be3ff029..5514781a 100644 --- a/tests/test_telemetry_submit_test_dualmode.py +++ b/tests/test_telemetry_submit_test_dualmode.py @@ -1,21 +1,18 @@ """Dual-mode-style coverage for TelemetryService.SubmitTestMetricReport.""" -import json from pathlib import Path import pytest +from conftest import MockRedfishService, _build_fixture_index from vendor_corpus import corpus_dir from redfish_ctl.idrac_manager import IDracManager from redfish_ctl.idrac_shared import ApiRequestType from redfish_ctl.redfish_manager import CommandResult -DELL_XR8620T_CORPUS = corpus_dir( +DELL_CORPUS = corpus_dir( Path(__file__).parent / "dell_xr8620t_corpus.tar.gz", "10.252.252.209" ) -DELL_XR8620T_INDEX = { - path.name.lower(): path for path in DELL_XR8620T_CORPUS.glob("*.json") -} TELEMETRY_SERVICE = "/redfish/v1/TelemetryService" SUBMIT_TARGET = ( "/redfish/v1/TelemetryService/Actions/" @@ -23,51 +20,45 @@ ) -def _fixture_for_path(path): - """Return the extracted Dell XR8620t fixture matching a Redfish path.""" - name = "_" + path.strip("/").replace("/", "_") + ".json" - return DELL_XR8620T_INDEX.get(name.lower()) +@pytest.fixture +def dell_telemetry_mock(): + """Return a manager and mock service backed by the Dell XR8620t corpus. + The vendor-faithful service realizes an Action POST the Dell way: 202 plus + a ``JID_`` OEM job id in the Location header, never a DMTF-generic token. -@pytest.fixture -def dell_xr8620t_telemetry_manager(): - """Serve the committed Dell XR8620t corpus over requests-mock.""" + :return: tuple of IDracManager and the recording MockRedfishService. + """ requests_mock = pytest.importorskip("requests_mock") - requests = [] - - def get_cb(request, context): - requests.append(request) - fixture = _fixture_for_path(request.path) - if fixture is None: - context.status_code = 404 - return json.dumps({"error": f"no fixture for {request.path}"}) - context.status_code = 200 - return fixture.read_text() - - def post_cb(request, context): - requests.append(request) - context.status_code = 202 - context.headers["Location"] = "/redfish/v1/TaskService/Tasks/telemetry-test-1" - return json.dumps({ - "Task": {"@odata.id": "/redfish/v1/TaskService/Tasks/telemetry-test-1"}, - }) - + service = MockRedfishService( + DELL_CORPUS, + index=_build_fixture_index(DELL_CORPUS), + ) with requests_mock.Mocker() as mocker: - mocker.get(requests_mock.ANY, text=get_cb) - mocker.post(requests_mock.ANY, text=post_cb) - manager = IDracManager( - idrac_ip="mock-dell-xr8620t", - idrac_username="root", - idrac_password="mock", - insecure=True, - is_debug=False, + mocker.get(requests_mock.ANY, text=service.get_cb) + mocker.patch(requests_mock.ANY, text=service.patch_cb) + mocker.post(requests_mock.ANY, text=service.post_cb) + mocker.delete(requests_mock.ANY, text=service.delete_cb) + service.mocker = mocker + yield ( + IDracManager( + idrac_ip="mock-dell-xr8620t", + idrac_username="root", + idrac_password="mock", + insecure=True, + is_debug=False, + ), + service, ) - yield manager, requests -def _post_requests(requests): - """Return POST requests recorded by the mock Redfish transport.""" - return [request for request in requests if request.method == "POST"] +def _post_requests(service): + """Return POST requests recorded by the mock Redfish service. + + :param service: the recording MockRedfishService. + :return: list of POST requests. + """ + return [request for request in service.requests if request.method == "POST"] def _submit(manager, **kwargs): @@ -83,9 +74,9 @@ def _submit(manager, **kwargs): def test_telemetry_submit_test_without_confirm_is_preview_only( - dell_xr8620t_telemetry_manager): + dell_telemetry_mock): """SubmitTestMetricReport resolves the target but does not POST by default.""" - manager, requests = dell_xr8620t_telemetry_manager + manager, service = dell_telemetry_mock result = _submit(manager) @@ -105,24 +96,25 @@ def test_telemetry_submit_test_without_confirm_is_preview_only( } assert result.data["level"] == "reversible" assert result.data["blocked"] == "test metric report submission requires --confirm" - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_telemetry_submit_test_confirm_posts_payload( - dell_xr8620t_telemetry_manager): - """--confirm POSTs the generated metric report to the discovered action.""" - manager, requests = dell_xr8620t_telemetry_manager + dell_telemetry_mock): + """--confirm POSTs the report; the Dell lens realizes a ``JID_`` job id.""" + manager, service = dell_telemetry_mock result = _submit(manager, confirm=True) - posts = _post_requests(requests) + posts = _post_requests(service) assert isinstance(result, CommandResult) assert result.error is None assert result.data["executed"] is True assert result.data["action"] == "#TelemetryService.SubmitTestMetricReport" assert result.data["target"] == SUBMIT_TARGET assert result.data["level"] == "reversible" - assert result.data["task_id"] == "telemetry-test-1" + assert result.data["task_id"] == service.JOB_ID + assert service.JOB_ID.startswith("JID_") assert len(posts) == 1 assert posts[0].path.lower() == SUBMIT_TARGET.lower() assert posts[0].json() == { @@ -137,9 +129,9 @@ def test_telemetry_submit_test_confirm_posts_payload( def test_telemetry_submit_test_confirm_dry_run_still_does_not_post( - dell_xr8620t_telemetry_manager): + dell_telemetry_mock): """--dry_run remains a no-POST preview even when --confirm is also set.""" - manager, requests = dell_xr8620t_telemetry_manager + manager, service = dell_telemetry_mock result = _submit(manager, confirm=True, dry_run=True) @@ -148,13 +140,13 @@ def test_telemetry_submit_test_confirm_dry_run_still_does_not_post( assert result.data["blocked"] is None assert result.data["target"] == SUBMIT_TARGET assert result.data["payload"]["MetricReportName"] == "SyntheticReport" - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_telemetry_submit_test_metric_property_is_optional( - dell_xr8620t_telemetry_manager): + dell_telemetry_mock): """MetricProperty is included only when supplied by the caller.""" - manager, requests = dell_xr8620t_telemetry_manager + manager, service = dell_telemetry_mock result = _submit( manager, @@ -169,7 +161,7 @@ def test_telemetry_submit_test_metric_property_is_optional( "MetricValue": "42", "MetricProperty": "/redfish/v1/TelemetryService#/ServiceEnabled", } - assert _post_requests(requests) == [] + assert _post_requests(service) == [] def test_telemetry_submit_test_no_action_reports_clear_error(redfish_mock_factory): @@ -187,4 +179,4 @@ def test_telemetry_submit_test_no_action_reports_clear_error(redfish_mock_factor f"not found on {TELEMETRY_SERVICE}" ) assert result.data["action"] == "#TelemetryService.SubmitTestMetricReport" - assert _post_requests(service.requests) == [] + assert _post_requests(service) == []