ref #9: [feat] add tests
This commit is contained in:
parent
3a7973accd
commit
fb974fff1e
5 changed files with 899 additions and 2 deletions
332
test/test_create_http.py
Normal file
332
test/test_create_http.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue