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
466 lines
16 KiB
Python
466 lines
16 KiB
Python
"""
|
|
Extended tests for news/core/utils.py
|
|
|
|
Tests cover:
|
|
- get_local_date_string() - timezone handling and date formatting
|
|
- generate_card_id() - UUID generation
|
|
- generate_subscription_id() - UUID generation
|
|
- utc_now() - UTC time with timezone
|
|
- hours_ago() - time difference calculation
|
|
- Edge cases and error handling
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock
|
|
from datetime import datetime, timezone, timedelta
|
|
import os
|
|
|
|
|
|
class TestGetLocalDateString:
|
|
"""Tests for get_local_date_string function."""
|
|
|
|
def test_returns_iso_date_format(self):
|
|
"""Returns date in YYYY-MM-DD format."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
result = get_local_date_string()
|
|
|
|
# Should match ISO date format
|
|
assert len(result) == 10
|
|
assert result[4] == "-"
|
|
assert result[7] == "-"
|
|
|
|
def test_uses_settings_manager_timezone(self):
|
|
"""Uses timezone from settings_manager when provided."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
mock_settings = MagicMock()
|
|
mock_settings.get_setting.return_value = "America/New_York"
|
|
|
|
# Clear TZ env var temporarily
|
|
original_tz = os.environ.get("TZ")
|
|
if "TZ" in os.environ:
|
|
del os.environ["TZ"]
|
|
|
|
try:
|
|
result = get_local_date_string(settings_manager=mock_settings)
|
|
mock_settings.get_setting.assert_called_once_with("app.timezone")
|
|
assert isinstance(result, str)
|
|
finally:
|
|
if original_tz is not None:
|
|
os.environ["TZ"] = original_tz
|
|
|
|
def test_uses_tz_env_var_fallback(self):
|
|
"""Uses TZ environment variable when no settings_manager."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
original_tz = os.environ.get("TZ")
|
|
|
|
try:
|
|
os.environ["TZ"] = "Europe/London"
|
|
result = get_local_date_string()
|
|
assert isinstance(result, str)
|
|
finally:
|
|
if original_tz is not None:
|
|
os.environ["TZ"] = original_tz
|
|
elif "TZ" in os.environ:
|
|
del os.environ["TZ"]
|
|
|
|
def test_defaults_to_utc(self):
|
|
"""Defaults to UTC when no timezone configured."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
original_tz = os.environ.get("TZ")
|
|
|
|
try:
|
|
if "TZ" in os.environ:
|
|
del os.environ["TZ"]
|
|
|
|
result = get_local_date_string()
|
|
assert isinstance(result, str)
|
|
finally:
|
|
if original_tz is not None:
|
|
os.environ["TZ"] = original_tz
|
|
|
|
def test_handles_settings_manager_exception(self):
|
|
"""Handles exception from settings_manager gracefully."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
mock_settings = MagicMock()
|
|
mock_settings.get_setting.side_effect = Exception("DB error")
|
|
|
|
# Should not raise, just fall back
|
|
result = get_local_date_string(settings_manager=mock_settings)
|
|
assert isinstance(result, str)
|
|
|
|
def test_handles_invalid_timezone(self):
|
|
"""Falls back to UTC for invalid timezone."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
mock_settings = MagicMock()
|
|
mock_settings.get_setting.return_value = "Invalid/Timezone"
|
|
|
|
result = get_local_date_string(settings_manager=mock_settings)
|
|
# Should fall back to UTC and still return valid date
|
|
assert isinstance(result, str)
|
|
assert len(result) == 10
|
|
|
|
def test_date_changes_with_timezone(self):
|
|
"""Date may differ based on timezone."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
# This test verifies the function works with different timezones
|
|
# without asserting specific dates (which would be time-dependent)
|
|
mock_settings = MagicMock()
|
|
mock_settings.get_setting.return_value = "Pacific/Auckland"
|
|
|
|
result = get_local_date_string(settings_manager=mock_settings)
|
|
assert isinstance(result, str)
|
|
|
|
def test_settings_manager_returns_none(self):
|
|
"""Handles settings_manager returning None."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
mock_settings = MagicMock()
|
|
mock_settings.get_setting.return_value = None
|
|
|
|
original_tz = os.environ.get("TZ")
|
|
try:
|
|
if "TZ" in os.environ:
|
|
del os.environ["TZ"]
|
|
|
|
result = get_local_date_string(settings_manager=mock_settings)
|
|
assert isinstance(result, str)
|
|
finally:
|
|
if original_tz is not None:
|
|
os.environ["TZ"] = original_tz
|
|
|
|
def test_settings_manager_returns_empty_string(self):
|
|
"""Handles settings_manager returning empty string."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
mock_settings = MagicMock()
|
|
mock_settings.get_setting.return_value = ""
|
|
|
|
original_tz = os.environ.get("TZ")
|
|
try:
|
|
if "TZ" in os.environ:
|
|
del os.environ["TZ"]
|
|
|
|
result = get_local_date_string(settings_manager=mock_settings)
|
|
assert isinstance(result, str)
|
|
finally:
|
|
if original_tz is not None:
|
|
os.environ["TZ"] = original_tz
|
|
|
|
|
|
class TestGenerateCardId:
|
|
"""Tests for generate_card_id function."""
|
|
|
|
def test_returns_string(self):
|
|
"""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_uuid_format(self):
|
|
"""Returns string in UUID format (36 characters with dashes)."""
|
|
from local_deep_research.news.core.utils import generate_card_id
|
|
|
|
result = generate_card_id()
|
|
assert len(result) == 36
|
|
assert result.count("-") == 4
|
|
|
|
def test_generates_unique_ids(self):
|
|
"""Generates unique IDs on each call."""
|
|
from local_deep_research.news.core.utils import generate_card_id
|
|
|
|
ids = [generate_card_id() for _ in range(100)]
|
|
assert len(ids) == len(set(ids)) # All unique
|
|
|
|
def test_id_is_valid_uuid(self):
|
|
"""Generated ID is a valid UUID."""
|
|
import uuid
|
|
from local_deep_research.news.core.utils import generate_card_id
|
|
|
|
result = generate_card_id()
|
|
# Should not raise
|
|
parsed = uuid.UUID(result)
|
|
assert str(parsed) == result
|
|
|
|
|
|
class TestGenerateSubscriptionId:
|
|
"""Tests for generate_subscription_id function."""
|
|
|
|
def test_returns_string(self):
|
|
"""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_uuid_format(self):
|
|
"""Returns string in UUID format."""
|
|
from local_deep_research.news.core.utils import generate_subscription_id
|
|
|
|
result = generate_subscription_id()
|
|
assert len(result) == 36
|
|
assert result.count("-") == 4
|
|
|
|
def test_generates_unique_ids(self):
|
|
"""Generates unique IDs on each call."""
|
|
from local_deep_research.news.core.utils import generate_subscription_id
|
|
|
|
ids = [generate_subscription_id() for _ in range(100)]
|
|
assert len(ids) == len(set(ids))
|
|
|
|
def test_id_is_valid_uuid(self):
|
|
"""Generated ID is a valid UUID."""
|
|
import uuid
|
|
from local_deep_research.news.core.utils import generate_subscription_id
|
|
|
|
result = generate_subscription_id()
|
|
parsed = uuid.UUID(result)
|
|
assert str(parsed) == result
|
|
|
|
|
|
class TestUtcNow:
|
|
"""Tests for utc_now function."""
|
|
|
|
def test_returns_datetime(self):
|
|
"""Returns a datetime object."""
|
|
from local_deep_research.news.core.utils import utc_now
|
|
|
|
result = utc_now()
|
|
assert isinstance(result, datetime)
|
|
|
|
def test_has_timezone_info(self):
|
|
"""Returned datetime has timezone info."""
|
|
from local_deep_research.news.core.utils import utc_now
|
|
|
|
result = utc_now()
|
|
assert result.tzinfo is not None
|
|
|
|
def test_is_utc_timezone(self):
|
|
"""Returned datetime is in UTC timezone."""
|
|
from local_deep_research.news.core.utils import utc_now
|
|
|
|
result = utc_now()
|
|
assert result.tzinfo == timezone.utc
|
|
|
|
def test_returns_current_time(self):
|
|
"""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_successive_calls_increase(self):
|
|
"""Successive calls return increasing times."""
|
|
from local_deep_research.news.core.utils import utc_now
|
|
import time
|
|
|
|
t1 = utc_now()
|
|
time.sleep(0.001) # Small delay
|
|
t2 = utc_now()
|
|
|
|
assert t2 >= t1
|
|
|
|
|
|
class TestHoursAgo:
|
|
"""Tests for hours_ago function."""
|
|
|
|
def test_recent_datetime(self):
|
|
"""Calculates hours ago for recent datetime."""
|
|
from local_deep_research.news.core.utils import hours_ago
|
|
|
|
# 2 hours ago
|
|
dt = datetime.now(timezone.utc) - timedelta(hours=2)
|
|
result = hours_ago(dt)
|
|
|
|
assert pytest.approx(result, abs=0.01) == 2.0
|
|
|
|
def test_exact_hours(self):
|
|
"""Calculates exact hours correctly."""
|
|
from local_deep_research.news.core.utils import hours_ago
|
|
|
|
# Exactly 24 hours ago
|
|
dt = datetime.now(timezone.utc) - timedelta(hours=24)
|
|
result = hours_ago(dt)
|
|
|
|
assert pytest.approx(result, abs=0.01) == 24.0
|
|
|
|
def test_fractional_hours(self):
|
|
"""Handles fractional hours."""
|
|
from local_deep_research.news.core.utils import hours_ago
|
|
|
|
# 1.5 hours ago (90 minutes)
|
|
dt = datetime.now(timezone.utc) - timedelta(minutes=90)
|
|
result = hours_ago(dt)
|
|
|
|
assert pytest.approx(result, abs=0.01) == 1.5
|
|
|
|
def test_future_datetime_negative(self):
|
|
"""Future datetime returns negative value."""
|
|
from local_deep_research.news.core.utils import hours_ago
|
|
|
|
# 2 hours in the future
|
|
dt = datetime.now(timezone.utc) + timedelta(hours=2)
|
|
result = hours_ago(dt)
|
|
|
|
assert pytest.approx(result, abs=0.01) == -2.0
|
|
|
|
def test_handles_naive_datetime(self):
|
|
"""Handles naive datetime by assuming UTC."""
|
|
from local_deep_research.news.core.utils import hours_ago
|
|
|
|
# Naive datetime (no timezone) - the function adds UTC timezone
|
|
dt = datetime.utcnow() - timedelta(hours=3)
|
|
result = hours_ago(dt)
|
|
|
|
# Should be approximately 3 hours (with some tolerance for test execution time)
|
|
assert pytest.approx(result, abs=0.5) == 3.0
|
|
|
|
def test_zero_hours(self):
|
|
"""Returns approximately zero for current time."""
|
|
from local_deep_research.news.core.utils import hours_ago
|
|
|
|
dt = datetime.now(timezone.utc)
|
|
result = hours_ago(dt)
|
|
|
|
assert pytest.approx(result, abs=0.01) == 0.0
|
|
|
|
def test_very_old_datetime(self):
|
|
"""Handles very old datetime."""
|
|
from local_deep_research.news.core.utils import hours_ago
|
|
|
|
# 30 days ago = 720 hours
|
|
dt = datetime.now(timezone.utc) - timedelta(days=30)
|
|
result = hours_ago(dt)
|
|
|
|
assert pytest.approx(result, abs=1) == 720.0
|
|
|
|
def test_seconds_precision(self):
|
|
"""Handles seconds precision."""
|
|
from local_deep_research.news.core.utils import hours_ago
|
|
|
|
# 30 minutes ago = 0.5 hours
|
|
dt = datetime.now(timezone.utc) - timedelta(minutes=30)
|
|
result = hours_ago(dt)
|
|
|
|
assert pytest.approx(result, abs=0.01) == 0.5
|
|
|
|
|
|
class TestEdgeCases:
|
|
"""Edge cases for utils functions."""
|
|
|
|
def test_generate_card_id_thread_safety(self):
|
|
"""IDs are unique even when generated concurrently."""
|
|
from local_deep_research.news.core.utils import generate_card_id
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
ids = list(executor.map(lambda _: generate_card_id(), range(100)))
|
|
|
|
assert len(ids) == len(set(ids))
|
|
|
|
def test_generate_subscription_id_thread_safety(self):
|
|
"""Subscription IDs are unique even when generated concurrently."""
|
|
from local_deep_research.news.core.utils import generate_subscription_id
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
ids = list(
|
|
executor.map(lambda _: generate_subscription_id(), range(100))
|
|
)
|
|
|
|
assert len(ids) == len(set(ids))
|
|
|
|
def test_hours_ago_with_different_timezones(self):
|
|
"""hours_ago works with different timezone-aware datetimes."""
|
|
from local_deep_research.news.core.utils import hours_ago
|
|
from zoneinfo import ZoneInfo
|
|
|
|
# Create datetime in a different timezone
|
|
ny_tz = ZoneInfo("America/New_York")
|
|
dt = datetime.now(ny_tz) - timedelta(hours=5)
|
|
result = hours_ago(dt)
|
|
|
|
assert pytest.approx(result, abs=0.1) == 5.0
|
|
|
|
def test_get_local_date_string_all_valid_timezones(self):
|
|
"""get_local_date_string works with various valid timezones."""
|
|
from local_deep_research.news.core.utils import get_local_date_string
|
|
|
|
timezones = [
|
|
"UTC",
|
|
"America/New_York",
|
|
"Europe/London",
|
|
"Asia/Tokyo",
|
|
"Australia/Sydney",
|
|
]
|
|
|
|
for tz_name in timezones:
|
|
mock_settings = MagicMock()
|
|
mock_settings.get_setting.return_value = tz_name
|
|
|
|
result = get_local_date_string(settings_manager=mock_settings)
|
|
assert isinstance(result, str)
|
|
assert len(result) == 10
|
|
|
|
def test_utc_now_called_rapidly(self):
|
|
"""utc_now can be called rapidly without issues."""
|
|
from local_deep_research.news.core.utils import utc_now
|
|
|
|
times = [utc_now() for _ in range(1000)]
|
|
|
|
# All should be valid datetime objects
|
|
assert all(isinstance(t, datetime) for t in times)
|
|
# All should have UTC timezone
|
|
assert all(t.tzinfo == timezone.utc for t in times)
|
|
# Times should be non-decreasing
|
|
for i in range(1, len(times)):
|
|
assert times[i] >= times[i - 1]
|
|
|
|
def test_hours_ago_leap_second(self):
|
|
"""hours_ago handles times that might span leap seconds."""
|
|
from local_deep_research.news.core.utils import hours_ago
|
|
|
|
# Use a fixed datetime that doesn't depend on current time
|
|
dt = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
|
# This will give hours from Jan 1, 2024 to now
|
|
result = hours_ago(dt)
|
|
|
|
# Should be positive and reasonable
|
|
assert result > 0
|
|
|
|
def test_card_id_format_consistent(self):
|
|
"""Card ID format is consistent."""
|
|
from local_deep_research.news.core.utils import generate_card_id
|
|
import re
|
|
|
|
uuid_pattern = re.compile(
|
|
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
)
|
|
|
|
for _ in range(10):
|
|
card_id = generate_card_id()
|
|
assert uuid_pattern.match(card_id)
|
|
|
|
def test_subscription_id_format_consistent(self):
|
|
"""Subscription ID format is consistent."""
|
|
from local_deep_research.news.core.utils import generate_subscription_id
|
|
import re
|
|
|
|
uuid_pattern = re.compile(
|
|
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
)
|
|
|
|
for _ in range(10):
|
|
sub_id = generate_subscription_id()
|
|
assert uuid_pattern.match(sub_id)
|