b55300ff6f
- Add TerminalSessionModel DB table with instance_id FK, name, status, created_at, last_activity_at, closed_at columns - Add Alembic migration for terminal_sessions table - Refactor TerminalManager to use composite key (instance_id, session_id) supporting up to 5 concurrent sessions per instance - Add create_session, get_session, get_sessions_for_instance, close_session - Preserve get_or_create_session for backward compatibility (default session) - Fix attach_websocket to only close sockets within same session - Add name (auto-generated 'Session N') and status tracking to TerminalSession - Add 7 unit tests for multi-session logic Quality gates: pytest (7 new passed, 174 total passed, 51 pre-existing failures)
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_manager import MaxSessionsExceededError, TerminalManager
|
|
from src.services.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)
|