Files
temp/features/steps/session_cli_uncovered_branches_steps.py
T

393 lines
13 KiB
Python

"""Step definitions for session CLI uncovered-branch coverage tests."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands.session import app as session_app
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionExportError,
SessionMessage,
SessionNotFoundError,
SessionService,
SessionTokenUsage,
)
runner = CliRunner()
# Valid ULIDs for tests (Crockford base32)
_ULID = "01KJ1N8YDN05N29P5RZV4SPDVZ"
_ULID2 = "01KJ1N8YDN05N29P5RZV4SPDW0"
_NOW = datetime(2025, 6, 15, 12, 0, 0)
def _make_session(
*,
session_id: str = _ULID,
actor_name: str | None = None,
namespace: str = "local",
messages: list[SessionMessage] | None = None,
linked_plan_ids: list[str] | None = None,
token_usage: SessionTokenUsage | None = None,
metadata: dict[str, Any] | None = None,
) -> Session:
"""Build a Session object with sensible defaults."""
return Session(
session_id=session_id,
actor_name=actor_name,
namespace=namespace,
messages=messages or [],
linked_plan_ids=linked_plan_ids or [],
token_usage=token_usage or SessionTokenUsage(),
created_at=_NOW,
updated_at=_NOW,
metadata=metadata or {},
)
def _make_message(
content: str = "hello",
role: MessageRole = MessageRole.USER,
sequence: int = 0,
) -> SessionMessage:
return SessionMessage(
message_id=_ULID2,
role=role,
content=content,
sequence=sequence,
timestamp=_NOW,
)
def _mock_service() -> MagicMock:
"""Create a MagicMock that passes isinstance checks for SessionService."""
svc = MagicMock(spec=SessionService)
return svc
# ---- helpers to patch the module-level _service ---------------------------
def _patch_service(context, svc):
"""Patch session._service and register cleanup."""
patcher = patch("cleveragents.cli.commands.session._service", svc)
patcher.start()
context._cleanup_handlers.append(patcher.stop)
# ===========================================================================
# Scenario: _get_session_service constructs service when _service is None
# ===========================================================================
@given("session cli branch the module-level _service is None")
def step_service_is_none(context):
import cleveragents.cli.commands.session as mod
context._original_service = mod._service
mod._service = None
context._cleanup_handlers.append(
lambda: setattr(mod, "_service", context._original_service)
)
@when("session cli branch I call _get_session_service with mocked container")
def step_call_get_session_service(context):
import cleveragents.cli.commands.session as mod
mock_db = MagicMock()
mock_container = MagicMock()
mock_container.db.return_value = mock_db
mock_session_repo_cls = MagicMock()
mock_message_repo_cls = MagicMock()
mock_persistent_cls = MagicMock()
context._mock_persistent_instance = mock_persistent_cls.return_value
with patch("cleveragents.cli.commands.session.get_container", create=True):
# We need to mock the imports that happen inside the function.
# The function does:
# from cleveragents.application.container import get_container
# from cleveragents.application.services.session_service import PersistentSessionService
# from cleveragents.infrastructure.database.repositories import SessionRepository, SessionMessageRepository
# We mock these at the module import level inside the function.
import sys
# Create mock modules
mock_container_mod = MagicMock()
mock_container_mod.get_container = MagicMock(return_value=mock_container)
mock_service_mod = MagicMock()
mock_service_mod.PersistentSessionService = mock_persistent_cls
mock_repo_mod = MagicMock()
mock_repo_mod.SessionRepository = mock_session_repo_cls
mock_repo_mod.SessionMessageRepository = mock_message_repo_cls
with patch.dict(
sys.modules,
{
"cleveragents.application.container": mock_container_mod,
"cleveragents.application.services.session_service": mock_service_mod,
"cleveragents.infrastructure.database.repositories": mock_repo_mod,
},
):
# Force re-import by deleting cached names if any
# The function uses local imports so they run every time
result = mod._get_session_service()
context._get_service_result = result
@then("session cli branch a PersistentSessionService is returned")
def step_persistent_returned(context):
assert context._get_service_result is context._mock_persistent_instance
# ===========================================================================
# Scenario: _reset_session_service sets _service to None
# ===========================================================================
@given("session cli branch the module-level _service holds a mock")
def step_service_holds_mock(context):
import cleveragents.cli.commands.session as mod
context._original_service = mod._service
mod._service = MagicMock()
context._cleanup_handlers.append(
lambda: setattr(mod, "_service", context._original_service)
)
@when("session cli branch I call _reset_session_service")
def step_call_reset(context):
import cleveragents.cli.commands.session as mod
mod._reset_session_service()
@then("session cli branch the module-level _service is None again")
def step_service_is_none_again(context):
import cleveragents.cli.commands.session as mod
assert mod._service is None
# Restore so after_scenario doesn't break
mod._service = context._original_service
# ===========================================================================
# Scenario: create command catches SessionNotFoundError
# ===========================================================================
@given(
"session cli branch a mock session service that raises SessionNotFoundError on create"
)
def step_service_raises_on_create(context):
svc = _mock_service()
svc.create.side_effect = SessionNotFoundError("actor not found")
_patch_service(context, svc)
@when("session cli branch I invoke the create command")
def step_invoke_create(context):
context.result = runner.invoke(session_app, ["create"])
@then("session cli branch the exit code is 1")
def step_exit_code_1(context):
assert context.result.exit_code == 1, (
f"Expected exit code 1, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
@then('session cli branch the output contains "{text}"')
def step_output_contains(context, text):
combined = context.result.output
assert text in combined, f"Expected '{text}' in output. Got:\n{combined}"
# ===========================================================================
# Scenario: show command with no messages
# ===========================================================================
@given("session cli branch a mock session service returning a session with no messages")
def step_service_no_messages(context):
session = _make_session(messages=[], linked_plan_ids=[])
svc = _mock_service()
svc.get.return_value = session
_patch_service(context, svc)
context.session_id = _ULID
@when("session cli branch I invoke the show command for that session")
def step_invoke_show_no_msgs(context):
context.result = runner.invoke(session_app, ["show", context.session_id])
@then("session cli branch the exit code is 0")
def step_exit_code_0(context):
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
@then('session cli branch the output does not contain "{text}"')
def step_output_not_contains(context, text):
combined = context.result.output
assert text not in combined, f"Did not expect '{text}' in output. Got:\n{combined}"
# ===========================================================================
# Scenario: show command with linked plan ids
# ===========================================================================
@given(
"session cli branch a mock session service returning a session with linked plans"
)
def step_service_linked_plans(context):
session = _make_session(
linked_plan_ids=["01PLAN1234567890ABCDEFGHIJ", "01PLAN1234567890ABCDEFGHIK"],
)
svc = _mock_service()
svc.get.return_value = session
_patch_service(context, svc)
context.session_id_linked = _ULID
@when("session cli branch I invoke the show command for the linked-plans session")
def step_invoke_show_linked(context):
context.result = runner.invoke(session_app, ["show", context.session_id_linked])
# ===========================================================================
# Scenario: show command with long message content truncated
# ===========================================================================
@given(
"session cli branch a mock session service returning a session with a long message"
)
def step_service_long_message(context):
long_content = "A" * 100 # longer than 80 chars → triggers truncation
msg = _make_message(content=long_content, sequence=0)
session = _make_session(messages=[msg])
svc = _mock_service()
svc.get.return_value = session
_patch_service(context, svc)
context.session_id_long = _ULID
@when("session cli branch I invoke the show command for the long-message session")
def step_invoke_show_long(context):
context.result = runner.invoke(session_app, ["show", context.session_id_long])
# ===========================================================================
# Scenario: delete command aborted via confirmation prompt
# ===========================================================================
@given("session cli branch a mock session service for delete")
def step_service_for_delete(context):
session = _make_session()
svc = _mock_service()
svc.get.return_value = session
_patch_service(context, svc)
context.session_id_del = _ULID
@when("session cli branch I invoke the delete command without --yes and answer no")
def step_invoke_delete_no(context):
# typer.confirm reads from stdin; CliRunner accepts `input` kwarg
context.result = runner.invoke(
session_app,
["delete", context.session_id_del],
input="n\n",
)
@then("session cli branch the delete is aborted")
def step_delete_aborted(context):
# typer.Abort() sets exit_code to 1
# The output should contain "Aborted"
combined = context.result.output
assert "Aborted" in combined or context.result.exit_code != 0, (
f"Expected abort. exit_code={context.result.exit_code}, output:\n{combined}"
)
# ===========================================================================
# Scenario: export command catches SessionExportError
# ===========================================================================
@given(
"session cli branch a mock session service that raises SessionExportError on export"
)
def step_service_export_error(context):
svc = _mock_service()
svc.export_session.side_effect = SessionExportError("checksum mismatch")
_patch_service(context, svc)
@when("session cli branch I invoke the export command")
def step_invoke_export(context):
context.result = runner.invoke(session_app, ["export", _ULID])
@then("session cli branch the export exit code is 1")
def step_export_exit_1(context):
assert context.result.exit_code == 1, (
f"Expected exit code 1, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
@then('session cli branch the export output contains "{text}"')
def step_export_output_contains(context, text):
combined = context.result.output
assert text in combined, f"Expected '{text}' in output. Got:\n{combined}"
# ===========================================================================
# Scenario: tell command with stream=True
# ===========================================================================
@given("session cli branch a mock session service for tell")
def step_service_for_tell(context):
svc = _mock_service()
# append_message doesn't need to return anything meaningful for the
# code path under test — the assistant content is computed inline.
svc.append_message.return_value = None
_patch_service(context, svc)
@when("session cli branch I invoke the tell command with --stream")
def step_invoke_tell_stream(context):
context.result = runner.invoke(
session_app,
["tell", "--session", _ULID, "--stream", "Hello world"],
)
@then("session cli branch the streamed output contains the assistant response")
def step_streamed_output(context):
# The assistant content for no actor is: "Acknowledged: Hello world"
assert "Acknowledged" in context.result.output, (
f"Expected 'Acknowledged' in output. Got:\n{context.result.output}"
)