7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
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
705 lines
24 KiB
Python
705 lines
24 KiB
Python
"""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')"
|
|
)
|
|
)
|