Skip to content

Commit 26ac7af

Browse files
committed
test(client): add comprehensive unit tests for client abstractions
- Implement tests for BaseClient list action and payload delegation - Add tests for HttpClient service info environment reading - Include tests for HttpClient fallback behavior with invalid service info - Add tests for HttpClient display formatting with response envelopes - Implement tests for MCPClient transport validation test(common utils): add unit tests for stream utility helpers - Create tests for chunk formatting across different transport formats - Add tests for stream task execution with remaining chunk flushing - Include tests for exception handling in stream tasks test(dingtalk utils): update tests to include timeout parameter validation - Modify fake post function to capture timeout value - Add assertion to verify timeout parameter is passed correctly test(env utils): add comprehensive tests for environment loading utilities - Test environment file parsing with comments and invalid lines - Add tests for explicit path loading with override behavior - Include tests for cached value handling in implicit loading test(schema): add unit tests for pydantic schema functionality - Test embedding node coercion to float16 and JSON serialization - Add tests for extra field allowance in request/response schemas - Include tests for component enum key acceptance in configs test(service utils): add comprehensive tests for service discovery helpers - Test PID retrieval from port with multiple results - Add tests for flowllm process scanning and argument parsing - Include tests for start precheck with existing instances - Add tests for port occupation detection and error handling
1 parent b695086 commit 26ac7af

6 files changed

Lines changed: 309 additions & 1 deletion

File tree

tests/unit/test_client.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Unit tests for client abstractions."""
2+
3+
# pylint: disable=protected-access,invalid-overridden-method
4+
5+
import asyncio
6+
import json
7+
8+
import pytest
9+
10+
from flowllm.components.client import BaseClient, HttpClient, MCPClient
11+
from flowllm.constants import FLOWLLM_DEFAULT_HOST, FLOWLLM_DEFAULT_PORT, FLOWLLM_SERVICE_INFO
12+
13+
14+
class _FakeClient(BaseClient):
15+
"""Concrete BaseClient for exercising wrapper behavior."""
16+
17+
def __init__(self, actions=None, **kwargs):
18+
super().__init__(**kwargs)
19+
self.actions = actions or [{"action": "search"}]
20+
self.payloads = []
21+
22+
async def list_actions(self):
23+
return self.actions
24+
25+
async def _execute(self, action: str, payload: dict):
26+
self.payloads.append((action, payload))
27+
yield "chunk"
28+
29+
30+
def test_base_client_list_action_returns_pretty_json():
31+
"""The synthetic list action uses list_actions instead of _execute."""
32+
33+
async def run():
34+
client = _FakeClient(actions=[{"action": "health_check", "method": "GET"}])
35+
chunks = [chunk async for chunk in client("list")]
36+
assert json.loads(chunks[0]) == [{"action": "health_check", "method": "GET"}]
37+
assert not client.payloads
38+
39+
asyncio.run(run())
40+
41+
42+
def test_base_client_delegates_non_list_actions():
43+
"""Regular actions pass through to _execute with kwargs as payload."""
44+
45+
async def run():
46+
client = _FakeClient()
47+
assert [chunk async for chunk in client("search", query="hi")] == ["chunk"]
48+
assert client.payloads == [("search", {"query": "hi"})]
49+
50+
asyncio.run(run())
51+
52+
53+
def test_http_client_reads_service_info_from_environment(monkeypatch):
54+
"""HTTP client host/port default from FLOWLLM_SERVICE_INFO when present."""
55+
monkeypatch.setenv(FLOWLLM_SERVICE_INFO, json.dumps({"host": "10.0.0.1", "port": 8123}))
56+
57+
client = HttpClient()
58+
59+
assert client.base_url == "http://10.0.0.1:8123"
60+
61+
62+
def test_http_client_falls_back_on_invalid_service_info(monkeypatch):
63+
"""Invalid service info is ignored in favor of built-in defaults."""
64+
monkeypatch.setenv(FLOWLLM_SERVICE_INFO, "not-json")
65+
66+
client = HttpClient()
67+
68+
assert client.base_url == f"http://{FLOWLLM_DEFAULT_HOST}:{FLOWLLM_DEFAULT_PORT}"
69+
70+
71+
def test_http_client_format_for_display_handles_response_envelope():
72+
"""Response envelopes render answer first with status and metadata."""
73+
rendered = HttpClient._format_for_display(
74+
json.dumps({"answer": "done", "success": True, "metadata": {"cost": 1}, "extra": "x"}),
75+
)
76+
77+
assert rendered.splitlines()[0] == "done"
78+
assert "\u2705" in rendered
79+
assert '{"cost": 1}' in rendered
80+
assert '"extra": "x"' in rendered
81+
82+
83+
def test_mcp_client_rejects_unknown_transport():
84+
"""Unsupported MCP transport names fail during construction."""
85+
with pytest.raises(ValueError, match="Unknown transport"):
86+
MCPClient(transport="websocket")

tests/unit/test_common_utils.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Unit tests for stream utility helpers."""
2+
3+
import asyncio
4+
import json
5+
6+
import pytest
7+
8+
from flowllm.enumeration import ChunkEnum
9+
from flowllm.schema import StreamChunk
10+
from flowllm.utils.common_utils import _format_chunk, execute_stream_task
11+
12+
13+
def test_format_chunk_supports_str_bytes_chunk_and_done_marker():
14+
"""Chunks are rendered correctly for all transport formats."""
15+
chunk = StreamChunk(chunk_type=ChunkEnum.CONTENT, chunk="hello")
16+
17+
as_str = _format_chunk(chunk, "str")
18+
assert isinstance(as_str, str)
19+
assert as_str.startswith("data:")
20+
assert json.loads(as_str.removeprefix("data:").strip())["chunk"] == "hello"
21+
22+
assert _format_chunk(chunk, "bytes") == as_str.encode()
23+
assert _format_chunk(chunk, "chunk") is chunk
24+
assert _format_chunk(StreamChunk(done=True), "str") == "data:[DONE]\n\n"
25+
26+
27+
def test_execute_stream_task_flushes_remaining_chunks_after_task_finishes():
28+
"""A completed producer still lets queued chunks drain before DONE."""
29+
30+
async def run():
31+
queue: asyncio.Queue[StreamChunk] = asyncio.Queue()
32+
await queue.put(StreamChunk(chunk="one"))
33+
await queue.put(StreamChunk(chunk="two"))
34+
35+
async def producer():
36+
return None
37+
38+
task = asyncio.create_task(producer())
39+
items = [item async for item in execute_stream_task(queue, task, output_format="chunk")]
40+
41+
assert [item.chunk for item in items] == ["one", "two", ""]
42+
assert items[-1].done is True
43+
assert items[-1].chunk_type == ChunkEnum.DONE
44+
45+
asyncio.run(run())
46+
47+
48+
def test_execute_stream_task_raises_task_exception():
49+
"""Producer exceptions are surfaced to callers."""
50+
51+
async def run():
52+
queue: asyncio.Queue[StreamChunk] = asyncio.Queue()
53+
54+
async def producer():
55+
raise RuntimeError("boom")
56+
57+
task = asyncio.create_task(producer())
58+
with pytest.raises(RuntimeError, match="boom"):
59+
_ = [item async for item in execute_stream_task(queue, task, task_name="job")]
60+
61+
asyncio.run(run())

tests/unit/test_dingtalk_utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,17 @@ def test_send_dingtalk_text_message(monkeypatch):
5151
monkeypatch.setenv("DING_DAILY_API_TOKEN", "daily-token")
5252
monkeypatch.setattr(dingtalk_utils, "load_env", lambda: {})
5353

54-
def fake_post(_url, json, _timeout):
54+
def fake_post(_url, json, timeout):
5555
seen["json"] = json
56+
seen["timeout"] = timeout
5657
return FakeResponse()
5758

5859
monkeypatch.setattr(dingtalk_utils.httpx, "post", fake_post)
5960

6061
dingtalk_utils.send_dingtalk_message("Ignored", "hello", msgtype="text")
6162

6263
assert seen["json"] == {"msgtype": "text", "text": {"content": "hello"}}
64+
assert seen["timeout"] == 10.0
6365

6466

6567
def test_send_dingtalk_message_requires_token(monkeypatch):

tests/unit/test_env_utils.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Unit tests for environment loading helpers."""
2+
3+
# pylint: disable=protected-access
4+
5+
from flowllm.utils import env_utils
6+
7+
8+
def test_parse_env_file_skips_comments_and_invalid_lines(tmp_path):
9+
"""Only valid KEY=VALUE entries are returned."""
10+
env_file = tmp_path / ".env"
11+
env_file.write_text(
12+
"""
13+
# comment
14+
FOO=bar
15+
QUOTED="hello world"
16+
EMPTY_KEY_IGNORED
17+
=missing
18+
SPACED = 'trimmed'
19+
""",
20+
encoding="utf-8",
21+
)
22+
23+
assert env_utils._parse_env_file(env_file) == {
24+
"FOO": "bar",
25+
"QUOTED": "hello world",
26+
"SPACED": "trimmed",
27+
}
28+
29+
30+
def test_load_env_from_explicit_path_respects_override(monkeypatch, tmp_path):
31+
"""Explicit .env loading does not overwrite existing values when override=False."""
32+
env_file = tmp_path / ".env"
33+
env_file.write_text("FLOWLLM_ENV_TEST=file\nNEW_VALUE=created\n", encoding="utf-8")
34+
monkeypatch.setenv("FLOWLLM_ENV_TEST", "existing")
35+
36+
loaded = env_utils.load_env(env_file, override=False)
37+
38+
assert loaded == {"NEW_VALUE": "created"}
39+
assert env_utils.os.environ["FLOWLLM_ENV_TEST"] == "existing"
40+
assert env_utils.os.environ["NEW_VALUE"] == "created"
41+
42+
43+
def test_load_env_searches_cwd_once_and_returns_cached_values(monkeypatch, tmp_path):
44+
"""Implicit .env loading caches the discovered values."""
45+
env_file = tmp_path / ".env"
46+
env_file.write_text("FLOWLLM_CACHED_ENV=first\n", encoding="utf-8")
47+
monkeypatch.chdir(tmp_path)
48+
monkeypatch.delenv("FLOWLLM_CACHED_ENV", raising=False)
49+
monkeypatch.setattr(env_utils, "_LOADED", False)
50+
monkeypatch.setattr(env_utils, "_LOADED_VALUES", {})
51+
52+
assert env_utils.load_env() == {"FLOWLLM_CACHED_ENV": "first"}
53+
54+
env_file.write_text("FLOWLLM_CACHED_ENV=second\n", encoding="utf-8")
55+
monkeypatch.setenv("FLOWLLM_CACHED_ENV", "changed")
56+
57+
assert env_utils.load_env() == {"FLOWLLM_CACHED_ENV": "first"}
58+
assert env_utils.os.environ["FLOWLLM_CACHED_ENV"] == "changed"

tests/unit/test_schema.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Unit tests for pydantic schema helpers."""
2+
3+
import numpy as np
4+
5+
from flowllm.enumeration import ComponentEnum
6+
from flowllm.schema import ApplicationConfig, ComponentConfig, EmbNode, Request, Response
7+
8+
9+
def test_emb_node_coerces_embedding_to_float16_and_serializes_to_list():
10+
"""Embedding vectors accept plain lists and remain JSON serializable."""
11+
node = EmbNode(text="hello", embedding=[1.25, 2.5], metadata={"source": "unit"})
12+
13+
assert isinstance(node.embedding, np.ndarray)
14+
assert node.embedding.dtype == np.float16
15+
assert node.model_dump()["embedding"] == [1.25, 2.5]
16+
assert node.metadata == {"source": "unit"}
17+
18+
19+
def test_request_response_component_config_allow_extra_fields():
20+
"""Schemas intentionally preserve backend-specific payload fields."""
21+
request = Request(metadata={"trace_id": "1"}, query="hello")
22+
response = Response(answer="ok", score=0.9)
23+
component = ComponentConfig(backend="custom", host="127.0.0.1")
24+
25+
assert request.query == "hello"
26+
assert response.score == 0.9
27+
assert component.host == "127.0.0.1"
28+
29+
30+
def test_application_config_accepts_component_enum_keys():
31+
"""Component config maps can be keyed by ComponentEnum values."""
32+
config = ApplicationConfig(
33+
components={
34+
ComponentEnum.CLIENT: {
35+
"http": ComponentConfig(backend="http"),
36+
},
37+
},
38+
)
39+
40+
assert config.components[ComponentEnum.CLIENT]["http"].backend == "http"

tests/unit/test_service_utils.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Unit tests for service discovery helpers."""
2+
3+
# pylint: disable=protected-access
4+
5+
import pytest
6+
7+
from flowllm.constants import FLOWLLM_DEFAULT_HOST, FLOWLLM_DEFAULT_PORT
8+
from flowllm.utils import service_utils
9+
10+
11+
def test_pid_on_port_returns_first_pid(monkeypatch):
12+
"""Only the first lsof result is used."""
13+
monkeypatch.setattr(service_utils, "_sh", lambda _cmd: "123\n456\n")
14+
15+
assert service_utils._pid_on_port(8080) == 123
16+
17+
18+
def test_scan_flowllm_procs_extracts_host_and_port(monkeypatch):
19+
"""flowllm start process args are parsed for service host and port."""
20+
monkeypatch.setattr(
21+
service_utils,
22+
"_sh",
23+
lambda _cmd: (
24+
"111 flowllm start service.host=0.0.0.0 service.port=9000\n"
25+
"222 python -m flowllm start\n"
26+
"bad not-a-pid\n"
27+
),
28+
)
29+
30+
assert service_utils._scan_flowllm_procs() == [
31+
(111, "0.0.0.0", 9000),
32+
(222, FLOWLLM_DEFAULT_HOST, FLOWLLM_DEFAULT_PORT),
33+
]
34+
35+
36+
def test_precheck_start_returns_false_when_flowllm_already_running(monkeypatch, capsys):
37+
"""Existing FlowLLM instance prevents a second start."""
38+
39+
async def fake_find_flowllm(host, port):
40+
assert (host, port) == ("127.0.0.9", 7777)
41+
return "flowllm"
42+
43+
monkeypatch.setattr(service_utils, "find_flowllm", fake_find_flowllm)
44+
45+
assert service_utils.precheck_start({"host": "127.0.0.9", "port": "7777"}) is False
46+
assert "flowllm already running at 127.0.0.9:7777" in capsys.readouterr().out
47+
48+
49+
def test_precheck_start_exits_when_port_occupied(monkeypatch, capsys):
50+
"""Non-FlowLLM listeners on the requested port are reported as errors."""
51+
52+
async def fake_find_flowllm(_host, _port):
53+
return "occupied"
54+
55+
monkeypatch.setattr(service_utils, "find_flowllm", fake_find_flowllm)
56+
57+
with pytest.raises(SystemExit) as exc:
58+
service_utils.precheck_start({"port": 8888})
59+
60+
assert exc.value.code == 1
61+
assert "port 8888 occupied" in capsys.readouterr().err

0 commit comments

Comments
 (0)