b7f52be4c9
CI / Run CI (push) Has been cancelled
CI / check-backend (push) Has been cancelled
CI / check-frontend (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / e2e-tests (push) Has been cancelled
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled
225 lines
7.8 KiB
Python
225 lines
7.8 KiB
Python
import json
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from chainlit.data.chainlit_data_layer import ChainlitDataLayer
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_thread_preserves_metadata_when_none_existing_thread():
|
|
"""Test that update_thread preserves existing metadata when metadata=None on an existing thread."""
|
|
data_layer = ChainlitDataLayer(
|
|
database_url="postgresql://test", storage_client=None, show_logger=False
|
|
)
|
|
|
|
existing_metadata = {"important": "data", "keep": "me"}
|
|
|
|
async def mock_execute_query(query, params):
|
|
if "SELECT" in query and "metadata" in query:
|
|
return [{"metadata": json.dumps(existing_metadata)}]
|
|
return None
|
|
|
|
data_layer.execute_query = AsyncMock(side_effect=mock_execute_query)
|
|
|
|
await data_layer.update_thread(thread_id="test-thread-123", name="Updated Name")
|
|
|
|
assert data_layer.execute_query.call_count == 2
|
|
|
|
update_call = data_layer.execute_query.call_args_list[1]
|
|
update_params = update_call[0][1]
|
|
|
|
metadata_json = None
|
|
for value in update_params.values():
|
|
if isinstance(value, str) and value.startswith("{"):
|
|
try:
|
|
metadata_json = json.loads(value)
|
|
break
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
assert metadata_json == existing_metadata
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_thread_noop_skips_upsert_on_existing_thread():
|
|
"""Test that update_thread with no arguments early-returns for an existing thread."""
|
|
data_layer = ChainlitDataLayer(
|
|
database_url="postgresql://test", storage_client=None, show_logger=False
|
|
)
|
|
|
|
async def mock_execute_query(query, params):
|
|
if "SELECT" in query and "metadata" in query:
|
|
return [{"metadata": json.dumps({"existing": "data"})}]
|
|
return None
|
|
|
|
data_layer.execute_query = AsyncMock(side_effect=mock_execute_query)
|
|
|
|
await data_layer.update_thread(thread_id="test-thread-123")
|
|
|
|
assert data_layer.execute_query.call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_thread_new_thread_includes_empty_metadata():
|
|
"""Test that update_thread includes metadata='{}' for a new thread (NOT NULL safe)."""
|
|
data_layer = ChainlitDataLayer(
|
|
database_url="postgresql://test", storage_client=None, show_logger=False
|
|
)
|
|
|
|
async def mock_execute_query(query, params):
|
|
if "SELECT" in query and "metadata" in query:
|
|
return []
|
|
return None
|
|
|
|
data_layer.execute_query = AsyncMock(side_effect=mock_execute_query)
|
|
|
|
await data_layer.update_thread(thread_id="new-thread", name="New Chat")
|
|
|
|
assert data_layer.execute_query.call_count == 2
|
|
|
|
update_call = data_layer.execute_query.call_args_list[1]
|
|
update_query = update_call[0][0]
|
|
update_params = update_call[0][1]
|
|
|
|
assert "metadata" in update_query.lower()
|
|
|
|
metadata_json = None
|
|
for value in update_params.values():
|
|
if isinstance(value, str) and value.startswith("{"):
|
|
try:
|
|
metadata_json = json.loads(value)
|
|
break
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
assert metadata_json == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_thread_merges_metadata_when_provided():
|
|
"""Test that update_thread merges metadata correctly when provided."""
|
|
# Create a mock data layer
|
|
data_layer = ChainlitDataLayer(
|
|
database_url="postgresql://test", storage_client=None, show_logger=False
|
|
)
|
|
|
|
# Mock the execute_query method to return existing metadata
|
|
existing_metadata = {"is_shared": True, "custom_field": "original"}
|
|
|
|
async def mock_execute_query(query, params):
|
|
if "SELECT" in query and "metadata" in query:
|
|
# Return existing thread metadata
|
|
return [{"metadata": json.dumps(existing_metadata)}]
|
|
# For the UPDATE/INSERT, return None
|
|
return None
|
|
|
|
data_layer.execute_query = AsyncMock(side_effect=mock_execute_query)
|
|
|
|
# Call update_thread with partial metadata update
|
|
new_metadata = {"custom_field": "updated", "new_field": "added"}
|
|
await data_layer.update_thread(
|
|
thread_id="test-thread-123", name="Updated Name", metadata=new_metadata
|
|
)
|
|
|
|
# Verify execute_query was called twice (once for SELECT, once for UPDATE)
|
|
assert data_layer.execute_query.call_count == 2
|
|
|
|
# Get the UPDATE call
|
|
update_call = data_layer.execute_query.call_args_list[1]
|
|
update_params = update_call[0][1]
|
|
|
|
# The metadata should be merged
|
|
# Expected: {"is_shared": True, "custom_field": "updated", "new_field": "added"}
|
|
# Find the JSON metadata in the params
|
|
metadata_json = None
|
|
for value in update_params.values():
|
|
if isinstance(value, str) and value.startswith("{"):
|
|
try:
|
|
metadata_json = json.loads(value)
|
|
break
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
assert metadata_json is not None
|
|
assert metadata_json.get("is_shared") is True
|
|
assert metadata_json.get("custom_field") == "updated"
|
|
assert metadata_json.get("new_field") == "added"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_thread_deletes_keys_with_none_values():
|
|
"""Test that update_thread deletes keys when value is None."""
|
|
# Create a mock data layer
|
|
data_layer = ChainlitDataLayer(
|
|
database_url="postgresql://test", storage_client=None, show_logger=False
|
|
)
|
|
|
|
# Mock the execute_query method to return existing metadata
|
|
existing_metadata = {
|
|
"is_shared": True,
|
|
"to_delete": "will be removed",
|
|
"keep": "stays",
|
|
}
|
|
|
|
async def mock_execute_query(query, params):
|
|
if "SELECT" in query and "metadata" in query:
|
|
# Return existing thread metadata
|
|
return [{"metadata": json.dumps(existing_metadata)}]
|
|
# For the UPDATE/INSERT, return None
|
|
return None
|
|
|
|
data_layer.execute_query = AsyncMock(side_effect=mock_execute_query)
|
|
|
|
# Call update_thread with None value to delete a key
|
|
new_metadata = {"to_delete": None, "new_field": "added"}
|
|
await data_layer.update_thread(thread_id="test-thread-123", metadata=new_metadata)
|
|
|
|
# Verify execute_query was called twice
|
|
assert data_layer.execute_query.call_count == 2
|
|
|
|
# Get the UPDATE call
|
|
update_call = data_layer.execute_query.call_args_list[1]
|
|
update_params = update_call[0][1]
|
|
|
|
# The metadata should have deleted "to_delete" key and added "new_field"
|
|
# Expected: {"is_shared": True, "keep": "stays", "new_field": "added"}
|
|
metadata_json = None
|
|
for value in update_params.values():
|
|
if isinstance(value, str) and value.startswith("{"):
|
|
try:
|
|
metadata_json = json.loads(value)
|
|
break
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
if metadata_json:
|
|
# Verify "to_delete" is not in the merged metadata
|
|
assert "to_delete" not in metadata_json
|
|
# Verify "new_field" was added
|
|
assert metadata_json.get("new_field") == "added"
|
|
# Verify "is_shared" and "keep" are preserved
|
|
assert metadata_json.get("is_shared") is True
|
|
assert metadata_json.get("keep") == "stays"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_step_uses_nullif_for_output_and_input():
|
|
"""Empty-string output/input should not overwrite existing content.
|
|
|
|
Regression test for https://github.com/Chainlit/chainlit/issues/2789
|
|
The SQL uses NULLIF(EXCLUDED.output, '') so that an empty string from the
|
|
initial Step.send() is treated as NULL by COALESCE, preventing it from
|
|
overwriting non-empty content saved by a subsequent Step.update().
|
|
"""
|
|
import inspect
|
|
|
|
source = inspect.getsource(ChainlitDataLayer.create_step)
|
|
|
|
assert "NULLIF(EXCLUDED.output, '')" in source, (
|
|
"output should use NULLIF to treat empty string as NULL"
|
|
)
|
|
assert "NULLIF(EXCLUDED.input, '')" in source, (
|
|
"input should use NULLIF to treat empty string as NULL"
|
|
)
|