# -*- coding: utf-8 -*- """Unit tests for trace_job_service (async trace job queue).""" from __future__ import annotations import json from unittest.mock import MagicMock, patch import pytest import mes_dashboard.services.trace_job_service as tjs # --------------------------------------------------------------------------- # is_async_available # --------------------------------------------------------------------------- def test_is_async_available_true(): """Should return True when rq is importable and Redis is up.""" tjs._RQ_AVAILABLE = None # reset cached flag with patch.object(tjs, "get_redis_client", return_value=MagicMock()): assert tjs.is_async_available() is True def test_is_async_available_false_no_redis(): """Should return False when Redis is unavailable.""" tjs._RQ_AVAILABLE = True with patch.object(tjs, "get_redis_client", return_value=None): assert tjs.is_async_available() is False # --------------------------------------------------------------------------- # enqueue_trace_events_job # --------------------------------------------------------------------------- @patch.object(tjs, "_get_rq_queue") @patch.object(tjs, "get_redis_client") def test_enqueue_success(mock_redis, mock_queue_fn): """Enqueue should return a job_id and store metadata in Redis.""" conn = MagicMock() mock_redis.return_value = conn queue = MagicMock() mock_queue_fn.return_value = queue job_id, err = tjs.enqueue_trace_events_job( "query_tool", ["CID-1", "CID-2"], ["history"], {"params": {}}, ) assert job_id is not None assert job_id.startswith("trace-evt-") assert err is None queue.enqueue.assert_called_once() conn.hset.assert_called_once() conn.expire.assert_called_once() @patch.object(tjs, "_get_rq_queue", return_value=None) def test_enqueue_no_queue(mock_queue_fn): """Enqueue should return error when queue is unavailable.""" job_id, err = tjs.enqueue_trace_events_job( "query_tool", ["CID-1"], ["history"], {}, ) assert job_id is None assert "unavailable" in err @patch.object(tjs, "_get_rq_queue") @patch.object(tjs, "get_redis_client") def test_enqueue_queue_error(mock_redis, mock_queue_fn): """Enqueue should return error when queue.enqueue raises.""" conn = MagicMock() mock_redis.return_value = conn queue = MagicMock() queue.enqueue.side_effect = RuntimeError("connection refused") mock_queue_fn.return_value = queue job_id, err = tjs.enqueue_trace_events_job( "query_tool", ["CID-1"], ["history"], {}, ) assert job_id is None assert "connection refused" in err # Meta key should be cleaned up conn.delete.assert_called_once() # --------------------------------------------------------------------------- # get_job_status # --------------------------------------------------------------------------- @patch.object(tjs, "get_redis_client") def test_get_job_status_found(mock_redis): """Should return status dict from Redis hash.""" conn = MagicMock() conn.hgetall.return_value = { "profile": "query_tool", "cid_count": "100", "domains": "history,materials", "status": "started", "progress": "fetching events", "created_at": "1740000000.0", "completed_at": "", "error": "", } mock_redis.return_value = conn status = tjs.get_job_status("trace-evt-abc123") assert status is not None assert status["job_id"] == "trace-evt-abc123" assert status["status"] == "started" assert status["cid_count"] == 100 assert status["domains"] == ["history", "materials"] assert status["error"] is None @patch.object(tjs, "get_redis_client") def test_get_job_status_not_found(mock_redis): """Should return None when job metadata does not exist.""" conn = MagicMock() conn.hgetall.return_value = {} mock_redis.return_value = conn assert tjs.get_job_status("trace-evt-nonexistent") is None # --------------------------------------------------------------------------- # get_job_result # --------------------------------------------------------------------------- @patch.object(tjs, "get_redis_client") def test_get_job_result_found_chunked(mock_redis): """Should return reconstructed result from chunked Redis keys.""" result_meta = { "profile": "query_tool", "domains": {"history": {"chunks": 1, "total": 1}}, "failed_domains": [], } chunk_data = [{"CONTAINERID": "CID-1"}] conn = MagicMock() def _get_side_effect(key): if key.endswith(":result:meta"): return json.dumps(result_meta) if key.endswith(":result:history:0"): return json.dumps(chunk_data) if key.endswith(":result:aggregation"): return None return None conn.get.side_effect = _get_side_effect mock_redis.return_value = conn result = tjs.get_job_result("trace-evt-abc123") assert result is not None assert result["stage"] == "events" assert result["results"]["history"]["count"] == 1 assert result["results"]["history"]["total"] == 1 @patch.object(tjs, "get_redis_client") def test_get_job_result_found_legacy(mock_redis): """Should fall back to legacy single-key result when no chunked meta exists.""" result_data = { "stage": "events", "results": {"history": {"data": [{"CONTAINERID": "CID-1"}], "count": 1}}, "aggregation": None, } conn = MagicMock() def _get_side_effect(key): if key.endswith(":result:meta"): return None # no chunked meta if key.endswith(":result"): return json.dumps(result_data) return None conn.get.side_effect = _get_side_effect mock_redis.return_value = conn result = tjs.get_job_result("trace-evt-abc123") assert result is not None assert result["stage"] == "events" assert result["results"]["history"]["count"] == 1 @patch.object(tjs, "get_redis_client") def test_get_job_result_not_found(mock_redis): """Should return None when result key does not exist.""" conn = MagicMock() conn.get.return_value = None mock_redis.return_value = conn assert tjs.get_job_result("trace-evt-expired") is None @patch.object(tjs, "get_redis_client") def test_get_job_result_with_domain_filter(mock_redis): """Should return filtered result with pagination from chunked storage.""" result_meta = { "profile": "query_tool", "domains": { "history": {"chunks": 1, "total": 3}, "materials": {"chunks": 1, "total": 1}, }, "failed_domains": [], } history_chunk = [{"id": 1}, {"id": 2}, {"id": 3}] materials_chunk = [{"id": 10}] conn = MagicMock() def _get_side_effect(key): if key.endswith(":result:meta"): return json.dumps(result_meta) if key.endswith(":result:history:0"): return json.dumps(history_chunk) if key.endswith(":result:materials:0"): return json.dumps(materials_chunk) if key.endswith(":result:aggregation"): return None return None conn.get.side_effect = _get_side_effect mock_redis.return_value = conn result = tjs.get_job_result("trace-evt-abc", domain="history", offset=1, limit=1) assert "history" in result["results"] assert "materials" not in result["results"] assert result["results"]["history"]["data"] == [{"id": 2}] assert result["results"]["history"]["total"] == 3 # --------------------------------------------------------------------------- # execute_trace_events_job (worker entry point) # --------------------------------------------------------------------------- @patch.object(tjs, "get_redis_client") @patch("mes_dashboard.services.event_fetcher.EventFetcher.fetch_events") def test_execute_job_success(mock_fetch, mock_redis): """Worker should fetch events, store result, and update meta to finished.""" mock_fetch.return_value = {"CID-1": [{"CONTAINERID": "CID-1"}]} conn = MagicMock() mock_redis.return_value = conn tjs.execute_trace_events_job( "test-job-1", "query_tool", ["CID-1"], ["history"], {}, ) mock_fetch.assert_called_once_with(["CID-1"], "history") # Result should be stored as chunked keys (chunk + result meta) setex_calls = [c for c in conn.method_calls if c[0] == "setex"] assert len(setex_calls) == 2 # 1 chunk + 1 result meta # Find the result meta setex call result_meta_call = [c for c in setex_calls if ":result:meta" in str(c)] assert len(result_meta_call) == 1 stored_meta = json.loads(result_meta_call[0][1][2]) assert "history" in stored_meta["domains"] assert stored_meta["domains"]["history"]["total"] == 1 # Find the chunk setex call chunk_call = [c for c in setex_calls if ":result:history:0" in str(c)] assert len(chunk_call) == 1 stored_chunk = json.loads(chunk_call[0][1][2]) assert len(stored_chunk) == 1 assert stored_chunk[0]["CONTAINERID"] == "CID-1" # Job meta should be updated to finished hset_calls = [c for c in conn.method_calls if c[0] == "hset"] last_meta = hset_calls[-1][2]["mapping"] assert last_meta["status"] == "finished" @patch.object(tjs, "get_redis_client") @patch("mes_dashboard.services.event_fetcher.EventFetcher.fetch_events") def test_execute_job_domain_failure_records_partial(mock_fetch, mock_redis): """Domain fetch failure should result in partial failure, not job crash.""" mock_fetch.side_effect = RuntimeError("db timeout") conn = MagicMock() mock_redis.return_value = conn tjs.execute_trace_events_job( "test-job-2", "query_tool", ["CID-1"], ["history"], {}, ) # Result meta should still be stored (with failed_domains) setex_calls = [c for c in conn.method_calls if c[0] == "setex"] assert len(setex_calls) == 1 # only result meta (no chunks since domain failed) stored_meta = json.loads(setex_calls[0][1][2]) assert "history" in stored_meta["failed_domains"] # Job meta should still be finished (partial failure is not a job crash) hset_calls = [c for c in conn.method_calls if c[0] == "hset"] last_meta = hset_calls[-1][2]["mapping"] assert last_meta["status"] == "finished" # --------------------------------------------------------------------------- # _store_chunked_result # --------------------------------------------------------------------------- @patch.object(tjs, "get_redis_client") def test_store_chunked_result_splits_batches(mock_redis): """Large domain data should be split into multiple chunks.""" conn = MagicMock() mock_redis.return_value = conn # 12 records, batch size 5 → 3 chunks (5+5+2) rows = [{"id": i} for i in range(12)] results = {"history": {"data": rows, "count": 12}} original_batch_size = tjs.TRACE_STREAM_BATCH_SIZE tjs.TRACE_STREAM_BATCH_SIZE = 5 try: tjs._store_chunked_result(conn, "job-1", "query_tool", results, None, []) finally: tjs.TRACE_STREAM_BATCH_SIZE = original_batch_size setex_calls = [c for c in conn.method_calls if c[0] == "setex"] # 3 chunk keys + 1 result meta = 4 assert len(setex_calls) == 4 # Verify result meta meta_call = [c for c in setex_calls if ":result:meta" in str(c)] assert len(meta_call) == 1 meta = json.loads(meta_call[0][1][2]) assert meta["domains"]["history"]["chunks"] == 3 assert meta["domains"]["history"]["total"] == 12 # Verify chunks chunk_calls = [c for c in setex_calls if ":result:history:" in str(c)] assert len(chunk_calls) == 3 chunk_0 = json.loads(chunk_calls[0][1][2]) assert len(chunk_0) == 5 @patch.object(tjs, "get_redis_client") def test_store_chunked_result_with_aggregation(mock_redis): """Aggregation should be stored in a separate key.""" conn = MagicMock() mock_redis.return_value = conn results = {"history": {"data": [{"id": 1}], "count": 1}} aggregation = {"summary": {"total": 100}} tjs._store_chunked_result(conn, "job-1", "mid_section_defect", results, aggregation, []) setex_calls = [c for c in conn.method_calls if c[0] == "setex"] agg_call = [c for c in setex_calls if ":result:aggregation" in str(c)] assert len(agg_call) == 1 stored_agg = json.loads(agg_call[0][1][2]) assert stored_agg["summary"]["total"] == 100 # --------------------------------------------------------------------------- # stream_job_result_ndjson # --------------------------------------------------------------------------- @patch.object(tjs, "get_redis_client") def test_stream_ndjson_chunked(mock_redis): """NDJSON stream should yield correct protocol lines for chunked result.""" result_meta = { "profile": "query_tool", "domains": {"history": {"chunks": 2, "total": 3}}, "failed_domains": [], } chunk_0 = [{"id": 1}, {"id": 2}] chunk_1 = [{"id": 3}] conn = MagicMock() def _get_side_effect(key): if key.endswith(":result:meta"): return json.dumps(result_meta) if key.endswith(":result:history:0"): return json.dumps(chunk_0) if key.endswith(":result:history:1"): return json.dumps(chunk_1) if key.endswith(":result:aggregation"): return None return None conn.get.side_effect = _get_side_effect mock_redis.return_value = conn lines = list(tjs.stream_job_result_ndjson("job-1")) parsed = [json.loads(line) for line in lines] types = [p["type"] for p in parsed] assert types == ["meta", "domain_start", "records", "records", "domain_end", "complete"] assert parsed[0]["domains"] == ["history"] assert parsed[1]["domain"] == "history" assert parsed[1]["total"] == 3 assert parsed[2]["count"] == 2 assert parsed[3]["count"] == 1 assert parsed[4]["count"] == 3 assert parsed[5]["total_records"] == 3 @patch.object(tjs, "get_redis_client") def test_stream_ndjson_with_aggregation(mock_redis): """NDJSON stream should include aggregation line when present.""" result_meta = { "profile": "mid_section_defect", "domains": {"upstream_history": {"chunks": 1, "total": 1}}, "failed_domains": [], } conn = MagicMock() def _get_side_effect(key): if key.endswith(":result:meta"): return json.dumps(result_meta) if key.endswith(":result:upstream_history:0"): return json.dumps([{"id": 1}]) if key.endswith(":result:aggregation"): return json.dumps({"summary": "ok"}) return None conn.get.side_effect = _get_side_effect mock_redis.return_value = conn lines = list(tjs.stream_job_result_ndjson("job-1")) parsed = [json.loads(line) for line in lines] types = [p["type"] for p in parsed] assert "aggregation" in types agg_line = [p for p in parsed if p["type"] == "aggregation"][0] assert agg_line["data"]["summary"] == "ok" @patch.object(tjs, "get_redis_client") def test_stream_ndjson_legacy_fallback(mock_redis): """NDJSON stream should emit full_result for legacy single-key storage.""" legacy_result = { "stage": "events", "results": {"history": {"data": [{"id": 1}], "count": 1}}, "aggregation": None, } conn = MagicMock() def _get_side_effect(key): if key.endswith(":result:meta"): return None # no chunked meta if key.endswith(":result"): return json.dumps(legacy_result) return None conn.get.side_effect = _get_side_effect mock_redis.return_value = conn lines = list(tjs.stream_job_result_ndjson("job-1")) parsed = [json.loads(line) for line in lines] assert len(parsed) == 1 assert parsed[0]["type"] == "full_result" assert parsed[0]["data"]["stage"] == "events" @patch.object(tjs, "get_redis_client") def test_stream_ndjson_with_failed_domains(mock_redis): """NDJSON stream should include warning line for partial failures.""" result_meta = { "profile": "query_tool", "domains": {"materials": {"chunks": 1, "total": 1}}, "failed_domains": ["history"], } conn = MagicMock() def _get_side_effect(key): if key.endswith(":result:meta"): return json.dumps(result_meta) if key.endswith(":result:materials:0"): return json.dumps([{"id": 1}]) if key.endswith(":result:aggregation"): return None return None conn.get.side_effect = _get_side_effect mock_redis.return_value = conn lines = list(tjs.stream_job_result_ndjson("job-1")) parsed = [json.loads(line) for line in lines] types = [p["type"] for p in parsed] assert "warning" in types warning = [p for p in parsed if p["type"] == "warning"][0] assert warning["code"] == "EVENTS_PARTIAL_FAILURE" assert "history" in warning["failed_domains"] # --------------------------------------------------------------------------- # _flatten_domain_records # --------------------------------------------------------------------------- def test_flatten_domain_records(): events_by_cid = { "CID-1": [{"CONTAINERID": "CID-1", "EVT": "A"}], "CID-2": [{"CONTAINERID": "CID-2", "EVT": "B"}, {"CONTAINERID": "CID-2", "EVT": "C"}], } rows = tjs._flatten_domain_records(events_by_cid) assert len(rows) == 3