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

484 lines
17 KiB
Python

"""
Tests for web/models/database.py
Tests cover:
- get_db_connection (deprecated)
- calculate_duration function
- get_logs_for_research function
- get_total_logs_for_research function
"""
import pytest
from unittest.mock import Mock, patch
from datetime import datetime, timezone, timedelta
class TestGetDbConnection:
"""Tests for deprecated get_db_connection function."""
def test_get_db_connection_raises_runtime_error(self):
"""Test that get_db_connection raises RuntimeError."""
from local_deep_research.web.models.database import get_db_connection
with pytest.raises(RuntimeError) as exc_info:
get_db_connection()
assert "Shared database access is deprecated" in str(exc_info.value)
class TestCalculateDuration:
"""Tests for calculate_duration function."""
def test_calculate_duration_iso_format(self):
"""Test duration calculation with ISO format timestamps."""
from local_deep_research.web.models.database import calculate_duration
created_at = "2025-01-01T10:00:00+00:00"
completed_at = "2025-01-01T10:05:00+00:00"
result = calculate_duration(created_at, completed_at)
assert result == 300 # 5 minutes = 300 seconds
def test_calculate_duration_with_microseconds(self):
"""Test duration calculation with microseconds in ISO format."""
from local_deep_research.web.models.database import calculate_duration
# Use ISO format with timezone for proper parsing
created_at = "2025-01-01T10:00:00.123456+00:00"
completed_at = "2025-01-01T10:01:00.654321+00:00"
result = calculate_duration(created_at, completed_at)
# Should be approximately 60 seconds
assert result == 60 or result == 61
def test_calculate_duration_without_completed_at(self):
"""Test duration calculation without completed_at uses current time."""
from local_deep_research.web.models.database import calculate_duration
# Create a recent timestamp
now = datetime.now(timezone.utc)
created_at = (now - timedelta(seconds=120)).isoformat()
result = calculate_duration(created_at)
# Should be approximately 120 seconds (with some tolerance)
assert result is not None
assert 115 <= result <= 130
def test_calculate_duration_none_created_at(self):
"""Test duration calculation with None created_at."""
from local_deep_research.web.models.database import calculate_duration
result = calculate_duration(None)
assert result is None
def test_calculate_duration_empty_created_at(self):
"""Test duration calculation with empty created_at."""
from local_deep_research.web.models.database import calculate_duration
result = calculate_duration("")
assert result is None
def test_calculate_duration_space_separated_format(self):
"""Test duration with space-separated timestamp format with timezone."""
from local_deep_research.web.models.database import calculate_duration
# Use format with timezone suffix for proper handling
created_at = "2025-01-01 10:00:00+00:00"
completed_at = "2025-01-01 10:10:00+00:00"
result = calculate_duration(created_at, completed_at)
assert result == 600 # 10 minutes = 600 seconds
def test_calculate_duration_replaces_space_with_t(self):
"""Test that space is replaced with T for parsing."""
from local_deep_research.web.models.database import calculate_duration
# This format should fall back to replacing space with T
created_at = "2025-01-01 10:00:00+00:00"
completed_at = "2025-01-01 10:02:00+00:00"
result = calculate_duration(created_at, completed_at)
assert result == 120 # 2 minutes
class TestGetLogsForResearch:
"""Tests for get_logs_for_research function."""
def test_get_logs_success(self):
"""Test successful log retrieval."""
mock_session = Mock()
mock_log = Mock()
mock_log.timestamp = "2025-01-01T10:00:00"
mock_log.message = "Test log message"
mock_log.level = "INFO"
mock_log.module = "test_module"
mock_log.line_no = 42
mock_query = Mock()
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [mock_log]
mock_session.query.return_value = mock_query
with patch(
"local_deep_research.web.models.database.get_user_db_session"
) as mock_get_session:
mock_get_session.return_value.__enter__ = Mock(
return_value=mock_session
)
mock_get_session.return_value.__exit__ = Mock(return_value=False)
from local_deep_research.web.models.database import (
get_logs_for_research,
)
result = get_logs_for_research("test-research-id")
assert len(result) == 1
assert result[0]["message"] == "Test log message"
assert result[0]["type"] == "INFO"
assert result[0]["module"] == "test_module"
assert result[0]["line_no"] == 42
def test_get_logs_empty(self):
"""Test log retrieval when no logs exist."""
mock_session = Mock()
mock_query = Mock()
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = []
mock_session.query.return_value = mock_query
with patch(
"local_deep_research.web.models.database.get_user_db_session"
) as mock_get_session:
mock_get_session.return_value.__enter__ = Mock(
return_value=mock_session
)
mock_get_session.return_value.__exit__ = Mock(return_value=False)
from local_deep_research.web.models.database import (
get_logs_for_research,
)
result = get_logs_for_research("nonexistent-id")
assert result == []
def test_get_logs_error_returns_empty(self):
"""Test that errors return empty list."""
with patch(
"local_deep_research.web.models.database.get_user_db_session"
) as mock_get_session:
mock_get_session.return_value.__enter__ = Mock(
side_effect=Exception("Database error")
)
from local_deep_research.web.models.database import (
get_logs_for_research,
)
result = get_logs_for_research("test-id")
assert result == []
def test_get_logs_with_limit_uses_desc_order_and_reverses(self):
"""When limit is set, the query takes the newest N rows (desc) and
the result is reversed so the caller still sees oldest-first.
"""
# Three rows ordered newest-first as the DB would return them.
rows = []
for i, msg in enumerate(["newest", "middle", "oldest"]):
row = Mock()
row.timestamp = f"2025-01-01T10:0{2 - i}:00"
row.message = msg
row.level = "INFO"
row.module = "m"
row.line_no = i
rows.append(row)
mock_session = Mock()
mock_query = Mock()
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = rows
mock_session.query.return_value = mock_query
with patch(
"local_deep_research.web.models.database.get_user_db_session"
) as mock_get_session:
mock_get_session.return_value.__enter__ = Mock(
return_value=mock_session
)
mock_get_session.return_value.__exit__ = Mock(return_value=False)
from local_deep_research.web.models.database import (
get_logs_for_research,
)
result = get_logs_for_research("rid", limit=3)
# limit() was called with the requested cap.
mock_query.limit.assert_called_once_with(3)
# Caller sees oldest-first ordering.
assert [r["message"] for r in result] == [
"oldest",
"middle",
"newest",
]
def test_get_logs_with_limit_returns_newest_n_in_ascending_order_real_sqlite(
self,
):
"""End-to-end SQL check against a real in-memory SQLite session.
The mock-based ordering test above only proves Python's ``reversed()``
is called — the mock returns a fixed list regardless of which
``order_by``/``limit`` was invoked. This test inserts 10 actual rows,
runs the function through SQLAlchemy, and asserts that the SQL
produces newest-first DESC + LIMIT and the caller sees the 3 newest
rows in oldest-first order. Catches any regression where ``desc()``
is dropped, ``limit()`` is moved, or ``reversed()`` is removed.
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import Base, ResearchLog
from local_deep_research.web.models.database import (
get_logs_for_research,
)
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
# 10 orphan log rows at 1-minute intervals. No parent ResearchHistory
# row is created — research_id is nullable and SQLite does not enforce
# FK constraints without PRAGMA foreign_keys=ON, so the insert is
# valid and isolates this test from the parent-table schema.
base_time = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
for i in range(10):
session.add(
ResearchLog(
research_id="test-rid",
timestamp=base_time + timedelta(minutes=i),
message=f"Log {i}",
module="test",
function="test",
line_no=i,
level="INFO",
)
)
session.commit()
try:
with patch(
"local_deep_research.web.models.database.get_user_db_session"
) as mock_get_session:
mock_get_session.return_value.__enter__ = Mock(
return_value=session
)
mock_get_session.return_value.__exit__ = Mock(
return_value=False
)
result = get_logs_for_research("test-rid", limit=3)
assert [r["message"] for r in result] == [
"Log 7",
"Log 8",
"Log 9",
]
finally:
session.close()
engine.dispose()
def test_get_logs_with_limit_tie_breaks_equal_timestamps_by_id(self):
"""When rows share an identical timestamp, ``id`` is the tie-break so
the newest ``limit`` selection is deterministic.
``timestamp`` is not unique, so ``order_by(timestamp.desc()).limit(N)``
alone leaves the rows surviving the boundary SQL-undefined. All 10 rows
here share one timestamp; Log i has id i+1, so the newest 3 by id are
Log 7/8/9, returned oldest-first.
"""
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import Base, ResearchLog
from local_deep_research.web.models.database import (
get_logs_for_research,
)
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)()
shared_time = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
for i in range(10):
session.add(
ResearchLog(
research_id="test-rid",
timestamp=shared_time,
message=f"Log {i}",
module="test",
function="test",
line_no=i,
level="INFO",
)
)
session.commit()
try:
with patch(
"local_deep_research.web.models.database.get_user_db_session"
) as mock_get_session:
mock_get_session.return_value.__enter__ = Mock(
return_value=session
)
mock_get_session.return_value.__exit__ = Mock(
return_value=False
)
result = get_logs_for_research("test-rid", limit=3)
assert [r["message"] for r in result] == [
"Log 7",
"Log 8",
"Log 9",
]
finally:
session.close()
engine.dispose()
def test_get_logs_without_limit_does_not_call_limit(self):
"""When limit is None, the function preserves legacy behaviour and
does not invoke .limit() on the query (returns every row, asc)."""
mock_session = Mock()
mock_query = Mock()
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = []
# Track whether limit() is invoked — it should NOT be.
mock_query.limit = Mock(side_effect=AssertionError("limit() called"))
mock_session.query.return_value = mock_query
with patch(
"local_deep_research.web.models.database.get_user_db_session"
) as mock_get_session:
mock_get_session.return_value.__enter__ = Mock(
return_value=mock_session
)
mock_get_session.return_value.__exit__ = Mock(return_value=False)
from local_deep_research.web.models.database import (
get_logs_for_research,
)
# Should complete without triggering the AssertionError side-effect.
result = get_logs_for_research("rid")
assert result == []
class TestGetTotalLogsForResearch:
"""Tests for get_total_logs_for_research function."""
def test_get_total_logs_success(self):
"""Test successful total log count."""
mock_session = Mock()
mock_query = Mock()
mock_query.filter.return_value = mock_query
mock_query.count.return_value = 42
mock_session.query.return_value = mock_query
with patch(
"local_deep_research.web.models.database.get_user_db_session"
) as mock_get_session:
mock_get_session.return_value.__enter__ = Mock(
return_value=mock_session
)
mock_get_session.return_value.__exit__ = Mock(return_value=False)
from local_deep_research.web.models.database import (
get_total_logs_for_research,
)
result = get_total_logs_for_research("test-research-id")
assert result == 42
def test_get_total_logs_zero(self):
"""Test total log count when no logs exist."""
mock_session = Mock()
mock_query = Mock()
mock_query.filter.return_value = mock_query
mock_query.count.return_value = 0
mock_session.query.return_value = mock_query
with patch(
"local_deep_research.web.models.database.get_user_db_session"
) as mock_get_session:
mock_get_session.return_value.__enter__ = Mock(
return_value=mock_session
)
mock_get_session.return_value.__exit__ = Mock(return_value=False)
from local_deep_research.web.models.database import (
get_total_logs_for_research,
)
result = get_total_logs_for_research("nonexistent-id")
assert result == 0
class TestDurationEdgeCases:
"""Edge case tests for calculate_duration."""
def test_negative_duration(self):
"""Test when completed_at is before created_at."""
from local_deep_research.web.models.database import calculate_duration
created_at = "2025-01-01T10:10:00+00:00"
completed_at = "2025-01-01T10:00:00+00:00"
result = calculate_duration(created_at, completed_at)
# Should return negative value
assert result == -600 # -10 minutes
def test_very_long_duration(self):
"""Test with very long duration."""
from local_deep_research.web.models.database import calculate_duration
created_at = "2025-01-01T00:00:00+00:00"
completed_at = "2025-01-02T00:00:00+00:00"
result = calculate_duration(created_at, completed_at)
assert result == 86400 # 24 hours = 86400 seconds
def test_zero_duration(self):
"""Test with same timestamps."""
from local_deep_research.web.models.database import calculate_duration
created_at = "2025-01-01T10:00:00+00:00"
completed_at = "2025-01-01T10:00:00+00:00"
result = calculate_duration(created_at, completed_at)
assert result == 0