Skip to content

Commit 43f8549

Browse files
authored
Merge pull request #16 from henry0816191/bugfix/rich-test-building
Added Some Tests
2 parents 57a075d + 3436550 commit 43f8549

11 files changed

Lines changed: 517 additions & 5 deletions

tests/conftest.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,35 +141,61 @@ def fetchall(self):
141141

142142

143143
class _FakeConn:
144-
def __init__(self, store: _FakeStore):
144+
def __init__(self, store: _FakeStore, pool: FakePool | None = None):
145145
self._cur = _FakeCursor(store)
146+
self._pool = pool
147+
self.rollback_called = False
146148

147149
def cursor(self):
148150
return self._cur
149151

150152
def commit(self):
151-
pass
153+
if self._pool is not None and self._pool.fail_on_commit:
154+
self._pool.fail_on_commit = False
155+
raise RuntimeError("simulated commit failure")
152156

153157
def rollback(self):
154-
pass
158+
self.rollback_called = True
159+
if self._pool is not None:
160+
self._pool.rollback_count += 1
155161

156162

157163
class FakePool:
158164
"""In-memory substitute for psycopg2.pool.ThreadedConnectionPool.
159165
160166
Each instance has its own isolated store. Pass the same instance to
161167
multiple storage objects when they need to share state.
168+
169+
Optional test hooks:
170+
171+
* ``fail_on_commit`` — next ``commit()`` raises ``RuntimeError`` once,
172+
exercising ``storage._conn`` rollback paths.
173+
* ``seed_watchlist_raw(rows)`` — insert ``(slack_user_id, entry, entry_type)``
174+
rows directly (bypasses ``UserWatchlist.add`` validation).
175+
* ``seed_paper_cache_invalid_json()`` — store malformed JSON for the
176+
wg21 index cache key so ``PaperCache.read()`` hits the decode-error path.
162177
"""
163178

164179
def __init__(self):
165180
self._store = _FakeStore()
181+
self.fail_on_commit = False
182+
self.rollback_count = 0
166183

167184
def getconn(self):
168-
return _FakeConn(self._store)
185+
return _FakeConn(self._store, self)
169186

170187
def putconn(self, conn):
171188
pass
172189

190+
def seed_watchlist_raw(self, rows: list[tuple[str, str, str]]) -> None:
191+
"""Directly populate ``user_watchlist`` rows for edge-case tests."""
192+
for uid, entry, etype in rows:
193+
self._store.watchlist[(uid, entry)] = etype
194+
195+
def seed_paper_cache_invalid_json(self) -> None:
196+
"""Store a non-JSON string as cached index data (see ``PaperCache.read``)."""
197+
self._store.paper_cache["wg21_index"] = ("{not-json", 1.0)
198+
173199

174200
# ── Settings factory ──────────────────────────────────────────────────────────
175201

tests/test_db.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Tests for paperscout.db (mocked psycopg2 pool — no real PostgreSQL)."""
2+
3+
from __future__ import annotations
4+
5+
from unittest.mock import MagicMock, patch
6+
7+
import pytest
8+
9+
from paperscout.db import init_db, init_pool
10+
11+
12+
@patch("paperscout.db.pg_pool.ThreadedConnectionPool")
13+
def test_init_pool_defaults(mock_tp_class):
14+
mock_tp_class.return_value = MagicMock(name="pool")
15+
pool = init_pool("postgresql://localhost/db")
16+
mock_tp_class.assert_called_once_with(1, 10, "postgresql://localhost/db")
17+
assert pool is mock_tp_class.return_value
18+
19+
20+
@patch("paperscout.db.pg_pool.ThreadedConnectionPool")
21+
def test_init_pool_custom_sizes(mock_tp_class):
22+
mock_tp_class.return_value = MagicMock()
23+
pool = init_pool("postgresql://x", minconn=3, maxconn=15)
24+
mock_tp_class.assert_called_once_with(3, 15, "postgresql://x")
25+
assert pool is mock_tp_class.return_value
26+
27+
28+
def test_init_db_executes_ddl_commits_putconn():
29+
pool = MagicMock()
30+
conn = MagicMock()
31+
cur = MagicMock()
32+
cm = MagicMock()
33+
cm.__enter__.return_value = cur
34+
cm.__exit__.return_value = None
35+
conn.cursor.return_value = cm
36+
pool.getconn.return_value = conn
37+
38+
init_db(pool)
39+
40+
cur.execute.assert_called_once()
41+
ddl = cur.execute.call_args[0][0]
42+
assert "CREATE TABLE IF NOT EXISTS paper_cache" in ddl
43+
assert "discovered_urls" in ddl
44+
assert "probe_miss_counts" in ddl
45+
assert "poll_state" in ddl
46+
assert "user_watchlist" in ddl
47+
conn.commit.assert_called_once()
48+
pool.putconn.assert_called_once_with(conn)
49+
50+
51+
def test_init_db_putconn_even_when_execute_fails():
52+
pool = MagicMock()
53+
conn = MagicMock()
54+
cur = MagicMock()
55+
cm = MagicMock()
56+
cm.__enter__.return_value = cur
57+
cm.__exit__.return_value = None
58+
conn.cursor.return_value = cm
59+
pool.getconn.return_value = conn
60+
cur.execute.side_effect = RuntimeError("DDL failed")
61+
62+
with pytest.raises(RuntimeError, match="DDL failed"):
63+
init_db(pool)
64+
65+
pool.putconn.assert_called_once_with(conn)

tests/test_health.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,18 @@ def test_other_path_returns_404(self, health_url):
6666
with pytest.raises(urllib.error.HTTPError) as exc_info:
6767
urllib.request.urlopen(f"{health_url}/notfound")
6868
assert exc_info.value.code == 404
69+
70+
def test_iso_probe_flag_follows_config_settings(self, health_url):
71+
import paperscout.config as cfg
72+
73+
original = cfg.settings.enable_iso_probe
74+
try:
75+
cfg.settings.enable_iso_probe = False
76+
data = json.loads(urllib.request.urlopen(f"{health_url}/health").read())
77+
assert data["iso_probe_enabled"] is False
78+
79+
cfg.settings.enable_iso_probe = True
80+
data = json.loads(urllib.request.urlopen(f"{health_url}/health").read())
81+
assert data["iso_probe_enabled"] is True
82+
finally:
83+
cfg.settings.enable_iso_probe = original

tests/test_init.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Tests for paperscout package metadata (__version__)."""
2+
3+
from __future__ import annotations
4+
5+
import importlib
6+
import importlib.metadata
7+
from unittest.mock import patch
8+
9+
import pytest
10+
11+
import paperscout
12+
13+
14+
@pytest.fixture(autouse=True)
15+
def restore_paperscout_module():
16+
yield
17+
importlib.reload(paperscout)
18+
19+
20+
def test_version_uses_installed_metadata():
21+
with patch.object(importlib.metadata, "version", return_value="9.9.9-test"):
22+
importlib.reload(paperscout)
23+
assert paperscout.__version__ == "9.9.9-test"
24+
25+
26+
def test_version_fallback_when_package_not_found():
27+
def _missing(_name: str):
28+
raise importlib.metadata.PackageNotFoundError()
29+
30+
with patch.object(importlib.metadata, "version", side_effect=_missing):
31+
importlib.reload(paperscout)
32+
assert paperscout.__version__ == "0.0.0-dev"

tests/test_message_queue.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Tests for paperscout.scout.MessageQueue (Slack chat.postMessage worker)."""
2+
3+
from __future__ import annotations
4+
5+
import threading
6+
from unittest.mock import MagicMock, patch
7+
8+
import pytest
9+
from slack_sdk.errors import SlackApiError
10+
11+
from paperscout.scout import MessageQueue
12+
13+
14+
def _slack_error(status: int, headers: dict | None = None) -> SlackApiError:
15+
resp = MagicMock()
16+
resp.status_code = status
17+
resp.headers = headers if headers is not None else {}
18+
return SlackApiError("slack error", resp)
19+
20+
21+
class TestMessageQueueDirect:
22+
"""Exercise ``_throttle`` / ``_send_with_retry`` without starting the daemon thread."""
23+
24+
def test_send_success_updates_last_send(self):
25+
app = MagicMock()
26+
mq = MessageQueue(app)
27+
with patch.object(mq, "_throttle"):
28+
mq._send_with_retry("C1", "hello", {})
29+
app.client.chat_postMessage.assert_called_once_with(
30+
channel="C1",
31+
text="hello",
32+
unfurl_links=False,
33+
unfurl_media=False,
34+
)
35+
36+
def test_send_forwards_extra_kwargs(self):
37+
app = MagicMock()
38+
mq = MessageQueue(app)
39+
with patch.object(mq, "_throttle"):
40+
mq._send_with_retry("C1", "x", {"thread_ts": "99.9"})
41+
app.client.chat_postMessage.assert_called_once_with(
42+
channel="C1",
43+
text="x",
44+
unfurl_links=False,
45+
unfurl_media=False,
46+
thread_ts="99.9",
47+
)
48+
49+
def test_429_retries_then_success(self):
50+
app = MagicMock()
51+
app.client.chat_postMessage.side_effect = [
52+
_slack_error(429, {"Retry-After": "2"}),
53+
None,
54+
]
55+
mq = MessageQueue(app)
56+
sleeps: list[float] = []
57+
58+
with patch.object(mq, "_throttle"):
59+
with patch("paperscout.scout.time.sleep", side_effect=sleeps.append):
60+
mq._send_with_retry("C1", "hi", {})
61+
62+
assert app.client.chat_postMessage.call_count == 2
63+
assert sleeps == [2.0]
64+
65+
def test_429_default_retry_after_when_header_missing(self):
66+
app = MagicMock()
67+
app.client.chat_postMessage.side_effect = [
68+
_slack_error(429, {}),
69+
None,
70+
]
71+
mq = MessageQueue(app)
72+
sleeps: list[float] = []
73+
74+
with patch.object(mq, "_throttle"):
75+
with patch("paperscout.scout.time.sleep", side_effect=sleeps.append):
76+
mq._send_with_retry("C1", "hi", {})
77+
78+
assert sleeps == [5.0]
79+
80+
def test_non_429_slack_error_stops(self):
81+
app = MagicMock()
82+
app.client.chat_postMessage.side_effect = _slack_error(500)
83+
mq = MessageQueue(app)
84+
85+
with patch.object(mq, "_throttle"):
86+
mq._send_with_retry("C1", "hi", {})
87+
88+
assert app.client.chat_postMessage.call_count == 1
89+
90+
def test_generic_exception_stops(self):
91+
app = MagicMock()
92+
app.client.chat_postMessage.side_effect = RuntimeError("network down")
93+
mq = MessageQueue(app)
94+
95+
with patch.object(mq, "_throttle"):
96+
mq._send_with_retry("C1", "hi", {})
97+
98+
assert app.client.chat_postMessage.call_count == 1
99+
100+
def test_throttle_sleeps_when_within_one_second(self):
101+
app = MagicMock()
102+
mq = MessageQueue(app)
103+
mq._last_send["C1"] = 1000.0
104+
105+
sleeps: list[float] = []
106+
107+
with patch("paperscout.scout.time.monotonic", return_value=1000.4):
108+
with patch("paperscout.scout.time.sleep", side_effect=sleeps.append):
109+
mq._throttle("C1")
110+
111+
assert len(sleeps) == 1
112+
assert sleeps[0] == pytest.approx(0.6, rel=1e-3)
113+
114+
def test_throttle_no_sleep_when_idle(self):
115+
app = MagicMock()
116+
mq = MessageQueue(app)
117+
mq._last_send["C1"] = 0.0
118+
119+
sleeps: list[float] = []
120+
121+
with patch("paperscout.scout.time.monotonic", return_value=5000.0):
122+
with patch("paperscout.scout.time.sleep", side_effect=sleeps.append):
123+
mq._throttle("C1")
124+
125+
assert sleeps == []
126+
127+
128+
class TestMessageQueueThreaded:
129+
def test_enqueue_processed_by_background_thread(self):
130+
app = MagicMock()
131+
mq = MessageQueue(app)
132+
done = threading.Event()
133+
134+
def side_effect(**kwargs):
135+
done.set()
136+
137+
app.client.chat_postMessage.side_effect = side_effect
138+
139+
mq.start()
140+
mq.enqueue("D123", "queued message")
141+
assert done.wait(timeout=5.0), "chat_postMessage was not invoked in time"
142+
app.client.chat_postMessage.assert_called()

tests/test_models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,21 @@ def test_paper_default_fields():
197197
assert p.long_link == ""
198198
assert p.github_url == ""
199199
assert p.issues == []
200+
201+
202+
@pytest.mark.parametrize(
203+
"pid,exp_prefix,exp_num,exp_rev",
204+
[
205+
("P0001R0", "P", 1, 0),
206+
("p0001r0", "P", 1, 0),
207+
("D2300R10", "D", 2300, 10),
208+
("N4950", "N", 4950, None),
209+
("CWG123", "CWG", 123, None),
210+
("garbage", "", None, None),
211+
],
212+
)
213+
def test_paper_id_prefix_number_revision(pid, exp_prefix, exp_num, exp_rev):
214+
p = Paper(id=pid)
215+
assert p.prefix == exp_prefix
216+
assert p.number == exp_num
217+
assert p.revision == exp_rev

tests/test_monitor.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,25 @@ def test_empty_to_empty(self):
110110
result = diff_snapshots({}, {})
111111
assert result.new_papers == [] and result.updated_papers == []
112112

113+
@pytest.mark.parametrize(
114+
"field,new_val",
115+
[
116+
("title", "New Title"),
117+
("author", "New Author"),
118+
("date", "2025-01-01"),
119+
("long_link", "https://new.example/paper.pdf"),
120+
],
121+
)
122+
def test_updated_paper_detected_single_field(self, field, new_val):
123+
base = dict(title="T", author="A", date="2024-01-01", long_link="")
124+
old_kw = dict(base)
125+
new_kw = dict(base)
126+
new_kw[field] = new_val
127+
old_p = Paper(id="P2300R10", **old_kw)
128+
new_p = Paper(id="P2300R10", **new_kw)
129+
result = diff_snapshots({"P2300R10": old_p}, {"P2300R10": new_p})
130+
assert len(result.updated_papers) == 1
131+
113132

114133
# ── PollResult ────────────────────────────────────────────────────────────────
115134

0 commit comments

Comments
 (0)