diff --git a/gateway/requirements-dev.txt b/gateway/requirements-dev.txt new file mode 100644 index 0000000..8d8ffdb --- /dev/null +++ b/gateway/requirements-dev.txt @@ -0,0 +1,2 @@ +# Dipendenze di sviluppo (test): installare con pip install -r requirements-dev.txt +pytest==8.3.4 diff --git a/gateway/tests/conftest.py b/gateway/tests/conftest.py new file mode 100644 index 0000000..22b1cb8 --- /dev/null +++ b/gateway/tests/conftest.py @@ -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 diff --git a/gateway/tests/test_api.py b/gateway/tests/test_api.py new file mode 100644 index 0000000..9322b2b --- /dev/null +++ b/gateway/tests/test_api.py @@ -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() diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..57f2e4c --- /dev/null +++ b/scripts/check.sh @@ -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"