refactor: unify exception handling, add ISO 8601 time, dedup commands, expand tests
- Replace _error_code_for_message() string matching with error_code attribute on exception classes - Add error_code to all TwitterError subclasses (AuthenticationError, RateLimitError, etc.) - Add InvalidInputError exception class - TwitterAPIError derives error_code from HTTP status code automatically - auth.py: use AuthenticationError instead of RuntimeError - cli.py: catch (TwitterError, RuntimeError) for backward compat - Extract _fetch_and_display_users() to deduplicate followers/following commands - Add format_iso8601() to timeutil.py - Add createdAtISO field to tweet and user profile serialization - New test files: test_output.py, test_cache.py, test_timeutil.py - Expand test_filter.py (topN, score mode, custom weights, empty input) - Tests: 152 → 194 unit tests, all passing
This commit is contained in:
101
tests/test_cache.py
Normal file
101
tests/test_cache.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Tests for twitter_cli.cache module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
|
||||
from twitter_cli.cache import resolve_cached_tweet, save_tweet_cache
|
||||
from twitter_cli.models import Author, Metrics, Tweet
|
||||
|
||||
|
||||
def _make_tweet(tweet_id: str, text: str = "hello") -> Tweet:
|
||||
return Tweet(
|
||||
id=tweet_id,
|
||||
text=text,
|
||||
author=Author(id="u1", name="Alice", screen_name="alice"),
|
||||
metrics=Metrics(likes=1),
|
||||
created_at="2025-01-01",
|
||||
)
|
||||
|
||||
|
||||
class TestSaveAndResolve:
|
||||
"""save_tweet_cache → resolve_cached_tweet round-trip."""
|
||||
|
||||
def test_round_trip(self, tmp_path, monkeypatch) -> None:
|
||||
cache_file = tmp_path / "last_results.json"
|
||||
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
|
||||
monkeypatch.setattr("twitter_cli.cache._CACHE_DIR", tmp_path)
|
||||
|
||||
tweets = [_make_tweet("100"), _make_tweet("200"), _make_tweet("300")]
|
||||
save_tweet_cache(tweets)
|
||||
|
||||
assert cache_file.exists()
|
||||
tweet_id, size = resolve_cached_tweet(1)
|
||||
assert tweet_id == "100"
|
||||
assert size == 3
|
||||
|
||||
tweet_id, size = resolve_cached_tweet(3)
|
||||
assert tweet_id == "300"
|
||||
assert size == 3
|
||||
|
||||
def test_out_of_range_returns_none(self, tmp_path, monkeypatch) -> None:
|
||||
cache_file = tmp_path / "last_results.json"
|
||||
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
|
||||
monkeypatch.setattr("twitter_cli.cache._CACHE_DIR", tmp_path)
|
||||
|
||||
save_tweet_cache([_make_tweet("100")])
|
||||
|
||||
tweet_id, size = resolve_cached_tweet(99)
|
||||
assert tweet_id is None
|
||||
assert size == 1
|
||||
|
||||
|
||||
class TestCacheExpiry:
|
||||
"""TTL expiration behavior."""
|
||||
|
||||
def test_expired_cache_returns_none(self, tmp_path, monkeypatch) -> None:
|
||||
cache_file = tmp_path / "last_results.json"
|
||||
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
|
||||
|
||||
# Write cache with old timestamp
|
||||
payload = {
|
||||
"created_at": time.time() - 7200, # 2 hours ago
|
||||
"tweets": [{"index": 1, "id": "100", "author": "alice", "text": "hi"}],
|
||||
}
|
||||
cache_file.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
tweet_id, size = resolve_cached_tweet(1)
|
||||
assert tweet_id is None
|
||||
assert size == 0
|
||||
|
||||
|
||||
class TestCacheEdgeCases:
|
||||
"""Corrupted and missing cache files."""
|
||||
|
||||
def test_missing_file(self, tmp_path, monkeypatch) -> None:
|
||||
cache_file = tmp_path / "does_not_exist.json"
|
||||
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
|
||||
|
||||
tweet_id, size = resolve_cached_tweet(1)
|
||||
assert tweet_id is None
|
||||
assert size == 0
|
||||
|
||||
def test_corrupted_json(self, tmp_path, monkeypatch) -> None:
|
||||
cache_file = tmp_path / "last_results.json"
|
||||
cache_file.write_text("{{invalid json", encoding="utf-8")
|
||||
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
|
||||
|
||||
tweet_id, size = resolve_cached_tweet(1)
|
||||
assert tweet_id is None
|
||||
assert size == 0
|
||||
|
||||
def test_wrong_structure(self, tmp_path, monkeypatch) -> None:
|
||||
cache_file = tmp_path / "last_results.json"
|
||||
cache_file.write_text('"just a string"', encoding="utf-8")
|
||||
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
|
||||
|
||||
tweet_id, size = resolve_cached_tweet(1)
|
||||
assert tweet_id is None
|
||||
assert size == 0
|
||||
@@ -104,10 +104,11 @@ def test_cli_commands_wrap_client_creation_errors(monkeypatch, args) -> None:
|
||||
|
||||
|
||||
def test_cli_user_error_yaml(monkeypatch) -> None:
|
||||
from twitter_cli.exceptions import NotFoundError
|
||||
monkeypatch.setenv("OUTPUT", "auto")
|
||||
monkeypatch.setattr(
|
||||
"twitter_cli.cli._get_client",
|
||||
lambda config=None, quiet=False: (_ for _ in ()).throw(RuntimeError("User not found")),
|
||||
lambda config=None, quiet=False: (_ for _ in ()).throw(NotFoundError("User not found")),
|
||||
)
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Tests for twitter_cli.filter module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from twitter_cli.filter import filter_tweets
|
||||
from twitter_cli.filter import filter_tweets, score_tweet
|
||||
|
||||
|
||||
def test_filter_tweets_does_not_mutate_input(tweet_factory) -> None:
|
||||
@@ -29,3 +31,58 @@ def test_filter_tweets_applies_language_and_retweet_filters(tweet_factory) -> No
|
||||
)
|
||||
|
||||
assert [tweet.id for tweet in output] == ["1"]
|
||||
|
||||
|
||||
def test_filter_topn_mode(tweet_factory) -> None:
|
||||
tweets = [tweet_factory(str(i)) for i in range(10)]
|
||||
output = filter_tweets(tweets, {"mode": "topN", "topN": 3})
|
||||
assert len(output) == 3
|
||||
|
||||
|
||||
def test_filter_topn_default(tweet_factory) -> None:
|
||||
"""Default topN is 20, so 5 tweets should all be returned."""
|
||||
tweets = [tweet_factory(str(i)) for i in range(5)]
|
||||
output = filter_tweets(tweets, {"mode": "topN"})
|
||||
assert len(output) == 5
|
||||
|
||||
|
||||
def test_filter_score_mode(tweet_factory) -> None:
|
||||
from twitter_cli.models import Metrics
|
||||
|
||||
tweets = [
|
||||
tweet_factory("high", metrics=Metrics(likes=1000, retweets=500, replies=200, views=100000, bookmarks=50)),
|
||||
tweet_factory("low", metrics=Metrics(likes=0, retweets=0, replies=0, views=1, bookmarks=0)),
|
||||
]
|
||||
output = filter_tweets(tweets, {"mode": "score", "minScore": 100.0})
|
||||
assert len(output) == 1
|
||||
assert output[0].id == "high"
|
||||
|
||||
|
||||
def test_filter_empty_input() -> None:
|
||||
output = filter_tweets([], {"mode": "all"})
|
||||
assert output == []
|
||||
|
||||
|
||||
def test_score_tweet_basic(tweet_factory) -> None:
|
||||
tweet = tweet_factory("1")
|
||||
score = score_tweet(tweet)
|
||||
assert isinstance(score, float)
|
||||
assert score > 0
|
||||
|
||||
|
||||
def test_score_tweet_custom_weights(tweet_factory) -> None:
|
||||
tweet = tweet_factory("1")
|
||||
weights = {"likes": 0, "retweets": 0, "replies": 0, "bookmarks": 0, "views_log": 0}
|
||||
assert score_tweet(tweet, weights) == 0.0
|
||||
|
||||
|
||||
def test_filter_all_mode_sorts_by_score(tweet_factory) -> None:
|
||||
from twitter_cli.models import Metrics
|
||||
|
||||
tweets = [
|
||||
tweet_factory("low", metrics=Metrics(likes=1)),
|
||||
tweet_factory("high", metrics=Metrics(likes=100)),
|
||||
]
|
||||
output = filter_tweets(tweets, {"mode": "all"})
|
||||
assert output[0].id == "high"
|
||||
assert output[1].id == "low"
|
||||
|
||||
147
tests/test_output.py
Normal file
147
tests/test_output.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Tests for twitter_cli.output module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import click
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from twitter_cli.output import (
|
||||
default_structured_format,
|
||||
emit_structured,
|
||||
ensure_utf8_streams,
|
||||
error_payload,
|
||||
success_payload,
|
||||
use_rich_output,
|
||||
)
|
||||
|
||||
|
||||
# ── ensure_utf8_streams ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ensure_utf8_streams_no_error() -> None:
|
||||
"""ensure_utf8_streams should not raise on any platform."""
|
||||
ensure_utf8_streams()
|
||||
|
||||
|
||||
# ── success_payload / error_payload ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_success_payload_structure() -> None:
|
||||
payload = success_payload({"key": "value"})
|
||||
assert payload["ok"] is True
|
||||
assert payload["schema_version"] == "1"
|
||||
assert payload["data"] == {"key": "value"}
|
||||
|
||||
|
||||
def test_success_payload_with_list() -> None:
|
||||
payload = success_payload([1, 2, 3])
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [1, 2, 3]
|
||||
|
||||
|
||||
def test_error_payload_structure() -> None:
|
||||
payload = error_payload("not_found", "User not found")
|
||||
assert payload["ok"] is False
|
||||
assert payload["schema_version"] == "1"
|
||||
assert payload["error"]["code"] == "not_found"
|
||||
assert payload["error"]["message"] == "User not found"
|
||||
assert "details" not in payload["error"]
|
||||
|
||||
|
||||
def test_error_payload_with_details() -> None:
|
||||
payload = error_payload("api_error", "oops", details={"id": "123"})
|
||||
assert payload["error"]["details"] == {"id": "123"}
|
||||
|
||||
|
||||
# ── default_structured_format ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_format_json_flag() -> None:
|
||||
assert default_structured_format(as_json=True, as_yaml=False) == "json"
|
||||
|
||||
|
||||
def test_format_yaml_flag() -> None:
|
||||
assert default_structured_format(as_json=False, as_yaml=True) == "yaml"
|
||||
|
||||
|
||||
def test_format_both_flags_raises() -> None:
|
||||
with pytest.raises(click.UsageError):
|
||||
default_structured_format(as_json=True, as_yaml=True)
|
||||
|
||||
|
||||
def test_format_env_yaml(monkeypatch) -> None:
|
||||
monkeypatch.setenv("OUTPUT", "yaml")
|
||||
assert default_structured_format(as_json=False, as_yaml=False) == "yaml"
|
||||
|
||||
|
||||
def test_format_env_json(monkeypatch) -> None:
|
||||
monkeypatch.setenv("OUTPUT", "json")
|
||||
assert default_structured_format(as_json=False, as_yaml=False) == "json"
|
||||
|
||||
|
||||
def test_format_env_rich(monkeypatch) -> None:
|
||||
monkeypatch.setenv("OUTPUT", "rich")
|
||||
assert default_structured_format(as_json=False, as_yaml=False) is None
|
||||
|
||||
|
||||
def test_format_auto_non_tty(monkeypatch) -> None:
|
||||
monkeypatch.setenv("OUTPUT", "auto")
|
||||
monkeypatch.setattr("sys.stdout", type("FakeStdout", (), {"isatty": lambda self: False})())
|
||||
assert default_structured_format(as_json=False, as_yaml=False) == "yaml"
|
||||
|
||||
|
||||
# ── use_rich_output ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_use_rich_when_no_structured(monkeypatch) -> None:
|
||||
monkeypatch.setenv("OUTPUT", "rich")
|
||||
assert use_rich_output(as_json=False, as_yaml=False) is True
|
||||
|
||||
|
||||
def test_no_rich_when_json() -> None:
|
||||
assert use_rich_output(as_json=True, as_yaml=False) is False
|
||||
|
||||
|
||||
def test_no_rich_when_compact(monkeypatch) -> None:
|
||||
monkeypatch.setenv("OUTPUT", "rich")
|
||||
assert use_rich_output(as_json=False, as_yaml=False, compact=True) is False
|
||||
|
||||
|
||||
# ── emit_structured ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_emit_structured_json(capsys) -> None:
|
||||
result = emit_structured({"key": "val"}, as_json=True, as_yaml=False)
|
||||
assert result is True
|
||||
captured = capsys.readouterr()
|
||||
parsed = json.loads(captured.out)
|
||||
assert parsed["ok"] is True
|
||||
assert parsed["data"]["key"] == "val"
|
||||
|
||||
|
||||
def test_emit_structured_yaml(capsys) -> None:
|
||||
result = emit_structured({"key": "val"}, as_json=False, as_yaml=True)
|
||||
assert result is True
|
||||
captured = capsys.readouterr()
|
||||
parsed = yaml.safe_load(captured.out)
|
||||
assert parsed["ok"] is True
|
||||
assert parsed["data"]["key"] == "val"
|
||||
|
||||
|
||||
def test_emit_structured_returns_false_when_rich(monkeypatch) -> None:
|
||||
monkeypatch.setenv("OUTPUT", "rich")
|
||||
result = emit_structured({"key": "val"}, as_json=False, as_yaml=False)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_emit_structured_wraps_already_wrapped_payload(capsys) -> None:
|
||||
"""If data is already in the agent schema, it should not be double-wrapped."""
|
||||
already_wrapped = {"ok": True, "schema_version": "1", "data": "hello"}
|
||||
emit_structured(already_wrapped, as_json=True, as_yaml=False)
|
||||
captured = capsys.readouterr()
|
||||
parsed = json.loads(captured.out)
|
||||
assert parsed["data"] == "hello"
|
||||
assert "data" not in str(parsed.get("data", {}) if isinstance(parsed.get("data"), dict) else "")
|
||||
73
tests/test_timeutil.py
Normal file
73
tests/test_timeutil.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Tests for twitter_cli.timeutil module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from twitter_cli.timeutil import format_iso8601, format_local_time, format_relative_time
|
||||
|
||||
|
||||
SAMPLE_TIMESTAMP = "Sat Mar 08 12:00:00 +0000 2026"
|
||||
|
||||
|
||||
# ── format_local_time ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_format_local_time_valid() -> None:
|
||||
result = format_local_time(SAMPLE_TIMESTAMP)
|
||||
# Should be in YYYY-MM-DD HH:MM format (local timezone)
|
||||
assert result.startswith("2026-03-")
|
||||
assert ":" in result
|
||||
|
||||
|
||||
def test_format_local_time_empty() -> None:
|
||||
assert format_local_time("") == ""
|
||||
|
||||
|
||||
def test_format_local_time_invalid() -> None:
|
||||
assert format_local_time("not a date") == "not a date"
|
||||
|
||||
|
||||
# ── format_relative_time ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_format_relative_time_old() -> None:
|
||||
# A timestamp from 2020 should show years ago
|
||||
old_ts = "Sat Jan 01 00:00:00 +0000 2020"
|
||||
result = format_relative_time(old_ts)
|
||||
assert result.endswith("ago")
|
||||
assert "y" in result or "mo" in result or "d" in result
|
||||
|
||||
|
||||
def test_format_relative_time_empty() -> None:
|
||||
assert format_relative_time("") == ""
|
||||
|
||||
|
||||
def test_format_relative_time_invalid() -> None:
|
||||
assert format_relative_time("garbage") == "garbage"
|
||||
|
||||
|
||||
# ── format_iso8601 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_format_iso8601_valid() -> None:
|
||||
result = format_iso8601(SAMPLE_TIMESTAMP)
|
||||
assert result.startswith("2026-03-08T12:00:00")
|
||||
assert "+00:00" in result or "Z" in result
|
||||
|
||||
|
||||
def test_format_iso8601_empty() -> None:
|
||||
assert format_iso8601("") == ""
|
||||
|
||||
|
||||
def test_format_iso8601_invalid() -> None:
|
||||
assert format_iso8601("not a date") == "not a date"
|
||||
|
||||
|
||||
def test_format_iso8601_roundtrip() -> None:
|
||||
"""ISO 8601 output should be parseable by datetime.fromisoformat."""
|
||||
from datetime import datetime
|
||||
|
||||
result = format_iso8601(SAMPLE_TIMESTAMP)
|
||||
dt = datetime.fromisoformat(result)
|
||||
assert dt.year == 2026
|
||||
assert dt.month == 3
|
||||
assert dt.day == 8
|
||||
Reference in New Issue
Block a user