diff --git a/apps/ai_agent/.github/workflows/ai_agent.yml b/apps/ai_agent/.github/workflows/ai_agent.yml new file mode 100644 index 00000000..3a6aeab0 --- /dev/null +++ b/apps/ai_agent/.github/workflows/ai_agent.yml @@ -0,0 +1,64 @@ +name: AI Agent CI + +on: + push: + paths: + - 'apps/ai_agent/**' + pull_request: + paths: + - 'apps/ai_agent/**' + schedule: + # Weekly Tuesday at 09:00 UTC + - cron: '0 9 * * 2' + +jobs: + test: + name: Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/ai_agent + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install + + - name: Install dependencies + run: uv sync --dev + + - name: Run tests + run: uv run pytest + + audit: + name: Dependency security audit + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/ai_agent + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install + + - name: Install dependencies (including dev) + run: uv sync --dev + + - name: Install pip-audit + run: uv add --dev "pip-audit>=2.7" + + - name: Run pip-audit + run: uv run pip-audit --requirement <(uv export --no-dev --format requirements-txt) diff --git a/apps/ai_agent/pyproject.toml b/apps/ai_agent/pyproject.toml index b1381e96..2b22a74b 100644 --- a/apps/ai_agent/pyproject.toml +++ b/apps/ai_agent/pyproject.toml @@ -17,6 +17,7 @@ dev = [ "httpx>=0.27.0", "pytest-mock>=3.14.0", "pytest-cov>=5.0.0", + "pip-audit>=2.7", ] [dependency-groups] @@ -24,6 +25,8 @@ dev = [ "pytest>=8.0.0", "pytest-cov>=5.0.0", "httpx>=0.27.0", + "pytest-mock>=3.14.0", + "pip-audit>=2.7", ] [tool.pytest.ini_options] diff --git a/apps/ai_agent/tests/test_proposals.py b/apps/ai_agent/tests/test_proposals.py new file mode 100644 index 00000000..3cfe0c32 --- /dev/null +++ b/apps/ai_agent/tests/test_proposals.py @@ -0,0 +1,139 @@ +"""Unit tests for POST /proposals/summarise (issue #147).""" + +import json +import pytest +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient +from main import app + +client = TestClient(app) + +_BASE_BODY = { + "title": "Fund community outreach", + "description": "Allocate XLM to grow the Clicked user base across Africa.", + "amount": 500.0, +} + + +def _fake_openai_response(payload: dict): + msg = MagicMock() + msg.content = json.dumps(payload) + choice = MagicMock() + choice.message = msg + resp = MagicMock() + resp.choices = [choice] + return resp + + +def _patch_openai(payload: dict): + """Context manager: patches _openai_client and returns a configured mock.""" + patcher = patch("main._openai_client") + mock_fn = patcher.start() + mock_client = MagicMock() + mock_fn.return_value = mock_client + mock_client.chat.completions.create.return_value = _fake_openai_response(payload) + return patcher, mock_fn + + +def test_happy_path_returns_summary_and_risk(): + with patch("main._openai_client") as mock_fn: + mock_client = MagicMock() + mock_fn.return_value = mock_client + mock_client.chat.completions.create.return_value = _fake_openai_response( + {"summary": "This proposal funds outreach. It is low risk.", "risk": "low"} + ) + response = client.post("/proposals/summarise", json=_BASE_BODY) + assert response.status_code == 200 + data = response.json() + assert data["summary"] == "This proposal funds outreach. It is low risk." + assert data["risk"] == "low" + + +def test_risk_level_low_accepted(): + with patch("main._openai_client") as mock_fn: + mock_client = MagicMock() + mock_fn.return_value = mock_client + mock_client.chat.completions.create.return_value = _fake_openai_response( + {"summary": "Short summary. Second sentence.", "risk": "low"} + ) + response = client.post("/proposals/summarise", json={**_BASE_BODY, "amount": 10.0}) + assert response.status_code == 200 + assert response.json()["risk"] == "low" + + +def test_risk_level_medium_accepted(): + with patch("main._openai_client") as mock_fn: + mock_client = MagicMock() + mock_fn.return_value = mock_client + mock_client.chat.completions.create.return_value = _fake_openai_response( + {"summary": "Moderate proposal. Needs review.", "risk": "medium"} + ) + response = client.post("/proposals/summarise", json=_BASE_BODY) + assert response.status_code == 200 + assert response.json()["risk"] == "medium" + + +def test_risk_level_high_accepted(): + with patch("main._openai_client") as mock_fn: + mock_client = MagicMock() + mock_fn.return_value = mock_client + mock_client.chat.completions.create.return_value = _fake_openai_response( + {"summary": "Very large transfer proposed. High risk detected.", "risk": "high"} + ) + response = client.post("/proposals/summarise", json={**_BASE_BODY, "amount": 1_000_000.0}) + assert response.status_code == 200 + assert response.json()["risk"] == "high" + + +def test_empty_summary_returns_502(): + with patch("main._openai_client") as mock_fn: + mock_client = MagicMock() + mock_fn.return_value = mock_client + mock_client.chat.completions.create.return_value = _fake_openai_response( + {"summary": "", "risk": "medium"} + ) + response = client.post("/proposals/summarise", json=_BASE_BODY) + assert response.status_code == 502 + + +def test_invalid_risk_falls_back_to_medium(): + with patch("main._openai_client") as mock_fn: + mock_client = MagicMock() + mock_fn.return_value = mock_client + mock_client.chat.completions.create.return_value = _fake_openai_response( + {"summary": "Valid summary here. Two sentences total.", "risk": "critical"} + ) + response = client.post("/proposals/summarise", json=_BASE_BODY) + assert response.status_code == 200 + assert response.json()["risk"] == "medium" + + +def test_missing_risk_key_falls_back_to_medium(): + with patch("main._openai_client") as mock_fn: + mock_client = MagicMock() + mock_fn.return_value = mock_client + mock_client.chat.completions.create.return_value = _fake_openai_response( + {"summary": "Summary without risk key. Still valid."} + ) + response = client.post("/proposals/summarise", json=_BASE_BODY) + assert response.status_code == 200 + assert response.json()["risk"] == "medium" + + +def test_missing_api_key_returns_500(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + response = client.post("/proposals/summarise", json=_BASE_BODY) + assert response.status_code == 500 + + +def test_missing_title_returns_422(): + body = {k: v for k, v in _BASE_BODY.items() if k != "title"} + response = client.post("/proposals/summarise", json=body) + assert response.status_code == 422 + + +def test_missing_amount_returns_422(): + body = {k: v for k, v in _BASE_BODY.items() if k != "amount"} + response = client.post("/proposals/summarise", json=body) + assert response.status_code == 422 diff --git a/apps/ai_agent/tests/test_search.py b/apps/ai_agent/tests/test_search.py new file mode 100644 index 00000000..911ba2ab --- /dev/null +++ b/apps/ai_agent/tests/test_search.py @@ -0,0 +1,121 @@ +"""Unit tests for GET /search (issue #149).""" + +import pytest +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient +from main import app + +client = TestClient(app) + +_BASE_PARAMS = {"q": "payment to Alice", "conversationId": "conv-abc"} + + +def _make_weaviate_client(*, exists: bool = True, objects=None): + """Build a mock weaviate client.""" + mock_client = MagicMock() + mock_client.collections.exists.return_value = exists + + if objects is not None: + mock_result = MagicMock() + mock_result.objects = objects + mock_client.collections.get.return_value.query.near_vector.return_value = mock_result + + return mock_client + + +def _make_openai_embedding(): + """Build a mock OpenAI client that returns a dummy embedding.""" + mock_openai = MagicMock() + embed_data = MagicMock() + embed_data.embedding = [0.1] * 1536 + embed_result = MagicMock() + embed_result.data = [embed_data] + mock_openai.embeddings.create.return_value = embed_result + return mock_openai + + +def test_weaviate_connection_failure_returns_503(): + with patch("main.weaviate.connect_to_local", side_effect=Exception("connection refused")): + response = client.get("/search", params=_BASE_PARAMS) + assert response.status_code == 503 + + +def test_missing_collection_returns_empty_results(): + """When collection doesn't exist, return empty results without querying Weaviate.""" + mock_wv = _make_weaviate_client(exists=False) + with patch("main.weaviate.connect_to_local", return_value=mock_wv): + response = client.get("/search", params=_BASE_PARAMS) + assert response.status_code == 200 + assert response.json() == {"results": []} + # Weaviate query must NOT have been called + mock_wv.collections.get.return_value.query.near_vector.assert_not_called() + + +def test_returns_results_with_correct_shape(): + obj = MagicMock() + obj.properties = { + "messageId": "msg-1", + "conversationId": "conv-abc", + "senderId": "user-1", + "content": "send 50 XLM to Alice", + } + mock_wv = _make_weaviate_client(exists=True, objects=[obj]) + + with patch("main.weaviate.connect_to_local", return_value=mock_wv), \ + patch("main._openai_client", return_value=_make_openai_embedding()): + response = client.get("/search", params=_BASE_PARAMS) + + assert response.status_code == 200 + data = response.json() + assert "results" in data + assert len(data["results"]) == 1 + hit = data["results"][0] + assert hit["messageId"] == "msg-1" + assert hit["conversationId"] == "conv-abc" + assert hit["senderId"] == "user-1" + assert hit["content"] == "send 50 XLM to Alice" + + +def test_filters_by_conversation_id(): + obj = MagicMock() + obj.properties = { + "messageId": "msg-2", + "conversationId": "conv-xyz", + "senderId": "user-2", + "content": "transfer 100 XLM", + } + mock_wv = _make_weaviate_client(exists=True, objects=[obj]) + + with patch("main.weaviate.connect_to_local", return_value=mock_wv), \ + patch("main._openai_client", return_value=_make_openai_embedding()): + response = client.get("/search", params={"q": "transfer", "conversationId": "conv-xyz"}) + + assert response.status_code == 200 + # Verify the near_vector call was made (filter is passed inside it) + mock_wv.collections.get.return_value.query.near_vector.assert_called_once() + call_kwargs = mock_wv.collections.get.return_value.query.near_vector.call_args[1] + # The filter argument must be present + assert "filters" in call_kwargs + + +def test_close_called_on_success(): + mock_wv = _make_weaviate_client(exists=True, objects=[]) + mock_wv.collections.get.return_value.query.near_vector.return_value.objects = [] + + with patch("main.weaviate.connect_to_local", return_value=mock_wv), \ + patch("main._openai_client", return_value=_make_openai_embedding()): + response = client.get("/search", params=_BASE_PARAMS) + + assert response.status_code == 200 + mock_wv.close.assert_called_once() + + +def test_missing_q_returns_422(): + response = client.get("/search", params={"conversationId": "conv-abc"}) + assert response.status_code == 422 + + +def test_missing_conversation_id_returns_422(): + response = client.get("/search", params={"q": "hello"}) + assert response.status_code == 422 diff --git a/apps/ai_agent/tests/test_transfers.py b/apps/ai_agent/tests/test_transfers.py index e3216ec8..dc1deb28 100644 --- a/apps/ai_agent/tests/test_transfers.py +++ b/apps/ai_agent/tests/test_transfers.py @@ -87,3 +87,72 @@ def test_llm_path_missing_api_key_returns_500(monkeypatch): "amount": 100.0, "sender": "GABC", "recipient": "GDEF", "memo": "test" }) assert response.status_code == 500 + + +# ── Rule-based path (issue #145) ────────────────────────────────────────────── + +def test_high_value_transfer_is_flagged_without_llm_call(): + """High-value path bypasses the LLM — mandatory security property.""" + with patch("main._openai_client") as mock_openai: + response = client.post( + "/transfers/analyse", + json={ + "amount": 10_001.0, + "sender": "GABC", + "recipient": "GDEF", + "memo": "large payment", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["flagged"] is True + assert data["confidence"] == 0.99 + mock_openai.assert_not_called() + + +def test_high_value_reason_mentions_threshold(): + """The flagging reason must reference the threshold value.""" + with patch("main._openai_client"): + response = client.post( + "/transfers/analyse", + json={ + "amount": 50_000.0, + "sender": "GABC", + "recipient": "GDEF", + "memo": "bulk transfer", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["flagged"] is True + reason = data["reason"] or "" + assert "10000" in reason or "threshold" in reason.lower() + + +def test_amount_exactly_at_threshold_takes_llm_path(): + """amount == threshold is NOT above it; should route to LLM.""" + with patch("main._openai_client") as mock_openai_fn: + mock_client = MagicMock() + mock_openai_fn.return_value = mock_client + mock_client.chat.completions.create.return_value = _fake_openai_response( + {"flagged": False, "reason": None, "confidence": 0.1} + ) + response = client.post( + "/transfers/analyse", + json={ + "amount": 10_000.0, + "sender": "GABC", + "recipient": "GDEF", + "memo": "exactly at threshold", + }, + ) + assert response.status_code == 200 + mock_openai_fn.assert_called_once() + + +def test_missing_fields_return_422(): + response = client.post( + "/transfers/analyse", + json={"sender": "GABC", "recipient": "GDEF", "memo": "no amount"}, + ) + assert response.status_code == 422