chore: import upstream snapshot with attribution
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,704 @@
|
||||
"""Tests for chat database models."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
|
||||
class TestChatSessionModel:
|
||||
"""Tests for the ChatSession database model."""
|
||||
|
||||
def test_chat_session_table_exists(self, setup_database_for_all_tests):
|
||||
"""Test that the chat_sessions table exists in the database."""
|
||||
from src.local_deep_research.database.models import Base
|
||||
|
||||
# Get all table names from metadata
|
||||
table_names = set(Base.metadata.tables.keys())
|
||||
|
||||
assert "chat_sessions" in table_names, (
|
||||
"chat_sessions table is missing from database schema. "
|
||||
"Ensure the ChatSession model is properly defined and imported."
|
||||
)
|
||||
|
||||
def test_chat_session_has_required_columns(
|
||||
self, setup_database_for_all_tests
|
||||
):
|
||||
"""Test that ChatSession has all required columns."""
|
||||
from src.local_deep_research.database.models.chat import ChatSession
|
||||
|
||||
required_columns = {
|
||||
"id",
|
||||
"title",
|
||||
"status",
|
||||
"accumulated_context",
|
||||
"created_at",
|
||||
"message_count",
|
||||
}
|
||||
actual_columns = set(ChatSession.__table__.columns.keys())
|
||||
|
||||
missing = required_columns - actual_columns
|
||||
assert not missing, (
|
||||
f"ChatSession is missing required columns: {missing}\n"
|
||||
"This will break chat session storage."
|
||||
)
|
||||
|
||||
def test_chat_session_json_fields_serialize(
|
||||
self, setup_database_for_all_tests
|
||||
):
|
||||
"""Test that accumulated_context JSON field serializes correctly."""
|
||||
from datetime import datetime, UTC
|
||||
from src.local_deep_research.database.models.chat import ChatSession
|
||||
|
||||
SessionLocal = setup_database_for_all_tests
|
||||
session = SessionLocal()
|
||||
|
||||
try:
|
||||
test_context = {
|
||||
"key_entities": ["test1", "test2"],
|
||||
"topics": ["topic1"],
|
||||
"summary": "",
|
||||
}
|
||||
|
||||
chat_session = ChatSession(
|
||||
id="test-session-json-1",
|
||||
title="Test Session",
|
||||
status="active",
|
||||
accumulated_context=test_context,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(chat_session)
|
||||
session.commit()
|
||||
|
||||
retrieved = (
|
||||
session.query(ChatSession)
|
||||
.filter_by(id="test-session-json-1")
|
||||
.first()
|
||||
)
|
||||
assert retrieved is not None
|
||||
assert retrieved.accumulated_context == test_context
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
def test_chat_session_status_default_is_active(
|
||||
self, setup_database_for_all_tests
|
||||
):
|
||||
"""Test that default status is 'active'."""
|
||||
from datetime import datetime, UTC
|
||||
from src.local_deep_research.database.models.chat import ChatSession
|
||||
|
||||
SessionLocal = setup_database_for_all_tests
|
||||
session = SessionLocal()
|
||||
|
||||
try:
|
||||
chat_session = ChatSession(
|
||||
id="test-session-default-1",
|
||||
title="Test Session",
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(chat_session)
|
||||
session.commit()
|
||||
|
||||
retrieved = (
|
||||
session.query(ChatSession)
|
||||
.filter_by(id="test-session-default-1")
|
||||
.first()
|
||||
)
|
||||
assert retrieved is not None
|
||||
assert retrieved.status == "active"
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
|
||||
class TestChatMessageModel:
|
||||
"""Tests for the ChatMessage database model."""
|
||||
|
||||
def test_chat_message_table_exists(self, setup_database_for_all_tests):
|
||||
"""Test that the chat_messages table exists in the database."""
|
||||
from src.local_deep_research.database.models import Base
|
||||
|
||||
table_names = set(Base.metadata.tables.keys())
|
||||
|
||||
assert "chat_messages" in table_names, (
|
||||
"chat_messages table is missing from database schema. "
|
||||
"Ensure the ChatMessage model is properly defined and imported."
|
||||
)
|
||||
|
||||
def test_chat_message_has_required_columns(
|
||||
self, setup_database_for_all_tests
|
||||
):
|
||||
"""Test that ChatMessage has all required columns."""
|
||||
from src.local_deep_research.database.models.chat import ChatMessage
|
||||
|
||||
required_columns = {
|
||||
"id",
|
||||
"session_id",
|
||||
"role",
|
||||
"content",
|
||||
"message_type",
|
||||
"research_id",
|
||||
"sequence_number",
|
||||
"created_at",
|
||||
}
|
||||
actual_columns = set(ChatMessage.__table__.columns.keys())
|
||||
|
||||
missing = required_columns - actual_columns
|
||||
assert not missing, (
|
||||
f"ChatMessage is missing required columns: {missing}\n"
|
||||
"This will break chat message storage."
|
||||
)
|
||||
|
||||
def test_chat_message_foreign_key_to_session(
|
||||
self, setup_database_for_all_tests
|
||||
):
|
||||
"""Test that ChatMessage has foreign key relationship to ChatSession."""
|
||||
from src.local_deep_research.database.models.chat import ChatMessage
|
||||
|
||||
# Check foreign keys
|
||||
foreign_keys = [
|
||||
fk.target_fullname for fk in ChatMessage.__table__.foreign_keys
|
||||
]
|
||||
|
||||
assert any("chat_sessions" in fk for fk in foreign_keys), (
|
||||
"ChatMessage should have a foreign key to chat_sessions table"
|
||||
)
|
||||
|
||||
def test_chat_message_research_id_nullable(
|
||||
self, setup_database_for_all_tests
|
||||
):
|
||||
"""Test that research_id column is nullable."""
|
||||
from datetime import datetime, UTC
|
||||
from src.local_deep_research.database.models.chat import (
|
||||
ChatMessage,
|
||||
ChatSession,
|
||||
)
|
||||
|
||||
SessionLocal = setup_database_for_all_tests
|
||||
session = SessionLocal()
|
||||
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Create session first
|
||||
chat_session = ChatSession(
|
||||
id="test-session-nullable-1",
|
||||
title="Test Session",
|
||||
created_at=now,
|
||||
)
|
||||
session.add(chat_session)
|
||||
session.commit()
|
||||
|
||||
# Create message without research_id
|
||||
message = ChatMessage(
|
||||
id="test-msg-nullable-1",
|
||||
session_id="test-session-nullable-1",
|
||||
role="user",
|
||||
content="Test question",
|
||||
message_type="query",
|
||||
sequence_number=1,
|
||||
created_at=now,
|
||||
# research_id intentionally omitted
|
||||
)
|
||||
session.add(message)
|
||||
session.commit()
|
||||
|
||||
# Retrieve and verify
|
||||
retrieved = (
|
||||
session.query(ChatMessage)
|
||||
.filter_by(id="test-msg-nullable-1")
|
||||
.first()
|
||||
)
|
||||
assert retrieved is not None
|
||||
assert retrieved.research_id is None
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
|
||||
class TestChatModelCascade:
|
||||
"""Tests for cascade delete behavior between ChatSession and ChatMessage."""
|
||||
|
||||
def test_delete_session_cascades_to_messages(
|
||||
self, setup_database_for_all_tests
|
||||
):
|
||||
"""Test that deleting a session also deletes its messages."""
|
||||
from datetime import datetime, UTC
|
||||
from src.local_deep_research.database.models.chat import (
|
||||
ChatSession,
|
||||
ChatMessage,
|
||||
)
|
||||
|
||||
SessionLocal = setup_database_for_all_tests
|
||||
session = SessionLocal()
|
||||
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Create session
|
||||
chat_session = ChatSession(
|
||||
id="test-session-cascade-1",
|
||||
title="Test Session",
|
||||
created_at=now,
|
||||
)
|
||||
session.add(chat_session)
|
||||
session.commit()
|
||||
|
||||
# Create messages
|
||||
for i in range(3):
|
||||
message = ChatMessage(
|
||||
id=f"test-msg-cascade-{i}",
|
||||
session_id="test-session-cascade-1",
|
||||
role="user" if i % 2 == 0 else "assistant",
|
||||
content=f"Message {i}",
|
||||
message_type="query" if i % 2 == 0 else "response",
|
||||
sequence_number=i + 1,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(message)
|
||||
session.commit()
|
||||
|
||||
# Verify messages exist
|
||||
messages_before = (
|
||||
session.query(ChatMessage)
|
||||
.filter_by(session_id="test-session-cascade-1")
|
||||
.all()
|
||||
)
|
||||
assert len(messages_before) == 3
|
||||
|
||||
# Delete session
|
||||
session.delete(chat_session)
|
||||
session.commit()
|
||||
|
||||
# Verify messages were also deleted
|
||||
messages_after = (
|
||||
session.query(ChatMessage)
|
||||
.filter_by(session_id="test-session-cascade-1")
|
||||
.all()
|
||||
)
|
||||
assert len(messages_after) == 0
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
def test_messages_ordered_by_sequence_number(
|
||||
self, setup_database_for_all_tests
|
||||
):
|
||||
"""Test that messages are properly ordered by sequence number."""
|
||||
from datetime import datetime, UTC
|
||||
from src.local_deep_research.database.models.chat import (
|
||||
ChatSession,
|
||||
ChatMessage,
|
||||
)
|
||||
|
||||
SessionLocal = setup_database_for_all_tests
|
||||
session = SessionLocal()
|
||||
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Create session
|
||||
chat_session = ChatSession(
|
||||
id="test-session-order-1",
|
||||
title="Test Session",
|
||||
created_at=now,
|
||||
)
|
||||
session.add(chat_session)
|
||||
session.commit()
|
||||
|
||||
# Create messages in random order
|
||||
sequence_numbers = [3, 1, 4, 2, 5]
|
||||
for seq in sequence_numbers:
|
||||
message = ChatMessage(
|
||||
id=f"test-msg-order-{seq}",
|
||||
session_id="test-session-order-1",
|
||||
role="user",
|
||||
content=f"Message {seq}",
|
||||
message_type="query",
|
||||
sequence_number=seq,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(message)
|
||||
session.commit()
|
||||
|
||||
# Query with order_by
|
||||
messages = (
|
||||
session.query(ChatMessage)
|
||||
.filter_by(session_id="test-session-order-1")
|
||||
.order_by(ChatMessage.sequence_number)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Verify order
|
||||
for i, msg in enumerate(messages):
|
||||
assert msg.sequence_number == i + 1
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
|
||||
class TestChatSessionStatus:
|
||||
"""Tests for chat session status management."""
|
||||
|
||||
def test_session_can_be_archived(self, setup_database_for_all_tests):
|
||||
"""Test that a session status can be changed to 'archived'."""
|
||||
from datetime import datetime, UTC
|
||||
from src.local_deep_research.database.models.chat import ChatSession
|
||||
|
||||
SessionLocal = setup_database_for_all_tests
|
||||
session = SessionLocal()
|
||||
|
||||
try:
|
||||
chat_session = ChatSession(
|
||||
id="test-session-archive-1",
|
||||
title="Test Session",
|
||||
status="active",
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(chat_session)
|
||||
session.commit()
|
||||
|
||||
# Update status
|
||||
chat_session.status = "archived"
|
||||
session.commit()
|
||||
|
||||
retrieved = (
|
||||
session.query(ChatSession)
|
||||
.filter_by(id="test-session-archive-1")
|
||||
.first()
|
||||
)
|
||||
assert retrieved.status == "archived"
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
|
||||
class TestChatMessageContentNotNull:
|
||||
"""Schema invariant: chat_messages.content is NOT NULL.
|
||||
|
||||
Negative tests ensuring the schema-level constraint is in place
|
||||
and the application-layer validator agrees with it.
|
||||
"""
|
||||
|
||||
def test_db_rejects_null_content(self, setup_database_for_all_tests):
|
||||
"""Inserting a chat_messages row with content=NULL must raise
|
||||
IntegrityError at commit time.
|
||||
|
||||
This pins the migration's ``content NOT NULL`` declaration.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, UTC
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from src.local_deep_research.database.models.chat import (
|
||||
ChatMessage,
|
||||
ChatSession,
|
||||
)
|
||||
|
||||
SessionLocal = setup_database_for_all_tests
|
||||
session = SessionLocal()
|
||||
try:
|
||||
# Parent session row
|
||||
parent = ChatSession(
|
||||
id="test-not-null-session",
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(parent)
|
||||
session.commit()
|
||||
|
||||
# Insert with content=None must fail at commit.
|
||||
msg = ChatMessage(
|
||||
id="test-not-null-msg",
|
||||
session_id="test-not-null-session",
|
||||
role="assistant",
|
||||
message_type="response",
|
||||
content=None,
|
||||
sequence_number=1,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(msg)
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
def test_service_rejects_none_content_via_value_error(self):
|
||||
"""ChatService.add_message validates content!=None before any DB
|
||||
write, raising ValueError so the route layer can surface a 400."""
|
||||
import pytest
|
||||
from src.local_deep_research.chat.service import ChatService
|
||||
|
||||
service = ChatService(username="not-real-for-validator-test")
|
||||
with pytest.raises(ValueError, match="content is required"):
|
||||
service.add_message(
|
||||
session_id="any",
|
||||
role="assistant",
|
||||
content=None,
|
||||
message_type="response",
|
||||
research_id="any",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB-level cascade / FK / uniqueness tests
|
||||
#
|
||||
# The shared `setup_database_for_all_tests` fixture in tests/conftest.py does
|
||||
# NOT enable PRAGMA foreign_keys=ON — SQLite defaults FKs OFF, so DB-level
|
||||
# `ondelete` rules silently no-op there. The tests below need real FK
|
||||
# enforcement, so they use a private `fk_enforced_engine` fixture that mirrors
|
||||
# the pattern at tests/database/test_research_strategy_fk_regression.py.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def fk_enforced_engine(tmp_path):
|
||||
"""Fresh-install SQLite engine with FK enforcement on for every connection."""
|
||||
from src.local_deep_research.database.models import Base
|
||||
|
||||
db_path = tmp_path / "fk_test.db"
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _enable_fk(dbapi_connection, _):
|
||||
dbapi_connection.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
Base.metadata.create_all(engine)
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _seed_session(conn, sid="s1"):
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO chat_sessions "
|
||||
"(id, status, message_count, created_at) "
|
||||
"VALUES (:id, 'active', 0, '2026-01-01T00:00:00')"
|
||||
),
|
||||
{"id": sid},
|
||||
)
|
||||
|
||||
|
||||
def _seed_research(conn, rid="r1"):
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO research_history "
|
||||
"(id, query, mode, status, created_at) "
|
||||
"VALUES (:id, 'q', 'quick', 'completed', "
|
||||
"'2026-01-01T00:00:00')"
|
||||
),
|
||||
{"id": rid},
|
||||
)
|
||||
|
||||
|
||||
def _insert_chat_message(
|
||||
conn, *, mid, sid, rid=None, seq=1, role="user", mtype="query"
|
||||
):
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO chat_messages "
|
||||
"(id, session_id, research_id, role, message_type, "
|
||||
" content, sequence_number, created_at) "
|
||||
"VALUES (:mid, :sid, :rid, :role, :mtype, 'x', :seq, "
|
||||
"'2026-01-01T00:00:00')"
|
||||
),
|
||||
{
|
||||
"mid": mid,
|
||||
"sid": sid,
|
||||
"rid": rid,
|
||||
"role": role,
|
||||
"mtype": mtype,
|
||||
"seq": seq,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _insert_progress_step(conn, *, pid, rid, sid, seq=1):
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO chat_progress_steps "
|
||||
"(id, research_id, session_id, content, "
|
||||
" sequence_number, created_at) "
|
||||
"VALUES (:pid, :rid, :sid, 'step content', :seq, "
|
||||
"'2026-01-01T00:00:00')"
|
||||
),
|
||||
{"pid": pid, "rid": rid, "sid": sid, "seq": seq},
|
||||
)
|
||||
|
||||
|
||||
class TestChatModelDBLevelCascade:
|
||||
"""Cascade rules enforced by the database (FK pragma on, raw SQL DELETE)."""
|
||||
|
||||
def test_research_delete_sets_chat_messages_research_id_to_null(
|
||||
self, fk_enforced_engine
|
||||
):
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
_seed_session(conn, sid="s1")
|
||||
_seed_research(conn, rid="r1")
|
||||
_insert_chat_message(conn, mid="m1", sid="s1", rid="r1")
|
||||
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
conn.execute(text("DELETE FROM research_history WHERE id='r1'"))
|
||||
|
||||
with fk_enforced_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT research_id FROM chat_messages WHERE id='m1'")
|
||||
).first()
|
||||
assert row is not None
|
||||
assert row.research_id is None
|
||||
|
||||
def test_session_delete_cascades_chat_progress_steps(
|
||||
self, fk_enforced_engine
|
||||
):
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
_seed_session(conn, sid="s1")
|
||||
_seed_research(conn, rid="r1")
|
||||
_insert_progress_step(conn, pid="p1", rid="r1", sid="s1")
|
||||
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
conn.execute(text("DELETE FROM chat_sessions WHERE id='s1'"))
|
||||
|
||||
with fk_enforced_engine.connect() as conn:
|
||||
count = conn.execute(
|
||||
text("SELECT COUNT(*) FROM chat_progress_steps")
|
||||
).scalar()
|
||||
assert count == 0
|
||||
|
||||
def test_session_delete_cascades_chat_messages(self, fk_enforced_engine):
|
||||
"""Migration 0009 declares ondelete=CASCADE on
|
||||
chat_messages.session_id. The ORM-level cascade is exercised by
|
||||
TestChatModelCascade::test_delete_session_cascades_to_messages,
|
||||
but until this test was added the DB-level CASCADE behaviour was
|
||||
only structurally defined, not verified — a migration regression
|
||||
that silently dropped the ondelete clause would not have failed
|
||||
any test. Drive the DELETE through raw SQL with the FK pragma on
|
||||
so we exercise the migration's CASCADE, not SQLAlchemy's.
|
||||
"""
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
_seed_session(conn, sid="s1")
|
||||
_insert_chat_message(conn, mid="m1", sid="s1", seq=1)
|
||||
_insert_chat_message(conn, mid="m2", sid="s1", seq=2)
|
||||
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
conn.execute(text("DELETE FROM chat_sessions WHERE id='s1'"))
|
||||
|
||||
with fk_enforced_engine.connect() as conn:
|
||||
count = conn.execute(
|
||||
text("SELECT COUNT(*) FROM chat_messages")
|
||||
).scalar()
|
||||
assert count == 0
|
||||
|
||||
def test_session_delete_sets_research_history_chat_session_id_null(
|
||||
self, fk_enforced_engine
|
||||
):
|
||||
"""Migration 0009 declares ondelete=SET NULL on
|
||||
research_history.chat_session_id so research artefacts survive
|
||||
a chat-session delete (just unlinked). Until this test was added,
|
||||
this behaviour was only structurally defined, not verified at the
|
||||
DB level — only the symmetric chat_messages.research_id SET
|
||||
NULL was covered. Seed a research row with a chat_session_id,
|
||||
delete the session, and assert the FK column was nulled rather
|
||||
than the row being cascade-deleted.
|
||||
"""
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
_seed_session(conn, sid="s1")
|
||||
# Seed a research row with chat_session_id pointing at s1.
|
||||
# The standard _seed_research helper does not set the FK,
|
||||
# so do it inline here.
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO research_history "
|
||||
"(id, query, mode, status, created_at, "
|
||||
" chat_session_id) "
|
||||
"VALUES (:id, 'q', 'quick', 'completed', "
|
||||
"'2026-01-01T00:00:00', :sid)"
|
||||
),
|
||||
{"id": "r1", "sid": "s1"},
|
||||
)
|
||||
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
conn.execute(text("DELETE FROM chat_sessions WHERE id='s1'"))
|
||||
|
||||
with fk_enforced_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, chat_session_id "
|
||||
"FROM research_history WHERE id='r1'"
|
||||
)
|
||||
).first()
|
||||
# Row must still exist — SET NULL preserves research artefacts.
|
||||
assert row is not None
|
||||
assert row.chat_session_id is None
|
||||
|
||||
|
||||
class TestChatModelDBLevelFKEnforcement:
|
||||
"""Negative tests against DB-level FK + unique constraints."""
|
||||
|
||||
def test_chat_messages_fk_session_id_enforced(self, fk_enforced_engine):
|
||||
# FK fires immediately at execute() under PRAGMA foreign_keys=ON, so
|
||||
# pytest.raises must wrap the engine.begin() block (otherwise its
|
||||
# __exit__ tries to commit an aborted DBAPI transaction).
|
||||
with pytest.raises(IntegrityError):
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
_insert_chat_message(conn, mid="m1", sid="nonexistent-session")
|
||||
|
||||
def test_chat_messages_unique_session_seq(self, fk_enforced_engine):
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
_seed_session(conn, sid="s1")
|
||||
_insert_chat_message(conn, mid="m1", sid="s1", seq=1)
|
||||
with pytest.raises(IntegrityError):
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
_insert_chat_message(conn, mid="m2", sid="s1", seq=1)
|
||||
|
||||
# The chat Enum columns deliberately omit a DB-level CHECK constraint
|
||||
# (matching the migration). Enum-value enforcement is
|
||||
# at the ORM/service layer (ChatRole(value) raises ValueError before
|
||||
# any INSERT). The tests below pin THAT contract — both the fact that
|
||||
# the raw INSERT succeeds (no CHECK) and that the service-layer guard
|
||||
# catches invalid values.
|
||||
|
||||
def test_chat_messages_role_no_db_check_constraint(
|
||||
self, fk_enforced_engine
|
||||
):
|
||||
"""Raw INSERT of an invalid `role` succeeds because the model has
|
||||
no `create_constraint=True` — same as the migration. Schema must
|
||||
agree between create_all (fresh installs) and the upgrade path."""
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
_seed_session(conn, sid="s1")
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO chat_messages "
|
||||
"(id, session_id, role, message_type, content, "
|
||||
" sequence_number, created_at) "
|
||||
"VALUES ('m1', 's1', 'BOGUS', 'query', 'x', 1, "
|
||||
"'2026-01-01T00:00:00')"
|
||||
)
|
||||
)
|
||||
|
||||
def test_chat_messages_message_type_no_db_check_constraint(
|
||||
self, fk_enforced_engine
|
||||
):
|
||||
"""Same as above for `message_type`."""
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
_seed_session(conn, sid="s1")
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO chat_messages "
|
||||
"(id, session_id, role, message_type, content, "
|
||||
" sequence_number, created_at) "
|
||||
"VALUES ('m1', 's1', 'user', 'BOGUS', 'x', 1, "
|
||||
"'2026-01-01T00:00:00')"
|
||||
)
|
||||
)
|
||||
|
||||
def test_chat_sessions_status_no_db_check_constraint(
|
||||
self, fk_enforced_engine
|
||||
):
|
||||
"""Same as above for `status`."""
|
||||
with fk_enforced_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO chat_sessions "
|
||||
"(id, status, message_count, created_at) "
|
||||
"VALUES ('s1', 'BOGUS', 0, '2026-01-01T00:00:00')"
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user