Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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')"
)
)