chore: initialize EverOS 1.0.0
md-first memory extraction framework for AI agents. Markdown is the single source of truth; SQLite holds state and LanceDB provides the rebuildable vector + BM25 + scalar index. The codebase follows a single-direction DDD layering (entrypoints -> service -> memory -> infra, with component / core / config cross-cutting) enforced by import-linter. Engineering surface: - Coding conventions in .claude/rules/ (path-scoped) and workflows in .claude/skills/ (/commit, /new-branch, /pr). - GitHub Actions CI runs make lint + test + integration; pre-commit mirrors the gates locally (ruff, hygiene hooks, gitlint commit-msg). - Commit messages follow Conventional Commits, enforced by gitlint. - make lint also enforces datetime two-zone discipline and OpenAPI drift.
This commit is contained in:
0
tests/unit/test_entrypoints/__init__.py
Normal file
0
tests/unit/test_entrypoints/__init__.py
Normal file
0
tests/unit/test_entrypoints/test_api/__init__.py
Normal file
0
tests/unit/test_entrypoints/test_api/__init__.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""``CascadeLifespanProvider`` — startup builds orchestrator, shutdown stops it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from everos.entrypoints.api.lifespans import cascade as cascade_lifespan_mod
|
||||
from everos.entrypoints.api.lifespans.cascade import CascadeLifespanProvider
|
||||
|
||||
|
||||
class _StubOrchestrator:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self.start_calls = 0
|
||||
self.stop_calls = 0
|
||||
|
||||
async def start(self) -> None:
|
||||
self.start_calls += 1
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.stop_calls += 1
|
||||
|
||||
|
||||
def test_provider_metadata() -> None:
|
||||
p = CascadeLifespanProvider(order=42)
|
||||
assert p.name == "cascade"
|
||||
assert p.order == 42
|
||||
|
||||
|
||||
async def test_startup_constructs_and_starts_orchestrator(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("EVEROS_MEMORY__ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("EVEROS_EMBEDDING__MODEL", "stub-model")
|
||||
monkeypatch.setenv("EVEROS_EMBEDDING__BASE_URL", "http://stub.invalid/v1")
|
||||
monkeypatch.setenv("EVEROS_EMBEDDING__API_KEY", "stub-key")
|
||||
|
||||
captured: list[_StubOrchestrator] = []
|
||||
|
||||
def fake_orch(**kwargs: object) -> _StubOrchestrator:
|
||||
o = _StubOrchestrator()
|
||||
captured.append(o)
|
||||
return o
|
||||
|
||||
monkeypatch.setattr(cascade_lifespan_mod, "CascadeOrchestrator", fake_orch)
|
||||
|
||||
p = CascadeLifespanProvider()
|
||||
result = await p.startup(FastAPI())
|
||||
assert len(captured) == 1
|
||||
assert result is captured[0]
|
||||
assert captured[0].start_calls == 1
|
||||
|
||||
|
||||
async def test_shutdown_without_startup_is_noop() -> None:
|
||||
p = CascadeLifespanProvider()
|
||||
await p.shutdown(FastAPI()) # must not raise
|
||||
|
||||
|
||||
async def test_shutdown_stops_orchestrator_and_clears_reference(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("EVEROS_MEMORY__ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("EVEROS_EMBEDDING__MODEL", "stub-model")
|
||||
monkeypatch.setenv("EVEROS_EMBEDDING__BASE_URL", "http://stub.invalid/v1")
|
||||
monkeypatch.setenv("EVEROS_EMBEDDING__API_KEY", "stub-key")
|
||||
|
||||
captured: list[_StubOrchestrator] = []
|
||||
|
||||
def fake_orch(**kwargs: object) -> _StubOrchestrator:
|
||||
o = _StubOrchestrator()
|
||||
captured.append(o)
|
||||
return o
|
||||
|
||||
monkeypatch.setattr(cascade_lifespan_mod, "CascadeOrchestrator", fake_orch)
|
||||
|
||||
p = CascadeLifespanProvider()
|
||||
app = FastAPI()
|
||||
await p.startup(app)
|
||||
await p.shutdown(app)
|
||||
assert captured[0].stop_calls == 1
|
||||
# Second shutdown is a no-op (reference cleared).
|
||||
await p.shutdown(app)
|
||||
assert captured[0].stop_calls == 1
|
||||
@ -0,0 +1,45 @@
|
||||
"""LLMLifespanProvider — startup raises on missing credentials, otherwise resolves."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from everos.component.llm import LLMNotConfiguredError
|
||||
from everos.entrypoints.api.lifespans import LLMLifespanProvider
|
||||
|
||||
|
||||
async def test_startup_raises_when_credentials_missing() -> None:
|
||||
provider = LLMLifespanProvider()
|
||||
app = FastAPI()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.entrypoints.api.lifespans.llm.get_llm_client",
|
||||
side_effect=LLMNotConfiguredError("missing api_key"),
|
||||
),
|
||||
pytest.raises(LLMNotConfiguredError),
|
||||
):
|
||||
await provider.startup(app)
|
||||
|
||||
|
||||
async def test_startup_returns_client_when_configured() -> None:
|
||||
provider = LLMLifespanProvider()
|
||||
app = FastAPI()
|
||||
sentinel = object()
|
||||
|
||||
with patch(
|
||||
"everos.entrypoints.api.lifespans.llm.get_llm_client",
|
||||
return_value=sentinel,
|
||||
):
|
||||
result = await provider.startup(app)
|
||||
|
||||
assert result is sentinel
|
||||
|
||||
|
||||
async def test_shutdown_is_noop() -> None:
|
||||
provider = LLMLifespanProvider()
|
||||
# Should not raise; the algo client is stateless.
|
||||
await provider.shutdown(FastAPI())
|
||||
@ -0,0 +1,34 @@
|
||||
"""OmeLifespanProvider — startup wires engine, shutdown stops it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from everos.entrypoints.api.lifespans import OmeLifespanProvider
|
||||
|
||||
|
||||
async def test_lifespan_starts_and_stops_engine(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from everos.core.persistence import MemoryRoot
|
||||
|
||||
svc = importlib.import_module("everos.service.memorize")
|
||||
|
||||
monkeypatch.setattr(
|
||||
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
|
||||
)
|
||||
monkeypatch.setattr(svc, "_ome_engine", None, raising=False)
|
||||
|
||||
provider = OmeLifespanProvider()
|
||||
app = FastAPI()
|
||||
|
||||
engine = await provider.startup(app)
|
||||
assert engine is not None
|
||||
assert engine._started is True # noqa: SLF001 — test introspection
|
||||
|
||||
await provider.shutdown(app)
|
||||
assert engine._started is False # noqa: SLF001
|
||||
@ -0,0 +1,72 @@
|
||||
"""SQLite + LanceDB lifespan providers — startup wires singletons, shutdown disposes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from everos.entrypoints.api.lifespans import (
|
||||
LanceDBLifespanProvider,
|
||||
SqliteLifespanProvider,
|
||||
)
|
||||
from everos.infra.persistence.lancedb import lancedb_manager
|
||||
from everos.infra.persistence.sqlite import sqlite_manager
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _reset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Redirect both managers at an isolated memory-root."""
|
||||
monkeypatch.setenv("EVEROS_MEMORY__ROOT", str(tmp_path))
|
||||
sqlite_manager._engine = None
|
||||
sqlite_manager._session_factory = None
|
||||
lancedb_manager._conn = None
|
||||
lancedb_manager._tables.clear()
|
||||
yield
|
||||
await sqlite_manager.dispose_engine()
|
||||
await lancedb_manager.dispose_connection()
|
||||
|
||||
|
||||
async def test_sqlite_provider_startup_builds_engine_and_creates_schema(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
provider = SqliteLifespanProvider()
|
||||
app = FastAPI()
|
||||
|
||||
engine = await provider.startup(app)
|
||||
|
||||
assert engine is sqlite_manager.get_engine() # singleton wired
|
||||
assert (
|
||||
tmp_path / ".index" / "sqlite" / "system.db"
|
||||
).exists() # schema create_all opened the file
|
||||
|
||||
|
||||
async def test_sqlite_provider_shutdown_disposes_singleton() -> None:
|
||||
provider = SqliteLifespanProvider()
|
||||
app = FastAPI()
|
||||
await provider.startup(app)
|
||||
assert sqlite_manager._engine is not None
|
||||
|
||||
await provider.shutdown(app)
|
||||
assert sqlite_manager._engine is None
|
||||
|
||||
|
||||
async def test_lancedb_provider_startup_opens_connection(tmp_path: Path) -> None:
|
||||
provider = LanceDBLifespanProvider()
|
||||
app = FastAPI()
|
||||
|
||||
conn = await provider.startup(app)
|
||||
|
||||
assert conn is await lancedb_manager.get_connection() # singleton wired
|
||||
assert (tmp_path / ".index" / "lancedb").is_dir()
|
||||
|
||||
|
||||
async def test_lancedb_provider_shutdown_disposes_singleton() -> None:
|
||||
provider = LanceDBLifespanProvider()
|
||||
app = FastAPI()
|
||||
await provider.startup(app)
|
||||
assert lancedb_manager._conn is not None
|
||||
|
||||
await provider.shutdown(app)
|
||||
assert lancedb_manager._conn is None
|
||||
@ -0,0 +1,157 @@
|
||||
"""422 validation paths for ``POST /api/v1/memory/get``.
|
||||
|
||||
These are route-layer error tests — they exercise:
|
||||
|
||||
- DTO-layer rejections (page_size cap, empty owner_id, missing /
|
||||
invalid memory_type, invalid sort_order, owner+memory_type mismatch)
|
||||
- service-layer ``compile_filters_for_get`` rejections (unknown filter
|
||||
field, malformed op shape)
|
||||
|
||||
No data is seeded; nothing reaches LanceDB. The full happy-path / data
|
||||
e2e suite (with seeded rows and 200 assertions) lives in
|
||||
``tests/integration/test_get_endpoint_e2e.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from everos.config import load_settings
|
||||
from everos.entrypoints.api.app import create_app
|
||||
from everos.infra.persistence.lancedb import lancedb_manager
|
||||
|
||||
# ``everos.service.__init__`` re-exports ``get`` shadowing the
|
||||
# submodule. Reach the real module via importlib so we can reset its
|
||||
# ``_manager`` lazy singleton.
|
||||
get_service_mod = import_module("everos.service.get")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> AsyncIterator[AsyncClient]:
|
||||
"""FastAPI app with no lifespan; resets get-path singletons per test."""
|
||||
monkeypatch.setenv("EVEROS_MEMORY__ROOT", str(tmp_path))
|
||||
load_settings.cache_clear()
|
||||
|
||||
lancedb_manager._conn = None
|
||||
lancedb_manager._tables.clear()
|
||||
get_service_mod._manager = None
|
||||
|
||||
app = create_app(lifespan_providers=[])
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
await lancedb_manager.dispose_connection()
|
||||
load_settings.cache_clear()
|
||||
|
||||
|
||||
# ── DTO-layer 422 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_page_size_above_cap_returns_422(client: AsyncClient) -> None:
|
||||
"""``page_size > 100`` violates the wiki cap → 422 at the DTO layer."""
|
||||
resp = await client.post(
|
||||
"/api/v1/memory/get",
|
||||
json={
|
||||
"user_id": "u1",
|
||||
"memory_type": "episode",
|
||||
"page_size": 200,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_empty_user_id_returns_422(client: AsyncClient) -> None:
|
||||
"""``user_id`` carries ``min_length=1`` end-to-end."""
|
||||
resp = await client.post(
|
||||
"/api/v1/memory/get",
|
||||
json={
|
||||
"user_id": "",
|
||||
"memory_type": "episode",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_missing_memory_type_returns_422(client: AsyncClient) -> None:
|
||||
"""Omitting the required ``memory_type`` field is rejected at the DTO layer."""
|
||||
resp = await client.post(
|
||||
"/api/v1/memory/get",
|
||||
json={"user_id": "u1"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_invalid_memory_type_value_returns_422(client: AsyncClient) -> None:
|
||||
"""``memory_type`` outside the four-kind enum → 422."""
|
||||
resp = await client.post(
|
||||
"/api/v1/memory/get",
|
||||
json={
|
||||
"user_id": "u1",
|
||||
"memory_type": "atomic_fact", # not a top-level kind
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_invalid_sort_order_returns_422(client: AsyncClient) -> None:
|
||||
"""``sort_order`` is a tight Literal — uppercase variant rejected."""
|
||||
resp = await client.post(
|
||||
"/api/v1/memory/get",
|
||||
json={
|
||||
"user_id": "u1",
|
||||
"memory_type": "episode",
|
||||
"sort_order": "DESC",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_owner_memory_type_mismatch_returns_422(client: AsyncClient) -> None:
|
||||
"""``user`` + ``agent_case`` is a hard pydantic error."""
|
||||
resp = await client.post(
|
||||
"/api/v1/memory/get",
|
||||
json={
|
||||
"user_id": "u1",
|
||||
"memory_type": "agent_case",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ── service.compile_filters_for_get 422 ───────────────────────────────
|
||||
|
||||
|
||||
async def test_unknown_filter_field_returns_422(client: AsyncClient) -> None:
|
||||
"""A field outside ``ALLOWED_FIELDS`` surfaces as 422 from the adapter."""
|
||||
resp = await client.post(
|
||||
"/api/v1/memory/get",
|
||||
json={
|
||||
"user_id": "u1",
|
||||
"memory_type": "episode",
|
||||
"filters": {"random_attr": "boom"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert "unsupported" in resp.text
|
||||
|
||||
|
||||
async def test_malformed_filter_in_op_returns_422(client: AsyncClient) -> None:
|
||||
"""``in`` op with a scalar (not list) surfaces as 422 from the adapter."""
|
||||
resp = await client.post(
|
||||
"/api/v1/memory/get",
|
||||
json={
|
||||
"user_id": "u1",
|
||||
"memory_type": "episode",
|
||||
"filters": {"session_id": {"in": "not_a_list"}},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
@ -0,0 +1,125 @@
|
||||
"""``GET /metrics`` — Prometheus exposition + middleware integration.
|
||||
|
||||
Verifies three contracts of the metrics path:
|
||||
|
||||
1. The route renders ``prometheus_client``-parseable exposition format.
|
||||
2. The ``PrometheusMiddleware`` actually bumps the per-route counter
|
||||
on a real round-trip (verified via before/after delta to avoid
|
||||
coupling to the global registry's cross-test accumulation).
|
||||
3. The ``_SKIP_PATHS`` set (``/metrics``, ``/health``) is honoured —
|
||||
those endpoints never appear in ``everos_http_requests_total``.
|
||||
|
||||
No lifespan / no LanceDB / no LLM needed — middleware lives at the ASGI
|
||||
layer above any of that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from prometheus_client.parser import text_string_to_metric_families
|
||||
|
||||
from everos.config import load_settings
|
||||
from everos.entrypoints.api.app import create_app
|
||||
|
||||
# ``prometheus_client.parser`` strips the ``_total`` counter suffix from
|
||||
# the *family* name but leaves *sample* names intact.
|
||||
_REQUESTS_FAMILY = "everos_http_requests"
|
||||
_REQUESTS_TOTAL = "everos_http_requests_total"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> AsyncIterator[AsyncClient]:
|
||||
"""FastAPI app with no lifespan; middleware stack is wired by ``create_app``."""
|
||||
monkeypatch.setenv("EVEROS_MEMORY__ROOT", str(tmp_path))
|
||||
load_settings.cache_clear()
|
||||
|
||||
app = create_app(lifespan_providers=[])
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
load_settings.cache_clear()
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _counter_value(text: str, path: str, status: str) -> float:
|
||||
"""Sum ``everos_http_requests_total`` samples matching path + status."""
|
||||
total = 0.0
|
||||
for fam in text_string_to_metric_families(text):
|
||||
if fam.name != _REQUESTS_FAMILY:
|
||||
continue
|
||||
for s in fam.samples:
|
||||
if s.name != _REQUESTS_TOTAL:
|
||||
continue
|
||||
if s.labels.get("path") == path and s.labels.get("status") == status:
|
||||
total += s.value
|
||||
return total
|
||||
|
||||
|
||||
def _all_recorded_paths(text: str) -> set[str]:
|
||||
"""Set of ``path`` label values present in ``everos_http_requests_total``."""
|
||||
paths: set[str] = set()
|
||||
for fam in text_string_to_metric_families(text):
|
||||
if fam.name != _REQUESTS_FAMILY:
|
||||
continue
|
||||
for s in fam.samples:
|
||||
if s.name == _REQUESTS_TOTAL:
|
||||
paths.add(s.labels.get("path", ""))
|
||||
return paths
|
||||
|
||||
|
||||
# ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_metrics_endpoint_renders_prometheus_format(
|
||||
client: AsyncClient,
|
||||
) -> None:
|
||||
"""``GET /metrics`` returns parsable Prometheus exposition format."""
|
||||
resp = await client.get("/metrics")
|
||||
assert resp.status_code == 200
|
||||
assert "text/plain" in resp.headers.get("content-type", "")
|
||||
|
||||
# Must parse cleanly + expose the request counter family.
|
||||
families = {f.name for f in text_string_to_metric_families(resp.text)}
|
||||
assert _REQUESTS_FAMILY in families
|
||||
|
||||
|
||||
async def test_metrics_counter_increments_on_request(client: AsyncClient) -> None:
|
||||
"""A real route hit bumps ``everos_http_requests_total`` for that label triple.
|
||||
|
||||
Uses a 422 to avoid needing LanceDB — Pydantic rejects the empty
|
||||
body before the route handler runs, but the middleware still sees
|
||||
a completed request/response with ``status=422``.
|
||||
"""
|
||||
before_resp = await client.get("/metrics")
|
||||
before = _counter_value(before_resp.text, "/api/v1/memory/get", "422")
|
||||
|
||||
bad = await client.post("/api/v1/memory/get", json={})
|
||||
assert bad.status_code == 422
|
||||
|
||||
after_resp = await client.get("/metrics")
|
||||
after = _counter_value(after_resp.text, "/api/v1/memory/get", "422")
|
||||
|
||||
assert after - before == 1.0, f"counter not bumped: {before} → {after}"
|
||||
|
||||
|
||||
async def test_metrics_skip_paths_not_recorded(client: AsyncClient) -> None:
|
||||
"""``_SKIP_PATHS`` (``/metrics``, ``/health``) never appear in the counter."""
|
||||
# Hit both endpoints. If they were *not* skipped, they'd show up in
|
||||
# the next /metrics dump.
|
||||
await client.get("/health")
|
||||
await client.get("/metrics")
|
||||
|
||||
resp = await client.get("/metrics")
|
||||
recorded = _all_recorded_paths(resp.text)
|
||||
assert "/metrics" not in recorded, recorded
|
||||
assert "/health" not in recorded, recorded
|
||||
@ -0,0 +1,133 @@
|
||||
"""422 validation paths for ``POST /api/v1/memory/search``.
|
||||
|
||||
These exercise the request → DTO / route → service.compile_filters
|
||||
error paths *without* needing any seeded data or external services
|
||||
(no embedder / no LLM / no LanceDB rows). The full data-driven e2e
|
||||
suite lives in ``tests/integration/test_search_endpoint_e2e.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from everos.config import load_settings
|
||||
from everos.entrypoints.api.app import create_app
|
||||
from everos.infra.persistence.lancedb import lancedb_manager
|
||||
|
||||
search_service_mod = import_module("everos.service.search")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> AsyncIterator[AsyncClient]:
|
||||
"""FastAPI app with no lifespan; resets search singletons per test."""
|
||||
monkeypatch.setenv("EVEROS_MEMORY__ROOT", str(tmp_path))
|
||||
load_settings.cache_clear()
|
||||
|
||||
lancedb_manager._conn = None
|
||||
lancedb_manager._tables.clear()
|
||||
for attr in ("_manager", "_embedding", "_reranker", "_llm_client"):
|
||||
setattr(search_service_mod, attr, None)
|
||||
for attr in ("_embedding_resolved", "_rerank_resolved", "_llm_resolved"):
|
||||
setattr(search_service_mod, attr, False)
|
||||
|
||||
app = create_app(lifespan_providers=[])
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
await lancedb_manager.dispose_connection()
|
||||
load_settings.cache_clear()
|
||||
|
||||
|
||||
def _body(**overrides) -> dict:
|
||||
"""Minimal valid SearchRequest body; tests override one field to break it.
|
||||
|
||||
``method="keyword"`` is pinned because the SearchRequest DTO defaults
|
||||
to HYBRID, which ``SearchManager._validate_components`` rejects when
|
||||
no ``[embedding]`` provider is configured (the case in CI). Keyword
|
||||
needs no embedder, so DTO / compile_filters validation paths fire
|
||||
cleanly without external services — which is exactly what this file
|
||||
is supposed to exercise.
|
||||
"""
|
||||
base = {
|
||||
"user_id": "u1",
|
||||
"query": "hello",
|
||||
"method": "keyword",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# ── DTO-layer 422 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_empty_query_returns_422(client: AsyncClient) -> None:
|
||||
"""``query`` carries ``min_length=1``."""
|
||||
resp = await client.post("/api/v1/memory/search", json=_body(query=""))
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_empty_user_id_returns_422(client: AsyncClient) -> None:
|
||||
"""``user_id`` carries ``min_length=1``."""
|
||||
resp = await client.post("/api/v1/memory/search", json=_body(user_id=""))
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_both_user_and_agent_id_returns_422(client: AsyncClient) -> None:
|
||||
"""Both ``user_id`` and ``agent_id`` set → xor validator rejects."""
|
||||
resp = await client.post("/api/v1/memory/search", json=_body(agent_id="agent_x"))
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_invalid_method_returns_422(client: AsyncClient) -> None:
|
||||
"""``method`` outside the SearchMethod enum → 422."""
|
||||
resp = await client.post("/api/v1/memory/search", json=_body(method="bm42"))
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_top_k_zero_returns_422(client: AsyncClient) -> None:
|
||||
"""``top_k=0`` violates the validator (must be -1 or 1..100)."""
|
||||
resp = await client.post("/api/v1/memory/search", json=_body(top_k=0))
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_top_k_above_cap_returns_422(client: AsyncClient) -> None:
|
||||
"""``top_k=101`` exceeds the 100 cap."""
|
||||
resp = await client.post("/api/v1/memory/search", json=_body(top_k=101))
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_radius_above_one_returns_422(client: AsyncClient) -> None:
|
||||
"""``radius`` is constrained to [0.0, 1.0]."""
|
||||
resp = await client.post("/api/v1/memory/search", json=_body(radius=1.5))
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ── service.compile_filters 422 ───────────────────────────────────────
|
||||
|
||||
|
||||
async def test_unknown_filter_field_returns_422(client: AsyncClient) -> None:
|
||||
"""A field outside ``ALLOWED_FIELDS`` surfaces as 422 from the adapter."""
|
||||
resp = await client.post(
|
||||
"/api/v1/memory/search",
|
||||
json=_body(filters={"random_attr": "boom"}),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert "unsupported" in resp.text
|
||||
|
||||
|
||||
async def test_reserved_owner_id_in_filters_returns_422(client: AsyncClient) -> None:
|
||||
"""``owner_id`` is reserved at the top level — must not appear inside filters."""
|
||||
resp = await client.post(
|
||||
"/api/v1/memory/search",
|
||||
json=_body(filters={"owner_id": "spoof"}),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
0
tests/unit/test_entrypoints/test_cli/__init__.py
Normal file
0
tests/unit/test_entrypoints/test_cli/__init__.py
Normal file
98
tests/unit/test_entrypoints/test_cli/test_cascade_command.py
Normal file
98
tests/unit/test_entrypoints/test_cli/test_cascade_command.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""``everos cascade`` — structural smoke + pure helper tests.
|
||||
|
||||
The orchestrator paths require live sqlite + lancedb singletons; those
|
||||
are exercised by integration tests. Here we cover:
|
||||
|
||||
- subcommand registration (sync / status / fix)
|
||||
- ``--help`` exit codes
|
||||
- ``_resolve_relative`` (path arithmetic vs. memory root)
|
||||
- ``_print_failed_table`` (formatting of failed rows)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from everos.entrypoints.cli.commands import cascade as cascade_mod
|
||||
|
||||
|
||||
def test_app_registers_three_commands() -> None:
|
||||
names = {cmd.name for cmd in cascade_mod.app.registered_commands}
|
||||
assert names == {"sync", "status", "fix"}
|
||||
|
||||
|
||||
def test_help_exits_zero() -> None:
|
||||
result = CliRunner().invoke(cascade_mod.app, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "sync" in result.stdout
|
||||
assert "status" in result.stdout
|
||||
assert "fix" in result.stdout
|
||||
|
||||
|
||||
def test_resolve_relative_under_root(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("EVEROS_MEMORY__ROOT", str(tmp_path))
|
||||
from everos.config import load_settings
|
||||
|
||||
load_settings.cache_clear()
|
||||
|
||||
rel = cascade_mod._resolve_relative(tmp_path / "users" / "u1" / "x.md")
|
||||
assert rel == "users/u1/x.md"
|
||||
|
||||
|
||||
def test_resolve_relative_outside_root_raises(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("EVEROS_MEMORY__ROOT", str(tmp_path / "memory"))
|
||||
from everos.config import load_settings
|
||||
|
||||
load_settings.cache_clear()
|
||||
|
||||
other = tmp_path / "somewhere-else.md"
|
||||
with pytest.raises(typer.BadParameter, match="not under memory root"):
|
||||
cascade_mod._resolve_relative(other)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FailedRow:
|
||||
md_path: str
|
||||
retryable: bool
|
||||
retry_count: int
|
||||
last_attempt_at: object
|
||||
error: str | None
|
||||
|
||||
|
||||
def test_print_failed_table_formats_rows(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
rows = [
|
||||
_FailedRow(
|
||||
md_path="users/u1/a.md",
|
||||
retryable=True,
|
||||
retry_count=2,
|
||||
last_attempt_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
error="boom",
|
||||
),
|
||||
_FailedRow(
|
||||
md_path="users/u2/b.md",
|
||||
retryable=False,
|
||||
retry_count=5,
|
||||
last_attempt_at=None,
|
||||
error=None,
|
||||
),
|
||||
]
|
||||
cascade_mod._print_failed_table(rows) # type: ignore[arg-type]
|
||||
out = capsys.readouterr().out
|
||||
assert "2 failed row(s):" in out
|
||||
assert "users/u1/a.md" in out
|
||||
assert "TRUE" in out
|
||||
assert "users/u2/b.md" in out
|
||||
assert "FALSE" in out
|
||||
# Header row present
|
||||
assert "md_path" in out and "retries" in out
|
||||
213
tests/unit/test_entrypoints/test_cli/test_init_command.py
Normal file
213
tests/unit/test_entrypoints/test_cli/test_init_command.py
Normal file
@ -0,0 +1,213 @@
|
||||
"""``everos init`` — CLI behavior + edge cases.
|
||||
|
||||
Covers:
|
||||
|
||||
- default ``./.env`` path, written with 0600 permissions
|
||||
- ``--to <path>`` creates parent dirs
|
||||
- ``--force`` overwrites; without it the command refuses with exit 1
|
||||
- ``--print`` writes to stdout, NOT to disk
|
||||
- ``--xdg`` and ``--to`` are mutually exclusive (exit 2)
|
||||
- ``--xdg`` honors ``XDG_CONFIG_HOME``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from everos.entrypoints.cli.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner() -> CliRunner:
|
||||
return CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def in_tmp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Run from a fresh tmp cwd so default ``./.env`` lands in tmp_path."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_default_writes_dotenv_in_cwd(runner: CliRunner, in_tmp: Path) -> None:
|
||||
result = runner.invoke(app, ["init"])
|
||||
assert result.exit_code == 0, result.output
|
||||
written = in_tmp / ".env"
|
||||
assert written.exists()
|
||||
assert written.stat().st_size > 0
|
||||
assert "EVEROS_LLM__API_KEY" in written.read_text()
|
||||
|
||||
|
||||
def test_default_file_permissions_are_0600(runner: CliRunner, in_tmp: Path) -> None:
|
||||
"""The generated .env holds API keys — must not be world-readable."""
|
||||
result = runner.invoke(app, ["init"])
|
||||
assert result.exit_code == 0
|
||||
mode = stat.S_IMODE((in_tmp / ".env").stat().st_mode)
|
||||
assert mode == 0o600, f"expected 0o600, got {oct(mode)}"
|
||||
|
||||
|
||||
def test_refuses_overwrite_without_force(runner: CliRunner, in_tmp: Path) -> None:
|
||||
(in_tmp / ".env").write_text("PREEXISTING=1\n")
|
||||
result = runner.invoke(app, ["init"])
|
||||
assert result.exit_code == 1
|
||||
assert "already exists" in (result.output + (result.stderr or ""))
|
||||
# Original content must be preserved.
|
||||
assert (in_tmp / ".env").read_text() == "PREEXISTING=1\n"
|
||||
|
||||
|
||||
def test_force_overwrites(runner: CliRunner, in_tmp: Path) -> None:
|
||||
(in_tmp / ".env").write_text("PREEXISTING=1\n")
|
||||
result = runner.invoke(app, ["init", "--force"])
|
||||
assert result.exit_code == 0
|
||||
body = (in_tmp / ".env").read_text()
|
||||
assert "PREEXISTING=1" not in body
|
||||
assert "EVEROS_LLM__API_KEY" in body
|
||||
|
||||
|
||||
def test_to_creates_parent_dirs(runner: CliRunner, in_tmp: Path) -> None:
|
||||
target = in_tmp / "nested" / "subdir" / ".env"
|
||||
result = runner.invoke(app, ["init", "--to", str(target)])
|
||||
assert result.exit_code == 0
|
||||
assert target.exists()
|
||||
assert "EVEROS_LLM__API_KEY" in target.read_text()
|
||||
|
||||
|
||||
def test_print_writes_stdout_not_disk(runner: CliRunner, in_tmp: Path) -> None:
|
||||
result = runner.invoke(app, ["init", "--print"])
|
||||
assert result.exit_code == 0
|
||||
assert "EVEROS_LLM__API_KEY" in result.output
|
||||
# No disk side-effect.
|
||||
assert not (in_tmp / ".env").exists()
|
||||
|
||||
|
||||
def test_xdg_writes_to_xdg_config_home(
|
||||
runner: CliRunner, in_tmp: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
xdg_root = in_tmp / "xdg"
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_root))
|
||||
result = runner.invoke(app, ["init", "--xdg"])
|
||||
assert result.exit_code == 0
|
||||
target = xdg_root / "everos" / ".env"
|
||||
assert target.exists()
|
||||
|
||||
|
||||
def test_xdg_falls_back_to_dot_config(
|
||||
runner: CliRunner, in_tmp: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""No ``XDG_CONFIG_HOME`` → default ``~/.config``.
|
||||
|
||||
We sandbox ``$HOME`` to ``in_tmp`` so the test does not touch a real
|
||||
user's ``~/.config``.
|
||||
"""
|
||||
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
||||
monkeypatch.setenv("HOME", str(in_tmp))
|
||||
result = runner.invoke(app, ["init", "--xdg"])
|
||||
assert result.exit_code == 0
|
||||
target = in_tmp / ".config" / "everos" / ".env"
|
||||
assert target.exists()
|
||||
|
||||
|
||||
def test_xdg_and_to_are_mutually_exclusive(runner: CliRunner, in_tmp: Path) -> None:
|
||||
result = runner.invoke(app, ["init", "--xdg", "--to", str(in_tmp / "other.env")])
|
||||
assert result.exit_code == 2
|
||||
assert "mutually exclusive" in (result.output + (result.stderr or ""))
|
||||
|
||||
|
||||
def test_template_resource_is_packaged_under_everos_templates() -> None:
|
||||
"""The packaged resource must remain at the canonical location.
|
||||
|
||||
Guards the wheel/sdist layout: ``init_cmd`` reads
|
||||
``everos.templates.env.template`` via ``importlib.resources``; if
|
||||
someone moves the file without updating ``_TEMPLATE_PACKAGE``, this
|
||||
test fails immediately.
|
||||
"""
|
||||
from importlib import resources
|
||||
|
||||
res = resources.files("everos.templates").joinpath("env.template")
|
||||
assert res.is_file()
|
||||
body = res.read_text(encoding="utf-8")
|
||||
assert "EVEROS_LLM__API_KEY" in body
|
||||
|
||||
|
||||
# ── 4-layer .env resolution for ``server start`` ────────────────────────
|
||||
|
||||
|
||||
def test_resolve_env_file_explicit_wins(in_tmp: Path) -> None:
|
||||
"""``--env-file <path>`` beats cwd / XDG / ~/.everos fallbacks."""
|
||||
from everos.entrypoints.cli.commands.server import _resolve_env_file
|
||||
|
||||
explicit = in_tmp / "explicit.env"
|
||||
explicit.write_text("X=1\n")
|
||||
# Also seed cwd .env so we can prove the explicit wins.
|
||||
(in_tmp / ".env").write_text("CWD=1\n")
|
||||
resolved = _resolve_env_file(str(explicit))
|
||||
assert resolved == explicit
|
||||
|
||||
|
||||
def test_resolve_env_file_cwd_wins_over_xdg(
|
||||
in_tmp: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from everos.entrypoints.cli.commands.server import _resolve_env_file
|
||||
|
||||
xdg_root = in_tmp / "xdg"
|
||||
(xdg_root / "everos").mkdir(parents=True)
|
||||
(xdg_root / "everos" / ".env").write_text("XDG=1\n")
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_root))
|
||||
cwd_env = in_tmp / ".env"
|
||||
cwd_env.write_text("CWD=1\n")
|
||||
resolved = _resolve_env_file(None)
|
||||
assert resolved == cwd_env
|
||||
|
||||
|
||||
def test_resolve_env_file_xdg_when_no_cwd(
|
||||
in_tmp: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from everos.entrypoints.cli.commands.server import _resolve_env_file
|
||||
|
||||
xdg_root = in_tmp / "xdg"
|
||||
(xdg_root / "everos").mkdir(parents=True)
|
||||
target = xdg_root / "everos" / ".env"
|
||||
target.write_text("XDG=1\n")
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_root))
|
||||
# No cwd/.env.
|
||||
resolved = _resolve_env_file(None)
|
||||
assert resolved == target
|
||||
|
||||
|
||||
def test_resolve_env_file_everos_home_fallback(
|
||||
in_tmp: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""``~/.everos/.env`` is the last fallback when nothing else exists."""
|
||||
from everos.entrypoints.cli.commands.server import _resolve_env_file
|
||||
|
||||
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
||||
monkeypatch.setenv("HOME", str(in_tmp))
|
||||
target = in_tmp / ".everos" / ".env"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("EVEROS_ROOT=1\n")
|
||||
resolved = _resolve_env_file(None)
|
||||
assert resolved == target
|
||||
|
||||
|
||||
def test_resolve_env_file_none_when_no_layer_matches(
|
||||
in_tmp: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""All four layers absent → ``None`` (the server then falls back to
|
||||
inherited process env, which is the documented CI/container path)."""
|
||||
from everos.entrypoints.cli.commands.server import _resolve_env_file
|
||||
|
||||
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
||||
monkeypatch.setenv("HOME", str(in_tmp))
|
||||
# Nothing in cwd, no XDG path, no ~/.everos/.
|
||||
assert not (in_tmp / ".env").exists()
|
||||
assert _resolve_env_file(None) is None
|
||||
|
||||
|
||||
# ``os`` imported above just to keep ruff from complaining; remove if Ruff
|
||||
# F401 hits.
|
||||
_ = os
|
||||
22
tests/unit/test_entrypoints/test_cli/test_main.py
Normal file
22
tests/unit/test_entrypoints/test_cli/test_main.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""CLI root app — verifies sub-typer wiring + ``--help`` exit code."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from everos.entrypoints.cli.main import app
|
||||
|
||||
|
||||
def test_help_exits_zero() -> None:
|
||||
result = CliRunner().invoke(app, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "everos" in result.stdout
|
||||
assert "server" in result.stdout
|
||||
assert "cascade" in result.stdout
|
||||
|
||||
|
||||
def test_no_args_shows_help_and_exits_nonzero() -> None:
|
||||
# ``no_args_is_help=True`` triggers a help exit with code 2 (typer default).
|
||||
result = CliRunner().invoke(app, [])
|
||||
assert result.exit_code != 0
|
||||
assert "Usage" in result.stdout or "Usage" in result.stderr
|
||||
134
tests/unit/test_entrypoints/test_cli/test_server_command.py
Normal file
134
tests/unit/test_entrypoints/test_cli/test_server_command.py
Normal file
@ -0,0 +1,134 @@
|
||||
"""``everos server start`` — argument resolution + uvicorn handoff.
|
||||
|
||||
Uvicorn ``run`` is the external boundary and is mocked. We assert the
|
||||
host/port/log_level resolution chain (CLI flag > env > default) and the
|
||||
KeyboardInterrupt / OSError exit paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from everos.entrypoints.cli.commands import server as server_mod
|
||||
from everos.entrypoints.cli.main import app as root_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]:
|
||||
"""Mock ``uvicorn.run`` and return the kwargs it was called with."""
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(*args: object, **kwargs: object) -> None:
|
||||
captured["args"] = args
|
||||
captured["kwargs"] = kwargs
|
||||
|
||||
monkeypatch.setattr(server_mod.uvicorn, "run", fake_run)
|
||||
# Strip env so default resolution path is deterministic.
|
||||
for k in ("EVEROS_HOST", "EVEROS_PORT", "EVEROS_LOG_LEVEL"):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
return captured
|
||||
|
||||
|
||||
# Typer lifts single-command sub-apps to root; we invoke via the real
|
||||
# ``everos server start`` path through the assembled root app.
|
||||
|
||||
|
||||
def test_start_uses_default_host_port_log_level(captured: dict[str, object]) -> None:
|
||||
result = CliRunner().invoke(
|
||||
root_app, ["server", "start", "--env-file", "/nonexistent"]
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
kwargs = captured["kwargs"]
|
||||
assert isinstance(kwargs, dict)
|
||||
assert kwargs["host"] == "127.0.0.1"
|
||||
assert kwargs["port"] == 8000
|
||||
assert kwargs["log_level"] == "info"
|
||||
assert kwargs["factory"] is True
|
||||
args = captured["args"]
|
||||
assert args == ("everos.entrypoints.api.app:create_app",)
|
||||
|
||||
|
||||
def test_start_cli_flags_override_env(
|
||||
captured: dict[str, object], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("EVEROS_API__HOST", "1.2.3.4")
|
||||
monkeypatch.setenv("EVEROS_API__PORT", "9000")
|
||||
monkeypatch.setenv("EVEROS_API__LOG_LEVEL", "debug")
|
||||
result = CliRunner().invoke(
|
||||
root_app,
|
||||
[
|
||||
"server",
|
||||
"start",
|
||||
"--env-file",
|
||||
"/nonexistent",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"8765",
|
||||
"--log-level",
|
||||
"warning",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
kwargs = captured["kwargs"]
|
||||
assert isinstance(kwargs, dict)
|
||||
assert kwargs["host"] == "127.0.0.1"
|
||||
assert kwargs["port"] == 8765
|
||||
assert kwargs["log_level"] == "warning"
|
||||
|
||||
|
||||
def test_start_falls_back_to_env_when_flags_omitted(
|
||||
captured: dict[str, object], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("EVEROS_API__HOST", "10.0.0.1")
|
||||
monkeypatch.setenv("EVEROS_API__PORT", "8765")
|
||||
result = CliRunner().invoke(
|
||||
root_app, ["server", "start", "--env-file", "/nonexistent"]
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
kwargs = captured["kwargs"]
|
||||
assert isinstance(kwargs, dict)
|
||||
assert kwargs["host"] == "10.0.0.1"
|
||||
assert kwargs["port"] == 8765
|
||||
|
||||
|
||||
def test_start_swallows_keyboard_interrupt(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def boom(*args: object, **kwargs: object) -> None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr(server_mod.uvicorn, "run", boom)
|
||||
result = CliRunner().invoke(
|
||||
root_app, ["server", "start", "--env-file", "/nonexistent"]
|
||||
)
|
||||
# KeyboardInterrupt path returns normally — exit 0.
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_start_exits_one_on_os_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def boom(*args: object, **kwargs: object) -> None:
|
||||
raise OSError("port in use")
|
||||
|
||||
monkeypatch.setattr(server_mod.uvicorn, "run", boom)
|
||||
result = CliRunner().invoke(
|
||||
root_app, ["server", "start", "--env-file", "/nonexistent"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
def test_load_env_file_missing_path_is_noop(tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
# Function should not raise when the file does not exist.
|
||||
server_mod._load_env_file(str(tmp_path / "does-not-exist.env"))
|
||||
|
||||
|
||||
def test_load_env_file_reads_present_file(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.delenv("EVEROS_TEST_DOTENV_VAR", raising=False)
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("EVEROS_TEST_DOTENV_VAR=loaded\n")
|
||||
server_mod._load_env_file(str(env_file))
|
||||
import os
|
||||
|
||||
assert os.environ.get("EVEROS_TEST_DOTENV_VAR") == "loaded"
|
||||
monkeypatch.delenv("EVEROS_TEST_DOTENV_VAR", raising=False)
|
||||
Reference in New Issue
Block a user