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
292 lines
9.1 KiB
Python
292 lines
9.1 KiB
Python
"""
|
|
Deep behavioral tests for _format_time_ago utility function.
|
|
Tests time formatting, edge cases, pluralization, timezone handling, and error recovery.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.news.api import _format_time_ago
|
|
|
|
|
|
# --- Days ago ---
|
|
|
|
|
|
class TestFormatTimeAgoDays:
|
|
"""Tests for day-based formatting."""
|
|
|
|
def test_one_day_ago(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "1 day ago"
|
|
|
|
def test_two_days_ago(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "2 days ago"
|
|
|
|
def test_seven_days_ago(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "7 days ago"
|
|
|
|
def test_thirty_days_ago(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "30 days ago"
|
|
|
|
def test_one_day_no_plural(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert "days" not in result
|
|
assert "day" in result
|
|
|
|
def test_three_days_plural(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(days=3)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert "days" in result
|
|
|
|
def test_365_days_ago(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(days=365)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert "365 days ago" == result
|
|
|
|
|
|
# --- Hours ago ---
|
|
|
|
|
|
class TestFormatTimeAgoHours:
|
|
"""Tests for hour-based formatting."""
|
|
|
|
def test_two_hours_ago(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "2 hours ago"
|
|
|
|
def test_one_hour_ago(self):
|
|
ts = (
|
|
datetime.now(timezone.utc) - timedelta(hours=1, minutes=30)
|
|
).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "1 hour ago"
|
|
|
|
def test_one_hour_no_plural(self):
|
|
ts = (
|
|
datetime.now(timezone.utc) - timedelta(hours=1, minutes=30)
|
|
).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert "hours" not in result
|
|
assert "hour" in result
|
|
|
|
def test_twelve_hours_ago(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(hours=12)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "12 hours ago"
|
|
|
|
def test_twenty_three_hours_ago(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(hours=23)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "23 hours ago"
|
|
|
|
def test_six_hours_plural(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(hours=6)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert "hours" in result
|
|
|
|
|
|
# --- Minutes ago ---
|
|
|
|
|
|
class TestFormatTimeAgoMinutes:
|
|
"""Tests for minute-based formatting."""
|
|
|
|
def test_five_minutes_ago(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "5 minutes ago"
|
|
|
|
def test_one_minute_ago(self):
|
|
ts = (
|
|
datetime.now(timezone.utc) - timedelta(minutes=1, seconds=30)
|
|
).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "1 minute ago"
|
|
|
|
def test_one_minute_no_plural(self):
|
|
ts = (
|
|
datetime.now(timezone.utc) - timedelta(minutes=1, seconds=30)
|
|
).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert "minutes" not in result
|
|
assert "minute" in result
|
|
|
|
def test_thirty_minutes_ago(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "30 minutes ago"
|
|
|
|
def test_fifty_nine_minutes_ago(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(minutes=59)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "59 minutes ago"
|
|
|
|
|
|
# --- Just now ---
|
|
|
|
|
|
class TestFormatTimeAgoJustNow:
|
|
"""Tests for 'Just now' output."""
|
|
|
|
def test_zero_seconds(self):
|
|
ts = datetime.now(timezone.utc).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "Just now"
|
|
|
|
def test_thirty_seconds_ago(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(seconds=30)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "Just now"
|
|
|
|
def test_fifty_nine_seconds_ago(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(seconds=59)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "Just now"
|
|
|
|
|
|
# --- Timezone handling ---
|
|
|
|
|
|
class TestFormatTimeAgoTimezone:
|
|
"""Tests for timezone handling."""
|
|
|
|
def test_utc_timestamp(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "3 hours ago"
|
|
|
|
def test_z_suffix(self):
|
|
dt = datetime.now(timezone.utc) - timedelta(hours=2)
|
|
ts = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
result = _format_time_ago(ts)
|
|
assert "2 hours ago" == result
|
|
|
|
def test_plus_offset(self):
|
|
dt = datetime.now(timezone.utc) - timedelta(hours=5)
|
|
ts = dt.strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
|
result = _format_time_ago(ts)
|
|
assert result == "5 hours ago"
|
|
|
|
def test_naive_datetime_assumed_utc(self):
|
|
"""Naive timestamps should be treated as UTC."""
|
|
dt = datetime.now(timezone.utc) - timedelta(hours=4)
|
|
# Strip timezone info to make it naive
|
|
ts = dt.strftime("%Y-%m-%dT%H:%M:%S")
|
|
result = _format_time_ago(ts)
|
|
assert result == "4 hours ago"
|
|
|
|
|
|
# --- Error handling ---
|
|
|
|
|
|
class TestFormatTimeAgoErrorHandling:
|
|
"""Unparseable input raises rather than returning a neutral label.
|
|
|
|
created_at is NOT NULL and always written as isoformat(), so anything
|
|
that won't parse is corrupt data. The per-row loop in get_news_feed
|
|
catches the raise, logs it, and skips the row.
|
|
"""
|
|
|
|
def test_invalid_string_raises(self):
|
|
with pytest.raises(ValueError):
|
|
_format_time_ago("not-a-timestamp")
|
|
|
|
def test_empty_string_raises(self):
|
|
with pytest.raises(ValueError):
|
|
_format_time_ago("")
|
|
|
|
def test_none_raises(self):
|
|
with pytest.raises(TypeError):
|
|
_format_time_ago(None)
|
|
|
|
def test_integer_raises(self):
|
|
with pytest.raises(TypeError):
|
|
_format_time_ago(12345)
|
|
|
|
def test_gibberish_raises(self):
|
|
with pytest.raises(ValueError):
|
|
_format_time_ago("xyz123!@#")
|
|
|
|
|
|
# --- Input format variants ---
|
|
|
|
|
|
class TestFormatTimeAgoInputFormats:
|
|
"""Tests for various timestamp format inputs."""
|
|
|
|
def test_iso_format(self):
|
|
ts = (
|
|
datetime.now(timezone.utc) - timedelta(hours=1, minutes=30)
|
|
).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert "hour" in result
|
|
|
|
def test_date_only_string(self):
|
|
# Yesterday's date
|
|
yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).strftime(
|
|
"%Y-%m-%d"
|
|
)
|
|
result = _format_time_ago(yesterday)
|
|
assert "day" in result
|
|
|
|
def test_datetime_object_as_string(self):
|
|
dt = datetime.now(timezone.utc) - timedelta(minutes=10)
|
|
result = _format_time_ago(str(dt))
|
|
assert "10 minutes ago" == result
|
|
|
|
def test_microseconds_in_timestamp(self):
|
|
ts = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert result == "2 hours ago"
|
|
|
|
|
|
# --- Boundary conditions ---
|
|
|
|
|
|
class TestFormatTimeAgoBoundaries:
|
|
"""Tests for boundary conditions between time ranges."""
|
|
|
|
def test_exactly_60_seconds(self):
|
|
"""60 seconds should show as 1 minute, not 'Just now'."""
|
|
ts = (datetime.now(timezone.utc) - timedelta(seconds=61)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert "minute" in result
|
|
|
|
def test_exactly_3600_seconds(self):
|
|
"""3600 seconds should show as 1 hour, not minutes."""
|
|
ts = (datetime.now(timezone.utc) - timedelta(seconds=3601)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert "hour" in result
|
|
|
|
def test_just_under_one_day(self):
|
|
"""23 hours 59 min should show hours, not days."""
|
|
ts = (
|
|
datetime.now(timezone.utc) - timedelta(hours=23, minutes=59)
|
|
).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert "hour" in result
|
|
|
|
def test_exactly_one_day(self):
|
|
"""24 hours should show as 1 day."""
|
|
ts = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert "1 day ago" == result
|
|
|
|
def test_just_over_one_hour(self):
|
|
"""1 hour 1 second should show 1 hour."""
|
|
ts = (
|
|
datetime.now(timezone.utc) - timedelta(hours=1, seconds=1)
|
|
).isoformat()
|
|
result = _format_time_ago(ts)
|
|
assert "1 hour ago" == result
|