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