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

365 lines
14 KiB
Python

"""API contract tests for the report-content display routes.
Locks the public shape of report content so a future change can't silently
regress it (#3665):
* ``content`` is the *assembled* legacy view — answer + ``## Sources`` (from
the ``research_resources`` table) + ``## Research Metrics`` (from
``research_meta``) — across every display/export route.
* ``GET /api/report/<id>`` exposes a ``sources`` field populated from
``research_resources``. Before the fix it read the dead
``all_links_of_system`` metadata key, which the post-refactor save path
never writes, so it returned ``[]`` for every new research (Fix A).
These exercise the *real* assembly path (``assemble_full_report`` /
``get_research_source_links_batch``) against real seeded rows; only auth and
the per-user-DB plumbing are stubbed.
"""
from contextlib import contextmanager
from datetime import datetime, UTC
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import Base
from local_deep_research.database.models.chat import (
ChatMessage,
ChatMessageType,
ChatRole,
ChatSession,
)
from local_deep_research.database.models.research import (
ResearchHistory,
ResearchResource,
)
RESEARCH_ID = "contract-research-1"
# A second research with zero research_resources rows: the assembled view
# must omit the ``## Sources`` block entirely rather than emit an empty one
# (item 7 regression fence).
RESEARCH_ID_NO_SOURCES = "contract-research-nosrc"
# A chat session whose assistant message is the answer-only report_content —
# chat is the ONE consumer that must NOT receive the assembled view (item 8,
# architecture invariant of the chat-mode-v2 refactor).
CHAT_SESSION_ID = "contract-chat-1"
ANSWER_ONLY = (
"X is explained here, citing [1](https://src1.example) "
"and [2](https://src2.example)."
)
# Deliberately > the batch helper's default top-N (3): the report API must
# return *every* source (no cap), so a reintroduced default limit would fail
# test_api_report_sources_field_matches_research_resources_count.
N_SOURCES = 5
@pytest.fixture(autouse=True)
def mock_db_manager():
"""Satisfy the auth middleware/decorators without a real encrypted DB.
Mirrors tests/test_followup_api.py: every module-level ``db_manager``
binding used by the Flask before_request middleware and ``login_required``
is mocked so an authenticated session passes. ``has_encryption=False``
skips the encrypted-DB password check.
"""
with (
patch("local_deep_research.web.auth.decorators.db_manager") as mock_db,
patch("local_deep_research.database.encrypted_db.db_manager"),
patch("local_deep_research.web.auth.database_middleware.db_manager"),
patch("local_deep_research.web.auth.session_cleanup.db_manager"),
patch("local_deep_research.web.auth.queue_middleware.db_manager"),
):
mock_db.connections = {"testuser": MagicMock()}
mock_db.has_encryption = False
mock_db.is_user_connected.return_value = True
yield mock_db
@pytest.fixture
def mock_auth_client(app):
"""Test client authenticated via a mock session.
Uses the shared ``app`` fixture (tests/conftest.py) but authenticates by
seeding the session directly rather than a real register/login, because
auth is stubbed via ``mock_db_manager`` and the per-user DB is patched to
a seeded SQLite file (``seeded_db``). The real ``authenticated_client``
fixture would create a real encrypted DB we couldn't seed sources into.
"""
client = app.test_client()
with client.session_transaction() as sess:
sess["username"] = "testuser"
sess["authenticated"] = True
return client
@pytest.fixture
def seeded_db(tmp_path):
"""Real temp-file SQLite seeded with one completed research + sources.
Patches ``get_user_db_session`` in both route modules to yield a fresh
session bound to the seeded DB (a fresh session per request, like the
real per-request session). A file DB (not ``:memory:``) so every
``SessionLocal()`` connection sees the same seeded rows.
"""
engine = create_engine(f"sqlite:///{tmp_path / 'report_contract.db'}")
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine)
seed = SessionLocal()
seed.add(
ResearchHistory(
id=RESEARCH_ID,
query="What is X?",
mode="quick",
status="completed",
created_at="2026-04-25T12:00:00+00:00",
completed_at="2026-04-25T12:05:00+00:00",
report_content=(
"X is explained here, citing [1](https://src1.example) "
"and [2](https://src2.example)."
),
research_meta={
"iterations": 2,
"generated_at": "2026-04-25T12:05:00+00:00",
# settings_snapshot is persisted in research_meta but must never
# be returned by an API response (it holds API keys / tokens) —
# see test_history_report_strips_settings_snapshot.
"settings_snapshot": {
"llm.openai.api_key": "sk-SECRET-LEAKED-KEY",
},
},
)
)
for i in range(1, N_SOURCES + 1):
url = f"https://src{i}.example"
seed.add(
ResearchResource(
research_id=RESEARCH_ID,
title=f"Source {i}",
url=url,
source_type="web",
resource_metadata={
"original_data": {
"index": str(i),
"url": url,
"title": f"Source {i}",
}
},
created_at=datetime.now(UTC).isoformat(),
)
)
# A completed research with NO research_resources rows (item 7).
seed.add(
ResearchHistory(
id=RESEARCH_ID_NO_SOURCES,
query="What is Y?",
mode="quick",
status="completed",
created_at="2026-04-25T13:00:00+00:00",
completed_at="2026-04-25T13:05:00+00:00",
report_content="Y has no catalogued sources but still has an answer.",
research_meta={
"iterations": 1,
"generated_at": "2026-04-25T13:05:00+00:00",
},
)
)
# A chat session with one assistant response message whose content is the
# answer-only report_content — never the assembled view (item 8).
seed.add(
ChatSession(
id=CHAT_SESSION_ID,
title="Contract chat",
)
)
seed.add(
ChatMessage(
id="contract-chat-msg-1",
session_id=CHAT_SESSION_ID,
research_id=RESEARCH_ID,
role=ChatRole.ASSISTANT,
message_type=ChatMessageType.RESPONSE,
content=ANSWER_ONLY,
sequence_number=1,
)
)
seed.commit()
seed.close()
@contextmanager
def _fake_user_db(username=None, password=None):
db = SessionLocal()
try:
yield db
finally:
db.close()
with (
patch(
"local_deep_research.web.routes.research_routes.get_user_db_session",
_fake_user_db,
),
patch(
"local_deep_research.web.routes.history_routes.get_user_db_session",
_fake_user_db,
),
# ChatService resolves its session through the chat.service binding,
# so the chat-route test (item 8) needs the same seeded DB.
patch(
"local_deep_research.chat.service.get_user_db_session",
_fake_user_db,
),
):
yield
engine.dispose()
# ---------------------------------------------------------------------------
# Assembled-view shape across display routes (locks the legacy report shape)
# ---------------------------------------------------------------------------
def test_history_report_includes_sources_and_metrics(
mock_auth_client, seeded_db
):
resp = mock_auth_client.get(f"/history/report/{RESEARCH_ID}")
assert resp.status_code == 200, resp.data
content = resp.get_json()["content"]
assert "## Sources\n" in content
assert "## Research Metrics\n" in content
# Sources block references the structured resources.
assert "src1.example" in content
def test_history_report_strips_settings_snapshot(mock_auth_client, seeded_db):
"""GET /history/report/<id> must NOT return settings_snapshot (API keys,
tokens, base URLs) in its metadata — it is stripped like the sibling
/details and /api/research/<id> routes (sensitive-data exposure)."""
resp = mock_auth_client.get(f"/history/report/{RESEARCH_ID}")
assert resp.status_code == 200, resp.data
body = resp.get_json()
assert "settings_snapshot" not in body["metadata"]
# Belt-and-suspenders: the secret value appears nowhere in the response.
assert "sk-SECRET-LEAKED-KEY" not in resp.get_data(as_text=True)
# Non-sensitive metadata fields are still preserved.
assert body["metadata"].get("iterations") == 2
def test_history_markdown_includes_sources_and_metrics(
mock_auth_client, seeded_db
):
resp = mock_auth_client.get(f"/history/markdown/{RESEARCH_ID}")
assert resp.status_code == 200, resp.data
content = resp.get_json()["content"]
assert "## Sources\n" in content
assert "## Research Metrics\n" in content
def test_api_report_content_includes_sources_and_metrics(
mock_auth_client, seeded_db
):
resp = mock_auth_client.get(f"/api/report/{RESEARCH_ID}")
assert resp.status_code == 200, resp.data
content = resp.get_json()["content"]
assert "## Sources\n" in content
assert "## Research Metrics\n" in content
def test_export_latex_content_includes_sources_and_metrics(
mock_auth_client, seeded_db
):
# LaTeX is the registered plain-text export format (markdown is served by
# /history/markdown; quarto is a .zip; pdf/odt are binary). LaTeX
# conversion drops the markdown `##` prefix but keeps the header words and
# body text. "Research Metrics" / "Search Iterations" exist ONLY in the
# assembled view, never in the answer-only report_content, so finding them
# proves the export path goes through assemble_full_report.
resp = mock_auth_client.post(f"/api/v1/research/{RESEARCH_ID}/export/latex")
assert resp.status_code == 200, resp.data
body = resp.data.decode("utf-8")
assert "Research Metrics" in body
assert "Search Iterations" in body
assert "Sources" in body
assert "src1.example" in body
# ---------------------------------------------------------------------------
# Fix A regression fence: /api/report sources field from research_resources
# ---------------------------------------------------------------------------
def test_api_report_sources_field_populated_from_research_resources(
mock_auth_client, seeded_db
):
resp = mock_auth_client.get(f"/api/report/{RESEARCH_ID}")
assert resp.status_code == 200, resp.data
sources = resp.get_json()["sources"]
assert isinstance(sources, list)
assert sources, "sources must not be empty (Fix A regression)"
for entry in sources:
assert entry.get("url")
assert entry.get("title")
def test_api_report_sources_field_matches_research_resources_count(
mock_auth_client, seeded_db
):
resp = mock_auth_client.get(f"/api/report/{RESEARCH_ID}")
assert resp.status_code == 200, resp.data
sources = resp.get_json()["sources"]
assert len(sources) == N_SOURCES
# ---------------------------------------------------------------------------
# Graceful empty-sources shape (item 7)
# ---------------------------------------------------------------------------
def test_response_with_no_sources_omits_sources_block_gracefully(
mock_auth_client, seeded_db
):
# A research with zero research_resources rows must still render a valid
# report — answer (+ metrics) — with NO empty ``## Sources`` section, and
# an empty ``sources`` list rather than an error.
resp = mock_auth_client.get(f"/api/report/{RESEARCH_ID_NO_SOURCES}")
assert resp.status_code == 200, resp.data
payload = resp.get_json()
content = payload["content"]
assert content, "content must be non-empty even with no sources"
assert "no catalogued sources" in content
assert "## Sources\n" not in content
assert payload["sources"] == []
# ---------------------------------------------------------------------------
# Chat is the one answer-only consumer (item 8) — architecture invariant
# ---------------------------------------------------------------------------
def test_chat_message_response_returns_answer_only(mock_auth_client, seeded_db):
# The chat messages endpoint must return the stored ``report_content``
# verbatim (answer-only). If a future change routed it through
# assemble_full_report, a ``## Sources`` / ``## Research Metrics`` block
# would leak in — this fences that off.
resp = mock_auth_client.get(
f"/api/chat/sessions/{CHAT_SESSION_ID}/messages"
)
assert resp.status_code == 200, resp.data
payload = resp.get_json()
assert payload["success"] is True
responses = [
m
for m in payload["messages"]
if m.get("message_type") == ChatMessageType.RESPONSE.value
]
assert len(responses) == 1, payload["messages"]
content = responses[0]["content"]
assert content == ANSWER_ONLY
assert "## Sources\n" not in content
assert "## Research Metrics\n" not in content