Skip to content
Merged
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
64 changes: 64 additions & 0 deletions apps/ai_agent/.github/workflows/ai_agent.yml
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions apps/ai_agent/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ dev = [
"httpx>=0.27.0",
"pytest-mock>=3.14.0",
"pytest-cov>=5.0.0",
"pip-audit>=2.7",
]

[dependency-groups]
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]
Expand Down
139 changes: 139 additions & 0 deletions apps/ai_agent/tests/test_proposals.py
Original file line number Diff line number Diff line change
@@ -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
121 changes: 121 additions & 0 deletions apps/ai_agent/tests/test_search.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading