Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,32 @@ pytest -v python/tests
node --test test/client.test.js
```

### Tests

**Every test file must pass on its own.** CI runs one process per file, so a test
that only passes alongside another file fails the build rather than hiding. That
rule exists because it has been broken twice: `test_memory_crud.py` and
`test_error_shape.py` both reached the HTTP app through state a previous file had
left on a module global, which held in a single-process run and broke the moment
one file ran alone.

A file that talks to the HTTP app takes the fixtures from `conftest.py`:

```python
@pytest.fixture(autouse=True)
def _state():
install_app_state() # fresh storage, engine, key store, limiter


async def test_something(app_client): # AsyncClient on that state
...
```

`install_app_state(...)` takes the knobs a file needs to vary - `api_key_store`,
`scoring_limit`, `scoring_clock`, `lifecycle_settings`, `retention_days` - and
returns what it installed. Never install state from a test body that the next test
depends on.

### Storage backends

`tests/test_adapter_contract.py` runs the same behaviour tests against every
Expand Down
15 changes: 13 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,20 @@ jobs:
- name: Install server package
working-directory: server
run: pip install -e ".[dev]"
- name: Run server tests
# One process per test file - the discipline `scripts/run_tests.sh` enforces
# in the project this repo borrows its test practice from, and for the same
# reason. Two files here reached the app through state a *previous* file had
# left on the module: they passed in a single-process run and failed the
# moment one file was run alone. The shell already runs with `-e`, so the
# loop stops at the first failing file.
- name: Run server tests (one process per file)
working-directory: server
run: pytest -v
run: |
for f in tests/test_*.py; do
echo "::group::$f"
pytest -q "$f"
echo "::endgroup::"
done
- name: Security scan (pip-audit)
working-directory: server
continue-on-error: true
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,15 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
module, so the file only passed as part of the whole suite. An autouse fixture
gives each test its own state.

- **One place installs app state for tests, and CI runs one process per test
file.** Two files (`test_memory_crud.py`, `test_error_shape.py`) reached the HTTP
app through state a *previous* file had left on a module global. They passed as
part of the whole suite and failed the moment a single file was run - which is
how anyone runs one while working on it. Five files had each grown their own
slightly different installer; that is now `conftest.install_app_state()` with
`app_state` / `app_client` fixtures, and CI runs the server suite file by file so
the whole class fails loudly instead of being averaged away by test ordering.

### Changed
- **Every endpoint returns one error shape.** `PATCH /memories/{id}` answered a
conflict with `{"detail": ...}` while `DELETE` answered with
Expand Down
70 changes: 70 additions & 0 deletions server/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os
import uuid
from datetime import UTC, datetime
from types import SimpleNamespace

import pytest

Expand All @@ -22,6 +23,7 @@
OwnerType,
SourceType,
)
from amp_server.retention import RETENTION_DAYS
from amp_server.storage.chroma import ChromaAdapter


Expand Down Expand Up @@ -119,6 +121,74 @@ def make_cell(
)


# --- The HTTP app -----------------------------------------------------------
#
# Every test file that talks to the HTTP app needs the app pointed at state the
# test owns, because `get_storage()` reads a module global that the lifespan sets
# when a real server boots. Each file used to install that itself - five slightly
# different copies - and a file that forgot reached whatever state a *previous
# file* had left behind. That held while the whole suite ran in one process and
# broke the moment a single file was run, which is how anyone runs it while
# working. One helper, in conftest, so a file cannot get it subtly differently and
# cannot silently inherit somebody else's.


def install_app_state(
*,
api_key_store=None,
scoring_limit=None,
scoring_clock=None,
lifecycle_settings=None,
retention_days: int = RETENTION_DAYS,
):
"""Point the app at fresh state the way the lifespan would.

Returns the objects it installed, so a test can assert against the same
storage or limiter the app is using rather than a second copy.
"""
import amp_server.main as main_mod
from amp_server.lifecycle import LifecycleEngine
from amp_server.ratelimit import ScoringPatchLimit, ScoringPatchLimiter

storage = ChromaAdapter(
collection_name=f"test_{uuid.uuid4().hex[:12]}",
retention_days=retention_days,
)
limit = scoring_limit or ScoringPatchLimit()
limiter = ScoringPatchLimiter(
limit, **({"clock": scoring_clock} if scoring_clock else {})
)

main_mod._storage = storage
main_mod._lifecycle = LifecycleEngine(storage)
main_mod._api_key_store = api_key_store
main_mod._scoring_limit = limit
main_mod._scoring_limiter = limiter
if lifecycle_settings is not None:
main_mod._lifecycle_settings = lifecycle_settings

return SimpleNamespace(storage=storage, limiter=limiter, scoring_limit=limit)


@pytest.fixture
def app_state():
"""Fresh app state for one test."""
return install_app_state()


@pytest.fixture
async def app_client(app_state):
"""An AsyncClient over the app, on state this test owns."""
from httpx import ASGITransport, AsyncClient

import amp_server.main as main_mod

async with AsyncClient(
transport=ASGITransport(app=main_mod.app), base_url="http://test"
) as client:
yield client


def make_create_body(
*,
owner_id: str = "user-123",
Expand Down
13 changes: 3 additions & 10 deletions server/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@
from __future__ import annotations

import json
import uuid

import pytest
from conftest import make_cell
from conftest import install_app_state, make_cell
from httpx import ASGITransport, AsyncClient

from amp_server.auth import ApiKeyStore, digest, load_store, store_from_env
Expand All @@ -28,14 +27,8 @@


def _install_app(store: ApiKeyStore | None = None) -> None:
"""Point the app at a fresh storage + engine, as the lifespan would."""
import amp_server.main as main_mod
from amp_server.lifecycle import LifecycleEngine
from amp_server.storage.chroma import ChromaAdapter

main_mod._storage = ChromaAdapter(collection_name=f"test_{uuid.uuid4().hex[:12]}")
main_mod._lifecycle = LifecycleEngine(main_mod._storage)
main_mod._api_key_store = store
"""Fresh state with this file's one difference: the key store."""
install_app_state(api_key_store=store)


def _store(monkeypatch, tmp_path, keys: dict[str, str]) -> ApiKeyStore:
Expand Down
46 changes: 18 additions & 28 deletions server/tests/test_error_shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
to a generic "HTTP error 409" in a client. Every protocol error now comes from
`amp_server.errors.AMPError`, and these tests fix the wire contract so a future
inline `JSONResponse` cannot quietly introduce a second shape.

These tests used to build their own client against the bare app, which meant they
reached whatever storage a *previous test file* had left on the module - so half of
them failed when this file ran on its own. They take `app_client` from conftest now,
which installs state this test owns.
"""

from __future__ import annotations

import pytest
from httpx import ASGITransport, AsyncClient

from amp_server.errors import (
AMPError,
Expand Down Expand Up @@ -41,45 +45,31 @@ def _assert_error_envelope(body: dict, expected_code: str) -> None:


@pytest.mark.asyncio
async def test_missing_agent_id_uses_the_error_envelope():
from amp_server.main import app

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
resp = await client.post("/amp/v1/memories", json=_minimal_body())
async def test_missing_agent_id_uses_the_error_envelope(app_client):
resp = await app_client.post("/amp/v1/memories", json=_minimal_body())

assert resp.status_code == 401
_assert_error_envelope(resp.json(), "MISSING_AGENT_ID")


@pytest.mark.asyncio
async def test_access_denied_uses_the_error_envelope():
from amp_server.main import app

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
resp = await client.get("/amp/v1/memories/does-not-exist", headers=_HEADERS)
async def test_access_denied_uses_the_error_envelope(app_client):
resp = await app_client.get("/amp/v1/memories/does-not-exist", headers=_HEADERS)

assert resp.status_code == 403
_assert_error_envelope(resp.json(), "ACCESS_DENIED")


@pytest.mark.asyncio
async def test_invalid_transition_uses_the_error_envelope():
from amp_server.main import app

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
created = await client.post(
"/amp/v1/memories", headers=_HEADERS, json=_minimal_body()
)
assert created.status_code == 201
resp = await client.delete(
f"/amp/v1/memories/{created.json()['id']}", headers=_HEADERS
)
async def test_invalid_transition_uses_the_error_envelope(app_client):
created = await app_client.post(
"/amp/v1/memories", headers=_HEADERS, json=_minimal_body()
)
assert created.status_code == 201

resp = await app_client.delete(
f"/amp/v1/memories/{created.json()['id']}", headers=_HEADERS
)

assert resp.status_code == 409
_assert_error_envelope(resp.json(), "INVALID_TRANSITION")
Expand Down
19 changes: 5 additions & 14 deletions server/tests/test_memory_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from datetime import UTC, datetime, timedelta

import pytest
from conftest import make_cell
from conftest import install_app_state, make_cell
from httpx import ASGITransport, AsyncClient

from amp_server.models import (
Expand All @@ -28,20 +28,11 @@ def _app_state():

The HTTP tests here used to reach the app through whatever storage a *previous
test file* had left on the module. That held while the whole suite ran in one
process and broke the moment this file ran alone - which is how CI runs it in
the Hermes repository this project borrows its test discipline from, and how
anyone runs a single file while working on it. A test that only passes in the
company of another test is not measuring what it claims to.
process and broke the moment this file ran alone - which is how a single file
gets run while working on it. A test that only passes in the company of another
test is not measuring what it claims to.
"""
import amp_server.main as main_mod
from amp_server.lifecycle import LifecycleEngine
from amp_server.ratelimit import ScoringPatchLimit, ScoringPatchLimiter

main_mod._storage = ChromaAdapter(collection_name=f"test_{uuid.uuid4().hex[:12]}")
main_mod._lifecycle = LifecycleEngine(main_mod._storage)
main_mod._api_key_store = None
main_mod._scoring_limit = ScoringPatchLimit()
main_mod._scoring_limiter = ScoringPatchLimiter(main_mod._scoring_limit)
install_app_state()


# ---------------------------------------------------------------------------
Expand Down
Loading
Loading