Compare commits

...

10 Commits

Author SHA1 Message Date
Quentin BEY 095bcaea1a 🔖(patch) bump release to 0.0.7
Fixed

- 🚑️(posthog) fix the posthog middleware for async mode #133
2025-10-28 19:01:52 +01:00
Quentin BEY fbe9e039cf 🚑️(posthog) fix the posthog middleware for async mode
The original Posthog middleware tries to get user from request
using the sync way, in both async and sync mode.
2025-10-28 18:19:34 +01:00
Quentin BEY 33a87c3959 ⚰️(settings) remove unused code from copy/paste
These lines where never meant to be used in this project.
2025-10-28 18:09:22 +01:00
Quentin BEY 18fc3390f3 🔖(patch) bump release to 0.0.6
Fixed

- 🚑️(stats) fix tracking id in upload event #130
2025-10-28 09:34:13 +01:00
Quentin BEY 1d54114a39 🚑️(posthog) use same distinct ID as set by frontend
The current behavior was duplicating users because the
frontend uses the user PK as distinct ID (which is
good) but the backend was using the email address.

=> burn the now useless middleware
=> use the user PK as distinct ID
2025-10-28 09:27:51 +01:00
Arnaud Robin 7d8b6fc07c 🚑️(stats) fix tracking id in upload event
Previously, upload event tracking used the user's email instead of
their user ID, preventing reliable association between uploads
and users.
2025-10-27 23:27:34 +01:00
Quentin BEY 2b96ba0597 🔧(django-lasuite) add new settings for back-channel logout
The latest version of the lib, allows to configure OIDC back-
channel logout which requires some specific settings.
While unused in our environment, this allows use by others.
2025-10-27 21:48:59 +01:00
Quentin BEY 34cf348f4c (pydantic-ai) update tests after last update
There is a new consistendy enforcement in the library, which
enforces each message to have a unique ID, therefor the UUID
mock fails (which was expected TBH).
2025-10-27 21:45:12 +01:00
Quentin BEY 0cce897c69 (django-lasuite) update tests after latest release
The library now does a `save` instead of an `update` which
triggers a unicity check.
2025-10-27 21:45:12 +01:00
renovate[bot] 7c8d8e9de7 ⬆️(dependencies) update python dependencies 2025-10-27 21:45:12 +01:00
25 changed files with 545 additions and 271 deletions
+18 -2
View File
@@ -8,6 +8,20 @@ and this project adheres to
## [Unreleased]
## [0.0.7] - 2025-10-28
### Fixed
- 🚑️(posthog) fix the posthog middleware for async mode #133
## [0.0.6] - 2025-10-28
### Fixed
- 🚑️(stats) fix tracking id in upload event #130
## [0.0.5] - 2025-10-27
### Fixed
@@ -112,8 +126,10 @@ and this project adheres to
- 💄(chat) add code highlighting for LLM responses #67
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.5...main
[0.0.4]: https://github.com/suitenumerique/conversations/releases/v0.0.5
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.7...main
[0.0.7]: https://github.com/suitenumerique/conversations/releases/v0.0.7
[0.0.6]: https://github.com/suitenumerique/conversations/releases/v0.0.6
[0.0.5]: https://github.com/suitenumerique/conversations/releases/v0.0.5
[0.0.4]: https://github.com/suitenumerique/conversations/releases/v0.0.4
[0.0.3]: https://github.com/suitenumerique/conversations/releases/v0.0.3
[0.0.2]: https://github.com/suitenumerique/conversations/releases/v0.0.2
@@ -3,11 +3,11 @@
import datetime
import json
import uuid
from unittest.mock import patch
from django.utils import timezone
import pytest
from dirty_equals import IsUUID
from freezegun import freeze_time
from pydantic_ai import ImageUrl
from pydantic_ai.messages import (
@@ -37,27 +37,22 @@ from chat.ai_sdk_types import (
from chat.clients.pydantic_ui_message_converter import model_message_to_ui_message
@pytest.fixture(autouse=True)
def mock_uuid4_fixture():
"""Fixture to mock UUID generation for testing."""
with patch("uuid.uuid4", return_value=uuid.UUID("f0cc3bb5-f207-401b-8281-4cba6202991d")):
yield
def test_model_message_to_ui_message_text_user_full():
"""Test converting a ModelRequest with UserPromptPart containing text to UIMessage."""
timestamp = datetime.datetime.now()
model_message = ModelRequest(
parts=[UserPromptPart(content="Hello!", timestamp=timestamp)], kind="request"
)
result = model_message_to_ui_message(model_message)
expected = UIMessage(
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
id=result.id, # Use the generated ID
role="user",
content="Hello!",
parts=[TextUIPart(type="text", text="Hello!")],
createdAt=timestamp,
)
result = model_message_to_ui_message(model_message)
assert result == expected
@@ -65,14 +60,15 @@ def test_model_message_to_ui_message_text_user_full():
def test_model_message_to_ui_message_text_assistant_full():
"""Test converting a ModelResponse with TextPart to UIMessage."""
model_message = ModelResponse(parts=[TextPart(content="Hi there!")])
result = model_message_to_ui_message(model_message)
expected = UIMessage(
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
id=result.id, # Use the generated ID
role="assistant",
content="Hi there!",
parts=[TextUIPart(type="text", text="Hi there!")],
createdAt=timezone.now(),
)
result = model_message_to_ui_message(model_message)
assert result == expected
@@ -83,8 +79,10 @@ def test_model_message_to_ui_message_tool_call_full():
model_message = ModelResponse(
parts=[ToolCallPart(tool_call_id="id1", tool_name="tool", args=args)]
)
result = model_message_to_ui_message(model_message)
expected = UIMessage(
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
id=result.id, # Use the generated ID
role="assistant",
content="",
parts=[
@@ -100,7 +98,7 @@ def test_model_message_to_ui_message_tool_call_full():
],
createdAt=timezone.now(),
)
result = model_message_to_ui_message(model_message)
assert result == expected
@@ -109,7 +107,7 @@ def test_model_message_to_ui_message_reasoning_full():
"""Test converting a ModelResponse with ThinkingPart to UIMessage."""
model_message = ModelResponse(parts=[ThinkingPart(content="reason", signature="sig")])
expected = UIMessage(
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
id=str(uuid.uuid4()), # not used in comparison
role="assistant",
content="",
parts=[
@@ -122,7 +120,7 @@ def test_model_message_to_ui_message_reasoning_full():
createdAt=timezone.now(),
)
result = model_message_to_ui_message(model_message)
assert result.id == expected.id
assert result.id == IsUUID(4)
assert result.role == expected.role
assert result.content == expected.content
assert result.createdAt == expected.createdAt
+11
View File
@@ -0,0 +1,11 @@
"""tools for testing chat functionality"""
import re
def replace_uuids_with_placeholder(text):
"""Replace all UUIDs in the given text with a placeholder."""
text = re.sub('"toolCallId":"([a-z0-9-]){36}"', '"toolCallId":"XXX"', text)
text = re.sub('"toolCallId":"pyd_ai_([a-z0-9]){32}"', '"toolCallId":"pyd_ai_YYY"', text)
text = re.sub('"([a-z0-9-]){36}"', '"<mocked_uuid>"', text)
return text
@@ -1,8 +1,6 @@
"""Common test fixtures for chat conversation endpoint tests."""
import json
import uuid
from unittest.mock import patch
from django.utils import timezone
@@ -12,14 +10,6 @@ import respx
from freezegun import freeze_time
@pytest.fixture(name="mock_uuid4")
def mock_uuid4_fixture():
"""Fixture to mock UUID generation for testing."""
value = uuid.uuid4()
with patch("uuid.uuid4", return_value=value):
yield value
@pytest.fixture(name="mock_openai_stream")
@freeze_time("2025-07-25T10:36:35.297675Z")
def fixture_mock_openai_stream():
@@ -9,6 +9,7 @@ from django.utils import timezone
import pytest
import respx
from asgiref.sync import sync_to_async
from dirty_equals import IsUUID
from freezegun import freeze_time
from rest_framework import status
@@ -23,6 +24,7 @@ from chat.ai_sdk_types import (
)
from chat.factories import ChatConversationFactory
from chat.llm_configuration import LLModel, LLMProvider
from chat.tests.utils import replace_uuids_with_placeholder
# enable database transactions for tests:
# transaction=True ensures that the data are available in the database
@@ -88,7 +90,7 @@ def test_post_conversation_invalid_protocol(api_client):
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uuid4):
def test_post_conversation_data_protocol(api_client, mock_openai_stream):
"""Test posting messages to a conversation using the 'data' protocol."""
chat_conversation = ChatConversationFactory(owner__language="en-us")
@@ -115,10 +117,14 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uu
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'0:" there"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -137,8 +143,9 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uu
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[0].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello",
reasoning=None,
@@ -149,8 +156,9 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uu
parts=[TextUIPart(type="text", text="Hello")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello there",
reasoning=None,
@@ -216,7 +224,7 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uu
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_text_protocol(api_client, mock_openai_stream, mock_uuid4):
def test_post_conversation_text_protocol(api_client, mock_openai_stream):
"""Test posting messages to a conversation using the 'text' protocol."""
chat_conversation = ChatConversationFactory(owner__language="en-us")
@@ -258,8 +266,9 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream, mock_uu
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[0].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello",
reasoning=None,
@@ -270,8 +279,9 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream, mock_uu
parts=[TextUIPart(type="text", text="Hello")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello there",
reasoning=None,
@@ -337,7 +347,7 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream, mock_uu
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock_uuid4):
def test_post_conversation_with_image(api_client, mock_openai_stream_image):
"""Ensure an image URL is correctly forwarded to the AI service."""
chat_conversation = ChatConversationFactory(owner__language="en-us")
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
@@ -375,10 +385,14 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"I see a cat"\n'
'0:" in the picture."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -439,8 +453,9 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[0].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello, what do you see on this picture?",
reasoning=None,
@@ -461,8 +476,9 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock
parts=[TextUIPart(type="text", text="Hello, what do you see on this picture?")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="I see a cat in the picture.",
reasoning=None,
@@ -540,7 +556,7 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_uuid4, settings):
def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settings):
"""Ensure tool calls are correctly forwarded and streamed back."""
settings.AI_AGENT_TOOLS = ["get_current_weather"]
@@ -569,6 +585,10 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":'
'"get_current_weather"}\n'
@@ -577,7 +597,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":{"location":'
'"Paris","temperature":22,"unit":"celsius"}}\n'
'0:"The current weather in Paris is nice"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -608,8 +628,9 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[0].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Weather in Paris?",
reasoning=None,
@@ -620,8 +641,9 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
parts=[TextUIPart(type="text", text="Weather in Paris?")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="The current weather in Paris is nice",
reasoning=None,
@@ -743,9 +765,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_tool_call_fails(
api_client, mock_openai_stream_tool, mock_uuid4, settings
):
def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool, settings):
"""Ensure tool calls are correctly forwarded and streamed back when failing."""
settings.AI_AGENT_TOOLS = []
@@ -774,6 +794,10 @@ def test_post_conversation_tool_call_fails(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":"get_current_weather"}\n'
'c:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","argsTextDelta":'
@@ -781,7 +805,7 @@ def test_post_conversation_tool_call_fails(
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":"Unknown tool '
"name: 'get_current_weather'. No tools available.\"}\n"
'0:"I cannot give you an answer to that."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -812,8 +836,9 @@ def test_post_conversation_tool_call_fails(
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[0].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Weather in Paris?",
reasoning=None,
@@ -824,8 +849,9 @@ def test_post_conversation_tool_call_fails(
parts=[TextUIPart(type="text", text="Weather in Paris?")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="I cannot give you an answer to that.",
reasoning=None,
@@ -972,7 +998,6 @@ def test_post_conversation_model_selection_invalid(api_client):
def test_post_conversation_model_selection_new(
api_client,
mock_openai_stream,
mock_uuid4,
settings,
):
"""Test the user can select a different model."""
@@ -1017,10 +1042,14 @@ def test_post_conversation_model_selection_new(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'0:" there"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -1034,7 +1063,6 @@ def test_post_conversation_model_selection_new(
def test_post_conversation_data_protocol_no_stream(
api_client,
mock_openai_no_stream,
mock_uuid4,
settings,
stream_delay,
):
@@ -1086,6 +1114,9 @@ def test_post_conversation_data_protocol_no_stream(
# Wait for the content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
if stream_delay:
assert response_content == (
'0:"The "\n'
@@ -1105,13 +1136,13 @@ def test_post_conversation_data_protocol_no_stream(
'0:" sca"\n'
'0:"tter"\n'
'0:"ing."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":135}}\n'
)
else:
assert response_content == (
'0:"The sky appears blue due to a phenomenon called Rayleigh scattering."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":135}}\n'
)
@@ -1130,8 +1161,9 @@ def test_post_conversation_data_protocol_no_stream(
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[0].id,
createdAt=timezone.now(), # Mocked timestamp
content="Why the sky is blue?",
reasoning=None,
@@ -1142,8 +1174,9 @@ def test_post_conversation_data_protocol_no_stream(
parts=[TextUIPart(type="text", text="Why the sky is blue?")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="The sky appears blue due to a phenomenon called Rayleigh scattering.",
reasoning=None,
@@ -1222,9 +1255,7 @@ def test_post_conversation_data_protocol_no_stream(
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
@pytest.mark.asyncio
async def test_post_conversation_async(
api_client, mock_openai_stream, mock_uuid4, monkeypatch, caplog
):
async def test_post_conversation_async(api_client, mock_openai_stream, monkeypatch, caplog):
"""Test posting messages to a conversation using the 'data' protocol."""
monkeypatch.setenv("PYTHON_SERVER_MODE", "async")
@@ -1261,10 +1292,14 @@ async def test_post_conversation_async(
response_content = b"".join([content async for content in response.streaming_content]).decode(
"utf-8"
)
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'0:" there"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -1283,8 +1318,9 @@ async def test_post_conversation_async(
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[0].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello",
reasoning=None,
@@ -1295,8 +1331,9 @@ async def test_post_conversation_async(
parts=[TextUIPart(type="text", text="Hello")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello there",
reasoning=None,
@@ -14,6 +14,7 @@ import httpx
import pytest
import responses
import respx
from dirty_equals import IsUUID
from freezegun import freeze_time
from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart
from pydantic_ai.models.function import AgentInfo, DeltaToolCall, FunctionModel
@@ -32,6 +33,7 @@ from chat.ai_sdk_types import (
UIMessage,
)
from chat.factories import ChatConversationFactory
from chat.tests.utils import replace_uuids_with_placeholder
# enable database transactions for tests:
# transaction=True ensures that the data are available in the database
@@ -214,12 +216,11 @@ def fixture_mock_openai_stream():
@responses.activate
@respx.mock
@freeze_time()
def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_document_upload( # pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
mock_albert_api, # pylint: disable=unused-argument
sample_pdf_content,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -273,9 +274,11 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
assert response.streaming
# Wait for the streaming content to be fully received
str_mock_uuid4 = str(mock_uuid4)
toolcall_id = f"pyd_ai_{str_mock_uuid4.replace('-', '')}"
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
@@ -283,19 +286,22 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
'b:{"toolCallId":"pyd_ai_YYY","toolName":"document_search_rag"}\n'
'9:{"toolCallId":"pyd_ai_YYY","toolName":"document_search_rag",'
'"args":{"query":"What does the document say?"}}\n'
'h:{"sourceType":"url","id":"XXX","url":"sample.pdf","title":null,"providerMetadata":{}}\n'
'h:{"sourceType":"url","id":"<mocked_uuid>","url":"sample.pdf","title":null,'
'"providerMetadata":{}}\n'
'a:{"toolCallId":"pyd_ai_YYY","result":[{"url":"sample.pdf","content":"This '
'is the content of the PDF.","score":0.9}]}\n'
"0:\"From the document, I can see that it says 'Hello PDF'.\"\n"
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":100,"completionTokens":20}}\n'
).replace("XXX", str_mock_uuid4).replace("pyd_ai_YYY", toolcall_id)
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str_mock_uuid4,
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="What does the document say?",
reasoning=None,
@@ -305,8 +311,10 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
toolInvocations=None,
parts=[TextUIPart(type="text", text="What does the document say?")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str_mock_uuid4,
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="From the document, I can see that it says 'Hello PDF'.",
reasoning=None,
@@ -318,7 +326,7 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
ToolInvocationUIPart(
type="tool-invocation",
toolInvocation=ToolInvocationCall(
toolCallId=toolcall_id,
toolCallId=chat_conversation.messages[1].parts[0].toolInvocation.toolCallId,
toolName="document_search_rag",
args={"query": "What does the document say?"},
state="call",
@@ -330,7 +338,7 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
type="source",
source=LanguageModelV1Source(
sourceType="url",
id=str_mock_uuid4,
id=chat_conversation.messages[1].parts[2].source.id,
url="sample.pdf",
title=None,
providerMetadata={},
@@ -405,7 +413,7 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
"args": '{"query": "What does the document say?"}',
"id": None,
"part_kind": "tool-call",
"tool_call_id": toolcall_id,
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
"tool_name": "document_search_rag",
}
],
@@ -439,7 +447,7 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
"metadata": {"sources": ["sample.pdf"]},
"part_kind": "tool-return",
"timestamp": timezone_now,
"tool_call_id": toolcall_id,
"tool_call_id": chat_conversation.pydantic_messages[2]["parts"][0]["tool_call_id"],
"tool_name": "document_search_rag",
}
],
@@ -475,13 +483,12 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
@responses.activate
@respx.mock
@freeze_time("2025-07-25T10:36:35.297675Z")
def test_post_conversation_with_document_upload_feature_disabled( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_document_upload_feature_disabled( # pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
caplog,
mock_openai_stream, # pylint: disable=unused-argument
sample_pdf_content,
feature_flags,
mock_uuid4,
):
"""
Test POST to /api/v1/chats/{pk}/conversation/ with a PDF document while feature is disabled.
@@ -526,10 +533,14 @@ def test_post_conversation_with_document_upload_feature_disabled( # noqa: PLR09
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"From the document, I can see that "\n'
"0:\"it says 'Hello PDF'.\"\n"
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":150,"completionTokens":25}}\n'
)
@@ -545,7 +556,6 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
mock_albert_api, # pylint: disable=unused-argument
sample_pdf_content,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
mock_summarization_agent, # pylint: disable=unused-argument
):
@@ -600,29 +610,33 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
assert response.streaming
# Wait for the streaming content to be fully received
str_mock_uuid4 = str(mock_uuid4)
toolcall_id = f"pyd_ai_{str_mock_uuid4.replace('-', '')}"
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
'a:{"toolCallId":"XXX","result":{"state":"done"}}\n'
'b:{"toolCallId":"pyd_ai_YYY","toolName":"summarize"}\n'
'9:{"toolCallId":"pyd_ai_YYY","toolName":"summarize","args":{}}\n'
'h:{"sourceType":"url","id":"XXX","url":"sample.pdf.md",'
'h:{"sourceType":"url","id":"<mocked_uuid>","url":"sample.pdf.md",'
'"title":null,"providerMetadata":{}}\n'
'a:{"toolCallId":"pyd_ai_YYY","result":"The '
'document discusses various topics."}\n'
'0:"The document discusses various topics."\n'
'f:{"messageId":"XXX"}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":201,"completionTokens":13}}\n'
).replace("XXX", str_mock_uuid4).replace("pyd_ai_YYY", toolcall_id)
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str_mock_uuid4,
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="Make a summary of this document.",
reasoning=None,
@@ -632,8 +646,10 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
toolInvocations=None,
parts=[TextUIPart(type="text", text="Make a summary of this document.")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str_mock_uuid4,
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="The document discusses various topics.",
reasoning=None,
@@ -645,7 +661,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
ToolInvocationUIPart(
type="tool-invocation",
toolInvocation=ToolInvocationCall(
toolCallId=toolcall_id,
toolCallId=chat_conversation.messages[1].parts[0].toolInvocation.toolCallId,
toolName="summarize",
args={},
state="call",
@@ -657,7 +673,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
type="source",
source=LanguageModelV1Source(
sourceType="url",
id=str_mock_uuid4,
id=chat_conversation.messages[1].parts[2].source.id,
url="sample.pdf.md", # might be fixed in the future
title=None,
providerMetadata={},
@@ -732,7 +748,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"args": "{}",
"id": None,
"part_kind": "tool-call",
"tool_call_id": toolcall_id,
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
"tool_name": "summarize",
}
],
@@ -760,7 +776,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"metadata": {"sources": ["sample.pdf.md"]},
"part_kind": "tool-return",
"timestamp": timezone_now,
"tool_call_id": toolcall_id,
"tool_call_id": chat_conversation.pydantic_messages[2]["parts"][0]["tool_call_id"],
"tool_name": "summarize",
}
],
@@ -1,6 +1,8 @@
"""Unit tests for chat conversation actions with document URL."""
# pylint: disable=too-many-lines
import uuid
# pylint: disable=too-many-lines
from io import BytesIO
from django.core.files.storage import default_storage
@@ -8,6 +10,7 @@ from django.utils import formats, timezone
import pytest
import responses
from dirty_equals import IsUUID
from freezegun import freeze_time
from pydantic_ai import ModelRequest, RequestUsage
from pydantic_ai.messages import (
@@ -27,6 +30,7 @@ from chat.ai_sdk_types import (
UIMessage,
)
from chat.factories import ChatConversationAttachmentFactory, ChatConversationFactory
from chat.tests.utils import replace_uuids_with_placeholder
# enable database transactions for tests:
# transaction=True ensures that the data are available in the database
@@ -61,7 +65,6 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
api_client,
sample_document_content,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -162,20 +165,26 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
f'a:{{"toolCallId":"{mock_uuid4}","result":{{"state":"done"}}}}\n'
'a:{"toolCallId":"XXX","result":{"state":"done"}}\n'
'0:"This is a document about a single pixel."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":9}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="What is in this document?",
reasoning=None,
@@ -189,8 +198,10 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
TextUIPart(type="text", text="What is in this document?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="This is a document about a single pixel.",
reasoning=None,
@@ -278,7 +289,6 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
@freeze_time()
def test_post_conversation_with_local_document_wrong_url(
api_client,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -287,7 +297,7 @@ def test_post_conversation_with_local_document_wrong_url(
chat_conversation = ChatConversationFactory(owner__language="en-us")
api_client.force_authenticate(user=chat_conversation.owner)
document_url = f"/media-key/{mock_uuid4}/sample.pdf"
document_url = f"/media-key/{uuid.uuid4()}/sample.pdf"
message = UIMessage(
id="1",
@@ -326,10 +336,14 @@ def test_post_conversation_with_local_document_wrong_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
f'a:{{"toolCallId":"{mock_uuid4}",'
'a:{"toolCallId":"XXX",'
'"result":{"state":"error","error":"Document '
'URL does not belong to the conversation."}}\n'
'd:{"finishReason":"error","usage":{"promptTokens":0,"completionTokens":0}}\n'
@@ -343,7 +357,6 @@ def test_post_conversation_with_local_document_wrong_url(
@freeze_time()
def test_post_conversation_with_remote_document_url(
api_client,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -391,10 +404,14 @@ def test_post_conversation_with_remote_document_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
f'a:{{"toolCallId":"{mock_uuid4}",'
'a:{"toolCallId":"XXX",'
'"result":{"state":"error","error":"External document '
'URL are not accepted yet."}}\n'
'd:{"finishReason":"error","usage":{"promptTokens":0,"completionTokens":0}}\n'
@@ -409,7 +426,6 @@ def test_post_conversation_with_remote_document_url(
def test_post_conversation_with_local_document_url_in_history( # pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -422,7 +438,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
owner__language="en-us",
messages=[
UIMessage(
id=str(mock_uuid4),
id=str(uuid.uuid4()),
createdAt=timezone.now(),
content="What is in this document?",
reasoning=None,
@@ -437,7 +453,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
),
UIMessage(
id=str(mock_uuid4),
id=str(uuid.uuid4()),
createdAt=timezone.now(),
content="This is a document about a single pixel.",
reasoning=None,
@@ -603,17 +619,23 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"This is a document of square, very small and nice."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":11}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2 + 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="What is in this document?",
reasoning=None,
@@ -627,8 +649,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
TextUIPart(type="text", text="What is in this document?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="This is a document about a single pixel.",
reasoning=None,
@@ -640,8 +664,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
TextUIPart(type="text", text="This is a document about a single pixel."),
],
)
assert chat_conversation.messages[2].id == IsUUID(4)
assert chat_conversation.messages[2] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[2].id,
createdAt=timezone.now(),
content="Give more details about this document.",
reasoning=None,
@@ -653,8 +679,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
TextUIPart(type="text", text="Give more details about this document."),
],
)
assert chat_conversation.messages[3].id == IsUUID(4)
assert chat_conversation.messages[3] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[3].id,
createdAt=timezone.now(),
content="This is a document of square, very small and nice.",
reasoning=None,
@@ -783,10 +811,9 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
("data.csv", "text/csv"),
],
)
def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
file_name,
content_type,
@@ -901,20 +928,26 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
f'"args":{{"documents":[{{"identifier":"{file_name}"}}]}}}}\n'
f'a:{{"toolCallId":"{mock_uuid4}","result":{{"state":"done"}}}}\n'
'a:{"toolCallId":"XXX","result":{"state":"done"}}\n'
'0:"This is a document about you."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":7}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="What is in this document?",
reasoning=None,
@@ -926,8 +959,10 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
TextUIPart(type="text", text="What is in this document?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="This is a document about you.",
reasoning=None,
@@ -7,6 +7,7 @@ from django.utils import timezone
import pytest
import respx
from dirty_equals import IsUUID
from freezegun import freeze_time
from rest_framework import status
@@ -18,6 +19,7 @@ from chat.ai_sdk_types import (
UIMessage,
)
from chat.factories import ChatConversationFactory
from chat.tests.utils import replace_uuids_with_placeholder
# enable database transactions for tests:
# transaction=True ensures that the data are available in the database
@@ -200,7 +202,7 @@ def history_conversation_fixture():
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_data_protocol_with_history(
api_client, mock_openai_stream, mock_uuid4, history_conversation
api_client, mock_openai_stream, history_conversation
):
"""Test posting messages to a conversation with history using the 'data' protocol."""
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
@@ -226,10 +228,14 @@ def test_post_conversation_data_protocol_with_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'0:" there"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -259,8 +265,9 @@ def test_post_conversation_data_protocol_with_history(
assert len(history_conversation.messages) == 6
# Verify the most recent message is the new one
assert history_conversation.messages[4].id == IsUUID(4)
assert history_conversation.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="Hello",
reasoning=None,
@@ -271,8 +278,9 @@ def test_post_conversation_data_protocol_with_history(
parts=[TextUIPart(type="text", text="Hello")],
)
assert history_conversation.messages[5].id == IsUUID(4)
assert history_conversation.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="Hello there",
reasoning=None,
@@ -290,7 +298,7 @@ def test_post_conversation_data_protocol_with_history(
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_text_protocol_with_history(
api_client, mock_openai_stream, mock_uuid4, history_conversation
api_client, mock_openai_stream, history_conversation
):
"""Test posting messages to a conversation with history using the 'text' protocol."""
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=text"
@@ -335,8 +343,9 @@ def test_post_conversation_text_protocol_with_history(
assert len(history_conversation.messages) == 6
# Verify the most recent messages are the new ones
assert history_conversation.messages[4].id == IsUUID(4)
assert history_conversation.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="Hello",
reasoning=None,
@@ -347,8 +356,9 @@ def test_post_conversation_text_protocol_with_history(
parts=[TextUIPart(type="text", text="Hello")],
)
assert history_conversation.messages[5].id == IsUUID(4)
assert history_conversation.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="Hello there",
reasoning=None,
@@ -363,7 +373,7 @@ def test_post_conversation_text_protocol_with_history(
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_with_image_with_history(
api_client, mock_openai_stream_image, mock_uuid4, history_conversation
api_client, mock_openai_stream_image, history_conversation
):
"""
Ensure an image URL is correctly forwarded to the AI service with a conversation with history.
@@ -403,10 +413,14 @@ def test_post_conversation_with_image_with_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"I see a cat"\n'
'0:" in the picture."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -452,8 +466,9 @@ def test_post_conversation_with_image_with_history(
assert len(history_conversation.messages) == 6
# Verify the most recent message has the image attachment
assert history_conversation.messages[4].id == IsUUID(4)
assert history_conversation.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="Hello, what do you see on this picture?",
reasoning=None,
@@ -474,8 +489,9 @@ def test_post_conversation_with_image_with_history(
parts=[TextUIPart(type="text", text="Hello, what do you see on this picture?")],
)
assert history_conversation.messages[5].id == IsUUID(4)
assert history_conversation.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="I see a cat in the picture.",
reasoning=None,
@@ -490,7 +506,7 @@ def test_post_conversation_with_image_with_history(
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_tool_call_with_history(
api_client, mock_openai_stream_tool, mock_uuid4, settings, history_conversation
api_client, mock_openai_stream_tool, settings, history_conversation
):
"""
Ensure tool calls are correctly forwarded and streamed back with a conversation with history.
@@ -521,6 +537,10 @@ def test_post_conversation_tool_call_with_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":'
'"get_current_weather"}\n'
@@ -529,7 +549,7 @@ def test_post_conversation_tool_call_with_history(
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":{"location":'
'"Paris","temperature":22,"unit":"celsius"}}\n'
'0:"The current weather in Paris is nice"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -561,8 +581,9 @@ def test_post_conversation_tool_call_with_history(
assert len(history_conversation.messages) == 6
# Verify the most recent message is the new one with tool invocation
assert history_conversation.messages[4].id == IsUUID(4)
assert history_conversation.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="Weather in Paris?",
reasoning=None,
@@ -573,8 +594,9 @@ def test_post_conversation_tool_call_with_history(
parts=[TextUIPart(type="text", text="Weather in Paris?")],
)
assert history_conversation.messages[5].id == IsUUID(4)
assert history_conversation.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="The current weather in Paris is nice",
reasoning=None,
@@ -606,7 +628,7 @@ def test_post_conversation_tool_call_with_history(
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_tool_call_fails_with_history(
api_client, mock_openai_stream_tool, mock_uuid4, settings, history_conversation
api_client, mock_openai_stream_tool, settings, history_conversation
):
"""
Ensure tool calls are correctly forwarded and streamed back when failing with a
@@ -638,6 +660,10 @@ def test_post_conversation_tool_call_fails_with_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":'
'"get_current_weather"}\n'
@@ -646,7 +672,7 @@ def test_post_conversation_tool_call_fails_with_history(
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":"Unknown tool '
"name: 'get_current_weather'. No tools available.\"}\n"
'0:"I cannot give you an answer to that."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -678,8 +704,9 @@ def test_post_conversation_tool_call_fails_with_history(
assert len(history_conversation.messages) == 6
# Verify the most recent message is the new one with tool invocation
assert history_conversation.messages[4].id == IsUUID(4)
assert history_conversation.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="Weather in Paris?",
reasoning=None,
@@ -690,8 +717,9 @@ def test_post_conversation_tool_call_fails_with_history(
parts=[TextUIPart(type="text", text="Weather in Paris?")],
)
assert history_conversation.messages[5].id == IsUUID(4)
assert history_conversation.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="I cannot give you an answer to that.",
reasoning=None,
@@ -1147,7 +1175,7 @@ def history_conversation_with_tool_fixture():
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_with_existing_image_history(
api_client, mock_openai_stream, mock_uuid4, history_conversation_with_image
api_client, mock_openai_stream, history_conversation_with_image
):
"""Test posting a message to a conversation that already has images in its history."""
url = f"/api/v1.0/chats/{history_conversation_with_image.pk}/conversation/?protocol=data"
@@ -1173,10 +1201,14 @@ def test_post_conversation_with_existing_image_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'0:" there"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -1207,8 +1239,9 @@ def test_post_conversation_with_existing_image_history(
assert len(history_conversation_with_image.messages) == 6
# Verify the most recent messages are the new ones
assert history_conversation_with_image.messages[4].id == IsUUID(4)
assert history_conversation_with_image.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation_with_image.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="What was in that image again?",
reasoning=None,
@@ -1219,8 +1252,9 @@ def test_post_conversation_with_existing_image_history(
parts=[TextUIPart(type="text", text="What was in that image again?")],
)
assert history_conversation_with_image.messages[5].id == IsUUID(4)
assert history_conversation_with_image.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation_with_image.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="Hello there",
reasoning=None,
@@ -1238,7 +1272,7 @@ def test_post_conversation_with_existing_image_history(
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_with_existing_tool_history(
api_client, mock_openai_stream_tool, mock_uuid4, settings, history_conversation_with_tool
api_client, mock_openai_stream_tool, settings, history_conversation_with_tool
):
"""Test posting a message to a conversation that already has tool calls in its history."""
settings.AI_AGENT_TOOLS = ["get_current_weather"]
@@ -1266,6 +1300,10 @@ def test_post_conversation_with_existing_tool_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":'
'"get_current_weather"}\n'
@@ -1274,7 +1312,7 @@ def test_post_conversation_with_existing_tool_history(
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":{"location":'
'"Paris","temperature":22,"unit":"celsius"}}\n'
'0:"The current weather in Paris is nice"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -1294,8 +1332,9 @@ def test_post_conversation_with_existing_tool_history(
assert len(history_conversation_with_tool.messages) == 6
# Verify the most recent message is the new one with tool invocation
assert history_conversation_with_tool.messages[4].id == IsUUID(4)
assert history_conversation_with_tool.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation_with_tool.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="How about Paris weather?",
reasoning=None,
@@ -1306,8 +1345,9 @@ def test_post_conversation_with_existing_tool_history(
parts=[TextUIPart(type="text", text="How about Paris weather?")],
)
assert history_conversation_with_tool.messages[5].id == IsUUID(4)
assert history_conversation_with_tool.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation_with_tool.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="The current weather in Paris is nice",
reasoning=None,
@@ -1417,7 +1457,7 @@ def test_post_conversation_with_existing_tool_history(
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_add_image_to_conversation_with_tool_history(
api_client, mock_openai_stream_image, mock_uuid4, history_conversation_with_tool
api_client, mock_openai_stream_image, history_conversation_with_tool
):
"""Test adding an image to a conversation that already has tool calls in its history."""
url = f"/api/v1.0/chats/{history_conversation_with_tool.pk}/conversation/?protocol=data"
@@ -1455,10 +1495,14 @@ def test_post_conversation_add_image_to_conversation_with_tool_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"I see a cat"\n'
'0:" in the picture."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -1484,8 +1528,9 @@ def test_post_conversation_add_image_to_conversation_with_tool_history(
assert len(history_conversation_with_tool.messages) == 6
# Verify the most recent message has the image attachment
assert history_conversation_with_tool.messages[4].id == IsUUID(4)
assert history_conversation_with_tool.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation_with_tool.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="How's the weather in this image?",
reasoning=None,
@@ -1506,8 +1551,9 @@ def test_post_conversation_add_image_to_conversation_with_tool_history(
parts=[TextUIPart(type="text", text="How's the weather in this image?")],
)
assert history_conversation_with_tool.messages[5].id == IsUUID(4)
assert history_conversation_with_tool.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation_with_tool.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="I see a cat in the picture.",
reasoning=None,
@@ -1,8 +1,11 @@
"""Unit tests for chat conversation actions with image URL."""
import uuid
from django.utils import timezone
import pytest
from dirty_equals import IsUUID
from freezegun import freeze_time
from pydantic_ai import ModelRequest, RequestUsage
from pydantic_ai.messages import (
@@ -22,6 +25,7 @@ from chat.ai_sdk_types import (
UIMessage,
)
from chat.factories import ChatConversationFactory
from chat.tests.utils import replace_uuids_with_placeholder
# enable database transactions for tests:
# transaction=True ensures that the data are available in the database
@@ -53,7 +57,6 @@ def fixture_sample_image_content():
@freeze_time("2025-10-18T20:48:20.286204Z")
def test_post_conversation_with_local_image_url(
api_client,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -131,17 +134,23 @@ def test_post_conversation_with_local_image_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"This is an image of a single pixel."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":9}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id, # don't test the value directly
createdAt=timezone.now(),
content="What is in this image?",
reasoning=None,
@@ -155,8 +164,10 @@ def test_post_conversation_with_local_image_url(
TextUIPart(type="text", text="What is in this image?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id, # don't test the value directly
createdAt=timezone.now(),
content="This is an image of a single pixel.",
reasoning=None,
@@ -238,7 +249,6 @@ def test_post_conversation_with_local_image_url(
def test_post_conversation_with_local_image_wrong_url(
api_client,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -247,7 +257,7 @@ def test_post_conversation_with_local_image_wrong_url(
chat_conversation = ChatConversationFactory(owner__language="en-us")
api_client.force_authenticate(user=chat_conversation.owner)
image_url = f"/media-key/{mock_uuid4}/sample.png"
image_url = f"/media-key/{uuid.uuid4()}/sample.png"
message = UIMessage(
id="1",
@@ -308,9 +318,13 @@ def test_post_conversation_with_local_image_wrong_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"cannot read image."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":4}}\n'
)
@@ -322,7 +336,6 @@ def test_post_conversation_with_local_image_wrong_url(
def test_post_conversation_with_remote_image_url(
api_client,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -392,17 +405,23 @@ def test_post_conversation_with_remote_image_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"This is an image of a single pixel."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":9}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id, # don't test the value directly
createdAt=timezone.now(),
content="What is in this image?",
reasoning=None,
@@ -416,8 +435,10 @@ def test_post_conversation_with_remote_image_url(
TextUIPart(type="text", text="What is in this image?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id, # don't test the value directly
createdAt=timezone.now(),
content="This is an image of a single pixel.",
reasoning=None,
@@ -435,7 +456,6 @@ def test_post_conversation_with_remote_image_url(
def test_post_conversation_with_local_image_url_in_history(
api_client,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -448,7 +468,7 @@ def test_post_conversation_with_local_image_url_in_history(
owner__language="en-us",
messages=[
UIMessage(
id=str(mock_uuid4),
id=str(uuid.uuid4()),
createdAt=timezone.now(),
content="What is in this image?",
reasoning=None,
@@ -463,7 +483,7 @@ def test_post_conversation_with_local_image_url_in_history(
],
),
UIMessage(
id=str(mock_uuid4),
id=str(uuid.uuid4()),
createdAt=timezone.now(),
content="This is an image of a single pixel.",
reasoning=None,
@@ -629,17 +649,23 @@ def test_post_conversation_with_local_image_url_in_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"This is an image of square, very small and nice."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":11}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2 + 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id, # don't test the value directly
createdAt=timezone.now(),
content="What is in this image?",
reasoning=None,
@@ -653,8 +679,10 @@ def test_post_conversation_with_local_image_url_in_history(
TextUIPart(type="text", text="What is in this image?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id, # don't test the value directly
createdAt=timezone.now(),
content="This is an image of a single pixel.",
reasoning=None,
@@ -666,8 +694,10 @@ def test_post_conversation_with_local_image_url_in_history(
TextUIPart(type="text", text="This is an image of a single pixel."),
],
)
assert chat_conversation.messages[2].id == IsUUID(4)
assert chat_conversation.messages[2] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[2].id, # don't test the value directly
createdAt=timezone.now(),
content="Give more details about this image.",
reasoning=None,
@@ -679,8 +709,10 @@ def test_post_conversation_with_local_image_url_in_history(
TextUIPart(type="text", text="Give more details about this image."),
],
)
assert chat_conversation.messages[3].id == IsUUID(4)
assert chat_conversation.messages[3] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[3].id, # don't test the value directly
createdAt=timezone.now(),
content="This is an image of square, very small and nice.",
reasoning=None,
+1 -1
View File
@@ -425,7 +425,7 @@ class ChatConversationAttachmentViewSet(
if settings.POSTHOG_KEY:
posthog.capture(
"item_uploaded",
distinct_id=request.user.email,
distinct_id=request.user.pk, # same as set by the frontend
properties={
"id": attachment.pk,
"file_name": attachment.file_name,
+7 -3
View File
@@ -312,8 +312,7 @@ class Base(BraveSettings, Configuration):
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"posthog.integrations.django.PosthogContextMiddleware",
"core.middleware.PostHogMiddleware",
"core.posthog.AsyncPosthogContextMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
]
@@ -483,7 +482,11 @@ class Base(BraveSettings, Configuration):
THUMBNAIL_ALIASES = {}
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_ENGINE = values.Value(
"django.contrib.sessions.backends.cache",
environ_name="SESSION_ENGINE",
environ_prefix=None,
)
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
@@ -503,6 +506,7 @@ class Base(BraveSettings, Configuration):
environ_name="OIDC_RP_CLIENT_SECRET",
environ_prefix=None,
)
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
OIDC_OP_JWKS_ENDPOINT = values.Value(environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None)
OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value(
environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None
@@ -1,7 +1,6 @@
"""Authentication Backends for the Conversations core app."""
import logging
import os
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
@@ -15,19 +14,6 @@ from core.models import DuplicateEmailError
logger = logging.getLogger(__name__)
# Settings renamed warnings
if os.environ.get("USER_OIDC_FIELDS_TO_FULLNAME"):
logger.warning(
"USER_OIDC_FIELDS_TO_FULLNAME has been renamed "
"to OIDC_USERINFO_FULLNAME_FIELDS please update your settings."
)
if os.environ.get("USER_OIDC_FIELD_TO_SHORTNAME"):
logger.warning(
"USER_OIDC_FIELD_TO_SHORTNAME has been renamed "
"to OIDC_USERINFO_SHORTNAME_FIELD please update your settings."
)
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
+1 -3
View File
@@ -9,7 +9,6 @@ User = get_user_model()
try:
import posthog
from posthog.contexts import get_tags
except ImportError:
posthog = None
@@ -39,8 +38,7 @@ def is_feature_enabled(
if posthog is not None:
return posthog.feature_enabled(
frontend_feature_name(feature_name),
user.email,
person_properties={"$host": get_tags().get("$host")},
user.pk, # same as set by the frontend
)
# No feature flag manager
-58
View File
@@ -1,58 +0,0 @@
"""Custom middleware(s) for the project."""
import json
import logging
from urllib.parse import unquote
from django.conf import settings
from django.core.exceptions import MiddlewareNotUsed
# We are importing posthog here, but it will only be used if the POSTHOG_KEY is set in settings.
# The settings are checked before any attempt to use posthog.
try:
import posthog
except ImportError:
posthog = None
logger = logging.getLogger(__name__)
class PostHogMiddleware:
"""
This middleware is used to alias the user's distinct_id from the PostHog cookie
with their email address when they are authenticated. This allows us to track
users across different sessions and devices.
"""
def __init__(self, get_response):
"""
Initialize the middleware and disable it if PostHog is not configured.
"""
if posthog is None or not settings.POSTHOG_KEY:
raise MiddlewareNotUsed("POSTHOG_KEY must be set in settings to use PostHogMiddleware.")
self.get_response = get_response
def __call__(self, request):
"""
Process the request to handle the PostHog alias.
"""
if posthog is not None and settings.POSTHOG_KEY:
posthog_cookie = request.COOKIES.get(f"ph_{posthog.project_api_key}_posthog")
if posthog_cookie:
try:
cookie_dict = json.loads(unquote(posthog_cookie))
if (
cookie_dict.get("distinct_id")
and request.user
and request.user.is_authenticated
):
posthog.alias(cookie_dict["distinct_id"], request.user.email)
except (json.JSONDecodeError, KeyError):
# If the cookie is malformed or doesn't contain the expected
# keys, we can't do anything with it, so we ignore it.
logger.warning("Malformed PostHog cookie: %s", posthog_cookie)
response = self.get_response(request)
return response
+113
View File
@@ -0,0 +1,113 @@
"""
Asynchronous PostHog context middleware for Django.
Since https://github.com/PostHog/posthog-python/commit/6af129f41413f4f7d55731763ff42e4c0fb66844
The official PostHog Django middleware supports both sync and async requests,
but the call to request.user fails in async contexts.
This is an horrible patch while waiting for an official fix.
Follow https://github.com/PostHog/posthog-python/issues/355
"""
from posthog import contexts
from posthog.integrations.django import PosthogContextMiddleware
class AsyncPosthogContextMiddleware(PosthogContextMiddleware):
"""
Asynchronous Django middleware to extract PostHog context from HTTP requests.
While the original PosthogContextMiddleware is supposed to manage both sync and async requests,
the call to request.user fails in async contexts.
"""
async def extract_tags_async(self, request):
"""Extract tags from the HTTP request asynchronously."""
tags = {}
(user_id, user_email) = await self.extract_request_user_async(request)
# Extract session ID from X-POSTHOG-SESSION-ID header
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
if session_id:
contexts.set_context_session(session_id)
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id
distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID") or user_id
if distinct_id:
contexts.identify_context(distinct_id)
# Extract user email
if user_email:
tags["email"] = user_email
# Extract current URL
absolute_url = request.build_absolute_uri()
if absolute_url:
tags["$current_url"] = absolute_url
# Extract request method
if request.method:
tags["$request_method"] = request.method
# Extract request path
if request.path:
tags["$request_path"] = request.path
# Extract IP address
ip_address = request.headers.get("X-Forwarded-For")
if ip_address:
tags["$ip_address"] = ip_address
# Extract user agent
user_agent = request.headers.get("User-Agent")
if user_agent:
tags["$user_agent"] = user_agent
# Apply extra tags if configured
if self.extra_tags:
extra = self.extra_tags(request)
if extra:
tags.update(extra)
# Apply tag mapping if configured
if self.tag_map:
tags = self.tag_map(tags)
return tags
async def extract_request_user_async(self, request):
"""Extract user ID and email from the HTTP request asynchronously."""
user_id = None
email = None
user = await request.auser()
if user and getattr(user, "is_authenticated", False):
try:
user_id = str(user.pk)
except Exception: # noqa: BLE001, S110 # pylint: disable=broad-except
pass
try:
email = str(user.email)
except Exception: # noqa: BLE001, S110 # pylint: disable=broad-except
pass
return user_id, email
async def __acall__(self, request):
"""
Asynchronous entry point for async request handling.
This method is called when the middleware chain is async.
"""
if self.request_filter and not self.request_filter(request):
return await self.get_response(request)
with contexts.new_context(self.capture_exceptions, client=self.client):
tags = await self.extract_tags_async(request)
for k, v in tags.items():
contexts.tag(k, v)
return await self.get_response(request)
@@ -58,7 +58,7 @@ def test_authentication_getter_existing_user_via_email(django_assert_num_queries
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(3): # user by sub, user by mail, update sub
with django_assert_num_queries(4): # user by sub, user by mail, unicity check, update sub
user = klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert user == db_user
@@ -205,7 +205,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
with django_assert_num_queries(3): # user by sub, unicity check, update sub
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -245,7 +245,7 @@ def test_authentication_getter_existing_user_change_fields_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(3):
with django_assert_num_queries(4): # user by sub, user by mail, unicity check, update sub
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -52,8 +52,7 @@ def test_is_feature_enabled_dynamic_posthog_true(mock_posthog, feature_flags):
assert is_feature_enabled(user, "web_search") is True
mock_posthog.feature_enabled.assert_called_once_with(
"web-search",
user.email,
person_properties={"$host": None},
user.pk,
)
+51 -1
View File
@@ -4,9 +4,10 @@ Test config API endpoints in the Conversations core app.
import json
from django.test import override_settings
from django.test import AsyncClient, override_settings
import pytest
from asgiref.sync import sync_to_async
from rest_framework.status import (
HTTP_200_OK,
)
@@ -156,3 +157,52 @@ def test_api_config_with_original_theme_customization(is_authenticated, settings
theme_customization = json.load(f)
assert content["theme_customization"] == theme_customization
@override_settings(
CRISP_WEBSITE_ID="123",
FRONTEND_CSS_URL="http://testcss/",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
SENTRY_DSN="https://sentry.test/123",
THEME_CUSTOMIZATION_FILE_PATH="",
RAG_FILES_ACCEPTED_FORMATS=[
"application/pdf",
"text/plain",
],
)
@pytest.mark.asyncio
@pytest.mark.parametrize("is_authenticated", [False, True])
async def test_api_config_async(is_authenticated):
"""Anonymous users should be allowed to get the configuration (async client)."""
client = AsyncClient()
if is_authenticated:
user = await sync_to_async(factories.UserFactory)()
await client.aforce_login(user)
response = await client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"ACTIVATION_REQUIRED": False,
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"},
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [
["en-us", "English"],
["fr-fr", "Français"],
# ["de-de", "Deutsch"],
["nl-nl", "Nederlands"],
# ["es-es", "Español"],
],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"SENTRY_DSN": "https://sentry.test/123",
"theme_customization": {},
"chat_upload_accept": "application/pdf,text/plain",
}
+12 -11
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "conversations"
version = "0.0.5"
version = "0.0.7"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -27,13 +27,13 @@ requires-python = ">=3.12"
dependencies = [
"deprecated",
"beautifulsoup4==4.14.2",
"boto3==1.40.51",
"boto3==1.40.59",
"Brotli==1.1.0",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==7.6.1",
"django-filter==25.2",
"django-lasuite[all]==0.0.14",
"django-lasuite[all]==0.0.16",
"django-parler==2.3",
"django-pydantic-field==0.3.13",
"django-redis==6.0.0",
@@ -47,23 +47,23 @@ dependencies = [
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"langfuse==3.6.2",
"langfuse==3.8.1",
"lxml==5.4.0",
"markdown==3.9",
"markitdown==0.0.2",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.6.0",
"posthog==6.7.7",
"pydantic==2.12.1",
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.0.18",
"psycopg[binary]==3.2.10",
"posthog==6.7.10",
"pydantic==2.12.3",
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.6.0",
"psycopg[binary]==3.2.12",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.5",
"sentry-sdk==2.41.0",
"sentry-sdk==2.42.1",
"trafilatura==2.0.0",
"uvicorn==0.28.0",
"uvicorn==0.38.0",
"whitenoise==6.11.0",
]
@@ -75,6 +75,7 @@ dependencies = [
[project.optional-dependencies]
dev = [
"dirty-equals==0.10.0",
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.10.1",
@@ -93,7 +94,7 @@ dev = [
"pytest-xdist==3.8.0",
"responses==0.25.8",
"respx==0.22.0",
"ruff==0.14.0",
"ruff==0.14.2",
"types-requests==2.32.4.20250913",
]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-conversations",
"version": "0.0.5",
"version": "0.0.7",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "0.0.5",
"version": "0.0.7",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "conversations",
"version": "0.0.5",
"version": "0.0.7",
"private": true,
"workspaces": {
"packages": [
@@ -1,6 +1,6 @@
{
"name": "eslint-config-conversations",
"version": "0.0.5",
"version": "0.0.7",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "0.0.5",
"version": "0.0.7",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:conversations",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.0.5",
"version": "0.0.7",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {