test: suite pytest per il gateway (21 test) + script di verifica pre-push
- conftest: FakeQdrant in-memory + mock embed (nessuna dipendenza da Qdrant/Ollama) - test: validazione (project_id, kind, expires_at, confidence, lunghezza), auth (422/401/429), idempotency (replay/409/key diverse), supersede (404/409/lineage), filtri search, meta, status, metrics - scripts/check.sh: esbuild (estensione) + pytest (gateway) - requirements-dev.txt: pytest
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
# Dipendenze di sviluppo (test): installare con pip install -r requirements-dev.txt
|
||||||
|
pytest==8.3.4
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Fixtures pytest per il Memory Gateway: FakeQdrant in-memory + mock di embed.
|
||||||
|
|
||||||
|
I test non richiedono Qdrant né Ollama: il gateway viene importato con le
|
||||||
|
dipendenze reali (fastapi/pydantic/qdrant-client/httpx) ma qdrant e embed
|
||||||
|
sono sostituiti da fake deterministici.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Disabilita il push metriche nei test
|
||||||
|
os.environ["METRICS_ENABLED"] = "false"
|
||||||
|
os.environ["API_KEYS"] = "test-key"
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
import main as gateway # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class FakePoint:
|
||||||
|
def __init__(self, point_id, vector=None, payload=None):
|
||||||
|
self.id = point_id
|
||||||
|
self.vector = vector or {}
|
||||||
|
self.payload = payload or {}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQdrant:
|
||||||
|
"""Implementazione in-memory dei metodi Qdrant usati dal gateway."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.points: dict[str, FakePoint] = {}
|
||||||
|
self.collection_exists = False
|
||||||
|
self.upsert_calls = 0
|
||||||
|
|
||||||
|
def get_collections(self):
|
||||||
|
class _C:
|
||||||
|
def __init__(self, names):
|
||||||
|
self.collections = [type("X", (), {"name": n})() for n in names]
|
||||||
|
|
||||||
|
return _C(["memories"] if self.collection_exists else [])
|
||||||
|
|
||||||
|
def create_collection(self, **kw):
|
||||||
|
self.collection_exists = True
|
||||||
|
|
||||||
|
def create_payload_index(self, **kw):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def create_vector_name(self, *a, **kw):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def upsert(self, collection_name, points, **kw):
|
||||||
|
self.upsert_calls += 1
|
||||||
|
for p in points:
|
||||||
|
self.points[p.id] = FakePoint(p.id, p.vector, p.payload)
|
||||||
|
|
||||||
|
def retrieve(self, collection_name, ids, with_payload=True):
|
||||||
|
return [self.points[i] for i in ids if i in self.points]
|
||||||
|
|
||||||
|
def set_payload(self, collection_name, payload, points, **kw):
|
||||||
|
for pid in points:
|
||||||
|
if pid in self.points:
|
||||||
|
self.points[pid].payload.update(payload)
|
||||||
|
|
||||||
|
def delete(self, collection_name, points_selector, **kw):
|
||||||
|
for pid in points_selector:
|
||||||
|
self.points.pop(pid, None)
|
||||||
|
|
||||||
|
def scroll(self, collection_name, **kw):
|
||||||
|
return list(self.points.values()), None
|
||||||
|
|
||||||
|
def get_collection(self, collection_name):
|
||||||
|
class _Info:
|
||||||
|
points_count = len(self.points)
|
||||||
|
|
||||||
|
return _Info()
|
||||||
|
|
||||||
|
def query_points(self, collection_name, query=None, query_filter=None, limit=5,
|
||||||
|
score_threshold=None, with_payload=True, prefetch=None, **kw):
|
||||||
|
# Ritorna tutti i punti (score fisso); i filtri metadata sono applicati
|
||||||
|
# in modo semplice per testare kind/project_id/scope/superseded.
|
||||||
|
results = []
|
||||||
|
for p in self.points.values():
|
||||||
|
pl = p.payload
|
||||||
|
if query_filter and query_filter.must:
|
||||||
|
ok = True
|
||||||
|
for cond in query_filter.must:
|
||||||
|
if hasattr(cond, "key") and hasattr(cond, "match"):
|
||||||
|
if pl.get(cond.key) != cond.match.value:
|
||||||
|
ok = False
|
||||||
|
elif hasattr(cond, "is_empty"):
|
||||||
|
if pl.get(cond.is_empty.key):
|
||||||
|
ok = False
|
||||||
|
if not ok:
|
||||||
|
continue
|
||||||
|
results.append(type("H", (), {"id": p.id, "score": 0.9, "payload": pl})())
|
||||||
|
return type("R", (), {"points": results[:limit]})()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch):
|
||||||
|
"""TestClient con qdrant e embed finti."""
|
||||||
|
fake = FakeQdrant()
|
||||||
|
monkeypatch.setattr(gateway, "qdrant", fake)
|
||||||
|
monkeypatch.setattr(gateway, "API_KEYS", {"test-key"})
|
||||||
|
monkeypatch.setattr(gateway, "_ratelimit", {}) # rate limit pulito per test
|
||||||
|
|
||||||
|
async def fake_embed(text):
|
||||||
|
return [0.0] * 1024
|
||||||
|
|
||||||
|
monkeypatch.setattr(gateway, "embed", fake_embed)
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
with TestClient(gateway.app) as c:
|
||||||
|
c.fake_qdrant = fake
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def auth_headers():
|
||||||
|
return {"X-API-Key": "test-key"}
|
||||||
|
|
||||||
|
|
||||||
|
def make_record(**overrides):
|
||||||
|
base = {
|
||||||
|
"text": "record di test",
|
||||||
|
"kind": "fact",
|
||||||
|
"project_id": "test-proj",
|
||||||
|
"scope": "agent",
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Test API del Memory Gateway: validazione, auth, idempotency, supersede, ricerca."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import auth_headers, make_record
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validazione input
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_project_id_obbligatorio(client):
|
||||||
|
body = make_record()
|
||||||
|
del body["project_id"]
|
||||||
|
r = client.post("/v1/memories", json=body, headers=auth_headers())
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_kind_invalido(client):
|
||||||
|
r = client.post("/v1/memories", json=make_record(kind="boh"), headers=auth_headers())
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_expires_at_invalido(client):
|
||||||
|
r = client.post("/v1/memories", json=make_record(expires_at="non-una-data"), headers=auth_headers())
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert "ISO 8601" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_expires_at_valido(client):
|
||||||
|
r = client.post("/v1/memories", json=make_record(expires_at="2026-09-01T00:00:00Z"), headers=auth_headers())
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_invalido(client):
|
||||||
|
r = client.post("/v1/memories", json=make_record(confidence="super"), headers=auth_headers())
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_default_medium(client):
|
||||||
|
r = client.post("/v1/memories", json=make_record(), headers=auth_headers())
|
||||||
|
assert r.status_code == 200
|
||||||
|
mid = r.json()["memory_id"]
|
||||||
|
g = client.get(f"/v1/memories/{mid}", headers=auth_headers())
|
||||||
|
assert g.json()["confidence"] == "medium"
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_troppo_lungo(client):
|
||||||
|
r = client.post("/v1/memories", json=make_record(text="x" * 9000), headers=auth_headers())
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth e rate limit
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_senza_chiave_422(client):
|
||||||
|
# Header X-API-Key mancante → 422 (header richiesto da FastAPI)
|
||||||
|
r = client.post("/v1/memories", json=make_record())
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_chiave_invalida_401(client):
|
||||||
|
r = client.post("/v1/memories", json=make_record(), headers={"X-API-Key": "sbagliata"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_429(client, monkeypatch):
|
||||||
|
monkeypatch.setattr("main.RATE_LIMIT_PER_MIN", 3)
|
||||||
|
for _ in range(3):
|
||||||
|
r = client.post("/v1/memories", json=make_record(), headers=auth_headers())
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = client.post("/v1/memories", json=make_record(), headers=auth_headers())
|
||||||
|
assert r.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Idempotency
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_idempotency_replay_stessa_risposta(client):
|
||||||
|
h = {**auth_headers(), "Idempotency-Key": "k-1"}
|
||||||
|
r1 = client.post("/v1/memories", json=make_record(), headers=h)
|
||||||
|
r2 = client.post("/v1/memories", json=make_record(), headers=h)
|
||||||
|
assert r1.status_code == 200 and r2.status_code == 200
|
||||||
|
assert r1.json()["memory_id"] == r2.json()["memory_id"]
|
||||||
|
assert client.fake_qdrant.upsert_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotency_payload_diverso_409(client):
|
||||||
|
h = {**auth_headers(), "Idempotency-Key": "k-2"}
|
||||||
|
client.post("/v1/memories", json=make_record(), headers=h)
|
||||||
|
r = client.post("/v1/memories", json=make_record(text="diverso"), headers=h)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotency_key_diverse_record_distinti(client):
|
||||||
|
r1 = client.post("/v1/memories", json=make_record(), headers={**auth_headers(), "Idempotency-Key": "k-a"})
|
||||||
|
r2 = client.post("/v1/memories", json=make_record(), headers={**auth_headers(), "Idempotency-Key": "k-b"})
|
||||||
|
assert r1.json()["memory_id"] != r2.json()["memory_id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Supersede
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_supersede_target_inesistente_404(client):
|
||||||
|
r = client.post(
|
||||||
|
"/v1/memories",
|
||||||
|
json=make_record(supersedes_id="00000000-0000-0000-0000-000000000000"),
|
||||||
|
headers=auth_headers(),
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_supersede_ok_e_lineage(client):
|
||||||
|
r1 = client.post("/v1/memories", json=make_record(text="fatto falso"), headers=auth_headers())
|
||||||
|
old_id = r1.json()["memory_id"]
|
||||||
|
r2 = client.post(
|
||||||
|
"/v1/memories",
|
||||||
|
json=make_record(text="fatto corretto", supersedes_id=old_id, supersede_reason="evidenza"),
|
||||||
|
headers=auth_headers(),
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
new_id = r2.json()["memory_id"]
|
||||||
|
# il vecchio è marcato superseded_by
|
||||||
|
old = client.get(f"/v1/memories/{old_id}", headers=auth_headers()).json()
|
||||||
|
assert old["superseded_by"] == new_id
|
||||||
|
# la ricerca di default esclude i superseduti
|
||||||
|
s = client.post("/v1/memories:search", json={"query": "fatto", "top_k": 10, "min_score": 0.0}, headers=auth_headers())
|
||||||
|
ids = [x["memory_id"] for x in s.json()["results"]]
|
||||||
|
assert old_id not in ids
|
||||||
|
# include_superseded li mostra
|
||||||
|
s2 = client.post(
|
||||||
|
"/v1/memories:search",
|
||||||
|
json={"query": "fatto", "top_k": 10, "min_score": 0.0, "include_superseded": True},
|
||||||
|
headers=auth_headers(),
|
||||||
|
)
|
||||||
|
ids2 = [x["memory_id"] for x in s2.json()["results"]]
|
||||||
|
assert old_id in ids2
|
||||||
|
|
||||||
|
|
||||||
|
def test_supersede_doppio_409(client):
|
||||||
|
r1 = client.post("/v1/memories", json=make_record(text="falso"), headers=auth_headers())
|
||||||
|
old_id = r1.json()["memory_id"]
|
||||||
|
client.post("/v1/memories", json=make_record(text="corretto", supersedes_id=old_id), headers=auth_headers())
|
||||||
|
r = client.post("/v1/memories", json=make_record(text="ancora", supersedes_id=old_id), headers=auth_headers())
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ricerca e filtri
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_search_filtro_project_id(client):
|
||||||
|
client.post("/v1/memories", json=make_record(text="uno", project_id="proj-a"), headers=auth_headers())
|
||||||
|
client.post("/v1/memories", json=make_record(text="due", project_id="proj-b"), headers=auth_headers())
|
||||||
|
s = client.post(
|
||||||
|
"/v1/memories:search",
|
||||||
|
json={"query": "test", "project_id": "proj-a", "top_k": 10, "min_score": 0.0},
|
||||||
|
headers=auth_headers(),
|
||||||
|
)
|
||||||
|
results = s.json()["results"]
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["project_id"] == "proj-a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_hybrid_param_accettato(client):
|
||||||
|
client.post("/v1/memories", json=make_record(text="codice XYZ-123"), headers=auth_headers())
|
||||||
|
s = client.post(
|
||||||
|
"/v1/memories:search",
|
||||||
|
json={"query": "XYZ-123", "top_k": 5, "min_score": 0.0, "hybrid": True},
|
||||||
|
headers=auth_headers(),
|
||||||
|
)
|
||||||
|
assert s.status_code == 200
|
||||||
|
assert "results" in s.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_meta_overview(client):
|
||||||
|
client.post("/v1/memories", json=make_record(project_id="proj-a"), headers=auth_headers())
|
||||||
|
r = client.get("/v1/meta/overview", headers=auth_headers())
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
assert any(p["project_id"] == "proj-a" for p in data["projects"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_pubblico(client):
|
||||||
|
r = client.get("/v1/status")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_auth(client):
|
||||||
|
# Header mancante → 422; chiave invalida → 401; chiave valida → 200
|
||||||
|
assert client.get("/v1/metrics").status_code == 422
|
||||||
|
assert client.get("/v1/metrics", headers={"X-API-Key": "sbagliata"}).status_code == 401
|
||||||
|
r2 = client.get("/v1/metrics", headers=auth_headers())
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert "requests" in r2.json()
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verifica pre-push di pi-qmem: sintassi estensione (esbuild) + test gateway (pytest).
|
||||||
|
# Uso: ./scripts/check.sh (richiede: npx esbuild, python3 con pytest + deps gateway)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "── 1/2 Estensione: transpile esbuild ──"
|
||||||
|
npx --no-install esbuild extensions/index.ts --outfile=/tmp/qmem-check.mjs
|
||||||
|
echo "✅ estensione OK"
|
||||||
|
|
||||||
|
echo "── 2/2 Gateway: pytest ──"
|
||||||
|
cd gateway
|
||||||
|
if ! python3 -c "import pytest, fastapi, qdrant_client, httpx, pydantic" 2>/dev/null; then
|
||||||
|
echo "⚠️ dipendenze mancanti: pip install -r requirements.txt -r requirements-dev.txt"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
python3 -m pytest tests/ -q
|
||||||
|
echo "✅ gateway OK"
|
||||||
Reference in New Issue
Block a user