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

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