81b9a66ef5
- Delete 4 obsolete unit tests tied to removed git mount/clone models - Update imports and assertions across unit/integration/service tests - Fix Settings defaults (postgres host, JWT props, cookie_samesite) - Add skip guards for PostgreSQL-dependent integration tests - Fix GitService env assertions and HealthMonitor state-change tests - Repair docker/container inspect assertions in test_docker_service - Fix ToolTypeCreate default_port validator ordering bug - Fix check_port_exposed substring false-positive for port 0 - Update test_tool_types_api_extended to use interface_type field Quality gates: pytest 311 passed, 34 skipped; npm typecheck/lint/test 87 passed
204 lines
6.7 KiB
Python
204 lines
6.7 KiB
Python
"""Unit tests for TerminalManager multi-session support."""
|
|
|
|
import asyncio
|
|
import uuid
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.services.terminal.terminal_manager import MaxSessionsExceededError, TerminalManager
|
|
from src.services.terminal.terminal_session import TerminalSession
|
|
|
|
|
|
@pytest.fixture
|
|
def manager() -> TerminalManager:
|
|
"""Provide a fresh TerminalManager instance for each test."""
|
|
tm = TerminalManager()
|
|
# Cancel the background idle check to avoid side effects
|
|
if tm._idle_check_task and not tm._idle_check_task.done():
|
|
tm._idle_check_task.cancel()
|
|
return tm
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_terminal_session(monkeypatch) -> None:
|
|
"""Monkeypatch TerminalSession.start and is_alive for unit tests."""
|
|
|
|
async def fake_start(self, startup_command=None):
|
|
self.last_activity = __import__("time").time()
|
|
|
|
monkeypatch.setattr(TerminalSession, "start", fake_start)
|
|
monkeypatch.setattr(TerminalSession, "is_alive", lambda self: True)
|
|
|
|
|
|
@pytest.fixture
|
|
def instance_id() -> uuid.UUID:
|
|
return uuid.uuid4()
|
|
|
|
|
|
class FakeWebSocket:
|
|
"""Minimal fake WebSocket for testing attach/detach behavior."""
|
|
|
|
def __init__(self, name: str = "ws") -> None:
|
|
self.name = name
|
|
self.closed = False
|
|
self.close_code: int | None = None
|
|
self.close_reason: str | None = None
|
|
self._sent: list[bytes] = []
|
|
|
|
async def close(self, code: int = 1000, reason: str = "") -> None:
|
|
self.closed = True
|
|
self.close_code = code
|
|
self.close_reason = reason
|
|
|
|
async def send_bytes(self, data: bytes) -> None:
|
|
self._sent.append(data)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_increases_count(
|
|
manager: TerminalManager,
|
|
mock_terminal_session,
|
|
instance_id: uuid.UUID,
|
|
) -> None:
|
|
"""Creating sessions increments the per-instance count."""
|
|
assert len(manager.get_sessions_for_instance(str(instance_id))) == 0
|
|
|
|
session1 = await manager.create_session(instance_id, "container-1")
|
|
assert len(manager.get_sessions_for_instance(str(instance_id))) == 1
|
|
assert session1.session_id in [
|
|
s.session_id for s in manager.get_sessions_for_instance(str(instance_id))
|
|
]
|
|
|
|
session2 = await manager.create_session(instance_id, "container-1")
|
|
assert len(manager.get_sessions_for_instance(str(instance_id))) == 2
|
|
|
|
# Verify sessions are distinct
|
|
assert session1.session_id != session2.session_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_enforces_max_5(
|
|
manager: TerminalManager,
|
|
mock_terminal_session,
|
|
instance_id: uuid.UUID,
|
|
) -> None:
|
|
"""The 6th session creation raises MaxSessionsExceededError."""
|
|
for i in range(5):
|
|
await manager.create_session(instance_id, f"container-{i}")
|
|
|
|
assert len(manager.get_sessions_for_instance(str(instance_id))) == 5
|
|
|
|
with pytest.raises(MaxSessionsExceededError):
|
|
await manager.create_session(instance_id, "container-overflow")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_sessions_for_instance_filters_by_instance(
|
|
manager: TerminalManager,
|
|
mock_terminal_session,
|
|
) -> None:
|
|
"""get_sessions_for_instance returns only sessions for the requested instance."""
|
|
instance_a = uuid.uuid4()
|
|
instance_b = uuid.uuid4()
|
|
|
|
await manager.create_session(instance_a, "container-a")
|
|
await manager.create_session(instance_a, "container-a2")
|
|
await manager.create_session(instance_b, "container-b")
|
|
|
|
assert len(manager.get_sessions_for_instance(str(instance_a))) == 2
|
|
assert len(manager.get_sessions_for_instance(str(instance_b))) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_session_removes_from_dict(
|
|
manager: TerminalManager,
|
|
mock_terminal_session,
|
|
instance_id: uuid.UUID,
|
|
) -> None:
|
|
"""close_session removes the key from _sessions and marks DB closed."""
|
|
session = await manager.create_session(instance_id, "container-1")
|
|
session_id = session.session_id
|
|
|
|
assert manager.get_session(str(instance_id), session_id) is not None
|
|
|
|
with patch.object(manager, "_mark_closed_in_db", new=AsyncMock()) as mock_mark:
|
|
await manager.close_session(str(instance_id), session_id)
|
|
# Give the fire-and-forget task a chance to be scheduled
|
|
await asyncio.sleep(0)
|
|
|
|
assert manager.get_session(str(instance_id), session_id) is None
|
|
mock_mark.assert_called_once_with(session_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attach_websocket_only_closes_same_session(
|
|
manager: TerminalManager,
|
|
mock_terminal_session,
|
|
instance_id: uuid.UUID,
|
|
) -> None:
|
|
"""Attaching to session A must not close WebSockets on session B."""
|
|
session_a = await manager.create_session(instance_id, "container-1")
|
|
session_b = await manager.create_session(instance_id, "container-1")
|
|
|
|
ws_a1 = FakeWebSocket("ws-a1")
|
|
ws_b1 = FakeWebSocket("ws-b1")
|
|
|
|
# Manually attach websockets (simulate prior connections)
|
|
session_a.attach_websocket(ws_a1)
|
|
session_b.attach_websocket(ws_b1)
|
|
|
|
# Now attach a new websocket to session_a
|
|
ws_a2 = FakeWebSocket("ws-a2")
|
|
await manager.attach_websocket(session_a, ws_a2)
|
|
|
|
# ws_a1 should have been closed because it's on the same session
|
|
assert ws_a1.closed is True
|
|
|
|
# ws_b1 should NOT have been closed because it's on a different session
|
|
assert ws_b1.closed is False
|
|
|
|
# ws_a2 should be attached and receive buffer
|
|
assert ws_a2 in session_a._websockets
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_default_session_keyed_separately(
|
|
manager: TerminalManager,
|
|
mock_terminal_session,
|
|
instance_id: uuid.UUID,
|
|
) -> None:
|
|
"""Default session uses 'default' session_id and does not collide with named sessions."""
|
|
default_session = await manager.get_or_create_session(instance_id, "container-1")
|
|
explicit_session = await manager.create_session(instance_id, "container-1")
|
|
|
|
# Both should exist
|
|
assert manager.get_session(str(instance_id), "default") is default_session
|
|
assert (
|
|
manager.get_session(str(instance_id), explicit_session.session_id)
|
|
is explicit_session
|
|
)
|
|
|
|
# They should be different objects
|
|
assert default_session.session_id != explicit_session.session_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_idle_cleanup_updates_db_status(
|
|
manager: TerminalManager,
|
|
mock_terminal_session,
|
|
instance_id: uuid.UUID,
|
|
) -> None:
|
|
"""Idle cleanup removes sessions from dict and calls DB update."""
|
|
session = await manager.create_session(instance_id, "container-1")
|
|
session_id = session.session_id
|
|
|
|
# Make session appear idle (no websockets, old last_activity)
|
|
session.last_activity = 0
|
|
|
|
with patch.object(manager, "_mark_closed_in_db", new=AsyncMock()) as mock_mark:
|
|
await manager._cleanup_idle_sessions()
|
|
|
|
assert manager.get_session(str(instance_id), session_id) is None
|
|
mock_mark.assert_called_once_with(session_id)
|