| 1 | """Tests for reddit_arctic — keyless post-score lookup via arctic-shift (U6).""" |
| 2 | |
| 3 | from unittest import mock |
| 4 | |
| 5 | import pytest |
| 6 | |
| 7 | from lib import reddit_arctic |
| 8 | |
| 9 | |
| 10 | @pytest.fixture(autouse=True) |
| 11 | def _no_arctic_network(): |
| 12 | # Override conftest's stub so this module exercises the real fetch_scores; |
| 13 | # network is mocked per-test at http.get. Clear the in-run cache each time. |
| 14 | reddit_arctic._cache.clear() |
| 15 | yield |
| 16 | reddit_arctic._cache.clear() |
| 17 | |
| 18 | |
| 19 | def _resp(rows): |
| 20 | return {"data": rows} |
| 21 | |
| 22 | |
| 23 | class TestFetchScores: |
| 24 | def test_returns_scores_by_id(self): |
| 25 | with mock.patch.object(reddit_arctic.http, "get", |
| 26 | return_value=_resp([{"id": "abc", "score": 1531, "num_comments": 336}])): |
| 27 | out = reddit_arctic.fetch_scores(["abc"]) |
| 28 | assert out == {"abc": {"score": 1531, "num_comments": 336}} |
| 29 | |
| 30 | def test_strips_t3_prefix(self): |
| 31 | with mock.patch.object(reddit_arctic.http, "get", |
| 32 | return_value=_resp([{"id": "t3_xyz", "score": 5, "num_comments": 2}])): |
| 33 | out = reddit_arctic.fetch_scores(["xyz"]) |
| 34 | assert out["xyz"]["score"] == 5 |
| 35 | |
| 36 | def test_rate_limit_response_degrades(self): |
| 37 | # arctic-shift answers {"error": "...slow down"} with no data list. |
| 38 | with mock.patch.object(reddit_arctic.http, "get", |
| 39 | return_value={"error": "Timeout. Maybe slow down a bit"}): |
| 40 | out = reddit_arctic.fetch_scores(["abc"]) |
| 41 | assert out == {} |
| 42 | |
| 43 | def test_network_error_degrades(self): |
| 44 | with mock.patch.object(reddit_arctic.http, "get", side_effect=Exception("boom")): |
| 45 | out = reddit_arctic.fetch_scores(["abc"]) |
| 46 | assert out == {} |
| 47 | |
| 48 | def test_in_run_cache_avoids_refetch(self): |
| 49 | with mock.patch.object(reddit_arctic.http, "get", |
| 50 | return_value=_resp([{"id": "abc", "score": 9, "num_comments": 1}])) as g: |
| 51 | reddit_arctic.fetch_scores(["abc"]) |
| 52 | reddit_arctic.fetch_scores(["abc"]) # served from cache, no 2nd call |
| 53 | assert g.call_count == 1 |
| 54 | |
| 55 | def test_dedupes_into_single_batch(self): |
| 56 | with mock.patch.object(reddit_arctic.http, "get", |
| 57 | return_value=_resp([{"id": "a", "score": 1, "num_comments": 0}, |
| 58 | {"id": "b", "score": 2, "num_comments": 0}])) as g: |
| 59 | out = reddit_arctic.fetch_scores(["a", "b", "a", ""]) |
| 60 | assert g.call_count == 1 |
| 61 | assert set(out) == {"a", "b"} |
| 62 | |
| 63 | def test_cache_is_size_bounded(self): |
| 64 | # The in-run memo never grows past CACHE_MAX; scores are still returned |
| 65 | # for the current call once the cache is full. |
| 66 | with mock.patch.object(reddit_arctic, "CACHE_MAX", 1), \ |
| 67 | mock.patch.object(reddit_arctic.http, "get", |
| 68 | return_value=_resp([{"id": "a", "score": 1, "num_comments": 0}, |
| 69 | {"id": "b", "score": 2, "num_comments": 0}])): |
| 70 | out = reddit_arctic.fetch_scores(["a", "b"]) |
| 71 | assert set(out) == {"a", "b"} # both returned |
| 72 | assert len(reddit_arctic._cache) <= 1 # cache stayed bounded |
| 73 | |
| 74 | def test_empty_input_makes_no_call(self): |
| 75 | with mock.patch.object(reddit_arctic.http, "get") as g: |
| 76 | out = reddit_arctic.fetch_scores([]) |
| 77 | assert out == {} |
| 78 | g.assert_not_called() |
| 79 | |
| 80 | def test_malformed_rows_skipped(self): |
| 81 | rows = [{"id": "ok", "score": 7, "num_comments": 3}, |
| 82 | {"id": "", "score": 1}, # no id |
| 83 | "not-a-dict", # junk |
| 84 | {"id": "bad", "score": "x"}] # unparseable score |
| 85 | with mock.patch.object(reddit_arctic.http, "get", return_value=_resp(rows)): |
| 86 | out = reddit_arctic.fetch_scores(["ok", "bad"]) |
| 87 | assert out == {"ok": {"score": 7, "num_comments": 3}} |
| 88 |