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
400 lines
15 KiB
Python
400 lines
15 KiB
Python
"""Tests for migration 0010: Add chat tables.
|
|
|
|
Migration 0010 introduces the chat schema in its final clean shape:
|
|
(originally numbered 0009; renumbered to 0010 when main's
|
|
0009_default_fetch_mode_summary landed first.)
|
|
|
|
- chat_sessions with status as Enum (ChatSessionStatus)
|
|
- chat_messages with content NOT NULL, no CHECK
|
|
- chat_progress_steps as a separate table for transient research
|
|
progress events (no longer mixed into chat_messages)
|
|
- research_history.chat_session_id (FK SET NULL) + step_count
|
|
|
|
The migration is fresh-install only; legacy 0007-shape dev DBs must
|
|
be recreated (or the chat tables dropped manually) before running.
|
|
|
|
Tests cover:
|
|
- Fresh-install path: chat tables exist, content is NOT NULL, no
|
|
CHECK constraint, status is Enum-typed.
|
|
- Idempotency: re-running migrations on a head DB is a no-op.
|
|
- Downgrade is NotImplementedError.
|
|
"""
|
|
|
|
import pytest
|
|
from alembic import command
|
|
from sqlalchemy import create_engine, event, inspect
|
|
|
|
from sqlalchemy import text
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from local_deep_research.database.alembic_runner import (
|
|
get_alembic_config,
|
|
run_migrations,
|
|
stamp_database,
|
|
)
|
|
|
|
|
|
_PARTIAL_UNIQUE_INDEX_NAME = "ux_research_history_chat_session_in_progress"
|
|
|
|
|
|
def _run_downgrade_to(engine, revision):
|
|
config = get_alembic_config(engine)
|
|
with engine.begin() as conn:
|
|
config.attributes["connection"] = conn
|
|
command.downgrade(config, revision)
|
|
|
|
|
|
@pytest.fixture
|
|
def fresh_engine(tmp_path):
|
|
db_path = tmp_path / "fresh_0010.db"
|
|
engine = create_engine(f"sqlite:///{db_path}")
|
|
yield engine
|
|
engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
def fully_migrated_engine(tmp_path):
|
|
db_path = tmp_path / "fully_migrated_0010.db"
|
|
engine = create_engine(f"sqlite:///{db_path}")
|
|
run_migrations(engine)
|
|
yield engine
|
|
engine.dispose()
|
|
|
|
|
|
class TestSchemaShape:
|
|
"""Chat schema invariants are in place after 0010 runs."""
|
|
|
|
def test_chat_tables_exist(self, fully_migrated_engine):
|
|
insp = inspect(fully_migrated_engine)
|
|
for table in ("chat_sessions", "chat_messages", "chat_progress_steps"):
|
|
assert insp.has_table(table), f"{table} missing"
|
|
|
|
def test_chat_messages_content_is_not_null(self, fully_migrated_engine):
|
|
insp = inspect(fully_migrated_engine)
|
|
cols = {c["name"]: c for c in insp.get_columns("chat_messages")}
|
|
assert "content" in cols
|
|
assert cols["content"]["nullable"] is False
|
|
|
|
def test_chat_messages_has_no_legacy_check(self, fully_migrated_engine):
|
|
insp = inspect(fully_migrated_engine)
|
|
checks = insp.get_check_constraints("chat_messages")
|
|
names = {c.get("name") for c in checks}
|
|
assert "ck_chat_message_has_content_source" not in names
|
|
|
|
def test_chat_session_status_typed(self, fully_migrated_engine):
|
|
"""status is typed VARCHAR with default 'active'.
|
|
|
|
Note: SQLAlchemy's Enum on SQLite stores as VARCHAR sized to
|
|
the longest enum value but does NOT emit a DB-level CHECK
|
|
unless `create_constraint=True` is explicitly set. The
|
|
codebase relies on ORM-layer validation (ChatSessionStatus(value))
|
|
for value enforcement — same pattern as ChatRole/ChatMessageType.
|
|
"""
|
|
insp = inspect(fully_migrated_engine)
|
|
cols = {c["name"]: c for c in insp.get_columns("chat_sessions")}
|
|
assert "status" in cols
|
|
# VARCHAR sized to the longest enum value ('archived' = 8 chars)
|
|
type_str = str(cols["status"]["type"]).upper()
|
|
assert "VARCHAR" in type_str
|
|
|
|
def test_research_history_chat_session_id_present(
|
|
self, fully_migrated_engine
|
|
):
|
|
insp = inspect(fully_migrated_engine)
|
|
cols = {c["name"] for c in insp.get_columns("research_history")}
|
|
assert "chat_session_id" in cols
|
|
assert "step_count" in cols
|
|
|
|
def test_chat_progress_steps_unique_per_research_seq(
|
|
self, fully_migrated_engine
|
|
):
|
|
insp = inspect(fully_migrated_engine)
|
|
uniques = insp.get_unique_constraints("chat_progress_steps")
|
|
names = {u.get("name") for u in uniques}
|
|
assert "uq_chat_progress_step_research_seq" in names
|
|
|
|
def test_composite_indexes_exist_after_upgrade(self, fully_migrated_engine):
|
|
"""Composite (session_id, created_at) indexes serve the load-older
|
|
pagination query in chat/service.py::get_session_messages. Without
|
|
them, SQLite uses the single-column session_id index and sorts in
|
|
memory — break-even at ~500 rows/session.
|
|
"""
|
|
insp = inspect(fully_migrated_engine)
|
|
|
|
msg_idx = {
|
|
i["name"]: i["column_names"]
|
|
for i in insp.get_indexes("chat_messages")
|
|
}
|
|
assert "ix_chat_messages_session_created" in msg_idx
|
|
assert msg_idx["ix_chat_messages_session_created"] == [
|
|
"session_id",
|
|
"created_at",
|
|
]
|
|
|
|
step_idx = {
|
|
i["name"]: i["column_names"]
|
|
for i in insp.get_indexes("chat_progress_steps")
|
|
}
|
|
assert "ix_chat_progress_steps_session_created" in step_idx
|
|
assert step_idx["ix_chat_progress_steps_session_created"] == [
|
|
"session_id",
|
|
"created_at",
|
|
]
|
|
|
|
|
|
class TestIdempotency:
|
|
"""Re-running migrations on a head DB is a no-op."""
|
|
|
|
def test_double_migrate_no_error(self, fresh_engine):
|
|
run_migrations(fresh_engine)
|
|
# Second run must not raise.
|
|
run_migrations(fresh_engine)
|
|
insp = inspect(fresh_engine)
|
|
assert insp.has_table("chat_progress_steps")
|
|
|
|
|
|
class TestDowngrade:
|
|
"""Downgrade is not supported and raises NotImplementedError.
|
|
|
|
Why: SQLite ALTER TABLE forbids dropping a column that is the
|
|
target of a FOREIGN KEY definition, and alembic's batch_alter_table
|
|
cannot rebuild research_history due to unnamed legacy constraints
|
|
on that table. The project is dev-stage; recreate the DB to roll
|
|
back. The parametrized stairway/down-leaves-no-residual tests in
|
|
test_alembic_migrations.py exempt 0010 via NON_REVERSIBLE_REVISIONS.
|
|
"""
|
|
|
|
def test_downgrade_raises_not_implemented(self, fully_migrated_engine):
|
|
with pytest.raises(NotImplementedError):
|
|
_run_downgrade_to(fully_migrated_engine, "0008")
|
|
|
|
|
|
class TestExistingDataBackfill:
|
|
"""Verify 0010 leaves pre-existing research_history rows in a sane state.
|
|
|
|
Note: we cannot use `run_migrations(target="0008")` to land at the
|
|
pre-0010 state because 0001 uses `Base.metadata.create_all` against the
|
|
live `Base`, which already includes `chat_session_id` and `step_count`.
|
|
Instead we hand-build a minimal pre-0010 `research_history` and stamp
|
|
the DB at 0009 so 0010 forward runs the actual ADD COLUMN path.
|
|
"""
|
|
|
|
def test_step_count_backfilled_for_existing_rows(self, tmp_path):
|
|
engine = create_engine(f"sqlite:///{tmp_path}/m.db")
|
|
|
|
# Hand-build pre-0010 research_history (subset of NOT NULL cols).
|
|
with engine.begin() as conn:
|
|
conn.execute(
|
|
text(
|
|
"CREATE TABLE research_history ("
|
|
" id TEXT PRIMARY KEY, "
|
|
" query TEXT NOT NULL, "
|
|
" mode TEXT NOT NULL, "
|
|
" status TEXT NOT NULL, "
|
|
" created_at TEXT NOT NULL"
|
|
")"
|
|
)
|
|
)
|
|
conn.execute(
|
|
text(
|
|
"INSERT INTO research_history (id, query, mode, status, "
|
|
"created_at) VALUES ('r1', 'q', 'quick', 'completed', "
|
|
"'2026-01-01T00:00:00')"
|
|
)
|
|
)
|
|
|
|
# Stamp at 0009 (main's settings-only fetch_mode migration) so
|
|
# 0010 (chat tables) is the next forward step we exercise. 0010
|
|
# is the migration that actually ADD COLUMNs onto our hand-built
|
|
# research_history shape.
|
|
stamp_database(engine, "0009")
|
|
run_migrations(engine, target="head")
|
|
|
|
with engine.connect() as conn:
|
|
row = conn.execute(
|
|
text(
|
|
"SELECT step_count, chat_session_id "
|
|
"FROM research_history WHERE id = 'r1'"
|
|
)
|
|
).first()
|
|
assert row is not None
|
|
# Relies on 0010 using server_default="0" (SQL-side, applied by
|
|
# SQLite ADD COLUMN at DDL time). If a future refactor switches
|
|
# to Python-side default=0, this assertion fails for the pre-
|
|
# existing 'r1' row — exactly the regression we want to catch.
|
|
assert row.step_count == 0
|
|
assert row.chat_session_id is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Partial unique index: at-most-one-in-progress per chat_session_id
|
|
#
|
|
# Originally lived in a separate migration 0011 with a separate test file;
|
|
# folded into 0010 to keep the chat schema landing in a single migration
|
|
# (chat is unreleased; one migration is easier to maintain than two).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def fully_migrated_engine_with_fk(tmp_path):
|
|
"""Fully migrated SQLite engine with FK enforcement on every connection.
|
|
|
|
Required for the partial-unique-index tests because they depend on
|
|
SQLite enforcing the constraint at INSERT time, which only happens
|
|
when ``PRAGMA foreign_keys = ON`` is active.
|
|
"""
|
|
db_path = tmp_path / "0010_partial_unique_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")
|
|
|
|
run_migrations(engine, target="head")
|
|
yield engine
|
|
engine.dispose()
|
|
|
|
|
|
def _seed_chat_session(conn, sid):
|
|
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 _insert_research(conn, *, rid, sid, status):
|
|
conn.execute(
|
|
text(
|
|
"INSERT INTO research_history "
|
|
"(id, query, mode, status, created_at, chat_session_id) "
|
|
"VALUES (:rid, 'q', 'quick', :status, "
|
|
"'2026-01-01T00:00:00', :sid)"
|
|
),
|
|
{"rid": rid, "sid": sid, "status": status},
|
|
)
|
|
|
|
|
|
class TestPartialUniqueInProgressIndex:
|
|
"""The partial unique index closes a SELECT-then-INSERT race in
|
|
chat/routes.py. Verify the constraint actually fires at the DB."""
|
|
|
|
def test_partial_unique_index_exists_after_upgrade(
|
|
self, fully_migrated_engine_with_fk
|
|
):
|
|
inspector = inspect(fully_migrated_engine_with_fk)
|
|
indexes = {
|
|
idx["name"]: idx
|
|
for idx in inspector.get_indexes("research_history")
|
|
}
|
|
assert _PARTIAL_UNIQUE_INDEX_NAME in indexes
|
|
idx = indexes[_PARTIAL_UNIQUE_INDEX_NAME]
|
|
# SQLAlchemy's SQLite inspector returns 1 / 0 rather than True /
|
|
# False for the unique flag, so compare on truthiness.
|
|
assert bool(idx["unique"])
|
|
assert idx["column_names"] == ["chat_session_id"]
|
|
|
|
def test_second_in_progress_for_same_chat_session_blocked(
|
|
self, fully_migrated_engine_with_fk
|
|
):
|
|
engine = fully_migrated_engine_with_fk
|
|
with engine.begin() as conn:
|
|
_seed_chat_session(conn, "s1")
|
|
_insert_research(conn, rid="r1", sid="s1", status="in_progress")
|
|
|
|
with engine.connect() as conn:
|
|
with pytest.raises(IntegrityError):
|
|
with conn.begin():
|
|
_insert_research(
|
|
conn, rid="r2", sid="s1", status="in_progress"
|
|
)
|
|
|
|
def test_completed_runs_for_same_chat_session_allowed(
|
|
self, fully_migrated_engine_with_fk
|
|
):
|
|
"""Partial: only in_progress rows are unique; completed history
|
|
of arbitrarily many runs per chat session must remain allowed."""
|
|
engine = fully_migrated_engine_with_fk
|
|
with engine.begin() as conn:
|
|
_seed_chat_session(conn, "s1")
|
|
_insert_research(conn, rid="r1", sid="s1", status="completed")
|
|
_insert_research(conn, rid="r2", sid="s1", status="completed")
|
|
_insert_research(conn, rid="r3", sid="s1", status="failed")
|
|
|
|
with engine.connect() as conn:
|
|
count = conn.execute(
|
|
text(
|
|
"SELECT COUNT(*) FROM research_history "
|
|
"WHERE chat_session_id='s1'"
|
|
)
|
|
).scalar()
|
|
assert count == 3
|
|
|
|
def test_in_progress_with_null_chat_session_id_unconstrained(
|
|
self, fully_migrated_engine_with_fk
|
|
):
|
|
"""Partial: NULL chat_session_id rows must be unconstrained so
|
|
non-chat research (news, scheduler, direct API) is unaffected."""
|
|
engine = fully_migrated_engine_with_fk
|
|
with engine.begin() as conn:
|
|
conn.execute(
|
|
text(
|
|
"INSERT INTO research_history "
|
|
"(id, query, mode, status, created_at) "
|
|
"VALUES (:rid, 'q', 'quick', 'in_progress', "
|
|
"'2026-01-01T00:00:00')"
|
|
),
|
|
{"rid": "r1"},
|
|
)
|
|
conn.execute(
|
|
text(
|
|
"INSERT INTO research_history "
|
|
"(id, query, mode, status, created_at) "
|
|
"VALUES (:rid, 'q', 'quick', 'in_progress', "
|
|
"'2026-01-01T00:00:00')"
|
|
),
|
|
{"rid": "r2"},
|
|
)
|
|
|
|
with engine.connect() as conn:
|
|
count = conn.execute(
|
|
text(
|
|
"SELECT COUNT(*) FROM research_history "
|
|
"WHERE chat_session_id IS NULL"
|
|
)
|
|
).scalar()
|
|
assert count == 2
|
|
|
|
def test_completing_a_run_releases_the_in_progress_slot(
|
|
self, fully_migrated_engine_with_fk
|
|
):
|
|
"""After r1 transitions away from in_progress, r2 must be able
|
|
to claim the slot."""
|
|
engine = fully_migrated_engine_with_fk
|
|
with engine.begin() as conn:
|
|
_seed_chat_session(conn, "s1")
|
|
_insert_research(conn, rid="r1", sid="s1", status="in_progress")
|
|
|
|
with engine.begin() as conn:
|
|
conn.execute(
|
|
text(
|
|
"UPDATE research_history "
|
|
"SET status='completed' WHERE id='r1'"
|
|
)
|
|
)
|
|
|
|
with engine.begin() as conn:
|
|
_insert_research(conn, rid="r2", sid="s1", status="in_progress")
|
|
|
|
with engine.connect() as conn:
|
|
count = conn.execute(
|
|
text(
|
|
"SELECT COUNT(*) FROM research_history "
|
|
"WHERE chat_session_id='s1' AND status='in_progress'"
|
|
)
|
|
).scalar()
|
|
assert count == 1
|