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
426 lines
16 KiB
Python
426 lines
16 KiB
Python
"""Tests for context overflow API logic paths not covered by existing tests.
|
|
|
|
Focus: pagination clamping, empty/null states, chart data token formula.
|
|
Production code: src/local_deep_research/web/routes/context_overflow_api.py
|
|
|
|
Uses Flask test client approach from test_context_overflow_api_http.py.
|
|
"""
|
|
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import Mock, patch
|
|
|
|
from flask import Flask, jsonify
|
|
|
|
from local_deep_research.web.auth.routes import auth_bp
|
|
from local_deep_research.web.routes.context_overflow_api import (
|
|
context_overflow_bp,
|
|
)
|
|
|
|
_ROUTES_MOD = "local_deep_research.web.routes.context_overflow_api"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test Infrastructure
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _create_test_app():
|
|
app = Flask(__name__)
|
|
app.config["SECRET_KEY"] = "test-secret"
|
|
app.config["WTF_CSRF_ENABLED"] = False
|
|
app.register_blueprint(auth_bp)
|
|
app.register_blueprint(context_overflow_bp)
|
|
|
|
@app.errorhandler(500)
|
|
def _handle_500(error):
|
|
return jsonify({"error": "Internal server error"}), 500
|
|
|
|
return app
|
|
|
|
|
|
@contextmanager
|
|
def _authenticated_client(app):
|
|
"""Provide test client with mocked auth and DB session."""
|
|
mock_db = Mock()
|
|
mock_db.connections = {"testuser": True}
|
|
mock_db.has_encryption = False
|
|
|
|
_mock_query = Mock()
|
|
_mock_query.all.return_value = []
|
|
_mock_query.first.return_value = None
|
|
_mock_query.count.return_value = 0
|
|
_mock_query.scalar.return_value = 0
|
|
_mock_query.filter_by.return_value = _mock_query
|
|
_mock_query.filter.return_value = _mock_query
|
|
_mock_query.order_by.return_value = _mock_query
|
|
_mock_query.limit.return_value = _mock_query
|
|
_mock_query.offset.return_value = _mock_query
|
|
_mock_query.group_by.return_value = _mock_query
|
|
_mock_query.with_entities.return_value = _mock_query
|
|
|
|
_mock_db_session = Mock()
|
|
_mock_db_session.query.return_value = _mock_query
|
|
|
|
@contextmanager
|
|
def _fake_session(*args, **kwargs):
|
|
yield _mock_db_session
|
|
|
|
patches = [
|
|
patch("local_deep_research.web.auth.decorators.db_manager", mock_db),
|
|
patch(f"{_ROUTES_MOD}.get_user_db_session", side_effect=_fake_session),
|
|
patch(
|
|
f"{_ROUTES_MOD}.SettingsManager",
|
|
return_value=Mock(get_setting=Mock(return_value=8192)),
|
|
),
|
|
]
|
|
|
|
try:
|
|
for p in patches:
|
|
p.start()
|
|
with app.test_client() as client:
|
|
with client.session_transaction() as sess:
|
|
sess["username"] = "testuser"
|
|
sess["session_id"] = "test-session-id"
|
|
yield client, _mock_db_session, _mock_query
|
|
finally:
|
|
for p in reversed(patches):
|
|
p.stop()
|
|
|
|
|
|
def _make_overview_and_token_rows(
|
|
total_requests=0,
|
|
requests_with_context=0,
|
|
truncated_requests=0,
|
|
):
|
|
"""Create the merged overview row (counts + AVG + token-summary fields).
|
|
|
|
The route now issues a single .first() call. The legacy two-row return
|
|
shape is kept so existing call sites don't change — `tk` is the same
|
|
object as `ov`, since fields are merged onto a single mock.
|
|
"""
|
|
overview_row = Mock(
|
|
total_requests=total_requests,
|
|
requests_with_context=requests_with_context,
|
|
truncated_requests=truncated_requests,
|
|
avg_tokens_truncated=0,
|
|
total_tokens=0,
|
|
total_prompt_tokens=0,
|
|
total_completion_tokens=0,
|
|
avg_prompt_tokens=0,
|
|
avg_completion_tokens=0,
|
|
max_prompt_tokens=0,
|
|
)
|
|
return overview_row, overview_row
|
|
|
|
|
|
def _setup_multi_first(mock_query, overview_row, token_row):
|
|
"""Wire mock_query.first() to return the (merged) overview row."""
|
|
mock_query.first.return_value = overview_row
|
|
mock_query.scalar.return_value = 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pagination logic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPaginationLogic:
|
|
"""Tests for pagination parameter clamping."""
|
|
|
|
def test_per_page_clamped_to_500(self):
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
ov, tk = _make_overview_and_token_rows()
|
|
_setup_multi_first(mock_query, ov, tk)
|
|
|
|
resp = client.get("/api/context-overflow?per_page=1000")
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["pagination"]["per_page"] == 500
|
|
|
|
def test_per_page_zero_clamped_to_1(self):
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
ov, tk = _make_overview_and_token_rows()
|
|
_setup_multi_first(mock_query, ov, tk)
|
|
|
|
resp = client.get("/api/context-overflow?per_page=0")
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["pagination"]["per_page"] == 1
|
|
|
|
def test_per_page_negative_clamped_to_1(self):
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
ov, tk = _make_overview_and_token_rows()
|
|
_setup_multi_first(mock_query, ov, tk)
|
|
|
|
resp = client.get("/api/context-overflow?per_page=-5")
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["pagination"]["per_page"] == 1
|
|
|
|
def test_page_zero_clamped_to_1(self):
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
ov, tk = _make_overview_and_token_rows()
|
|
_setup_multi_first(mock_query, ov, tk)
|
|
|
|
resp = client.get("/api/context-overflow?page=0")
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["pagination"]["page"] == 1
|
|
|
|
def test_page_2_with_items(self):
|
|
"""page=2, per_page=10 with 15 total → correct pagination metadata."""
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
ov, tk = _make_overview_and_token_rows(total_requests=15)
|
|
_setup_multi_first(mock_query, ov, tk)
|
|
mock_query.count.return_value = 15
|
|
|
|
resp = client.get("/api/context-overflow?page=2&per_page=10")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["pagination"]["page"] == 2
|
|
assert data["pagination"]["per_page"] == 10
|
|
assert data["pagination"]["total_count"] == 15
|
|
assert data["pagination"]["total_pages"] == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Empty/null state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEmptyNullStates:
|
|
"""Tests for zero/null edge cases."""
|
|
|
|
def test_zero_records_overview_all_zeros(self):
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
ov, tk = _make_overview_and_token_rows()
|
|
_setup_multi_first(mock_query, ov, tk)
|
|
|
|
resp = client.get("/api/context-overflow")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["overview"]["total_requests"] == 0
|
|
assert data["overview"]["truncation_rate"] == 0
|
|
assert data["chart_data"] == []
|
|
|
|
def test_requests_with_context_zero_truncation_rate_zero(self):
|
|
"""requests_with_context=0 → truncation_rate=0 (not NaN)."""
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
ov, tk = _make_overview_and_token_rows(
|
|
total_requests=5, requests_with_context=0, truncated_requests=0
|
|
)
|
|
_setup_multi_first(mock_query, ov, tk)
|
|
|
|
resp = client.get("/api/context-overflow")
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["overview"]["truncation_rate"] == 0
|
|
|
|
def test_avg_tokens_truncated_none_becomes_zero(self):
|
|
"""scalar() returns None → avg_tokens_truncated=0."""
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
ov, tk = _make_overview_and_token_rows()
|
|
_setup_multi_first(mock_query, ov, tk)
|
|
# scalar for avg_tokens_truncated returns None
|
|
mock_query.scalar.return_value = None
|
|
|
|
resp = client.get("/api/context-overflow")
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["overview"]["avg_tokens_truncated"] == 0
|
|
|
|
def test_nonexistent_research_id_empty_response(self):
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
mock_query.all.return_value = []
|
|
|
|
resp = client.get("/api/research/nonexistent-id/context-overflow")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["status"] == "success"
|
|
assert data["data"]["overview"]["total_requests"] == 0
|
|
|
|
def test_all_context_truncated_false(self):
|
|
"""All records have context_truncated=False → truncated_requests=0."""
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
ov, tk = _make_overview_and_token_rows(
|
|
total_requests=3, requests_with_context=3, truncated_requests=0
|
|
)
|
|
_setup_multi_first(mock_query, ov, tk)
|
|
|
|
resp = client.get("/api/context-overflow")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["overview"]["truncated_requests"] == 0
|
|
assert data["overview"]["truncation_rate"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chart data token formula
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_usage_mock(
|
|
prompt_tokens=100,
|
|
completion_tokens=20,
|
|
ollama_prompt_eval_count=None,
|
|
context_truncated=False,
|
|
tokens_truncated=0,
|
|
context_limit=4096,
|
|
research_id="res-1",
|
|
model_name="gpt-4",
|
|
model_provider="openai",
|
|
research_query="test",
|
|
research_phase="search",
|
|
total_tokens=120,
|
|
truncation_ratio=None,
|
|
ollama_eval_count=None,
|
|
calling_function="search",
|
|
response_time_ms=500,
|
|
):
|
|
"""Create a mock TokenUsage record."""
|
|
m = Mock()
|
|
m.prompt_tokens = prompt_tokens
|
|
m.completion_tokens = completion_tokens
|
|
m.ollama_prompt_eval_count = ollama_prompt_eval_count
|
|
m.context_truncated = context_truncated
|
|
m.tokens_truncated = tokens_truncated
|
|
m.context_limit = context_limit
|
|
m.research_id = research_id
|
|
m.model_name = model_name
|
|
m.model_provider = model_provider
|
|
m.research_query = research_query
|
|
m.research_phase = research_phase
|
|
m.total_tokens = total_tokens
|
|
m.truncation_ratio = truncation_ratio
|
|
m.calling_function = calling_function
|
|
m.response_time_ms = response_time_ms
|
|
m.timestamp = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
|
return m
|
|
|
|
|
|
def _setup_chart_data_mocks(mock_query, ov, tk, usage_list):
|
|
"""Set up mocks for chart_data tests.
|
|
|
|
The production code calls .all() 7 times in order:
|
|
1. model_token_query 2. phase_query 3. context_limits
|
|
4. recent_truncated 5. time_series_data 6. model_stats 7. all_requests_data
|
|
We put usage_list at position 5 (time_series) and 7 (all_requests), empty elsewhere.
|
|
"""
|
|
_setup_multi_first(mock_query, ov, tk)
|
|
mock_query.all.side_effect = [
|
|
[], # model_token_query
|
|
[], # phase_query
|
|
[], # context_limits
|
|
[], # recent_truncated
|
|
usage_list, # time_series_data → chart_data
|
|
[], # model_stats
|
|
usage_list, # all_requests_data
|
|
]
|
|
mock_query.count.return_value = len(usage_list)
|
|
|
|
|
|
class TestChartDataTokenFormula:
|
|
"""Tests for chart_data token calculation logic."""
|
|
|
|
def test_ollama_prompt_eval_count_used_when_present(self):
|
|
"""ollama_prompt_eval_count present → used instead of prompt_tokens."""
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
usage = _make_usage_mock(
|
|
prompt_tokens=100,
|
|
ollama_prompt_eval_count=80,
|
|
context_truncated=False,
|
|
)
|
|
ov, tk = _make_overview_and_token_rows(
|
|
total_requests=1, requests_with_context=1
|
|
)
|
|
_setup_chart_data_mocks(mock_query, ov, tk, [usage])
|
|
|
|
resp = client.get("/api/context-overflow")
|
|
assert resp.status_code == 200
|
|
chart = resp.get_json()["chart_data"]
|
|
assert len(chart) == 1
|
|
assert chart[0]["ollama_prompt_tokens"] == 80
|
|
# original = 80 (ollama used), no truncation
|
|
assert chart[0]["original_prompt_tokens"] == 80
|
|
|
|
def test_ollama_none_falls_back_to_prompt_tokens(self):
|
|
"""ollama_prompt_eval_count=None → falls back to prompt_tokens."""
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
usage = _make_usage_mock(
|
|
prompt_tokens=100,
|
|
ollama_prompt_eval_count=None,
|
|
context_truncated=False,
|
|
)
|
|
ov, tk = _make_overview_and_token_rows(total_requests=1)
|
|
_setup_chart_data_mocks(mock_query, ov, tk, [usage])
|
|
|
|
resp = client.get("/api/context-overflow")
|
|
assert resp.status_code == 200
|
|
chart = resp.get_json()["chart_data"]
|
|
assert chart[0]["original_prompt_tokens"] == 100
|
|
|
|
def test_truncated_adds_tokens_truncated(self):
|
|
"""context_truncated=True → original = prompt + tokens_truncated."""
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
usage = _make_usage_mock(
|
|
prompt_tokens=100,
|
|
ollama_prompt_eval_count=None,
|
|
context_truncated=True,
|
|
tokens_truncated=500,
|
|
)
|
|
ov, tk = _make_overview_and_token_rows(
|
|
total_requests=1, requests_with_context=1, truncated_requests=1
|
|
)
|
|
_setup_chart_data_mocks(mock_query, ov, tk, [usage])
|
|
|
|
resp = client.get("/api/context-overflow")
|
|
assert resp.status_code == 200
|
|
chart = resp.get_json()["chart_data"]
|
|
assert chart[0]["original_prompt_tokens"] == 600 # 100 + 500
|
|
|
|
def test_not_truncated_no_addition(self):
|
|
"""context_truncated=False → original = prompt (no addition)."""
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
usage = _make_usage_mock(
|
|
prompt_tokens=200,
|
|
context_truncated=False,
|
|
tokens_truncated=0,
|
|
)
|
|
ov, tk = _make_overview_and_token_rows(total_requests=1)
|
|
_setup_chart_data_mocks(mock_query, ov, tk, [usage])
|
|
|
|
resp = client.get("/api/context-overflow")
|
|
assert resp.status_code == 200
|
|
chart = resp.get_json()["chart_data"]
|
|
assert chart[0]["original_prompt_tokens"] == 200
|
|
|
|
def test_all_null_token_fields_no_crash(self):
|
|
"""All null token fields → no crash, 0 values."""
|
|
app = _create_test_app()
|
|
with _authenticated_client(app) as (client, _, mock_query):
|
|
usage = _make_usage_mock(
|
|
prompt_tokens=0,
|
|
completion_tokens=0,
|
|
ollama_prompt_eval_count=None,
|
|
context_truncated=False,
|
|
tokens_truncated=None,
|
|
total_tokens=0,
|
|
)
|
|
ov, tk = _make_overview_and_token_rows(total_requests=1)
|
|
_setup_chart_data_mocks(mock_query, ov, tk, [usage])
|
|
|
|
resp = client.get("/api/context-overflow")
|
|
assert resp.status_code == 200
|
|
chart = resp.get_json()["chart_data"]
|
|
assert chart[0]["original_prompt_tokens"] == 0
|
|
assert chart[0]["tokens_truncated"] == 0
|