From e5046803dda43ffed7a3a6b1d5fa8b0c2ff23ceb Mon Sep 17 00:00:00 2001 From: pUrGe12 Date: Fri, 4 Jul 2025 01:25:05 +0530 Subject: [PATCH 1/6] test cases for module.py --- tests/core/test_module.py | 428 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 tests/core/test_module.py diff --git a/tests/core/test_module.py b/tests/core/test_module.py new file mode 100644 index 000000000..c3f9befb1 --- /dev/null +++ b/tests/core/test_module.py @@ -0,0 +1,428 @@ +import json +from unittest.mock import patch, MagicMock + +import pytest + +from nettacker.core.module import Module + + +class DummyOptions: + def __init__(self): + self.modules_extra_args = {"foo": "bar"} + self.skip_service_discovery = False + self.time_sleep_between_requests = 0 + self.thread_per_host = 2 + + +@pytest.fixture +def options(): + return DummyOptions() + + +@pytest.fixture +def module_args(): + return { + "target": "127.0.0.1", + "scan_id": "scan123", + "process_number": 1, + "thread_number": 1, + "total_number_threads": 1, + } + + +@patch("nettacker.core.module.TemplateLoader") +def test_init_and_service_discovery_signature(mock_loader, options, module_args): + mock_instance = MagicMock() + mock_instance.load.return_value = { + "payloads": [{"steps": [{"response": {"conditions": {"service": {"http": {}}}}}]}] + } + mock_loader.return_value = mock_instance + + module = Module("port_scan", options, **module_args) + assert "http" in module.service_discovery_signatures + + +@patch("os.listdir", return_value=["http.py"]) +@patch("nettacker.core.module.find_events") +@patch("nettacker.core.module.TemplateLoader") +def test_load_with_service_discovery( + mock_loader, mock_find_events, mock_listdir, options, module_args +): + mock_loader_inst = MagicMock() + mock_loader_inst.load.return_value = { + "payloads": [ + { + "library": "http", + "steps": [{"response": {"conditions": {"service": {"http": {}}}}}], + } + ] + } + mock_loader.return_value = mock_loader_inst + + mock_find_events.return_value = [ + MagicMock(json_event='{"port": 80, "response": {"conditions_results": {"http": {}}}}') + ] + + module = Module("test_module", options, **module_args) + module.load() + + assert module.discovered_services == {"http": [80]} + assert len(module.module_content["payloads"]) == 1 + + +@patch("nettacker.core.module.find_events") +@patch("nettacker.core.module.TemplateLoader") +def test_sort_loops(mock_loader, mock_find_events, options, module_args): + mock_loader_inst = MagicMock() + mock_loader_inst.load.return_value = { + "payloads": [ + { + "library": "http", + "steps": [ + {"response": {"conditions": {"service": {}}}}, + { + "response": { + "conditions": {}, + "dependent_on_temp_event": True, + "save_to_temp_events_only": True, + } + }, + {"response": {"conditions": {}, "dependent_on_temp_event": True}}, + ], + } + ] + } + mock_loader.return_value = mock_loader_inst + + mock_event = MagicMock() + mock_event.json_event = json.dumps( + {"port": 80, "response": {"conditions_results": {"http": True}}} + ) + mock_find_events.return_value = [mock_event] + + module = Module("test_module", options, **module_args) + module.libraries = ["http"] + module.load() # Should not raise + + +@patch("nettacker.core.module.find_events") +@patch("nettacker.core.module.importlib.import_module") +@patch("nettacker.core.module.wait_for_threads_to_finish") +@patch("nettacker.core.module.time.sleep", return_value=None) +@patch("nettacker.core.module.Thread") +@patch("nettacker.core.module.TemplateLoader") +def test_start_all_conditions( + mock_loader, + mock_thread, + mock_sleep, + mock_wait, + mock_import, + mock_find_events, + options, + module_args, +): + engine_mock = MagicMock() + mock_import.return_value = MagicMock(HttpEngine=MagicMock(return_value=engine_mock)) + + mock_loader_inst = MagicMock() + mock_loader_inst.load.return_value = { + "payloads": [ + { + "library": "http", + "steps": [{"step_id": 1, "response": {"conditions": {"service": {}}}}], + } + ] + } + mock_loader.return_value = mock_loader_inst + + mock_event = MagicMock() + mock_event.json_event = json.dumps( + {"port": 80, "response": {"conditions_results": {"http": True}}} + ) + mock_find_events.return_value = [mock_event] + + module = Module("test_module", options, **module_args) + module.libraries = ["http"] + module.load() + module.start() + + mock_wait.assert_called() + + +@patch("nettacker.core.module.find_events") +@patch("nettacker.core.module.TemplateLoader") +def test_start_unsupported_library(mock_loader, mock_find_events, options, module_args): + mock_loader_inst = MagicMock() + mock_loader_inst.load.return_value = { + "payloads": [ + { + "library": "unsupported_lib", + "steps": [{"step_id": 1, "response": {"conditions": {"service": {}}}}], + } + ] + } + mock_loader.return_value = mock_loader_inst + + mock_event = MagicMock() + mock_event.json_event = json.dumps( + {"port": 1234, "response": {"conditions_results": {"unsupported_lib": True}}} + ) + mock_find_events.return_value = [mock_event] + + module = Module("test_module", options, **module_args) + module.libraries = ["http"] + module.service_discovery_signatures.append("unsupported_lib") + + module.load() + result = module.start() + + assert result is None + + +def template_loader_side_effect(name, inputs): + # NOT A TEST CASE + mock_instance = MagicMock() + + # as in inside Module.__init__ + if name == "port_scan": + mock_instance.load.return_value = { + "payloads": [{"steps": [{"response": {"conditions": {"service": {"http": {}}}}}]}] + } + # as in module.load() + elif name == "test_module": + mock_instance.load.return_value = { + "payloads": [ + { + "library": "http", + "steps": [ + [{"response": {"conditions": {"service": {}}}}], + [ + { + "response": { + "conditions": {}, + "dependent_on_temp_event": True, + "save_to_temp_events_only": True, + } + } + ], + [{"response": {"conditions": {}, "dependent_on_temp_event": True}}], + ], + } + ] + } + else: + raise ValueError(f"Unexpected module name: {name}") + + return mock_instance + + +@patch("nettacker.core.module.TemplateLoader.parse", side_effect=lambda step, _: step) +@patch("nettacker.core.module.find_events") +@patch("nettacker.core.module.TemplateLoader") +def test_sort_loops_behavior(mock_loader_cls, mock_find_events, mock_parse, options, module_args): + # This one is painful + mock_loader_cls.side_effect = template_loader_side_effect + + mock_event = MagicMock() + mock_event.json_event = json.dumps( + {"port": 80, "response": {"conditions_results": {"http": True}}} + ) + mock_find_events.return_value = [mock_event] + + module = Module("test_module", options, **module_args) + module.libraries = ["http"] + module.load() + module.sort_loops() + + steps = module.module_content["payloads"][0]["steps"] + + assert steps[0][0]["response"]["conditions"] == {"service": {}} + assert steps[1][0]["response"]["dependent_on_temp_event"] is True + assert steps[1][0]["response"]["save_to_temp_events_only"] is True + assert steps[2][0]["response"]["dependent_on_temp_event"] is True + assert "save_to_temp_events_only" not in steps[2][0]["response"] + + +def loader_side_effect(name, inputs): + # HELPER + mock_inst = MagicMock() + + if name == "port_scan": + mock_inst.load.return_value = { + "payloads": [{"steps": [{"response": {"conditions": {"service": {"http": {}}}}}]}] + } + elif name == "test_module": + mock_inst.load.return_value = { + "payloads": [ + { + "library": "http", + "steps": [ + [{"id": 1}], + [{"id": 2}], + ], + } + ] + } + else: + raise ValueError(f"Unexpected module name: {name}") + + return mock_inst + + +@patch("nettacker.core.module.TemplateLoader.parse") +@patch("nettacker.core.module.time.sleep", return_value=None) +@patch("nettacker.core.module.wait_for_threads_to_finish") +@patch("nettacker.core.module.Thread") +@patch("nettacker.core.module.importlib.import_module") +@patch("nettacker.core.module.TemplateLoader") +@patch("nettacker.core.module.find_events") +def test_start_creates_threads( + mock_find_events, + mock_loader_cls, + mock_import_module, + mock_thread_cls, + mock_wait_for_threads, + mock_sleep, + mock_parse, + options, + module_args, +): + mock_parse.side_effect = lambda x, _: x + mock_loader_cls.side_effect = loader_side_effect + + fake_engine = MagicMock() + mock_import_module.return_value = MagicMock(HttpEngine=MagicMock(return_value=fake_engine)) + + mock_thread_instance = MagicMock() + mock_thread_cls.return_value = mock_thread_instance + + mock_event = MagicMock() + mock_event.json_event = json.dumps( + {"port": 80, "response": {"conditions_results": {"http": True}}} + ) + mock_find_events.return_value = [mock_event] + + module = Module("test_module", options, **module_args) + module.libraries = ["http"] + module.load() + module.generate_loops() + module.start() + + assert mock_thread_cls.call_count == 2 + + for _, kwargs in mock_thread_cls.call_args_list: + assert kwargs["target"] == fake_engine.run + + expected_ids = {1, 2} + actual_ids = set() + + for _, kwargs in mock_thread_cls.call_args_list: + sub_step = kwargs["args"][0] + # Some additional handling required here + if isinstance(sub_step, list): + sub_step = sub_step[0] + + assert isinstance(sub_step, dict) + actual_ids.add(sub_step["id"]) + + assert actual_ids == expected_ids + + +@patch("nettacker.core.module.TemplateLoader.parse", side_effect=lambda x, _: x) +@patch("nettacker.core.module.log") +@patch("nettacker.core.module.TemplateLoader") +@patch("nettacker.core.module.find_events") +def test_start_library_not_supported( + mock_find_events, + mock_loader_cls, + mock_log, + mock_parse, + module_args, +): + def loader_side_effect_specific(name, inputs): + mock_inst = MagicMock() + if name == "port_scan": + mock_inst.load.return_value = { + "payloads": [{"steps": [{"response": {"conditions": {"service": {"http": {}}}}}]}] + } + elif name == "test_module": + mock_inst.load.return_value = { + "payloads": [ + { + "library": "unsupported_lib", + "steps": [ + [{"id": 1}], + ], + } + ] + } + return mock_inst + + mock_loader_cls.side_effect = loader_side_effect_specific + + mock_event = MagicMock() + mock_event.json_event = json.dumps( + {"port": 80, "response": {"conditions_results": {"http": True}}} + ) + mock_find_events.return_value = [mock_event] + + # Had to add this small workaround + class DummyOptionsSpecific: + def __init__(self): + self.modules_extra_args = {} + self.skip_service_discovery = True + self.time_sleep_between_requests = 0 + self.thread_per_host = 2 + + options = DummyOptionsSpecific() + + module = Module("test_module", options, **module_args) + module.libraries = ["http"] + module.load() + + result = module.start() + + assert result is None + mock_log.warn.assert_called_once() + assert "unsupported_lib" in mock_log.warn.call_args[0][0] + + +@patch("nettacker.core.module.TemplateLoader.parse", side_effect=lambda step, _: step) +@patch("nettacker.core.module.find_events") +@patch("nettacker.core.module.TemplateLoader") +def test_load_appends_port_to_existing_protocol( + mock_loader_cls, + mock_find_events, + mock_parse, + options, + module_args, +): + def loader_side_effect_specific(name, inputs): + mock_inst = MagicMock() + mock_inst.load.return_value = { + "payloads": [ + { + "library": "http", + "steps": [ + {"response": {"conditions": {"service": {}}}} # .load() requires no [] + ], + } + ] + } + return mock_inst + + mock_loader_cls.side_effect = loader_side_effect_specific + mock_find_events.return_value = [ + MagicMock( + json_event=json.dumps({"port": 80, "response": {"conditions_results": {"http": {}}}}) + ), + MagicMock( + json_event=json.dumps({"port": 443, "response": {"conditions_results": {"http": {}}}}) + ), + ] + + module = Module("test_module", options, **module_args) + module.libraries = ["http"] + module.service_discovery_signatures = ["http"] + module.load() + assert module.discovered_services == {"http": [80, 443]} From b2929c88e0c3b58d3f2efb47d7e29f85512d7b18 Mon Sep 17 00:00:00 2001 From: pUrGe12 Date: Fri, 4 Jul 2025 01:53:10 +0530 Subject: [PATCH 2/6] removing complexity from the threading test case --- tests/core/test_module.py | 48 ++++++++++++++------------------------- 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/tests/core/test_module.py b/tests/core/test_module.py index c3f9befb1..d2a71e4da 100644 --- a/tests/core/test_module.py +++ b/tests/core/test_module.py @@ -269,63 +269,49 @@ def loader_side_effect(name, inputs): return mock_inst -@patch("nettacker.core.module.TemplateLoader.parse") -@patch("nettacker.core.module.time.sleep", return_value=None) -@patch("nettacker.core.module.wait_for_threads_to_finish") @patch("nettacker.core.module.Thread") @patch("nettacker.core.module.importlib.import_module") -@patch("nettacker.core.module.TemplateLoader") -@patch("nettacker.core.module.find_events") -def test_start_creates_threads( - mock_find_events, - mock_loader_cls, +@patch("nettacker.core.module.time.sleep", return_value=None) +@patch("nettacker.core.module.wait_for_threads_to_finish") +def test_start_creates_threads_minimal( + mock_wait, + mock_sleep, mock_import_module, mock_thread_cls, - mock_wait_for_threads, - mock_sleep, - mock_parse, options, module_args, ): - mock_parse.side_effect = lambda x, _: x - mock_loader_cls.side_effect = loader_side_effect - fake_engine = MagicMock() mock_import_module.return_value = MagicMock(HttpEngine=MagicMock(return_value=fake_engine)) mock_thread_instance = MagicMock() mock_thread_cls.return_value = mock_thread_instance - mock_event = MagicMock() - mock_event.json_event = json.dumps( - {"port": 80, "response": {"conditions_results": {"http": True}}} - ) - mock_find_events.return_value = [mock_event] - module = Module("test_module", options, **module_args) module.libraries = ["http"] - module.load() - module.generate_loops() - module.start() + module.discovered_services = {"http": [80]} + module.service_discovery_signatures = ["http"] + module.module_content = { + "payloads": [ + { + "library": "http", + "steps": [[{"response": {}, "id": 1}], [{"response": {}, "id": 2}]], + } + ] + } + module.start() assert mock_thread_cls.call_count == 2 - for _, kwargs in mock_thread_cls.call_args_list: - assert kwargs["target"] == fake_engine.run - expected_ids = {1, 2} actual_ids = set() for _, kwargs in mock_thread_cls.call_args_list: sub_step = kwargs["args"][0] - # Some additional handling required here - if isinstance(sub_step, list): - sub_step = sub_step[0] - - assert isinstance(sub_step, dict) actual_ids.add(sub_step["id"]) assert actual_ids == expected_ids + assert mock_thread_instance.start.call_count == 2 @patch("nettacker.core.module.TemplateLoader.parse", side_effect=lambda x, _: x) From d693fc10f4e50527b2bc00e8d19dcbdff9369319 Mon Sep 17 00:00:00 2001 From: Arkadii Yakovets Date: Fri, 29 Aug 2025 21:04:24 -0700 Subject: [PATCH 3/6] Update tests --- tests/core/test_module.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/core/test_module.py b/tests/core/test_module.py index d2a71e4da..7b9ab8c71 100644 --- a/tests/core/test_module.py +++ b/tests/core/test_module.py @@ -10,8 +10,8 @@ class DummyOptions: def __init__(self): self.modules_extra_args = {"foo": "bar"} self.skip_service_discovery = False - self.time_sleep_between_requests = 0 self.thread_per_host = 2 + self.time_sleep_between_requests = 0 @pytest.fixture @@ -22,9 +22,9 @@ def options(): @pytest.fixture def module_args(): return { - "target": "127.0.0.1", - "scan_id": "scan123", "process_number": 1, + "scan_id": "scan123", + "target": "127.0.0.1", "thread_number": 1, "total_number_threads": 1, } @@ -237,9 +237,9 @@ def test_sort_loops_behavior(mock_loader_cls, mock_find_events, mock_parse, opti steps = module.module_content["payloads"][0]["steps"] assert steps[0][0]["response"]["conditions"] == {"service": {}} - assert steps[1][0]["response"]["dependent_on_temp_event"] is True - assert steps[1][0]["response"]["save_to_temp_events_only"] is True - assert steps[2][0]["response"]["dependent_on_temp_event"] is True + assert steps[1][0]["response"]["dependent_on_temp_event"] + assert steps[1][0]["response"]["save_to_temp_events_only"] + assert steps[2][0]["response"]["dependent_on_temp_event"] assert "save_to_temp_events_only" not in steps[2][0]["response"] From cb4891d5823f756319717ec13781785f264480d2 Mon Sep 17 00:00:00 2001 From: pUrGe12 Date: Tue, 2 Sep 2025 17:49:24 +0530 Subject: [PATCH 4/6] rebased and bot suggestions --- tests/core/test_module.py | 46 ++------------------------------------- 1 file changed, 2 insertions(+), 44 deletions(-) diff --git a/tests/core/test_module.py b/tests/core/test_module.py index 7b9ab8c71..78a5e9313 100644 --- a/tests/core/test_module.py +++ b/tests/core/test_module.py @@ -70,41 +70,6 @@ def test_load_with_service_discovery( assert len(module.module_content["payloads"]) == 1 -@patch("nettacker.core.module.find_events") -@patch("nettacker.core.module.TemplateLoader") -def test_sort_loops(mock_loader, mock_find_events, options, module_args): - mock_loader_inst = MagicMock() - mock_loader_inst.load.return_value = { - "payloads": [ - { - "library": "http", - "steps": [ - {"response": {"conditions": {"service": {}}}}, - { - "response": { - "conditions": {}, - "dependent_on_temp_event": True, - "save_to_temp_events_only": True, - } - }, - {"response": {"conditions": {}, "dependent_on_temp_event": True}}, - ], - } - ] - } - mock_loader.return_value = mock_loader_inst - - mock_event = MagicMock() - mock_event.json_event = json.dumps( - {"port": 80, "response": {"conditions_results": {"http": True}}} - ) - mock_find_events.return_value = [mock_event] - - module = Module("test_module", options, **module_args) - module.libraries = ["http"] - module.load() # Should not raise - - @patch("nettacker.core.module.find_events") @patch("nettacker.core.module.importlib.import_module") @patch("nettacker.core.module.wait_for_threads_to_finish") @@ -352,15 +317,8 @@ def loader_side_effect_specific(name, inputs): ) mock_find_events.return_value = [mock_event] - # Had to add this small workaround - class DummyOptionsSpecific: - def __init__(self): - self.modules_extra_args = {} - self.skip_service_discovery = True - self.time_sleep_between_requests = 0 - self.thread_per_host = 2 - - options = DummyOptionsSpecific() + options.modules_extra_args = {} + options.skip_service_discovery = True module = Module("test_module", options, **module_args) module.libraries = ["http"] From 62a9a3bdc041699cde4eb125f786242d41cf6d06 Mon Sep 17 00:00:00 2001 From: pUrGe12 Date: Tue, 2 Sep 2025 18:02:13 +0530 Subject: [PATCH 5/6] updated path structure inside config --- nettacker/config.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/nettacker/config.py b/nettacker/config.py index f573ef515..cdf1fb08c 100644 --- a/nettacker/config.py +++ b/nettacker/config.py @@ -7,6 +7,7 @@ CWD = Path.cwd() PACKAGE_PATH = Path(__file__).parent +ROOT_PATH = Path(__file__).parent.parent @lru_cache(maxsize=128) @@ -114,19 +115,19 @@ class PathConfig: a JSON contain the working, tmp and results path """ - data_dir = CWD / ".nettacker/data" - new_database_file = CWD / ".nettacker/data/nettacker.db" - old_database_file = CWD / ".data/nettacker.db" + data_dir = ROOT_PATH / ".nettacker/data" + new_database_file = ROOT_PATH / ".nettacker/data/nettacker.db" + old_database_file = ROOT_PATH / ".data/nettacker.db" graph_dir = PACKAGE_PATH / "lib/graph" - home_dir = CWD + home_dir = ROOT_PATH locale_dir = PACKAGE_PATH / "locale" logo_file = PACKAGE_PATH / "logo.txt" module_protocols_dir = PACKAGE_PATH / "core/lib" modules_dir = PACKAGE_PATH / "modules" payloads_dir = PACKAGE_PATH / "lib/payloads" release_name_file = PACKAGE_PATH / "release_name.txt" - results_dir = CWD / ".nettacker/data/results" - tmp_dir = CWD / ".nettacker/data/tmp" + results_dir = ROOT_PATH / ".nettacker/data/results" + tmp_dir = ROOT_PATH / ".nettacker/data/tmp" web_static_dir = PACKAGE_PATH / "web/static" user_agents_file = PACKAGE_PATH / "lib/payloads/User-Agents/web_browsers_user_agents.txt" From 64dd5550d0a79c6162bd86063e9a59917efab8ee Mon Sep 17 00:00:00 2001 From: pUrGe12 Date: Tue, 2 Sep 2025 18:24:53 +0530 Subject: [PATCH 6/6] maybe patching find_events will help --- nettacker/config.py | 13 ++++++------- tests/core/test_module.py | 21 +++++++++++++++------ 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/nettacker/config.py b/nettacker/config.py index cdf1fb08c..f573ef515 100644 --- a/nettacker/config.py +++ b/nettacker/config.py @@ -7,7 +7,6 @@ CWD = Path.cwd() PACKAGE_PATH = Path(__file__).parent -ROOT_PATH = Path(__file__).parent.parent @lru_cache(maxsize=128) @@ -115,19 +114,19 @@ class PathConfig: a JSON contain the working, tmp and results path """ - data_dir = ROOT_PATH / ".nettacker/data" - new_database_file = ROOT_PATH / ".nettacker/data/nettacker.db" - old_database_file = ROOT_PATH / ".data/nettacker.db" + data_dir = CWD / ".nettacker/data" + new_database_file = CWD / ".nettacker/data/nettacker.db" + old_database_file = CWD / ".data/nettacker.db" graph_dir = PACKAGE_PATH / "lib/graph" - home_dir = ROOT_PATH + home_dir = CWD locale_dir = PACKAGE_PATH / "locale" logo_file = PACKAGE_PATH / "logo.txt" module_protocols_dir = PACKAGE_PATH / "core/lib" modules_dir = PACKAGE_PATH / "modules" payloads_dir = PACKAGE_PATH / "lib/payloads" release_name_file = PACKAGE_PATH / "release_name.txt" - results_dir = ROOT_PATH / ".nettacker/data/results" - tmp_dir = ROOT_PATH / ".nettacker/data/tmp" + results_dir = CWD / ".nettacker/data/results" + tmp_dir = CWD / ".nettacker/data/tmp" web_static_dir = PACKAGE_PATH / "web/static" user_agents_file = PACKAGE_PATH / "lib/payloads/User-Agents/web_browsers_user_agents.txt" diff --git a/tests/core/test_module.py b/tests/core/test_module.py index 78a5e9313..23d817d52 100644 --- a/tests/core/test_module.py +++ b/tests/core/test_module.py @@ -234,6 +234,7 @@ def loader_side_effect(name, inputs): return mock_inst +@patch("nettacker.core.module.find_events", return_value=[]) @patch("nettacker.core.module.Thread") @patch("nettacker.core.module.importlib.import_module") @patch("nettacker.core.module.time.sleep", return_value=None) @@ -243,15 +244,19 @@ def test_start_creates_threads_minimal( mock_sleep, mock_import_module, mock_thread_cls, + mock_find_events, options, module_args, ): + # Mock HttpEngine from the imported module fake_engine = MagicMock() mock_import_module.return_value = MagicMock(HttpEngine=MagicMock(return_value=fake_engine)) + # Mock thread instances mock_thread_instance = MagicMock() mock_thread_cls.return_value = mock_thread_instance + # Create module with mocked attributes module = Module("test_module", options, **module_args) module.libraries = ["http"] module.discovered_services = {"http": [80]} @@ -261,19 +266,23 @@ def test_start_creates_threads_minimal( "payloads": [ { "library": "http", - "steps": [[{"response": {}, "id": 1}], [{"response": {}, "id": 2}]], + "steps": [ + [{"response": {}, "id": 1}], + [{"response": {}, "id": 2}], + ], } ] } + + # Run module.start() + + # Assert threads created twice assert mock_thread_cls.call_count == 2 + # Collect actual IDs passed to Thread expected_ids = {1, 2} - actual_ids = set() - - for _, kwargs in mock_thread_cls.call_args_list: - sub_step = kwargs["args"][0] - actual_ids.add(sub_step["id"]) + actual_ids = {kwargs["args"][0]["id"] for _, kwargs in mock_thread_cls.call_args_list} assert actual_ids == expected_ids assert mock_thread_instance.start.call_count == 2