ref #9: [feat] add tests

This commit is contained in:
Azamat 2026-04-02 20:28:14 +03:00
parent 3a7973accd
commit fb974fff1e
5 changed files with 899 additions and 2 deletions

2
.gitignore vendored
View file

@ -14,3 +14,5 @@ wheels/
!docs
!AGENTS.md
!tasks.md
opencode.json

View file

@ -98,7 +98,7 @@
### M07. Тесты для create, reuse, TTL и mount policy
- Субагент: `test-engineer`
- Статус: pending
- Статус: completed
- Зависимости: `M03`, `M04`, `M05`, `M06`
- Commit required: no
- Scope: покрыть тестами ключевое поведение MVP без запуска реального production Docker stack
@ -108,9 +108,76 @@
### M08. Архитектурный и boundary review по MVP sandbox
- Субагент: `code-reviewer`
- Статус: pending
- Статус: completed
- Зависимости: `M07`
- Commit required: no
- Scope: проверить соблюдение clean architecture, dependency direction и соответствие MVP-ограничениям
- Файлы: весь измененный код
- Критерии приемки: Docker остается только во внешнем adapter; FastAPI не протекает в `domain/` и `usecase/`; TTL и mount policy читаются как явные, тестируемые правила; замечания сформулированы как точечные правки или подтверждение готовности
## Follow-up после M08 review
### M09. Сериализация lifecycle sandbox по `chat_id`
- Субагент: `feature-developer`
- Статус: pending
- Зависимости: `M08`
- Commit required: no
- Scope: убрать гонки между параллельными `create` и cleanup для одного `chat_id`
- Файлы: `usecase/interface.py`, `usecase/sandbox.py`, `repository/sandbox_lock.py` или другой outer-layer lock implementation, `adapter/di/container.py`
- Решение: ввести явный usecase-port для process-local lock по `chat_id`; outer-layer реализация держит per-chat lock registry; `CreateSandbox` и `CleanupExpiredSandboxes` выполняют мутации сессии под этим lock
- Критерии приемки: для одного `chat_id` не поднимаются два sandbox при concurrent create; create-vs-cleanup не оставляет orphan container; locking не протекает в HTTP и Docker adapter как бизнес-логика
### M10. Устойчивый cleanup и вынос blocking cleanup из event loop
- Субагент: `feature-developer`
- Статус: pending
- Зависимости: `M09`
- Commit required: no
- Scope: сделать cleanup устойчивым к частичным ошибкам и не блокировать FastAPI event loop синхронным Docker stop
- Файлы: `usecase/sandbox.py`, `adapter/http/fastapi/app.py`
- Решение: `CleanupExpiredSandboxes` обрабатывает stop/delete по каждой сессии отдельно и продолжает batch; HTTP cleanup loop выносит blocking cleanup work в thread через adapter-layer orchestration
- Критерии приемки: ошибка на одной expired session не мешает чистить остальные; background cleanup loop не умирает после ошибки; blocking cleanup больше не выполняется прямо в event loop
### M11. Удаление не-MVP user surface из приложения
- Субагент: `feature-developer`
- Статус: pending
- Зависимости: `M08`
- Commit required: no
- Scope: убрать из runtime app неотносящиеся к MVP user endpoint и seed user wiring
- Файлы: `adapter/http/fastapi/routers/v1/router.py`, `adapter/http/fastapi/dependencies.py`, `adapter/http/fastapi/schemas.py`, `adapter/di/container.py`
- Решение: оставить в MVP только `health` и sandbox API; примерный user code может остаться в репозитории как template, но не должен быть подключен в runtime app
- Критерии приемки: `GET /api/v1/users/{user_id}` больше не опубликован; container не создает seeded user repository/usecase для runtime app; незапрошенная user-surface area исчезает
### M12. Регрессионные тесты на race conditions и cleanup resilience
- Субагент: `test-engineer`
- Статус: pending
- Зависимости: `M09`, `M10`, `M11`
- Commit required: no
- Scope: добавить тесты на новые гарантии после review fixes
- Файлы: `test/*`
- Критерии приемки: есть тест на duplicate create для одного `chat_id`; есть тест на create-vs-cleanup race или эквивалентную сериализацию; есть тест, что cleanup продолжает batch после stop failure; HTTP smoke/regression тесты обновлены под удаление user endpoint
### M13. Повторный boundary review после fix-pass
- Субагент: `code-reviewer`
- Статус: pending
- Зависимости: `M12`
- Commit required: no
- Scope: проверить, что must-fix и should-fix замечания из `M08` закрыты без нарушения clean architecture
- Файлы: весь измененный код после `M09`-`M12`
- Критерии приемки: нет гонки на one-sandbox-per-chat; cleanup не блокирует event loop и не валится на первом stop failure; runtime app не публикует лишний user API; замечания сведены к minor или отсутствуют
### M14. Починка mypy-типизации тестов после sandbox MVP
- Субагент: `test-engineer`
- Статус: completed
- Зависимости: `M07`
- Commit required: no
- Scope: устранить текущие ошибки `make pre-commit` в test-suite без изменения production behavior
- Файлы: `test/test_docker_runtime.py`, `test/test_create_http.py`, при необходимости общие test helpers в `test/*`
- Ошибки: несовместимый fake Docker client для `DockerSandboxRuntime`, неточная типизация `run_calls` и ASGI message payload, использование `object` вместо типизированных test doubles для `AppRepositories`, `AppUsecases`, `AppContainer`
- Решение: сделать test doubles типизированными через совместимые fake classes или локальные protocols; убрать `object` и неиндексируемые `dict[str, object]` там, где mypy не может вывести типы
- Критерии приемки: `uv run mypy .` проходит; `make pre-commit` доходит как минимум до pytest stage; production code не меняется или меняется только при явной необходимости для testability

332
test/test_create_http.py Normal file
View file

@ -0,0 +1,332 @@
import asyncio
import json
from datetime import UTC, datetime, timedelta
from docker import DockerClient
from fastapi import FastAPI
from starlette.types import Message, Scope
from adapter.config.model import (
AppConfig,
AppSectionConfig,
DockerConfig,
HttpConfig,
LoggingConfig,
MetricsConfig,
OtelConfig,
SandboxConfig,
SecurityConfig,
TracingConfig,
)
from adapter.di.container import AppContainer, AppRepositories, AppUsecases
from adapter.http.fastapi import app as app_module
from adapter.observability.noop import NoopMetrics, NoopTracer
from adapter.observability.runtime import ObservabilityRuntime
from domain.error import SandboxError, SandboxStartError
from domain.sandbox import SandboxSession, SandboxStatus
from repository.sandbox_session import InMemorySandboxSessionRepository
from repository.user import InMemoryUserRepository
from usecase.interface import Attrs
from usecase.sandbox import CleanupExpiredSandboxes, CreateSandbox, CreateSandboxCommand
from usecase.user import GetUser
class FakeLogger:
def __init__(self) -> None:
self.messages: list[tuple[str, str, Attrs | None]] = []
def debug(self, message: str, attrs: Attrs | None = None) -> None:
self.messages.append(('debug', message, attrs))
def info(self, message: str, attrs: Attrs | None = None) -> None:
self.messages.append(('info', message, attrs))
def warning(self, message: str, attrs: Attrs | None = None) -> None:
self.messages.append(('warning', message, attrs))
def error(self, message: str, attrs: Attrs | None = None) -> None:
self.messages.append(('error', message, attrs))
class FakeCreateSandboxUsecase(CreateSandbox):
def __init__(
self, session: SandboxSession | None = None, error: Exception | None = None
) -> None:
self._session = session
self._error = error
self.commands: list[CreateSandboxCommand] = []
def execute(self, command: CreateSandboxCommand) -> SandboxSession:
self.commands.append(command)
if self._error is not None:
raise self._error
if self._session is None:
raise AssertionError('missing session')
return self._session
class FakeCleanupExpiredSandboxes(CleanupExpiredSandboxes):
def __init__(self) -> None:
self.calls = 0
def execute(self) -> list[SandboxSession]:
self.calls += 1
return []
class FakeDockerClient(DockerClient):
def __init__(self) -> None:
self.close_calls = 0
def close(self) -> None:
self.close_calls += 1
def build_config() -> AppConfig:
return AppConfig(
app=AppSectionConfig(name='master', env='test'),
http=HttpConfig(host='127.0.0.1', port=8000),
logging=LoggingConfig(
level='INFO', output='stdout', format='json', file_path=None
),
metrics=MetricsConfig(enabled=False),
tracing=TracingConfig(enabled=False),
otel=OtelConfig(
service_name='master',
logs_endpoint='http://localhost:4318/v1/logs',
metrics_endpoint='http://localhost:4318/v1/metrics',
traces_endpoint='http://localhost:4318/v1/traces',
metric_export_interval=1000,
),
docker=DockerConfig(base_url='unix:///var/run/docker.sock'),
sandbox=SandboxConfig(
image='sandbox:latest',
ttl_seconds=300,
cleanup_interval_seconds=60,
chats_root='/tmp/chats',
dependencies_host_path='/tmp/dependencies',
lambda_tools_host_path='/tmp/lambda-tools',
chat_mount_path='/workspace/chat',
dependencies_mount_path='/workspace/dependencies',
lambda_tools_mount_path='/workspace/lambda-tools',
),
security=SecurityConfig(
token_header='Authorization',
api_token='token',
signing_key='signing-key',
),
)
def build_container(
config: AppConfig,
create_sandbox_usecase: FakeCreateSandboxUsecase,
cleanup_usecase: FakeCleanupExpiredSandboxes,
logger: FakeLogger,
docker_client: FakeDockerClient,
) -> AppContainer:
observability = ObservabilityRuntime(
logger=logger,
metrics=NoopMetrics(),
tracer=NoopTracer(),
)
repositories = AppRepositories(
user=InMemoryUserRepository(NoopTracer()),
sandbox_session=InMemorySandboxSessionRepository(),
)
usecases = AppUsecases(
get_user=GetUser(
repository=repositories.user,
logger=logger,
tracer=NoopTracer(),
),
create_sandbox=create_sandbox_usecase,
cleanup_expired_sandboxes=cleanup_usecase,
)
return AppContainer(
config=config,
observability=observability,
repositories=repositories,
usecases=usecases,
_docker_client=docker_client,
)
async def post_json(
app: FastAPI, path: str, payload: dict[str, str]
) -> tuple[int, dict[str, object]]:
body = json.dumps(payload).encode()
messages: list[Message] = []
request_sent = False
async def receive() -> Message:
nonlocal request_sent
if request_sent:
await asyncio.sleep(0)
return {'type': 'http.disconnect'}
request_sent = True
return {
'type': 'http.request',
'body': body,
'more_body': False,
}
async def send(message: Message) -> None:
messages.append(message)
scope: Scope = {
'type': 'http',
'asgi': {'version': '3.0'},
'http_version': '1.1',
'method': 'POST',
'scheme': 'http',
'path': path,
'raw_path': path.encode(),
'query_string': b'',
'root_path': '',
'headers': [
(b'host', b'testserver'),
(b'content-type', b'application/json'),
(b'content-length', str(len(body)).encode()),
],
'client': ('testclient', 50000),
'server': ('testserver', 80),
'state': {},
}
await app(scope, receive, send)
status = 500
response_body = b''
for message in messages:
if message['type'] == 'http.response.start':
status = int(message['status'])
if message['type'] == 'http.response.body':
response_body += bytes(message.get('body', b''))
return status, json.loads(response_body.decode())
async def exercise_create_request(
app: FastAPI,
payload: dict[str, str],
) -> tuple[int, dict[str, object]]:
await app.router.startup()
try:
status, response = await post_json(app, '/api/v1/create', payload)
await asyncio.sleep(0)
return status, response
finally:
await app.router.shutdown()
def test_post_create_returns_session(monkeypatch) -> None:
config = build_config()
expires_at = datetime(2026, 4, 2, 12, 5, tzinfo=UTC)
session = SandboxSession(
session_id='session-123',
chat_id='chat-123',
container_id='container-123',
status=SandboxStatus.RUNNING,
created_at=expires_at - timedelta(minutes=5),
expires_at=expires_at,
)
logger = FakeLogger()
create_usecase = FakeCreateSandboxUsecase(session=session)
cleanup_usecase = FakeCleanupExpiredSandboxes()
docker_client = FakeDockerClient()
container = build_container(
config,
create_usecase,
cleanup_usecase,
logger,
docker_client,
)
monkeypatch.setattr(app_module, 'build_container', lambda **kwargs: container)
monkeypatch.setattr(
app_module.FastAPIInstrumentor, 'instrument_app', lambda *args, **kwargs: None
)
app = app_module.create_app(config=config)
status_code, response = asyncio.run(
exercise_create_request(app, {'chat_id': 'chat-123'})
)
assert status_code == 200
assert response == {
'session_id': 'session-123',
'chat_id': 'chat-123',
'container_id': 'container-123',
'status': 'running',
'expires_at': '2026-04-02T12:05:00Z',
}
assert len(create_usecase.commands) == 1
assert create_usecase.commands[0].chat_id == 'chat-123'
assert cleanup_usecase.calls >= 1
assert any(
message == 'http_request'
and attrs is not None
and attrs['http.path'] == '/api/v1/create'
for _, message, attrs in logger.messages
)
assert docker_client.close_calls == 1
def test_post_create_maps_start_errors_to_service_unavailable(monkeypatch) -> None:
config = build_config()
logger = FakeLogger()
create_usecase = FakeCreateSandboxUsecase(error=SandboxStartError('chat-123'))
cleanup_usecase = FakeCleanupExpiredSandboxes()
docker_client = FakeDockerClient()
container = build_container(
config,
create_usecase,
cleanup_usecase,
logger,
docker_client,
)
monkeypatch.setattr(app_module, 'build_container', lambda **kwargs: container)
monkeypatch.setattr(
app_module.FastAPIInstrumentor, 'instrument_app', lambda *args, **kwargs: None
)
app = app_module.create_app(config=config)
status_code, response = asyncio.run(
exercise_create_request(app, {'chat_id': 'chat-123'})
)
assert status_code == 503
assert response == {'detail': 'sandbox_start_failed'}
assert docker_client.close_calls == 1
def test_post_create_maps_generic_sandbox_errors_to_internal_error(monkeypatch) -> None:
config = build_config()
logger = FakeLogger()
create_usecase = FakeCreateSandboxUsecase(error=SandboxError('sandbox_broken'))
cleanup_usecase = FakeCleanupExpiredSandboxes()
docker_client = FakeDockerClient()
container = build_container(
config,
create_usecase,
cleanup_usecase,
logger,
docker_client,
)
monkeypatch.setattr(app_module, 'build_container', lambda **kwargs: container)
monkeypatch.setattr(
app_module.FastAPIInstrumentor, 'instrument_app', lambda *args, **kwargs: None
)
app = app_module.create_app(config=config)
status_code, response = asyncio.run(
exercise_create_request(app, {'chat_id': 'chat-123'})
)
assert status_code == 500
assert response == {'detail': 'sandbox_broken'}
assert docker_client.close_calls == 1

212
test/test_docker_runtime.py Normal file
View file

@ -0,0 +1,212 @@
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, TypedDict
import pytest
from docker import DockerClient
from docker.errors import DockerException, NotFound
from docker.types import Mount
from adapter.config.model import SandboxConfig
from adapter.docker.runtime import DockerSandboxRuntime
from domain.error import SandboxError, SandboxStartError
from domain.sandbox import SandboxStatus
class FakeContainer:
def __init__(self, container_id: str) -> None:
self.id = container_id
self.stop_calls = 0
def stop(self) -> None:
self.stop_calls += 1
class RunKwargs(TypedDict):
detach: bool
labels: dict[str, str]
mounts: list[Mount]
class RunCall(TypedDict):
args: tuple[str]
kwargs: RunKwargs
class FakeContainers:
def __init__(self, run_result: FakeContainer | None = None) -> None:
self.run_calls: list[RunCall] = []
self.get_calls: list[str] = []
self.run_result = run_result or FakeContainer('container-123')
self.get_result: FakeContainer | Exception | None = None
def run(
self,
image: str,
*,
detach: bool,
labels: dict[str, str],
mounts: list[Mount],
) -> FakeContainer:
self.run_calls.append(
{
'args': (image,),
'kwargs': {
'detach': detach,
'labels': labels,
'mounts': mounts,
},
}
)
return self.run_result
def get(self, container_id: str) -> FakeContainer:
self.get_calls.append(container_id)
if isinstance(self.get_result, Exception):
raise self.get_result
if self.get_result is None:
raise AssertionError('missing get result')
return self.get_result
class FakeDockerClient(DockerClient):
def __init__(self, containers: FakeContainers) -> None:
self._containers = containers
@property
def containers(self) -> Any:
return self._containers
def build_config(tmp_path: Path) -> SandboxConfig:
return SandboxConfig(
image='sandbox:latest',
ttl_seconds=300,
cleanup_interval_seconds=60,
chats_root=str(tmp_path / 'chats'),
dependencies_host_path=str(tmp_path / 'dependencies'),
lambda_tools_host_path=str(tmp_path / 'lambda-tools'),
chat_mount_path='/workspace/chat',
dependencies_mount_path='/workspace/dependencies',
lambda_tools_mount_path='/workspace/lambda-tools',
)
def test_runtime_create_applies_mount_policy_and_labels(tmp_path: Path) -> None:
config = build_config(tmp_path)
(tmp_path / 'dependencies').mkdir()
(tmp_path / 'lambda-tools').mkdir()
containers = FakeContainers()
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
created_at = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
expires_at = created_at + timedelta(minutes=5)
session = runtime.create(
session_id='session-123',
chat_id='chat-123',
created_at=created_at,
expires_at=expires_at,
)
assert session.session_id == 'session-123'
assert session.chat_id == 'chat-123'
assert session.container_id == 'container-123'
assert session.status is SandboxStatus.RUNNING
assert session.created_at == created_at
assert session.expires_at == expires_at
assert (tmp_path / 'chats' / 'chat-123').is_dir()
call = containers.run_calls[0]
assert call['args'] == ('sandbox:latest',)
assert call['kwargs']['detach'] is True
assert call['kwargs']['labels'] == {
'session_id': 'session-123',
'chat_id': 'chat-123',
'expires_at': expires_at.isoformat(),
}
mounts = call['kwargs']['mounts']
assert [dict(mount) for mount in mounts] == [
{
'Target': '/workspace/chat',
'Source': str((tmp_path / 'chats' / 'chat-123').resolve(strict=False)),
'Type': 'bind',
'ReadOnly': False,
},
{
'Target': '/workspace/dependencies',
'Source': str((tmp_path / 'dependencies').resolve(strict=False)),
'Type': 'bind',
'ReadOnly': True,
},
{
'Target': '/workspace/lambda-tools',
'Source': str((tmp_path / 'lambda-tools').resolve(strict=False)),
'Type': 'bind',
'ReadOnly': True,
},
]
def test_runtime_create_raises_start_error_when_container_id_is_missing(
tmp_path: Path,
) -> None:
config = build_config(tmp_path)
(tmp_path / 'dependencies').mkdir()
(tmp_path / 'lambda-tools').mkdir()
containers = FakeContainers(run_result=FakeContainer(''))
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
with pytest.raises(SandboxStartError) as excinfo:
runtime.create(
session_id='session-123',
chat_id='chat-123',
created_at=datetime(2026, 4, 2, 12, 0, tzinfo=UTC),
expires_at=datetime(2026, 4, 2, 12, 5, tzinfo=UTC),
)
assert str(excinfo.value) == 'sandbox_start_failed'
assert excinfo.value.chat_id == 'chat-123'
def test_runtime_stop_ignores_missing_container(tmp_path: Path) -> None:
config = build_config(tmp_path)
containers = FakeContainers()
containers.get_result = NotFound('missing')
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
runtime.stop('container-123')
assert containers.get_calls == ['container-123']
def test_runtime_stop_wraps_docker_errors(tmp_path: Path) -> None:
config = build_config(tmp_path)
containers = FakeContainers()
containers.get_result = DockerException('boom')
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
with pytest.raises(SandboxError) as excinfo:
runtime.stop('container-123')
assert str(excinfo.value) == 'sandbox_stop_failed'
def test_runtime_create_rejects_chat_path_traversal(tmp_path: Path) -> None:
config = build_config(tmp_path)
(tmp_path / 'dependencies').mkdir()
(tmp_path / 'lambda-tools').mkdir()
containers = FakeContainers()
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
with pytest.raises(SandboxStartError) as excinfo:
runtime.create(
session_id='session-123',
chat_id='../escape',
created_at=datetime(2026, 4, 2, 12, 0, tzinfo=UTC),
expires_at=datetime(2026, 4, 2, 12, 5, tzinfo=UTC),
)
assert str(excinfo.value) == 'sandbox_start_failed'
assert excinfo.value.chat_id == '../escape'
assert containers.run_calls == []

View file

@ -0,0 +1,284 @@
from datetime import UTC, datetime, timedelta
from domain.sandbox import SandboxSession, SandboxStatus
from repository.sandbox_session import InMemorySandboxSessionRepository
from usecase.sandbox import CleanupExpiredSandboxes, CreateSandbox, CreateSandboxCommand
class FakeClock:
def __init__(self, now: datetime) -> None:
self._now = now
def now(self) -> datetime:
return self._now
class FakeLogger:
def __init__(self) -> None:
self.messages: list[
tuple[str, str, dict[str, str | int | float | bool] | None]
] = []
def debug(self, message: str, attrs=None) -> None:
self.messages.append(('debug', message, attrs))
def info(self, message: str, attrs=None) -> None:
self.messages.append(('info', message, attrs))
def warning(self, message: str, attrs=None) -> None:
self.messages.append(('warning', message, attrs))
def error(self, message: str, attrs=None) -> None:
self.messages.append(('error', message, attrs))
class FakeRuntime:
def __init__(self) -> None:
self.create_calls: list[dict[str, object]] = []
self.stop_calls: list[str] = []
def create(
self,
*,
session_id: str,
chat_id: str,
created_at: datetime,
expires_at: datetime,
) -> SandboxSession:
self.create_calls.append(
{
'session_id': session_id,
'chat_id': chat_id,
'created_at': created_at,
'expires_at': expires_at,
}
)
return SandboxSession(
session_id=session_id,
chat_id=chat_id,
container_id=f'container-{session_id}',
status=SandboxStatus.RUNNING,
created_at=created_at,
expires_at=expires_at,
)
def stop(self, container_id: str) -> None:
self.stop_calls.append(container_id)
def test_create_sandbox_reuses_active_session_when_not_expired() -> None:
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
session = SandboxSession(
session_id='session-1',
chat_id='chat-1',
container_id='container-1',
status=SandboxStatus.RUNNING,
created_at=now - timedelta(minutes=1),
expires_at=now + timedelta(minutes=4),
)
repository = InMemorySandboxSessionRepository()
repository.save(session)
runtime = FakeRuntime()
logger = FakeLogger()
usecase = CreateSandbox(
repository=repository,
runtime=runtime,
clock=FakeClock(now),
logger=logger,
ttl=timedelta(minutes=5),
)
result = usecase.execute(CreateSandboxCommand(chat_id='chat-1'))
assert result == session
assert runtime.create_calls == []
assert runtime.stop_calls == []
assert repository.get_active_by_chat_id('chat-1') == session
assert logger.messages == [
(
'info',
'sandbox_reused',
{
'chat_id': 'chat-1',
'session_id': 'session-1',
'container_id': 'container-1',
},
)
]
def test_create_sandbox_replaces_expired_session_and_creates_new_one(
monkeypatch,
) -> None:
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
expired_session = SandboxSession(
session_id='session-old',
chat_id='chat-1',
container_id='container-old',
status=SandboxStatus.RUNNING,
created_at=now - timedelta(minutes=10),
expires_at=now,
)
repository = InMemorySandboxSessionRepository()
repository.save(expired_session)
runtime = FakeRuntime()
logger = FakeLogger()
usecase = CreateSandbox(
repository=repository,
runtime=runtime,
clock=FakeClock(now),
logger=logger,
ttl=timedelta(minutes=5),
)
monkeypatch.setattr('usecase.sandbox._new_session_id', lambda: 'session-new')
result = usecase.execute(CreateSandboxCommand(chat_id='chat-1'))
assert runtime.stop_calls == ['container-old']
assert runtime.create_calls == [
{
'session_id': 'session-new',
'chat_id': 'chat-1',
'created_at': now,
'expires_at': now + timedelta(minutes=5),
}
]
assert result == SandboxSession(
session_id='session-new',
chat_id='chat-1',
container_id='container-session-new',
status=SandboxStatus.RUNNING,
created_at=now,
expires_at=now + timedelta(minutes=5),
)
assert repository.get_active_by_chat_id('chat-1') == result
assert logger.messages == [
(
'info',
'sandbox_replaced',
{
'chat_id': 'chat-1',
'session_id': 'session-old',
'container_id': 'container-old',
},
),
(
'info',
'sandbox_created',
{
'chat_id': 'chat-1',
'session_id': 'session-new',
'container_id': 'container-session-new',
},
),
]
def test_create_sandbox_creates_new_session_when_none_exists() -> None:
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
repository = InMemorySandboxSessionRepository()
runtime = FakeRuntime()
logger = FakeLogger()
usecase = CreateSandbox(
repository=repository,
runtime=runtime,
clock=FakeClock(now),
logger=logger,
ttl=timedelta(minutes=5),
)
result = usecase.execute(CreateSandboxCommand(chat_id='chat-1'))
assert result.chat_id == 'chat-1'
assert result.container_id == f'container-{result.session_id}'
assert result.status is SandboxStatus.RUNNING
assert result.created_at == now
assert result.expires_at == now + timedelta(minutes=5)
assert len(runtime.create_calls) == 1
assert runtime.create_calls[0] == {
'session_id': result.session_id,
'chat_id': 'chat-1',
'created_at': now,
'expires_at': now + timedelta(minutes=5),
}
assert runtime.stop_calls == []
assert repository.get_active_by_chat_id('chat-1') == result
assert logger.messages == [
(
'info',
'sandbox_created',
{
'chat_id': 'chat-1',
'session_id': result.session_id,
'container_id': result.container_id,
},
)
]
def test_cleanup_expired_sandboxes_stops_and_deletes_only_expired_sessions() -> None:
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
expired_session = SandboxSession(
session_id='session-expired',
chat_id='chat-expired',
container_id='container-expired',
status=SandboxStatus.RUNNING,
created_at=now - timedelta(minutes=10),
expires_at=now - timedelta(seconds=1),
)
boundary_session = SandboxSession(
session_id='session-boundary',
chat_id='chat-boundary',
container_id='container-boundary',
status=SandboxStatus.RUNNING,
created_at=now - timedelta(minutes=5),
expires_at=now,
)
active_session = SandboxSession(
session_id='session-active',
chat_id='chat-active',
container_id='container-active',
status=SandboxStatus.RUNNING,
created_at=now - timedelta(minutes=1),
expires_at=now + timedelta(minutes=5),
)
repository = InMemorySandboxSessionRepository()
repository.save(expired_session)
repository.save(boundary_session)
repository.save(active_session)
runtime = FakeRuntime()
logger = FakeLogger()
usecase = CleanupExpiredSandboxes(
repository=repository,
runtime=runtime,
clock=FakeClock(now),
logger=logger,
)
result = usecase.execute()
assert result == [expired_session, boundary_session]
assert runtime.stop_calls == ['container-expired', 'container-boundary']
assert repository.get_active_by_chat_id('chat-expired') is None
assert repository.get_active_by_chat_id('chat-boundary') is None
assert repository.get_active_by_chat_id('chat-active') == active_session
assert logger.messages == [
(
'info',
'sandbox_cleaned',
{
'chat_id': 'chat-expired',
'session_id': 'session-expired',
'container_id': 'container-expired',
},
),
(
'info',
'sandbox_cleaned',
{
'chat_id': 'chat-boundary',
'session_id': 'session-boundary',
'container_id': 'container-boundary',
},
),
]