Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/trendflow/_trends_http/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def _normalize_timeout(timeout: httpx.Timeout | tuple[float, float] | float) ->
return timeout
if isinstance(timeout, tuple):
a, b = float(timeout[0]), float(timeout[1])
return httpx.Timeout(connect=a, read=b)
return httpx.Timeout(b, connect=a, read=b)
return httpx.Timeout(float(timeout))


Expand Down
124 changes: 124 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Shared fixtures for the trendflow test suite."""

from __future__ import annotations

from datetime import datetime

import pytest

from trendflow.enums import Resolution
from trendflow.models import (
InterestByRegionResult,
InterestOverTimeResult,
RegionalInterestRow,
RelatedQuery,
RelatedResult,
TrendingItem,
TrendingResult,
TrendPoint,
)


@pytest.fixture
def dt_jan1() -> datetime:
return datetime(2024, 1, 1, 0, 0, 0)


@pytest.fixture
def dt_jan8() -> datetime:
return datetime(2024, 1, 8, 0, 0, 0)


@pytest.fixture
def two_kw_points() -> list[TrendPoint]:
return [
TrendPoint(date=datetime(2024, 1, 1), scores={"Python": 80, "JavaScript": 70}),
TrendPoint(date=datetime(2024, 1, 8), scores={"Python": 85, "JavaScript": 65}),
TrendPoint(date=datetime(2024, 1, 15), scores={"Python": 90, "JavaScript": 60}),
]


@pytest.fixture
def iot_result(two_kw_points: list[TrendPoint]) -> InterestOverTimeResult:
return InterestOverTimeResult(
keywords=["Python", "JavaScript"],
granularity="weekly",
points=two_kw_points,
)


@pytest.fixture
def empty_iot_result() -> InterestOverTimeResult:
return InterestOverTimeResult(keywords=["Python"], granularity="unknown", points=[])


@pytest.fixture
def region_rows() -> list[RegionalInterestRow]:
return [
RegionalInterestRow(label="California", value=90),
RegionalInterestRow(label="Texas", value=70),
]


@pytest.fixture
def ibr_result(region_rows: list[RegionalInterestRow]) -> InterestByRegionResult:
return InterestByRegionResult(keyword="Python", resolution=Resolution.REGION, rows=region_rows)


@pytest.fixture
def trending_result() -> TrendingResult:
return TrendingResult(
results=[
TrendingItem(title="AI tools", traffic="500K+", articles=[]),
TrendingItem(title="Python 4", traffic="200K+", articles=[]),
]
)


@pytest.fixture
def related_result() -> RelatedResult:
return RelatedResult(
top=[RelatedQuery(term="python tutorial", value=100)],
rising=[RelatedQuery(term="python ai", breakout="+250%")],
)


@pytest.fixture
def timeline_data_weekly() -> dict:
"""Two entries 7 days apart for granularity inference."""
ts0 = int(datetime(2024, 1, 1).timestamp())
ts1 = int(datetime(2024, 1, 8).timestamp())
ts2 = int(datetime(2024, 1, 15).timestamp())
return {
"timelineData": [
{"time": str(ts0), "value": "[80, 70]"},
{"time": str(ts1), "value": "[85, 65]"},
{"time": str(ts2), "value": "[90, 60]"},
]
}


@pytest.fixture
def timeline_data_daily() -> dict:
"""Two entries 1 day apart."""
ts0 = int(datetime(2024, 1, 1).timestamp())
ts1 = int(datetime(2024, 1, 2).timestamp())
return {
"timelineData": [
{"time": str(ts0), "value": "[50]"},
{"time": str(ts1), "value": "[55]"},
]
}


@pytest.fixture
def timeline_data_hourly() -> dict:
"""Two entries 1 hour apart."""
ts0 = int(datetime(2024, 1, 1, 0, 0, 0).timestamp())
ts1 = int(datetime(2024, 1, 1, 1, 0, 0).timestamp())
return {
"timelineData": [
{"time": str(ts0), "value": "[40]"},
{"time": str(ts1), "value": "[42]"},
]
}
88 changes: 88 additions & 0 deletions tests/test_enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Tests for trendflow.enums."""

from __future__ import annotations

from trendflow.enums import ExportFormat, Region, Resolution, Timeframe


class TestRegion:
def test_worldwide_is_empty_string(self) -> None:
assert Region.WORLDWIDE == ""

def test_us_value(self) -> None:
assert Region.US == "US"

def test_all_non_worldwide_are_uppercase_two_letter(self) -> None:
for region in Region:
if region is not Region.WORLDWIDE:
assert len(region.value) == 2
assert region.value.isupper()

def test_str_serialization(self) -> None:
assert str(Region.GB) == "GB"
assert str(Region.DE) == "DE"

def test_all_expected_regions_present(self) -> None:
codes = {r.value for r in Region}
for expected in ("US", "GB", "DE", "FR", "IT", "ES", "CA", "AU", "JP", "IN", "BR", "MX"):
assert expected in codes

def test_comparable_to_string(self) -> None:
assert Region.US == "US"
assert "US" == Region.US

def test_usable_in_f_string(self) -> None:
assert f"geo={Region.US}" == "geo=US"
assert f"geo={Region.WORLDWIDE}" == "geo="


class TestTimeframe:
def test_past_day_value(self) -> None:
assert Timeframe.PAST_DAY == "now 1-d"

def test_past_week_value(self) -> None:
assert Timeframe.PAST_WEEK == "now 7-d"

def test_past_year_value(self) -> None:
assert Timeframe.PAST_YEAR == "today 12-m"

def test_past_5_years_value(self) -> None:
assert Timeframe.PAST_5_YEARS == "today 5-y"

def test_all_four_timeframes_exist(self) -> None:
assert len(list(Timeframe)) == 4

def test_str_serialization(self) -> None:
assert str(Timeframe.PAST_DAY) == "now 1-d"


class TestResolution:
def test_country_value(self) -> None:
assert Resolution.COUNTRY == "COUNTRY"

def test_region_value(self) -> None:
assert Resolution.REGION == "REGION"

def test_city_value(self) -> None:
assert Resolution.CITY == "CITY"

def test_all_three_exist(self) -> None:
assert len(list(Resolution)) == 3

def test_str_serialization(self) -> None:
assert str(Resolution.CITY) == "CITY"


class TestExportFormat:
def test_csv_value(self) -> None:
assert ExportFormat.CSV == "csv"

def test_json_value(self) -> None:
assert ExportFormat.JSON == "json"

def test_both_formats_exist(self) -> None:
assert len(list(ExportFormat)) == 2

def test_str_serialization(self) -> None:
assert str(ExportFormat.CSV) == "csv"
assert str(ExportFormat.JSON) == "json"
79 changes: 79 additions & 0 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Tests for trendflow._trends_http.exceptions."""

from __future__ import annotations

from unittest.mock import MagicMock

import httpx
import pytest

from trendflow._trends_http.exceptions import ResponseError, TooManyRequestsError


def _make_response(status_code: int) -> httpx.Response:
mock = MagicMock(spec=httpx.Response)
mock.status_code = status_code
return mock


class TestResponseError:
def test_message_in_args(self) -> None:
response = _make_response(500)
err = ResponseError("something went wrong", response)
assert "something went wrong" in str(err)

def test_response_attached(self) -> None:
response = _make_response(500)
err = ResponseError("fail", response)
assert err.response is response

def test_is_exception(self) -> None:
response = _make_response(500)
err = ResponseError("fail", response)
assert isinstance(err, Exception)

def test_from_response_classmethod(self) -> None:
response = _make_response(503)
err = ResponseError.from_response(response)
assert isinstance(err, ResponseError)
assert "503" in str(err)
assert err.response is response

def test_from_response_message_format(self) -> None:
response = _make_response(404)
err = ResponseError.from_response(response)
assert "404" in str(err)
assert "Google" in str(err)

def test_can_be_raised_and_caught(self) -> None:
response = _make_response(500)
with pytest.raises(ResponseError) as exc_info:
raise ResponseError("test error", response)
assert exc_info.value.response is response


class TestTooManyRequestsError:
def test_is_response_error_subclass(self) -> None:
response = _make_response(429)
err = TooManyRequestsError("rate limited", response)
assert isinstance(err, ResponseError)

def test_is_exception(self) -> None:
response = _make_response(429)
err = TooManyRequestsError("rate limited", response)
assert isinstance(err, Exception)

def test_from_response_returns_too_many_requests_error(self) -> None:
response = _make_response(429)
err = TooManyRequestsError.from_response(response)
assert isinstance(err, TooManyRequestsError)

def test_response_attached(self) -> None:
response = _make_response(429)
err = TooManyRequestsError("rate limited", response)
assert err.response is response

def test_can_catch_as_response_error(self) -> None:
response = _make_response(429)
with pytest.raises(ResponseError):
raise TooManyRequestsError("rate limited", response)
Loading