Skip to content

Commit c40f679

Browse files
committed
Introduces testing setup for gen-ai service
1 parent 142b013 commit c40f679

13 files changed

Lines changed: 398 additions & 4 deletions

.github/workflows/test-build-push.yml

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,29 @@ jobs:
3333
- name: Run tests with Maven
3434
run: cd server/${{ matrix.service }} && ./mvnw -B test
3535

36+
test-gen-ai:
37+
name: Run Gen-AI Tests
38+
runs-on: ubuntu-latest
39+
steps:
40+
- name: Checkout
41+
uses: actions/checkout@v4
42+
43+
- name: Set up Python 3.12
44+
uses: actions/setup-python@v5
45+
with:
46+
python-version: '3.12'
47+
cache: pip
48+
cache-dependency-path: gen-ai/requirements*.txt
49+
50+
- name: Install dependencies
51+
run: pip install -r gen-ai/requirements-dev.txt
52+
53+
- name: Run tests with pytest
54+
run: pytest gen-ai
55+
3656
test-gatekeeper:
37-
name: Run Java Tests
38-
needs: test
57+
name: Run Java Tests
58+
needs: [test, test-gen-ai]
3959
runs-on: ubuntu-latest
4060
steps:
4161
- name: Success Gate

gen-ai/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
.env
22
__pycache__/
33
*.pyc
4+
.coverage

gen-ai/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,10 @@ def parse_json_content(content: str | None) -> dict:
219219

220220
try:
221221
return json.loads(content)
222-
except json.JSONDecodeError:
222+
except json.JSONDecodeError as e:
223223
match = re.search(r"\{.*\}", content, re.DOTALL)
224224
if not match:
225-
raise
225+
raise ValueError(f"LLM response was not valid JSON: {e}") from None
226226
return json.loads(match.group(0))
227227

228228

gen-ai/pytest.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[pytest]
2+
testpaths = tests
3+
addopts = -ra --cov=main --cov-report=term-missing --cov-fail-under=85

gen-ai/requirements-dev.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-r requirements.txt
2+
pytest
3+
pytest-cov
4+
httpx

gen-ai/tests/__init__.py

Whitespace-only changes.

gen-ai/tests/conftest.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import json
2+
from types import SimpleNamespace
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
from fastapi.testclient import TestClient
7+
8+
import main
9+
10+
11+
@pytest.fixture(autouse=True)
12+
def stub_env(monkeypatch):
13+
monkeypatch.setenv("OPENAI_API_KEY", "test-openai-key")
14+
monkeypatch.setenv("LOGOS_KEY", "test-logos-key")
15+
16+
17+
@pytest.fixture
18+
def client():
19+
return TestClient(main.app)
20+
21+
22+
def _fake_response(content: str):
23+
return SimpleNamespace(
24+
choices=[SimpleNamespace(message=SimpleNamespace(content=content))]
25+
)
26+
27+
28+
@pytest.fixture
29+
def mock_openai_client():
30+
with patch("main.OpenAI") as mock_openai_cls:
31+
mock_instance = mock_openai_cls.return_value
32+
mock_instance.chat.completions.create = MagicMock(
33+
return_value=_fake_response(json.dumps({"ingredients": []}))
34+
)
35+
yield mock_openai_cls
36+
37+
38+
@pytest.fixture
39+
def sample_ingredient_json():
40+
return json.dumps(
41+
{
42+
"ingredients": [
43+
{
44+
"name": "flour",
45+
"quantity": "200",
46+
"unit": "g",
47+
"category": "Pantry",
48+
"restricted": False,
49+
"alternative": None,
50+
},
51+
{
52+
"name": "milk",
53+
"quantity": "240",
54+
"unit": "ml",
55+
"category": "Dairy",
56+
"restricted": True,
57+
"alternative": "oat milk",
58+
},
59+
]
60+
}
61+
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
def test_health_returns_ok(client):
2+
response = client.get("/health")
3+
assert response.status_code == 200
4+
assert response.json() == {"status": "ok"}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import json
2+
3+
from tests.conftest import _fake_response
4+
5+
6+
def _set_llm_content(mock_openai_client, content):
7+
mock_openai_client.return_value.chat.completions.create.return_value = (
8+
_fake_response(content)
9+
)
10+
11+
12+
def _ingredient(name="flour", quantity="100", unit="g", category="Pantry"):
13+
return {
14+
"name": name,
15+
"quantity": quantity,
16+
"unit": unit,
17+
"category": category,
18+
"restricted": False,
19+
"alternative": None,
20+
}
21+
22+
23+
def test_happy_path_merges_and_returns_ingredients(
24+
client, mock_openai_client, sample_ingredient_json
25+
):
26+
_set_llm_content(mock_openai_client, sample_ingredient_json)
27+
response = client.post(
28+
"/api/ai/merge",
29+
json={"recipes": [[_ingredient()], [_ingredient(name="sugar")]]},
30+
)
31+
assert response.status_code == 200
32+
assert len(response.json()["ingredients"]) == 2
33+
34+
35+
def test_recipes_serialized_into_user_message(
36+
client, mock_openai_client, sample_ingredient_json
37+
):
38+
_set_llm_content(mock_openai_client, sample_ingredient_json)
39+
recipes = [[_ingredient()], [_ingredient(name="sugar")]]
40+
client.post("/api/ai/merge", json={"recipes": recipes})
41+
_, kwargs = mock_openai_client.return_value.chat.completions.create.call_args
42+
user_message = next(m for m in kwargs["messages"] if m["role"] == "user")
43+
sent = json.loads(user_message["content"])
44+
assert sent == recipes
45+
46+
47+
def test_malformed_llm_json_returns_500(client, mock_openai_client):
48+
_set_llm_content(mock_openai_client, "not json")
49+
response = client.post("/api/ai/merge", json={"recipes": [[_ingredient()]]})
50+
assert response.status_code == 500
51+
52+
53+
def test_openai_error_returns_502(client, mock_openai_client):
54+
from openai import OpenAIError
55+
56+
mock_openai_client.return_value.chat.completions.create.side_effect = OpenAIError(
57+
"boom"
58+
)
59+
response = client.post(
60+
"/api/ai/merge",
61+
json={"recipes": [[_ingredient()]], "llm_provider": "openai"},
62+
)
63+
assert response.status_code == 502
64+
65+
66+
def test_missing_api_key_returns_500(client, monkeypatch):
67+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
68+
response = client.post(
69+
"/api/ai/merge",
70+
json={"recipes": [[_ingredient()]], "llm_provider": "openai"},
71+
)
72+
assert response.status_code == 500
73+
74+
75+
def test_empty_recipes_list_still_calls_llm(
76+
client, mock_openai_client, sample_ingredient_json
77+
):
78+
_set_llm_content(mock_openai_client, sample_ingredient_json)
79+
response = client.post("/api/ai/merge", json={"recipes": []})
80+
assert response.status_code == 200
81+
mock_openai_client.return_value.chat.completions.create.assert_called_once()
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import json
2+
3+
from main import LOGOS_BASE_URL, LOGOS_MODEL, OPENAI_MODEL
4+
5+
from tests.conftest import _fake_response
6+
7+
8+
def _set_llm_content(mock_openai_client, content):
9+
mock_openai_client.return_value.chat.completions.create.return_value = (
10+
_fake_response(content)
11+
)
12+
13+
14+
def test_happy_path_returns_ingredients(client, mock_openai_client, sample_ingredient_json):
15+
_set_llm_content(mock_openai_client, sample_ingredient_json)
16+
response = client.post("/api/ai/parse", json={"dish": "Pancakes"})
17+
assert response.status_code == 200
18+
body = response.json()
19+
assert body["dish"] == "Pancakes"
20+
assert len(body["ingredients"]) == 2
21+
22+
23+
def test_restricted_and_alternative_fields_roundtrip(
24+
client, mock_openai_client, sample_ingredient_json
25+
):
26+
_set_llm_content(mock_openai_client, sample_ingredient_json)
27+
response = client.post("/api/ai/parse", json={"dish": "Pancakes"})
28+
milk = next(i for i in response.json()["ingredients"] if i["name"] == "milk")
29+
assert milk["restricted"] is True
30+
assert milk["alternative"] == "oat milk"
31+
32+
33+
def test_malformed_llm_json_returns_500(client, mock_openai_client):
34+
_set_llm_content(mock_openai_client, "not json at all")
35+
response = client.post("/api/ai/parse", json={"dish": "Pancakes"})
36+
assert response.status_code == 500
37+
assert "Failed to parse LLM response" in response.json()["detail"]
38+
39+
40+
def test_missing_ingredients_key_returns_500(client, mock_openai_client):
41+
_set_llm_content(mock_openai_client, json.dumps({"foo": "bar"}))
42+
response = client.post("/api/ai/parse", json={"dish": "Pancakes"})
43+
assert response.status_code == 500
44+
assert "Failed to parse LLM response" in response.json()["detail"]
45+
46+
47+
def test_openai_error_returns_502(client, mock_openai_client):
48+
from openai import OpenAIError
49+
50+
mock_openai_client.return_value.chat.completions.create.side_effect = OpenAIError(
51+
"boom"
52+
)
53+
response = client.post(
54+
"/api/ai/parse", json={"dish": "Pancakes", "llm_provider": "openai"}
55+
)
56+
assert response.status_code == 502
57+
assert "openai error" in response.json()["detail"]
58+
59+
60+
def test_missing_api_key_returns_500(client, monkeypatch):
61+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
62+
response = client.post(
63+
"/api/ai/parse", json={"dish": "Pancakes", "llm_provider": "openai"}
64+
)
65+
assert response.status_code == 500
66+
assert "OPENAI_API_KEY" in response.json()["detail"]
67+
68+
69+
def test_default_provider_is_logos(client, mock_openai_client, sample_ingredient_json):
70+
_set_llm_content(mock_openai_client, sample_ingredient_json)
71+
client.post("/api/ai/parse", json={"dish": "Pancakes"})
72+
_, kwargs = mock_openai_client.return_value.chat.completions.create.call_args
73+
assert kwargs["model"] == LOGOS_MODEL
74+
assert "response_format" not in kwargs
75+
_, client_kwargs = mock_openai_client.call_args
76+
assert client_kwargs["base_url"] == LOGOS_BASE_URL
77+
78+
79+
def test_openai_provider_selected(client, mock_openai_client, sample_ingredient_json):
80+
_set_llm_content(mock_openai_client, sample_ingredient_json)
81+
client.post(
82+
"/api/ai/parse", json={"dish": "Pancakes", "llm_provider": "openai"}
83+
)
84+
_, kwargs = mock_openai_client.return_value.chat.completions.create.call_args
85+
assert kwargs["model"] == OPENAI_MODEL
86+
assert kwargs["response_format"] == {"type": "json_object"}
87+
88+
89+
def test_dietary_restrictions_passed_into_system_prompt(
90+
client, mock_openai_client, sample_ingredient_json
91+
):
92+
_set_llm_content(mock_openai_client, sample_ingredient_json)
93+
client.post(
94+
"/api/ai/parse",
95+
json={"dish": "Pancakes", "dietary_restrictions": ["Vegan"]},
96+
)
97+
_, kwargs = mock_openai_client.return_value.chat.completions.create.call_args
98+
system_message = next(m for m in kwargs["messages"] if m["role"] == "system")
99+
assert "animal product" in system_message["content"]

0 commit comments

Comments
 (0)