A real API call returned 429 "You have no credits remaining" without the word "quota", so the UI wrongly said the AI was busy. Match quota, credit and billing wording; test uses the real message. Co-Authored-By: Claude Opus 5.5 <[email protected]>
395 lines
16 KiB
Python
395 lines
16 KiB
Python
import json
|
|
|
|
import httpx2
|
|
import openai
|
|
import pytest
|
|
from django.urls import reverse
|
|
|
|
from recipes import ai
|
|
from recipes.models import Recipe
|
|
from recipes.parsing import parse_ingredients
|
|
|
|
from .recipe_helpers import form_data, image_upload
|
|
|
|
VALID = {
|
|
"is_recipe": True,
|
|
"title": "Linsen-Dal",
|
|
"description": "Cremig und würzig.",
|
|
"servings": 4,
|
|
"servings_label": "Portionen",
|
|
"prep_minutes": 10,
|
|
"cook_minutes": 25,
|
|
"ingredients": [
|
|
{"group": "", "amount": 200, "amount_max": None, "unit": "g", "name": "rote Linsen", "note": "gewaschen"},
|
|
{"group": "", "amount": 0.5, "amount_max": None, "unit": "TL", "name": "Kurkuma", "note": ""},
|
|
{"group": "Zum Servieren", "amount": 2, "amount_max": 3, "unit": "EL", "name": "Joghurt", "note": ""},
|
|
{"group": "Zum Servieren", "amount": None, "amount_max": None, "unit": "", "name": "Koriander", "note": ""},
|
|
],
|
|
"steps": ["Zwiebeln anbraten.", " ", "Linsen zugeben und 20 Minuten köcheln."],
|
|
"tags": ["Indisch", "#Vegetarisch", "indisch"],
|
|
"author_notes": "",
|
|
}
|
|
|
|
|
|
def ai_answer(**changes):
|
|
return json.dumps({**VALID, **changes})
|
|
|
|
|
|
# --- Parsing & validation of AI answers ----------------------------------------------
|
|
|
|
|
|
def test_parse_valid_response():
|
|
recipe = ai.parse_response(ai_answer())
|
|
assert recipe.title == "Linsen-Dal"
|
|
assert recipe.steps == ["Zwiebeln anbraten.", "Linsen zugeben und 20 Minuten köcheln."]
|
|
assert recipe.tags == ["indisch", "vegetarisch"]
|
|
|
|
|
|
@pytest.mark.parametrize("content", ["", "kein json", "[]", '{"title": "x"}', json.dumps({**VALID, "ingredients": "200 g"})])
|
|
def test_invalid_response_rejected(content):
|
|
with pytest.raises(ai.AIError, match="ungültige Antwort"):
|
|
ai.parse_response(content)
|
|
|
|
|
|
def test_not_a_recipe():
|
|
with pytest.raises(ai.AIError, match="kein Rezept"):
|
|
ai.parse_response(ai_answer(is_recipe=False))
|
|
with pytest.raises(ai.AIError, match="kein Rezept"):
|
|
ai.parse_response(ai_answer(ingredients=[], steps=[]))
|
|
|
|
|
|
def test_implausible_values_are_dropped():
|
|
recipe = ai.parse_response(ai_answer(
|
|
servings=0, prep_minutes=-5, cook_minutes=99999,
|
|
ingredients=[{"group": "", "amount": -3, "amount_max": None, "unit": "g", "name": "Mehl", "note": ""}],
|
|
))
|
|
assert recipe.servings is None and recipe.prep_minutes is None and recipe.cook_minutes is None
|
|
assert recipe.ingredients[0].amount is None
|
|
|
|
|
|
def test_form_initial_roundtrips_into_data_model():
|
|
initial = ai.parse_response(ai_answer()).to_form_initial()
|
|
items = parse_ingredients(initial["ingredients_text"])
|
|
assert [(i.group, str(i.amount) if i.amount is not None else None, i.unit, i.name, i.note) for i in items] == [
|
|
("", "200", "g", "rote Linsen", "gewaschen"),
|
|
("", "0.5", "TL", "Kurkuma", ""),
|
|
("Zum Servieren", "2", "EL", "Joghurt", ""),
|
|
("Zum Servieren", None, "", "Koriander", ""),
|
|
]
|
|
assert items[2].amount_max == 3
|
|
assert initial["servings"] == 4 and initial["tags_text"] == "indisch, vegetarisch"
|
|
|
|
|
|
def test_schema_is_strict_structured_output():
|
|
def check(node):
|
|
if node.get("type") == "object":
|
|
assert node["additionalProperties"] is False
|
|
assert set(node["required"]) == set(node["properties"])
|
|
for child in node["properties"].values():
|
|
check(child)
|
|
if node.get("type") == "array":
|
|
check(node["items"])
|
|
|
|
check(ai.RECIPE_JSON_SCHEMA)
|
|
|
|
|
|
# --- Error handling ----------------------------------------------------------------------
|
|
|
|
REQUEST = httpx2.Request("POST", "https://api.openai.com/v1/chat/completions")
|
|
|
|
|
|
def api_error(cls, status, message="error", body=None):
|
|
return cls(message, response=httpx2.Response(status, request=REQUEST), body=body)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"error, text",
|
|
[
|
|
(openai.APITimeoutError(request=REQUEST), "zu lange"),
|
|
(api_error(openai.RateLimitError, 429, "Rate limit reached"), "ausgelastet"),
|
|
(api_error(openai.RateLimitError, 429, "You exceeded your current quota", {"code": "insufficient_quota"}), "Guthaben"),
|
|
# Real-world message seen from the API (2026-10) for an account without credit
|
|
(api_error(openai.RateLimitError, 429, "Error code: 429 - You have no credits remaining. Add credits to "
|
|
"continue using the API at https://platform.openai.com/settings/organization/billing/."), "Guthaben"),
|
|
(api_error(openai.AuthenticationError, 401), "API-Key"),
|
|
(api_error(openai.NotFoundError, 404), "OPENAI_MODEL"),
|
|
(api_error(openai.BadRequestError, 400), "nicht verarbeiten"),
|
|
(openai.APIConnectionError(request=REQUEST), "nicht erreichbar"),
|
|
(api_error(openai.InternalServerError, 500), "Fehler aufgetreten"),
|
|
],
|
|
)
|
|
def test_api_errors_are_mapped(error, text):
|
|
assert text in ai.map_api_error(error).user_message
|
|
|
|
|
|
@pytest.fixture
|
|
def ai_on(settings):
|
|
settings.OPENAI_API_KEY = "sk-test-not-real"
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_openai(monkeypatch, ai_on):
|
|
calls = []
|
|
|
|
def fake(messages):
|
|
calls.append(messages)
|
|
return ai_answer()
|
|
|
|
monkeypatch.setattr(ai, "_create_completion", fake)
|
|
return calls
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_api_error_shown_in_ui(monkeypatch, ai_on, user_client):
|
|
def boom(messages):
|
|
raise openai.APITimeoutError(request=REQUEST)
|
|
|
|
monkeypatch.setattr(ai, "_create_completion", boom)
|
|
response = user_client.post(reverse("recipes:create"), form_data(ai="1"), follow=True)
|
|
assert "zu lange gebraucht" in response.content.decode()
|
|
assert not Recipe.objects.exists()
|
|
|
|
|
|
# --- Without API key -------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_without_key_ai_is_disabled_but_app_works(settings, user_client):
|
|
settings.OPENAI_API_KEY = ""
|
|
form_page = user_client.get(reverse("recipes:create")).content.decode()
|
|
assert "Mit KI aufbereiten" not in form_page
|
|
assert "nicht eingerichtet" in user_client.get(reverse("recipes:import")).content.decode()
|
|
response = user_client.post(reverse("recipes:create"), form_data(ai="1"))
|
|
assert "nicht eingerichtet" in response.content.decode()
|
|
assert not Recipe.objects.exists()
|
|
# Saving manually still works
|
|
assert user_client.post(reverse("recipes:create"), form_data()).status_code == 302
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_without_key_text_import_becomes_description(settings, user_client):
|
|
settings.OPENAI_API_KEY = ""
|
|
text = "200 g Mehl, 2 Eier, alles verrühren und backen"
|
|
response = user_client.post(reverse("recipes:import_text"), {"text": text})
|
|
assert "nicht eingerichtet" in response.content.decode()
|
|
assert response.context["form"].initial["description"] == text
|
|
assert not Recipe.objects.exists()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_without_key_photo_import_gives_clear_message(settings, user_client):
|
|
settings.OPENAI_API_KEY = ""
|
|
response = user_client.post(reverse("recipes:import_photo"), {"photos": [image_upload()]}, follow=True)
|
|
assert "nicht eingerichtet" in response.content.decode()
|
|
|
|
|
|
# --- Views with a (fake) model ------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_ai_cleanup_returns_draft_without_saving(fake_openai, user_client):
|
|
response = user_client.post(reverse("recipes:create"), form_data(ai="1", visibility="public"))
|
|
assert response.status_code == 200
|
|
assert not Recipe.objects.exists()
|
|
form = response.context["form"]
|
|
assert form.initial["title"] == "Linsen-Dal" and form.initial["visibility"] == "public"
|
|
assert "rote Linsen" in form.initial["ingredients_text"]
|
|
assert "bitte prüfen" in response.content.decode()
|
|
# user text went to the model as data
|
|
assert "Pfannkuchen" in fake_openai[0][1]["content"]
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_ai_cleanup_on_edit_keeps_recipe_until_saved(fake_openai, user_client):
|
|
user_client.post(reverse("recipes:create"), form_data())
|
|
recipe = Recipe.objects.get()
|
|
response = user_client.post(reverse("recipes:edit", args=[recipe.pk]), form_data(ai="1"))
|
|
assert response.context["form"].initial["title"] == "Linsen-Dal"
|
|
recipe.refresh_from_db()
|
|
assert recipe.title == "Pfannkuchen"
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_text_import_creates_draft(fake_openai, user_client):
|
|
response = user_client.post(reverse("recipes:import_text"), {
|
|
"text": "Dal: 200g rote Linsen, Kurkuma, Joghurt. Linsen kochen.", "source_url": "https://www.instagram.com/p/abc/",
|
|
})
|
|
assert response.status_code == 200
|
|
assert response.context["form"].initial["source_url"] == "https://www.instagram.com/p/abc/"
|
|
assert response.context["form_action"] == reverse("recipes:create")
|
|
assert not Recipe.objects.exists()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_photo_import_sends_downscaled_jpeg(fake_openai, user_client):
|
|
response = user_client.post(reverse("recipes:import_photo"), {"photos": [image_upload(size=(4000, 3000))]})
|
|
assert response.status_code == 200
|
|
parts = fake_openai[0][1]["content"]
|
|
image_part = next(p for p in parts if p["type"] == "image_url")
|
|
assert image_part["image_url"]["url"].startswith("data:image/jpeg;base64,")
|
|
assert response.context["form"].initial["title"] == "Linsen-Dal"
|
|
assert not Recipe.objects.exists()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_photo_import_rejects_non_image(fake_openai, user_client):
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
|
|
bad = SimpleUploadedFile("x.jpg", b"nope", content_type="image/jpeg")
|
|
response = user_client.post(reverse("recipes:import_photo"), {"photos": [bad]})
|
|
assert "kein unterstütztes Bild" in response.content.decode()
|
|
assert not fake_openai
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_ai_rate_limit_per_user(fake_openai, user_client, settings):
|
|
settings.AI_RATE_LIMIT_PER_USER_PER_DAY = 2
|
|
for _ in range(3):
|
|
response = user_client.post(reverse("recipes:create"), form_data(ai="1"))
|
|
assert len(fake_openai) == 2
|
|
assert "Tageslimit" in response.content.decode()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_ai_global_limit(fake_openai, client, user, other_user, settings):
|
|
settings.AI_RATE_LIMIT_GLOBAL_PER_DAY = 1
|
|
for u in (user, other_user):
|
|
client.force_login(u)
|
|
response = client.post(reverse("recipes:create"), form_data(ai="1"))
|
|
assert len(fake_openai) == 1
|
|
assert "Kontingent" in response.content.decode()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_ai_unlimited_by_default(fake_openai, user_client):
|
|
for _ in range(5):
|
|
user_client.post(reverse("recipes:create"), form_data(ai="1"))
|
|
assert len(fake_openai) == 5
|
|
|
|
|
|
def test_text_too_short():
|
|
with pytest.raises(ai.AIError, match="mehr Text"):
|
|
ai.structure_text(None, "Mehl")
|
|
|
|
|
|
# --- The real request builder against a stub client --------------------------------------
|
|
|
|
|
|
class StubClient:
|
|
def __init__(self, content=None, refusal=None, finish_reason="stop"):
|
|
from types import SimpleNamespace
|
|
|
|
self.kwargs = None
|
|
message = SimpleNamespace(content=content, refusal=refusal)
|
|
self._response = SimpleNamespace(choices=[SimpleNamespace(message=message, finish_reason=finish_reason)])
|
|
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create))
|
|
|
|
def _create(self, **kwargs):
|
|
self.kwargs = kwargs
|
|
return self._response
|
|
|
|
|
|
def test_request_uses_strict_json_schema(monkeypatch, settings):
|
|
settings.OPENAI_MODEL = "test-model"
|
|
settings.OPENAI_REASONING_EFFORT = "low"
|
|
stub = StubClient(content=ai_answer())
|
|
monkeypatch.setattr(ai, "_client", lambda: stub)
|
|
assert ai._create_completion([{"role": "user", "content": "x"}]) == ai_answer()
|
|
assert stub.kwargs["model"] == "test-model"
|
|
assert stub.kwargs["reasoning_effort"] == "low"
|
|
fmt = stub.kwargs["response_format"]
|
|
assert fmt["type"] == "json_schema" and fmt["json_schema"]["strict"] is True
|
|
|
|
|
|
def test_reasoning_effort_omitted_when_empty(monkeypatch, settings):
|
|
settings.OPENAI_REASONING_EFFORT = ""
|
|
stub = StubClient(content=ai_answer())
|
|
monkeypatch.setattr(ai, "_client", lambda: stub)
|
|
ai._create_completion([])
|
|
assert "reasoning_effort" not in stub.kwargs
|
|
|
|
|
|
def test_refusal_and_truncation(monkeypatch):
|
|
monkeypatch.setattr(ai, "_client", lambda: StubClient(refusal="nein"))
|
|
with pytest.raises(ai.AIError, match="abgelehnt"):
|
|
ai._create_completion([])
|
|
monkeypatch.setattr(ai, "_client", lambda: StubClient(content='{"is_re', finish_reason="length"))
|
|
with pytest.raises(ai.AIError, match="zu lang"):
|
|
ai._create_completion([])
|
|
|
|
|
|
# --- End to end through the real OpenAI SDK against a local stub server --------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def stub_openai_server(settings):
|
|
"""A local HTTP server that speaks enough of the Chat Completions API for one request."""
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
received = {}
|
|
reply = {"status": 200, "body": None}
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_POST(self):
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
received["path"] = self.path
|
|
received["auth"] = self.headers.get("Authorization")
|
|
received["body"] = json.loads(self.rfile.read(length))
|
|
body = reply["body"] or {
|
|
"id": "chatcmpl-test", "object": "chat.completion", "created": 0, "model": received["body"]["model"],
|
|
"choices": [{"index": 0, "finish_reason": "stop",
|
|
"message": {"role": "assistant", "content": ai_answer(), "refusal": None}}],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
}
|
|
data = json.dumps(body).encode()
|
|
self.send_response(reply["status"])
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), Handler)
|
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
settings.OPENAI_API_KEY = "sk-test-local"
|
|
settings.OPENAI_BASE_URL = f"http://127.0.0.1:{server.server_port}/v1"
|
|
settings.OPENAI_MODEL = "stub-model"
|
|
yield received, reply
|
|
server.shutdown()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_end_to_end_ai_cleanup_through_sdk(stub_openai_server, user_client):
|
|
received, _ = stub_openai_server
|
|
response = user_client.post(reverse("recipes:create"), form_data(ai="1"))
|
|
assert response.status_code == 200
|
|
assert response.context["form"].initial["title"] == "Linsen-Dal"
|
|
assert received["path"] == "/v1/chat/completions"
|
|
assert received["auth"] == "Bearer sk-test-local"
|
|
body = received["body"]
|
|
assert body["model"] == "stub-model"
|
|
assert body["response_format"]["json_schema"]["strict"] is True
|
|
assert body["messages"][0]["role"] == "system"
|
|
assert not Recipe.objects.exists()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_end_to_end_api_error_through_sdk(stub_openai_server, user_client):
|
|
_, reply = stub_openai_server
|
|
reply["status"] = 401
|
|
reply["body"] = {"error": {"message": "Incorrect API key", "type": "invalid_request_error", "code": "invalid_api_key"}}
|
|
response = user_client.post(reverse("recipes:create"), form_data(ai="1"))
|
|
assert "API-Key ist ungültig" in response.content.decode()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_end_to_end_photo_import_through_sdk(stub_openai_server, user_client):
|
|
received, _ = stub_openai_server
|
|
response = user_client.post(reverse("recipes:import_photo"), {"photos": [image_upload()]})
|
|
assert response.context["form"].initial["title"] == "Linsen-Dal"
|
|
parts = received["body"]["messages"][1]["content"]
|
|
assert any(p["type"] == "image_url" and p["image_url"]["url"].startswith("data:image/jpeg;base64,") for p in parts)
|