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
484 lines
16 KiB
Python
484 lines
16 KiB
Python
"""
|
|
Comprehensive tests for news core utilities.
|
|
Tests generate_card_id, generate_subscription_id, utc_now, hours_ago, get_local_date_string.
|
|
"""
|
|
|
|
from unittest.mock import Mock, patch
|
|
from datetime import datetime, timezone, timedelta
|
|
import uuid
|
|
import os
|
|
|
|
|
|
class TestGenerateCardId:
|
|
"""Tests for generate_card_id function."""
|
|
|
|
def test_returns_string(self):
|
|
"""Test returns a string."""
|
|
from local_deep_research.news.core.utils import generate_card_id
|
|
|
|
result = generate_card_id()
|
|
assert isinstance(result, str)
|
|
|
|
def test_returns_valid_uuid(self):
|
|
"""Test returns a valid UUID string."""
|
|
from local_deep_research.news.core.utils import generate_card_id
|
|
|
|
result = generate_card_id()
|
|
# Should be parseable as UUID
|
|
parsed = uuid.UUID(result)
|
|
assert str(parsed) == result
|
|
|
|
def test_returns_unique_ids(self):
|
|
"""Test returns unique IDs on multiple calls."""
|
|
from local_deep_research.news.core.utils import generate_card_id
|
|
|
|
ids = [generate_card_id() for _ in range(100)]
|
|
assert len(set(ids)) == 100
|
|
|
|
def test_returns_uuid4_format(self):
|
|
"""Test returns UUID version 4."""
|
|
from local_deep_research.news.core.utils import generate_card_id
|
|
|
|
result = generate_card_id()
|
|
parsed = uuid.UUID(result)
|
|
assert parsed.version == 4
|
|
|
|
def test_returns_lowercase(self):
|
|
"""Test returns lowercase UUID."""
|
|
from local_deep_research.news.core.utils import generate_card_id
|
|
|
|
result = generate_card_id()
|
|
assert result == result.lower()
|
|
|
|
def test_length_is_36(self):
|
|
"""Test UUID string length is 36 characters."""
|
|
from local_deep_research.news.core.utils import generate_card_id
|
|
|
|
result = generate_card_id()
|
|
assert len(result) == 36
|
|
|
|
|
|
class TestGenerateSubscriptionId:
|
|
"""Tests for generate_subscription_id function."""
|
|
|
|
def test_returns_string(self):
|
|
"""Test returns a string."""
|
|
from local_deep_research.news.core.utils import generate_subscription_id
|
|
|
|
result = generate_subscription_id()
|
|
assert isinstance(result, str)
|
|
|
|
def test_returns_valid_uuid(self):
|
|
"""Test returns a valid UUID string."""
|
|
from local_deep_research.news.core.utils import generate_subscription_id
|
|
|
|
result = generate_subscription_id()
|
|
parsed = uuid.UUID(result)
|
|
assert str(parsed) == result
|
|
|
|
def test_returns_unique_ids(self):
|
|
"""Test returns unique IDs on multiple calls."""
|
|
from local_deep_research.news.core.utils import generate_subscription_id
|
|
|
|
ids = [generate_subscription_id() for _ in range(100)]
|
|
assert len(set(ids)) == 100
|
|
|
|
def test_different_from_card_id(self):
|
|
"""Test subscription ID is different from card ID (statistically)."""
|
|
from local_deep_research.news.core.utils import (
|
|
generate_card_id,
|
|
generate_subscription_id,
|
|
)
|
|
|
|
card_id = generate_card_id()
|
|
sub_id = generate_subscription_id()
|
|
assert card_id != sub_id
|
|
|
|
|
|
class TestUtcNow:
|
|
"""Tests for utc_now function."""
|
|
|
|
def test_returns_datetime(self):
|
|
"""Test returns a datetime object."""
|
|
from local_deep_research.news.core.utils import utc_now
|
|
|
|
result = utc_now()
|
|
assert isinstance(result, datetime)
|
|
|
|
def test_has_utc_timezone(self):
|
|
"""Test returned datetime has UTC timezone."""
|
|
from local_deep_research.news.core.utils import utc_now
|
|
|
|
result = utc_now()
|
|
assert result.tzinfo is not None
|
|
assert result.tzinfo == timezone.utc
|
|
|
|
def test_is_close_to_current_time(self):
|
|
"""Test returned time is close to current time."""
|
|
from local_deep_research.news.core.utils import utc_now
|
|
|
|
before = datetime.now(timezone.utc)
|
|
result = utc_now()
|
|
after = datetime.now(timezone.utc)
|
|
|
|
assert before <= result <= after
|
|
|
|
def test_consistent_on_multiple_calls(self):
|
|
"""Test times are consistent and increasing."""
|
|
from local_deep_research.news.core.utils import utc_now
|
|
|
|
times = [utc_now() for _ in range(10)]
|
|
|
|
# Should be monotonically non-decreasing
|
|
for i in range(len(times) - 1):
|
|
assert times[i] <= times[i + 1]
|
|
|
|
def test_not_naive_datetime(self):
|
|
"""Test returned datetime is not naive."""
|
|
from local_deep_research.news.core.utils import utc_now
|
|
|
|
result = utc_now()
|
|
# Naive datetimes have tzinfo=None
|
|
assert result.tzinfo is not None
|
|
|
|
|
|
class TestHoursAgo:
|
|
"""Tests for hours_ago function."""
|
|
|
|
def test_returns_float(self):
|
|
"""Test returns a float."""
|
|
from local_deep_research.news.core.utils import hours_ago, utc_now
|
|
|
|
past = utc_now() - timedelta(hours=1)
|
|
result = hours_ago(past)
|
|
assert isinstance(result, float)
|
|
|
|
def test_one_hour_ago(self):
|
|
"""Test datetime 1 hour ago returns ~1."""
|
|
from local_deep_research.news.core.utils import hours_ago, utc_now
|
|
|
|
past = utc_now() - timedelta(hours=1)
|
|
result = hours_ago(past)
|
|
assert 0.99 <= result <= 1.01
|
|
|
|
def test_two_hours_ago(self):
|
|
"""Test datetime 2 hours ago returns ~2."""
|
|
from local_deep_research.news.core.utils import hours_ago, utc_now
|
|
|
|
past = utc_now() - timedelta(hours=2)
|
|
result = hours_ago(past)
|
|
assert 1.99 <= result <= 2.01
|
|
|
|
def test_future_returns_negative(self):
|
|
"""Test future datetime returns negative value."""
|
|
from local_deep_research.news.core.utils import hours_ago, utc_now
|
|
|
|
future = utc_now() + timedelta(hours=1)
|
|
result = hours_ago(future)
|
|
assert result < 0
|
|
assert -1.01 <= result <= -0.99
|
|
|
|
def test_now_returns_zero(self):
|
|
"""Test current datetime returns ~0."""
|
|
from local_deep_research.news.core.utils import hours_ago, utc_now
|
|
|
|
now = utc_now()
|
|
result = hours_ago(now)
|
|
assert -0.01 <= result <= 0.01
|
|
|
|
def test_half_hour_ago(self):
|
|
"""Test datetime 30 minutes ago returns ~0.5."""
|
|
from local_deep_research.news.core.utils import hours_ago, utc_now
|
|
|
|
past = utc_now() - timedelta(minutes=30)
|
|
result = hours_ago(past)
|
|
assert 0.49 <= result <= 0.51
|
|
|
|
def test_handles_naive_datetime(self):
|
|
"""Test handles naive datetime by assuming UTC."""
|
|
from local_deep_research.news.core.utils import hours_ago
|
|
|
|
naive = datetime.utcnow() - timedelta(hours=1)
|
|
result = hours_ago(naive)
|
|
assert 0.99 <= result <= 1.01
|
|
|
|
def test_handles_aware_datetime(self):
|
|
"""Test handles timezone-aware datetime."""
|
|
from local_deep_research.news.core.utils import hours_ago
|
|
|
|
aware = datetime.now(timezone.utc) - timedelta(hours=2)
|
|
result = hours_ago(aware)
|
|
assert 1.99 <= result <= 2.01
|
|
|
|
def test_24_hours_ago(self):
|
|
"""Test datetime 24 hours ago returns ~24."""
|
|
from local_deep_research.news.core.utils import hours_ago, utc_now
|
|
|
|
past = utc_now() - timedelta(days=1)
|
|
result = hours_ago(past)
|
|
assert 23.99 <= result <= 24.01
|
|
|
|
def test_fractional_hours(self):
|
|
"""Test fractional hours are accurate."""
|
|
from local_deep_research.news.core.utils import hours_ago, utc_now
|
|
|
|
past = utc_now() - timedelta(hours=1, minutes=30)
|
|
result = hours_ago(past)
|
|
assert 1.49 <= result <= 1.51
|
|
|
|
|
|
class TestGetLocalDateString:
|
|
"""Tests for get_local_date_string function."""
|
|
|
|
def test_returns_string(self):
|
|
"""Test returns a string."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
result = get_local_date_string()
|
|
assert isinstance(result, str)
|
|
|
|
def test_returns_iso_format(self):
|
|
"""Test returns ISO date format (YYYY-MM-DD)."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
import re
|
|
|
|
result = get_local_date_string()
|
|
# Should match YYYY-MM-DD pattern
|
|
assert re.match(r"^\d{4}-\d{2}-\d{2}$", result)
|
|
|
|
def test_uses_utc_as_default(self):
|
|
"""Test uses UTC as default timezone."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
# Clear TZ env var
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
result = get_local_date_string()
|
|
|
|
expected = datetime.now(timezone.utc).date().isoformat()
|
|
assert result == expected
|
|
|
|
def test_uses_settings_manager_timezone(self):
|
|
"""Test uses timezone from settings_manager."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
from zoneinfo import ZoneInfo
|
|
|
|
mock_settings = Mock()
|
|
mock_settings.get_setting.return_value = "America/New_York"
|
|
|
|
result = get_local_date_string(mock_settings)
|
|
|
|
mock_settings.get_setting.assert_called_once_with("app.timezone")
|
|
expected = datetime.now(ZoneInfo("America/New_York")).date().isoformat()
|
|
assert result == expected
|
|
|
|
def test_uses_tz_env_var_as_fallback(self):
|
|
"""Test uses TZ environment variable as fallback."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
from zoneinfo import ZoneInfo
|
|
|
|
with patch.dict(os.environ, {"TZ": "Europe/London"}):
|
|
result = get_local_date_string(None)
|
|
|
|
expected = datetime.now(ZoneInfo("Europe/London")).date().isoformat()
|
|
assert result == expected
|
|
|
|
def test_handles_invalid_timezone(self):
|
|
"""Test handles invalid timezone by falling back to UTC."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
mock_settings = Mock()
|
|
mock_settings.get_setting.return_value = "Invalid/Timezone"
|
|
|
|
result = get_local_date_string(mock_settings)
|
|
|
|
# Should fall back to UTC
|
|
expected = datetime.now(timezone.utc).date().isoformat()
|
|
assert result == expected
|
|
|
|
def test_handles_settings_manager_exception(self):
|
|
"""Test handles exception from settings_manager."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
mock_settings = Mock()
|
|
mock_settings.get_setting.side_effect = Exception("Database error")
|
|
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
result = get_local_date_string(mock_settings)
|
|
|
|
# Should fall back to UTC
|
|
expected = datetime.now(timezone.utc).date().isoformat()
|
|
assert result == expected
|
|
|
|
def test_handles_none_settings_manager(self):
|
|
"""Test handles None settings_manager."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
result = get_local_date_string(None)
|
|
|
|
expected = datetime.now(timezone.utc).date().isoformat()
|
|
assert result == expected
|
|
|
|
def test_settings_priority_over_env_var(self):
|
|
"""Test settings_manager takes priority over TZ env var."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
from zoneinfo import ZoneInfo
|
|
|
|
mock_settings = Mock()
|
|
mock_settings.get_setting.return_value = "Asia/Tokyo"
|
|
|
|
with patch.dict(os.environ, {"TZ": "Europe/Paris"}):
|
|
result = get_local_date_string(mock_settings)
|
|
|
|
expected = datetime.now(ZoneInfo("Asia/Tokyo")).date().isoformat()
|
|
assert result == expected
|
|
|
|
def test_common_timezones(self):
|
|
"""Test works with common timezones."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
timezones = [
|
|
"UTC",
|
|
"America/New_York",
|
|
"Europe/London",
|
|
"Asia/Tokyo",
|
|
"Australia/Sydney",
|
|
"Pacific/Honolulu",
|
|
]
|
|
|
|
for tz_name in timezones:
|
|
mock_settings = Mock()
|
|
mock_settings.get_setting.return_value = tz_name
|
|
|
|
result = get_local_date_string(mock_settings)
|
|
# Should return valid date format
|
|
assert len(result) == 10
|
|
|
|
|
|
class TestUtilsEdgeCases:
|
|
"""Edge case tests for utils module."""
|
|
|
|
def test_generate_card_id_thread_safe(self):
|
|
"""Test generate_card_id is thread-safe."""
|
|
from local_deep_research.news.core.utils import generate_card_id
|
|
import threading
|
|
|
|
ids = []
|
|
lock = threading.Lock()
|
|
|
|
def generate_ids():
|
|
for _ in range(100):
|
|
card_id = generate_card_id()
|
|
with lock:
|
|
ids.append(card_id)
|
|
|
|
threads = [threading.Thread(target=generate_ids) for _ in range(10)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
# All IDs should be unique
|
|
assert len(ids) == 1000
|
|
assert len(set(ids)) == 1000
|
|
|
|
def test_hours_ago_very_old_datetime(self):
|
|
"""Test hours_ago with very old datetime."""
|
|
from local_deep_research.news.core.utils import hours_ago
|
|
|
|
old = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
|
result = hours_ago(old)
|
|
assert result > 8760 # More than 1 year in hours
|
|
|
|
def test_hours_ago_very_far_future(self):
|
|
"""Test hours_ago with far future datetime."""
|
|
from local_deep_research.news.core.utils import hours_ago
|
|
|
|
future = datetime(2030, 1, 1, tzinfo=timezone.utc)
|
|
result = hours_ago(future)
|
|
assert result < 0
|
|
|
|
def test_get_local_date_string_empty_string_timezone(self):
|
|
"""Test get_local_date_string with empty string timezone."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
mock_settings = Mock()
|
|
mock_settings.get_setting.return_value = ""
|
|
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
result = get_local_date_string(mock_settings)
|
|
|
|
# Empty string should fall back to UTC
|
|
expected = datetime.now(timezone.utc).date().isoformat()
|
|
assert result == expected
|
|
|
|
def test_get_local_date_string_whitespace_timezone(self):
|
|
"""Test get_local_date_string with whitespace timezone."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
mock_settings = Mock()
|
|
mock_settings.get_setting.return_value = " "
|
|
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
result = get_local_date_string(mock_settings)
|
|
|
|
# Whitespace should likely be treated as invalid, fall back to UTC
|
|
expected = datetime.now(timezone.utc).date().isoformat()
|
|
assert result == expected
|
|
|
|
def test_hours_ago_preserves_precision(self):
|
|
"""Test hours_ago preserves sub-hour precision."""
|
|
from local_deep_research.news.core.utils import hours_ago, utc_now
|
|
|
|
past = utc_now() - timedelta(hours=3, minutes=45, seconds=30)
|
|
result = hours_ago(past)
|
|
# 3 hours + 45.5 minutes = 3.7583...
|
|
assert 3.75 <= result <= 3.77
|
|
|
|
|
|
class TestUtilsIntegration:
|
|
"""Integration tests for utils module."""
|
|
|
|
def test_card_id_in_uuid_format_for_database(self):
|
|
"""Test card ID can be stored in database UUID field."""
|
|
from local_deep_research.news.core.utils import generate_card_id
|
|
|
|
card_id = generate_card_id()
|
|
# Should be valid for PostgreSQL UUID type
|
|
assert len(card_id) == 36
|
|
assert card_id.count("-") == 4
|
|
|
|
def test_subscription_id_in_uuid_format_for_database(self):
|
|
"""Test subscription ID can be stored in database UUID field."""
|
|
from local_deep_research.news.core.utils import generate_subscription_id
|
|
|
|
sub_id = generate_subscription_id()
|
|
assert len(sub_id) == 36
|
|
assert sub_id.count("-") == 4
|
|
|
|
def test_utc_now_compatible_with_database_timestamps(self):
|
|
"""Test utc_now produces database-compatible timestamps."""
|
|
from local_deep_research.news.core.utils import utc_now
|
|
|
|
now = utc_now()
|
|
# Should have standard datetime attributes
|
|
assert hasattr(now, "year")
|
|
assert hasattr(now, "month")
|
|
assert hasattr(now, "day")
|
|
assert hasattr(now, "hour")
|
|
assert hasattr(now, "minute")
|
|
assert hasattr(now, "second")
|
|
assert hasattr(now, "microsecond")
|
|
|
|
def test_date_string_format_for_news_placeholders(self):
|
|
"""Test date string works as YYYY-MM-DD placeholder replacement."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
date_str = get_local_date_string()
|
|
|
|
# Should work for replacing placeholders
|
|
template = "News from YYYY-MM-DD"
|
|
result = template.replace("YYYY-MM-DD", date_str)
|
|
assert "YYYY-MM-DD" not in result
|
|
assert date_str in result
|