From 6db1db9f5c8510e8dd7f78a19a613f0fb1eac88f Mon Sep 17 00:00:00 2001 From: "Antonio Melo Jr." Date: Wed, 8 Jul 2026 13:10:27 +0000 Subject: [PATCH 1/2] Increasing Unit Tests coverage back to the required minimum percentage --- tests/test_run/test_log_stream_handler.py | 237 +++++++++++++++++ tests/test_run/test_logs_http_server.py | 305 ++++++++++++++++++++++ 2 files changed, 542 insertions(+) diff --git a/tests/test_run/test_log_stream_handler.py b/tests/test_run/test_log_stream_handler.py index 71093cd..08a9fc2 100644 --- a/tests/test_run/test_log_stream_handler.py +++ b/tests/test_run/test_log_stream_handler.py @@ -299,3 +299,240 @@ def test_uses_local_ip(self): with patch.object(h, "_get_local_ip", return_value="172.16.0.5"): url = h._get_log_viewer_url() assert "172.16.0.5" in url + + +# --------------------------------------------------------------------------- +# stop() — error path +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestLogStreamHandlerStopError: + def test_logs_error_when_http_server_stop_raises(self): + h, mock_srv = _make_handler() + h.is_running = True + mock_srv.stop.side_effect = RuntimeError("stop failed") + with patch("th_cli.test_run.log_stream_handler.logger") as mock_logger: + h.stop() + mock_logger.error.assert_called() + + +# --------------------------------------------------------------------------- +# init_tree() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestInitTree: + def test_noop_when_not_running(self): + h, _ = _make_handler() + h.is_running = False + with patch.object(h, "_broadcast") as mock_bcast: + h.init_tree(MagicMock()) + mock_bcast.assert_not_called() + + def test_broadcasts_tree_init_event(self): + h, _ = _make_handler() + h.is_running = True + client_q = queue.Queue() + h._clients.add(client_q) + run = MagicMock() + run.title = "My Run" + run.test_suite_executions = [] + with patch("th_cli.test_run.log_stream_handler.logger"): + h.init_tree(run) + event = client_q.get_nowait() + assert event["type"] == "tree_init" + assert "data" in event + + def test_updates_tree_state_in_place(self): + h, _ = _make_handler() + h.is_running = True + run = MagicMock() + run.title = "Run Title" + run.test_suite_executions = [] + with patch("th_cli.test_run.log_stream_handler.logger"): + h.init_tree(run) + assert h.tree_state.get("title") == "Run Title" + + def test_handles_build_exception_silently(self): + h, _ = _make_handler() + h.is_running = True + with patch.object(h, "_build_tree", side_effect=RuntimeError("fail")): + with patch("th_cli.test_run.log_stream_handler.logger"): + h.init_tree(MagicMock()) # must not raise + + +# --------------------------------------------------------------------------- +# update_tree_node() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestUpdateTreeNode: + def test_noop_when_not_running(self): + h, _ = _make_handler() + h.is_running = False + with patch.object(h, "_broadcast") as mock_bcast: + h.update_tree_node(state="passed") + mock_bcast.assert_not_called() + + def test_run_level_update(self): + h, _ = _make_handler() + h.is_running = True + h.tree_state = {"state": "pending", "suites": []} + client_q = queue.Queue() + h._clients.add(client_q) + h.update_tree_node(state="executing") + assert h.tree_state["state"] == "executing" + event = client_q.get_nowait() + assert event["node_type"] == "run" + assert event["state"] == "executing" + + def test_suite_level_update(self): + h, _ = _make_handler() + h.is_running = True + h.tree_state = {"suites": [{"state": "pending", "cases": []}]} + client_q = queue.Queue() + h._clients.add(client_q) + h.update_tree_node(state="passed", suite_idx=0) + assert h.tree_state["suites"][0]["state"] == "passed" + event = client_q.get_nowait() + assert event["node_type"] == "suite" + + def test_case_level_update(self): + h, _ = _make_handler() + h.is_running = True + h.tree_state = {"suites": [{"cases": [{"state": "pending", "steps": []}]}]} + client_q = queue.Queue() + h._clients.add(client_q) + h.update_tree_node(state="failed", suite_idx=0, case_idx=0) + assert h.tree_state["suites"][0]["cases"][0]["state"] == "failed" + event = client_q.get_nowait() + assert event["node_type"] == "case" + + def test_step_level_update(self): + h, _ = _make_handler() + h.is_running = True + h.tree_state = {"suites": [{"cases": [{"steps": [{"state": "pending"}]}]}]} + client_q = queue.Queue() + h._clients.add(client_q) + h.update_tree_node(state="passed", suite_idx=0, case_idx=0, step_idx=0) + assert h.tree_state["suites"][0]["cases"][0]["steps"][0]["state"] == "passed" + event = client_q.get_nowait() + assert event["node_type"] == "step" + + def test_tolerates_index_error_on_invalid_tree_state(self): + h, _ = _make_handler() + h.is_running = True + h.tree_state = {} # missing expected structure + h.update_tree_node(state="passed", suite_idx=99) # must not raise + + +# --------------------------------------------------------------------------- +# _build_tree() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestBuildTree: + def test_minimal_run_no_suites(self): + h, _ = _make_handler() + run = MagicMock() + run.title = "My Run" + run.state = MagicMock(value="pending") + run.test_suite_executions = [] + tree = h._build_tree(run) + assert tree["title"] == "My Run" + assert tree["suites"] == [] + + def test_builds_suite_case_step_hierarchy(self): + h, _ = _make_handler() + + step = MagicMock() + step.title = "Step 1" + step.state = MagicMock(value="pending") + + case = MagicMock() + case.test_case_metadata.title = "Case 1" + case.test_case_metadata.public_id = "TC-1" + case.state = MagicMock(value="pending") + case.test_step_executions = [step] + + suite = MagicMock() + suite.test_suite_metadata.title = "Suite 1" + suite.state = MagicMock(value="pending") + suite.test_case_executions = [case] + + run = MagicMock() + run.title = "Run" + run.state = MagicMock(value="executing") + run.test_suite_executions = [suite] + + tree = h._build_tree(run) + assert len(tree["suites"]) == 1 + assert tree["suites"][0]["title"] == "Suite 1" + assert len(tree["suites"][0]["cases"]) == 1 + assert len(tree["suites"][0]["cases"][0]["steps"]) == 1 + assert tree["suites"][0]["cases"][0]["steps"][0]["title"] == "Step 1" + + def test_falls_back_to_default_titles_when_metadata_none(self): + h, _ = _make_handler() + + case = MagicMock() + case.test_case_metadata = None + case.state = MagicMock(value="pending") + case.test_step_executions = [] + + suite = MagicMock() + suite.test_suite_metadata = None + suite.state = MagicMock(value="pending") + suite.test_case_executions = [case] + + run = MagicMock() + run.title = "Run" + run.state = MagicMock(value="pending") + run.test_suite_executions = [suite] + + tree = h._build_tree(run) + assert "Suite 0" in tree["suites"][0]["title"] + assert "Case 0" in tree["suites"][0]["cases"][0]["title"] + + +# --------------------------------------------------------------------------- +# _get_state() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetState: + def test_returns_pending_when_state_is_none(self): + h, _ = _make_handler() + obj = MagicMock() + obj.state = None + assert h._get_state(obj) == "pending" + + def test_returns_state_value_for_enum_like_object(self): + h, _ = _make_handler() + obj = MagicMock() + obj.state = MagicMock(value="executing") + assert h._get_state(obj) == "executing" + + def test_returns_str_state_when_no_value_attr(self): + h, _ = _make_handler() + obj = MagicMock() + obj.state = "passed" # plain string — no .value attribute + assert h._get_state(obj) == "passed" + + def test_returns_pending_on_exception(self): + h, _ = _make_handler() + + class BrokenState: + @property + def value(self): + raise RuntimeError("broken") + + obj = MagicMock() + obj.state = BrokenState() + assert h._get_state(obj) == "pending" + diff --git a/tests/test_run/test_logs_http_server.py b/tests/test_run/test_logs_http_server.py index c5a586f..e8bed67 100644 --- a/tests/test_run/test_logs_http_server.py +++ b/tests/test_run/test_logs_http_server.py @@ -29,6 +29,7 @@ ENDPOINT_DOWNLOAD_LOGS, ENDPOINT_LOGS_STREAM, ENDPOINT_ROOT, + ENDPOINT_STATUS, LogStreamingHandler, LogsHTTPServer, ) @@ -468,3 +469,307 @@ def test_clears_server_and_thread_refs(self): assert srv.server is None assert srv.server_thread is None + + +# --------------------------------------------------------------------------- +# serve_status() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestServeStatus: + def test_routes_to_serve_status(self): + h = _make_handler(path=ENDPOINT_STATUS) + with patch.object(h, "serve_status") as mock_fn: + h.do_GET() + mock_fn.assert_called_once() + + def test_returns_200_with_json_content_type(self): + h = _make_handler(server_attrs={"test_run_title": "MyRun", "start_time": "2025-01-01"}) + h.serve_status() + assert h._response_code == 200 + assert h._headers_sent.get("Content-Type") == "application/json" + + def test_body_contains_run_title_and_start_time(self): + h = _make_handler(server_attrs={"test_run_title": "RunTitle", "start_time": "2025-06-01"}) + h.serve_status() + body = json.loads(h.wfile.getvalue()) + assert body["run_title"] == "RunTitle" + assert body["start_time"] == "2025-06-01" + + +# --------------------------------------------------------------------------- +# download_logs() — generic exception path +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDownloadLogsExceptions: + def test_handles_generic_exception_gracefully(self, tmp_path): + log_file = tmp_path / "test.log" + log_file.write_bytes(b"data") + h = _make_handler(server_attrs={"log_file_path": str(log_file)}) + h.wfile = MagicMock() + h.wfile.write.side_effect = RuntimeError("unexpected write error") + with patch("th_cli.test_run.logs_http_server.logger") as mock_logger: + h.download_logs() + mock_logger.error.assert_called() + + +# --------------------------------------------------------------------------- +# _send_sse_event() — generic exception path +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSendSseEventException: + def test_returns_false_on_generic_exception(self): + h = _make_handler() + h.wfile = MagicMock() + h.wfile.write.side_effect = OSError("disk full") + with patch("th_cli.test_run.logs_http_server.logger"): + result = h._send_sse_event("ev", {}) + assert result is False + + +# --------------------------------------------------------------------------- +# stream_logs() — additional paths +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestStreamLogsAdditional: + def test_deregisters_client_when_connected_send_fails(self): + h = _make_handler() + h._send_sse_event = lambda ev, data: False # all sends fail immediately + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue()): + with patch("th_cli.test_run.logs_http_server.logger"): + h.stream_logs() + assert len(h.server.active_clients) == 0 + + def test_sends_tree_snapshot_when_tree_state_not_empty(self): + tree = {"title": "Run", "state": "pending", "suites": []} + h = _make_handler(server_attrs={"tree_state": tree}) + sent_events = [] + h._send_sse_event = lambda ev, data: sent_events.append(ev) or True + + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue(None)): + with patch("th_cli.test_run.logs_http_server.logger"): + h.stream_logs() + + assert "tree_init" in sent_events + + def test_tree_snapshot_sent_using_tree_lock(self): + tree = {"title": "Run", "state": "pending", "suites": []} + lock = threading.Lock() + h = _make_handler(server_attrs={"tree_state": tree, "tree_lock": lock}) + sent_events = [] + h._send_sse_event = lambda ev, data: sent_events.append(ev) or True + + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue(None)): + with patch("th_cli.test_run.logs_http_server.logger"): + h.stream_logs() + + assert "tree_init" in sent_events + + def test_early_return_when_tree_snapshot_send_fails(self): + tree = {"title": "Run", "state": "pending", "suites": []} + h = _make_handler(server_attrs={"tree_state": tree}) + call_count = [0] + + def _send(ev, data): + call_count[0] += 1 + return ev == "connected" # True only for connected, False for tree_init + + h._send_sse_event = _send + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue(None)): + with patch("th_cli.test_run.logs_http_server.logger"): + h.stream_logs() + + assert call_count[0] == 2 # connected + tree_init (which failed → early return) + + def test_keepalive_sent_on_queue_empty(self): + call_num = [0] + mock_q = MagicMock() + + def get_side_effect(timeout=None): + call_num[0] += 1 + if call_num[0] == 1: + raise queue.Empty + return None # sentinel on second call + + mock_q.get.side_effect = get_side_effect + + h = _make_handler() + sent_events = [] + h._send_sse_event = lambda ev, data: sent_events.append(ev) or True + + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=mock_q): + with patch("th_cli.test_run.logs_http_server.logger"): + h.stream_logs() + + assert "keepalive" in sent_events + + def test_disconnect_when_keepalive_send_fails(self): + mock_q = MagicMock() + mock_q.get.side_effect = queue.Empty + + h = _make_handler() + h._send_sse_event = lambda ev, data: ev == "connected" # keepalive returns False + + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=mock_q): + with patch("th_cli.test_run.logs_http_server.logger"): + h.stream_logs() # must exit cleanly + + def test_queue_read_generic_exception_exits_loop(self): + call_num = [0] + mock_q = MagicMock() + + def get_side_effect(timeout=None): + call_num[0] += 1 + if call_num[0] == 1: + raise RuntimeError("unexpected queue error") + return None + + mock_q.get.side_effect = get_side_effect + + h = _make_handler() + sent_events = [] + h._send_sse_event = lambda ev, data: sent_events.append(ev) or True + + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=mock_q): + with patch("th_cli.test_run.logs_http_server.logger") as mock_logger: + h.stream_logs() + + mock_logger.debug.assert_called() + + def test_skips_non_dict_entries(self): + h = _make_handler() + sent_events = [] + h._send_sse_event = lambda ev, data: sent_events.append(ev) or True + + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue("not-a-dict", None)): + with patch("th_cli.test_run.logs_http_server.logger"): + h.stream_logs() + + assert "log" not in sent_events + + def test_streams_tree_init_event_from_queue(self): + tree_event = {"type": "tree_init", "data": {"title": "Run", "suites": []}} + h = _make_handler() # tree_state={} so snapshot is not sent first + sent_events = [] + h._send_sse_event = lambda ev, data: sent_events.append(ev) or True + + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue(tree_event, None)): + with patch("th_cli.test_run.logs_http_server.logger"): + h.stream_logs() + + assert "tree_init" in sent_events + + def test_skips_duplicate_tree_init_when_snapshot_already_sent(self): + tree = {"title": "Run", "state": "pending", "suites": []} + tree_event = {"type": "tree_init", "data": tree} + h = _make_handler(server_attrs={"tree_state": tree}) # snapshot sent on connect + sent_events = [] + h._send_sse_event = lambda ev, data: sent_events.append(ev) or True + + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue(tree_event, None)): + with patch("th_cli.test_run.logs_http_server.logger"): + h.stream_logs() + + assert sent_events.count("tree_init") == 1 # snapshot only, queue duplicate skipped + + def test_streams_tree_update_event(self): + update = {"type": "tree_update", "node_type": "step", "suite_idx": 0, + "case_idx": 0, "step_idx": 0, "state": "passed"} + h = _make_handler() + sent_events = [] + h._send_sse_event = lambda ev, data: sent_events.append(ev) or True + + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue(update, None)): + with patch("th_cli.test_run.logs_http_server.logger"): + h.stream_logs() + + assert "tree_update" in sent_events + + def test_broken_pipe_in_stream_loop_handled_gracefully(self): + h = _make_handler() + + def _send(ev, data): + if ev == "connected": + return True + raise BrokenPipeError("pipe broken") + + h._send_sse_event = _send + entry = {"message": "msg", "level": "INFO", "timestamp": "t"} + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue(entry, None)): + with patch("th_cli.test_run.logs_http_server.logger") as mock_logger: + h.stream_logs() # must not raise + + mock_logger.debug.assert_called() + + def test_generic_exception_in_stream_loop_handled_gracefully(self): + h = _make_handler() + + def _send(ev, data): + if ev == "connected": + return True + raise RuntimeError("unexpected error") + + h._send_sse_event = _send + entry = {"message": "msg", "level": "INFO", "timestamp": "t"} + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue(entry, None)): + with patch("th_cli.test_run.logs_http_server.logger") as mock_logger: + h.stream_logs() # must not raise + + mock_logger.debug.assert_called() + + +# --------------------------------------------------------------------------- +# LogsHTTPServer.start() — run_server thread exception path +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestLogsHTTPServerRunServerException: + def test_run_server_exception_is_logged(self): + srv = LogsHTTPServer(port=0) + clients = set() + lock = threading.Lock() + captured_target = [None] + + def capture_thread(target=None, daemon=None): + captured_target[0] = target + t = MagicMock() + t.start = lambda: None + return t + + with patch("th_cli.test_run.logs_http_server.ThreadingHTTPServer") as mock_cls: + mock_ths = MagicMock() + mock_ths.serve_forever.side_effect = RuntimeError("server crashed") + mock_cls.return_value = mock_ths + with patch("th_cli.test_run.logs_http_server.threading.Thread", side_effect=capture_thread): + with patch("th_cli.test_run.logs_http_server.logger") as mock_logger: + srv.start(active_clients=clients, clients_lock=lock, tree_state={}, test_run_title="Run") + captured_target[0]() # invoke thread target directly + mock_logger.error.assert_called() + + +# --------------------------------------------------------------------------- +# LogsHTTPServer.stop() — exception path +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestLogsHTTPServerStopException: + def test_stop_handles_shutdown_exception_gracefully(self): + srv = LogsHTTPServer() + mock_ths = MagicMock() + mock_ths.shutdown.side_effect = RuntimeError("shutdown failed") + srv.server = mock_ths + + with patch("th_cli.test_run.logs_http_server.logger"): + srv.stop() # must not raise + + assert srv.server is None + From a74298d2ffa7480e8b15ff3cb9de5d715b50cf2a Mon Sep 17 00:00:00 2001 From: "Antonio Melo Jr." Date: Wed, 8 Jul 2026 13:54:48 +0000 Subject: [PATCH 2/2] Applying gemini suggestions: Using specific assertions and more --- tests/test_run/test_log_stream_handler.py | 7 ++++--- tests/test_run/test_logs_http_server.py | 15 +++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/test_run/test_log_stream_handler.py b/tests/test_run/test_log_stream_handler.py index 08a9fc2..2c93c72 100644 --- a/tests/test_run/test_log_stream_handler.py +++ b/tests/test_run/test_log_stream_handler.py @@ -314,7 +314,7 @@ def test_logs_error_when_http_server_stop_raises(self): mock_srv.stop.side_effect = RuntimeError("stop failed") with patch("th_cli.test_run.log_stream_handler.logger") as mock_logger: h.stop() - mock_logger.error.assert_called() + mock_logger.error.assert_called_once_with("Error stopping log stream handler: stop failed") # --------------------------------------------------------------------------- @@ -359,8 +359,9 @@ def test_handles_build_exception_silently(self): h, _ = _make_handler() h.is_running = True with patch.object(h, "_build_tree", side_effect=RuntimeError("fail")): - with patch("th_cli.test_run.log_stream_handler.logger"): - h.init_tree(MagicMock()) # must not raise + with patch("th_cli.test_run.log_stream_handler.logger") as mock_logger: + h.init_tree(MagicMock()) + mock_logger.debug.assert_called_once_with("Error initialising tree: fail") # --------------------------------------------------------------------------- diff --git a/tests/test_run/test_logs_http_server.py b/tests/test_run/test_logs_http_server.py index e8bed67..156ecf7 100644 --- a/tests/test_run/test_logs_http_server.py +++ b/tests/test_run/test_logs_http_server.py @@ -513,7 +513,7 @@ def test_handles_generic_exception_gracefully(self, tmp_path): h.wfile.write.side_effect = RuntimeError("unexpected write error") with patch("th_cli.test_run.logs_http_server.logger") as mock_logger: h.download_logs() - mock_logger.error.assert_called() + mock_logger.error.assert_called_once_with("Error serving log file: unexpected write error") # --------------------------------------------------------------------------- @@ -561,16 +561,15 @@ def test_sends_tree_snapshot_when_tree_state_not_empty(self): def test_tree_snapshot_sent_using_tree_lock(self): tree = {"title": "Run", "state": "pending", "suites": []} - lock = threading.Lock() + lock = MagicMock() h = _make_handler(server_attrs={"tree_state": tree, "tree_lock": lock}) sent_events = [] h._send_sse_event = lambda ev, data: sent_events.append(ev) or True - with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue(None)): with patch("th_cli.test_run.logs_http_server.logger"): h.stream_logs() - assert "tree_init" in sent_events + lock.__enter__.assert_called_once() def test_early_return_when_tree_snapshot_send_fails(self): tree = {"title": "Run", "state": "pending", "suites": []} @@ -641,7 +640,7 @@ def get_side_effect(timeout=None): with patch("th_cli.test_run.logs_http_server.logger") as mock_logger: h.stream_logs() - mock_logger.debug.assert_called() + mock_logger.debug.assert_called_once_with("Queue read error: unexpected queue error") def test_skips_non_dict_entries(self): h = _make_handler() @@ -706,7 +705,7 @@ def _send(ev, data): with patch("th_cli.test_run.logs_http_server.logger") as mock_logger: h.stream_logs() # must not raise - mock_logger.debug.assert_called() + mock_logger.debug.assert_called_once_with("Client disconnected (broken pipe)") def test_generic_exception_in_stream_loop_handled_gracefully(self): h = _make_handler() @@ -722,7 +721,7 @@ def _send(ev, data): with patch("th_cli.test_run.logs_http_server.logger") as mock_logger: h.stream_logs() # must not raise - mock_logger.debug.assert_called() + mock_logger.debug.assert_called_once_with("Log streaming error: unexpected error") # --------------------------------------------------------------------------- @@ -752,7 +751,7 @@ def capture_thread(target=None, daemon=None): with patch("th_cli.test_run.logs_http_server.logger") as mock_logger: srv.start(active_clients=clients, clients_lock=lock, tree_state={}, test_run_title="Run") captured_target[0]() # invoke thread target directly - mock_logger.error.assert_called() + mock_logger.error.assert_called_once_with("Logs HTTP server error: server crashed") # ---------------------------------------------------------------------------