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_core/test_middleware/__init__.py
Normal file
0
tests/unit/test_core/test_middleware/__init__.py
Normal file
106
tests/unit/test_core/test_middleware/test_global_exception.py
Normal file
106
tests/unit/test_core/test_middleware/test_global_exception.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""``global_exception_handler`` — uniform error envelope per v1 API §1.
|
||||
|
||||
We mount the handler on a minimal FastAPI app with three error-emitting
|
||||
routes (HTTPException 4xx / 5xx, RequestValidationError, raw exception)
|
||||
and assert the envelope shape + status code each route produces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
from everos.core.middleware.global_exception import global_exception_handler
|
||||
|
||||
|
||||
class _Body(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
def _build_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.add_exception_handler(HTTPException, global_exception_handler)
|
||||
app.add_exception_handler(RequestValidationError, global_exception_handler)
|
||||
app.add_exception_handler(Exception, global_exception_handler)
|
||||
|
||||
@app.get("/raise-400")
|
||||
async def raise_400() -> None:
|
||||
raise HTTPException(status_code=400, detail="bad input")
|
||||
|
||||
@app.get("/raise-500-http")
|
||||
async def raise_500_http() -> None:
|
||||
raise HTTPException(status_code=503, detail="upstream dead")
|
||||
|
||||
@app.get("/boom")
|
||||
async def boom() -> None:
|
||||
raise RuntimeError("hidden internals")
|
||||
|
||||
@app.post("/validate")
|
||||
async def validate(_body: _Body) -> dict[str, str]:
|
||||
return {"ok": "yes"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncIterator[AsyncClient]:
|
||||
app = _build_app()
|
||||
# raise_app_exceptions=False — let the registered handler convert the
|
||||
# RuntimeError into a 500 response instead of re-raising into the test.
|
||||
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _assert_envelope(body: dict[str, object], *, code: str, path: str) -> None:
|
||||
"""Wiki §1 envelope: ``{request_id, error: {code, message, timestamp, path}}``."""
|
||||
assert isinstance(body["request_id"], str) and body["request_id"]
|
||||
error = body["error"]
|
||||
assert isinstance(error, dict)
|
||||
assert error["code"] == code
|
||||
assert isinstance(error["message"], str) and error["message"]
|
||||
assert isinstance(error["timestamp"], str) and "T" in error["timestamp"]
|
||||
assert error["path"] == path
|
||||
|
||||
|
||||
async def test_http_exception_4xx(client: AsyncClient) -> None:
|
||||
resp = await client.get("/raise-400")
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
_assert_envelope(body, code="HTTP_ERROR", path="/raise-400")
|
||||
assert body["error"]["message"] == "bad input"
|
||||
|
||||
|
||||
async def test_http_exception_5xx_uses_system_error(client: AsyncClient) -> None:
|
||||
"""5xx routed through HTTPException still produces SYSTEM_ERROR + generic msg."""
|
||||
resp = await client.get("/raise-500-http")
|
||||
assert resp.status_code == 503
|
||||
body = resp.json()
|
||||
_assert_envelope(body, code="SYSTEM_ERROR", path="/raise-500-http")
|
||||
# Internal detail "upstream dead" is suppressed in 5xx envelopes.
|
||||
assert body["error"]["message"] == "Internal server error"
|
||||
|
||||
|
||||
async def test_unhandled_exception_5xx(client: AsyncClient) -> None:
|
||||
"""RuntimeError → 500 with generic ``SYSTEM_ERROR`` envelope; details hidden."""
|
||||
resp = await client.get("/boom")
|
||||
assert resp.status_code == 500
|
||||
body = resp.json()
|
||||
_assert_envelope(body, code="SYSTEM_ERROR", path="/boom")
|
||||
assert body["error"]["message"] == "Internal server error"
|
||||
# Must not leak the internal exception message.
|
||||
assert "hidden internals" not in resp.text
|
||||
|
||||
|
||||
async def test_validation_error_returns_422(client: AsyncClient) -> None:
|
||||
resp = await client.post("/validate", json={}) # missing ``name``
|
||||
assert resp.status_code == 422
|
||||
body = resp.json()
|
||||
_assert_envelope(body, code="HTTP_ERROR", path="/validate")
|
||||
# First-error message includes the offending field somewhere.
|
||||
assert "name" in body["error"]["message"].lower()
|
||||
148
tests/unit/test_core/test_middleware/test_profile.py
Normal file
148
tests/unit/test_core/test_middleware/test_profile.py
Normal file
@ -0,0 +1,148 @@
|
||||
"""``ProfileMiddleware`` — env gating, query-param gating, pyinstrument output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from everos.core.middleware.profile import ProfileMiddleware, _profiling_enabled
|
||||
|
||||
|
||||
def _build_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.add_middleware(ProfileMiddleware)
|
||||
|
||||
@app.get("/hello")
|
||||
async def hello() -> dict[str, str]:
|
||||
return {"ok": "yes"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disable_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("PROFILING_ENABLED", raising=False)
|
||||
monkeypatch.delenv("PROFILING", raising=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enable_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PROFILING_ENABLED", "true")
|
||||
|
||||
|
||||
def test_profiling_enabled_truthy_variants(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for v in ("1", "true", "TRUE", "yes"):
|
||||
monkeypatch.setenv("PROFILING_ENABLED", v)
|
||||
assert _profiling_enabled() is True
|
||||
|
||||
|
||||
def test_profiling_enabled_falsy_variants(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for v in ("0", "false", "no", "", "anything-else"):
|
||||
monkeypatch.setenv("PROFILING_ENABLED", v)
|
||||
assert _profiling_enabled() is False
|
||||
|
||||
|
||||
def test_profiling_falls_back_to_legacy_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("PROFILING_ENABLED", raising=False)
|
||||
monkeypatch.setenv("PROFILING", "yes")
|
||||
assert _profiling_enabled() is True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def disabled_client(disable_env: None) -> AsyncIterator[AsyncClient]:
|
||||
app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def enabled_client(enable_env: None) -> AsyncIterator[AsyncClient]:
|
||||
app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def test_disabled_passthrough(disabled_client: AsyncClient) -> None:
|
||||
"""When profiling is disabled, ``?profile=true`` is ignored — JSON returned."""
|
||||
resp = await disabled_client.get("/hello?profile=true")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": "yes"}
|
||||
|
||||
|
||||
async def test_enabled_without_query_passthrough(enabled_client: AsyncClient) -> None:
|
||||
"""Enabled middleware but request without ``?profile=true`` → normal response."""
|
||||
resp = await enabled_client.get("/hello")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": "yes"}
|
||||
|
||||
|
||||
async def test_enabled_with_query_returns_html(enabled_client: AsyncClient) -> None:
|
||||
"""With ``?profile=true`` and pyinstrument available, response is HTML."""
|
||||
try:
|
||||
import pyinstrument # noqa: F401
|
||||
except ImportError:
|
||||
pytest.skip("pyinstrument not installed in this env")
|
||||
|
||||
resp = await enabled_client.get("/hello?profile=true")
|
||||
assert resp.status_code == 200
|
||||
assert "text/html" in resp.headers.get("content-type", "")
|
||||
# Pyinstrument output contains the word "pyinstrument" in its template.
|
||||
assert "pyinstrument" in resp.text.lower() or "<html" in resp.text.lower()
|
||||
|
||||
|
||||
async def test_enabled_with_query_returns_html_when_inner_raises(
|
||||
enabled_client: AsyncClient,
|
||||
) -> None:
|
||||
"""An exception inside the wrapped handler is logged but still produces HTML."""
|
||||
try:
|
||||
import pyinstrument # noqa: F401
|
||||
except ImportError:
|
||||
pytest.skip("pyinstrument not installed in this env")
|
||||
|
||||
# Rebuild a tiny app whose route raises so the middleware's except branch
|
||||
# fires; the profile HTML is still emitted regardless.
|
||||
app = FastAPI()
|
||||
app.add_middleware(ProfileMiddleware)
|
||||
|
||||
@app.get("/bang")
|
||||
async def bang() -> None:
|
||||
raise RuntimeError("inner exception")
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app, raise_app_exceptions=False),
|
||||
base_url="http://test",
|
||||
) as c:
|
||||
resp = await c.get("/bang?profile=true")
|
||||
assert resp.status_code == 200
|
||||
assert "text/html" in resp.headers.get("content-type", "")
|
||||
|
||||
|
||||
async def test_enabled_without_pyinstrument(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""If pyinstrument import fails, middleware degrades to passthrough."""
|
||||
monkeypatch.setenv("PROFILING_ENABLED", "true")
|
||||
# Force the import inside ProfileMiddleware.__init__ to fail.
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fail_pyinstrument(name: str, *args: object, **kwargs: object) -> object:
|
||||
if name == "pyinstrument":
|
||||
raise ImportError("simulated")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fail_pyinstrument)
|
||||
app = _build_app() # ProfileMiddleware ctor runs here
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as c:
|
||||
resp = await c.get("/hello?profile=true")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": "yes"}
|
||||
162
tests/unit/test_core/test_middleware/test_prometheus.py
Normal file
162
tests/unit/test_core/test_middleware/test_prometheus.py
Normal file
@ -0,0 +1,162 @@
|
||||
"""``PrometheusMiddleware`` — increments counters / histograms, skips /metrics.
|
||||
|
||||
We isolate the test from the production registry by overriding it with a
|
||||
fresh :class:`prometheus_client.CollectorRegistry` for the duration of
|
||||
the test. The middleware was already imported with module-level Counter /
|
||||
Histogram bound to whatever the registry was at import time — those
|
||||
metric objects continue to record to the real registry. The test
|
||||
therefore reads via ``_http_requests_total`` directly rather than via
|
||||
``generate_metrics_response()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from everos.core.middleware import prometheus as prom_mod
|
||||
|
||||
|
||||
def _sample_value(metric: object, **labels: str) -> float:
|
||||
"""Read the current value of a labeled prometheus metric (test helper)."""
|
||||
labeled = metric.labels(**labels)._labeled # type: ignore[attr-defined]
|
||||
for sample in labeled.collect()[0].samples:
|
||||
if sample.name.endswith("_total"):
|
||||
return float(sample.value)
|
||||
return float("nan")
|
||||
|
||||
|
||||
def _histogram_count(metric: object, **labels: str) -> float:
|
||||
labeled = metric.labels(**labels)._labeled # type: ignore[attr-defined]
|
||||
for sample in labeled.collect()[0].samples:
|
||||
if sample.name.endswith("_count"):
|
||||
return float(sample.value)
|
||||
return float("nan")
|
||||
|
||||
|
||||
def _build_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.add_middleware(prom_mod.PrometheusMiddleware)
|
||||
|
||||
@app.get("/hello")
|
||||
async def hello() -> dict[str, str]:
|
||||
return {"ok": "yes"}
|
||||
|
||||
@app.get("/users/{user_id}")
|
||||
async def get_user(user_id: str) -> dict[str, str]:
|
||||
return {"user": user_id}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncIterator[AsyncClient]:
|
||||
app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def test_increments_counter_on_200(client: AsyncClient) -> None:
|
||||
before = _sample_value(
|
||||
prom_mod._http_requests_total, method="GET", path="/hello", status="200"
|
||||
)
|
||||
resp = await client.get("/hello")
|
||||
assert resp.status_code == 200
|
||||
after = _sample_value(
|
||||
prom_mod._http_requests_total, method="GET", path="/hello", status="200"
|
||||
)
|
||||
assert after == before + 1
|
||||
|
||||
|
||||
async def test_observes_duration_histogram(client: AsyncClient) -> None:
|
||||
before = _histogram_count(
|
||||
prom_mod._http_request_duration_seconds, method="GET", path="/hello"
|
||||
)
|
||||
await client.get("/hello")
|
||||
after = _histogram_count(
|
||||
prom_mod._http_request_duration_seconds, method="GET", path="/hello"
|
||||
)
|
||||
assert after == before + 1
|
||||
|
||||
|
||||
def test_skip_paths_constant_contains_known_endpoints() -> None:
|
||||
"""Skip set is the contract — assert membership directly to avoid
|
||||
|
||||
polluting the global registry by ``.labels(path='/metrics')``-ing it
|
||||
(that creates a zero-valued sample which then leaks into the
|
||||
exposition format that test_metrics_route inspects).
|
||||
"""
|
||||
assert "/metrics" in prom_mod._SKIP_PATHS
|
||||
assert "/health" in prom_mod._SKIP_PATHS
|
||||
assert "/healthz" in prom_mod._SKIP_PATHS
|
||||
assert "/favicon.ico" in prom_mod._SKIP_PATHS
|
||||
|
||||
|
||||
async def test_path_params_normalized(client: AsyncClient) -> None:
|
||||
"""``/users/abc`` should record against the route template ``/users/{user_id}``."""
|
||||
before = _sample_value(
|
||||
prom_mod._http_requests_total,
|
||||
method="GET",
|
||||
path="/users/{user_id}",
|
||||
status="200",
|
||||
)
|
||||
resp = await client.get("/users/abc")
|
||||
assert resp.status_code == 200
|
||||
after = _sample_value(
|
||||
prom_mod._http_requests_total,
|
||||
method="GET",
|
||||
path="/users/{user_id}",
|
||||
status="200",
|
||||
)
|
||||
assert after == before + 1
|
||||
|
||||
|
||||
# ── _normalize_path direct tests (defensive fallback branches) ─────────
|
||||
|
||||
|
||||
def test_normalize_path_uses_path_params_fallback() -> None:
|
||||
"""When scope has no ``route`` but ``path_params`` is set, substitute names."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from everos.core.middleware.prometheus import _normalize_path
|
||||
|
||||
fake_req = SimpleNamespace(
|
||||
scope={},
|
||||
url=SimpleNamespace(path="/x/abc/y"),
|
||||
path_params={"id": "abc"},
|
||||
)
|
||||
# type: ignore[arg-type] — helper accepts anything duck-typed.
|
||||
assert _normalize_path(fake_req) == "/x/{id}/y" # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_normalize_path_unmatched_fallback() -> None:
|
||||
"""No route, no path_params → ``{unmatched}`` sentinel."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from everos.core.middleware.prometheus import _normalize_path
|
||||
|
||||
fake_req = SimpleNamespace(
|
||||
scope={},
|
||||
url=SimpleNamespace(path="/x"),
|
||||
path_params={},
|
||||
)
|
||||
assert _normalize_path(fake_req) == "{unmatched}" # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_normalize_path_non_dict_scope_falls_through() -> None:
|
||||
"""Defensive: a non-dict ``scope`` skips the route lookup entirely."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from everos.core.middleware.prometheus import _normalize_path
|
||||
|
||||
fake_req = SimpleNamespace(
|
||||
scope="not-a-dict",
|
||||
url=SimpleNamespace(path="/x"),
|
||||
path_params={},
|
||||
)
|
||||
assert _normalize_path(fake_req) == "{unmatched}" # type: ignore[arg-type]
|
||||
Reference in New Issue
Block a user