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
34 changes: 30 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,35 +141,61 @@ def fetchall(self):


class _FakeConn:
def __init__(self, store: _FakeStore):
def __init__(self, store: _FakeStore, pool: FakePool | None = None):
self._cur = _FakeCursor(store)
self._pool = pool
self.rollback_called = False

def cursor(self):
return self._cur

def commit(self):
pass
if self._pool is not None and self._pool.fail_on_commit:
self._pool.fail_on_commit = False
raise RuntimeError("simulated commit failure")

def rollback(self):
pass
self.rollback_called = True
if self._pool is not None:
self._pool.rollback_count += 1


class FakePool:
"""In-memory substitute for psycopg2.pool.ThreadedConnectionPool.

Each instance has its own isolated store. Pass the same instance to
multiple storage objects when they need to share state.

Optional test hooks:

* ``fail_on_commit`` — next ``commit()`` raises ``RuntimeError`` once,
exercising ``storage._conn`` rollback paths.
* ``seed_watchlist_raw(rows)`` — insert ``(slack_user_id, entry, entry_type)``
rows directly (bypasses ``UserWatchlist.add`` validation).
* ``seed_paper_cache_invalid_json()`` — store malformed JSON for the
wg21 index cache key so ``PaperCache.read()`` hits the decode-error path.
"""

def __init__(self):
self._store = _FakeStore()
self.fail_on_commit = False
self.rollback_count = 0

def getconn(self):
return _FakeConn(self._store)
return _FakeConn(self._store, self)

def putconn(self, conn):
pass

def seed_watchlist_raw(self, rows: list[tuple[str, str, str]]) -> None:
"""Directly populate ``user_watchlist`` rows for edge-case tests."""
for uid, entry, etype in rows:
self._store.watchlist[(uid, entry)] = etype

def seed_paper_cache_invalid_json(self) -> None:
"""Store a non-JSON string as cached index data (see ``PaperCache.read``)."""
self._store.paper_cache["wg21_index"] = ("{not-json", 1.0)


# ── Settings factory ──────────────────────────────────────────────────────────

Expand Down
65 changes: 65 additions & 0 deletions tests/test_db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Tests for paperscout.db (mocked psycopg2 pool — no real PostgreSQL)."""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest

from paperscout.db import init_db, init_pool


@patch("paperscout.db.pg_pool.ThreadedConnectionPool")
def test_init_pool_defaults(mock_tp_class):
mock_tp_class.return_value = MagicMock(name="pool")
pool = init_pool("postgresql://localhost/db")
mock_tp_class.assert_called_once_with(1, 10, "postgresql://localhost/db")
assert pool is mock_tp_class.return_value


@patch("paperscout.db.pg_pool.ThreadedConnectionPool")
def test_init_pool_custom_sizes(mock_tp_class):
mock_tp_class.return_value = MagicMock()
pool = init_pool("postgresql://x", minconn=3, maxconn=15)
mock_tp_class.assert_called_once_with(3, 15, "postgresql://x")
assert pool is mock_tp_class.return_value


def test_init_db_executes_ddl_commits_putconn():
pool = MagicMock()
conn = MagicMock()
cur = MagicMock()
cm = MagicMock()
cm.__enter__.return_value = cur
cm.__exit__.return_value = None
conn.cursor.return_value = cm
pool.getconn.return_value = conn

init_db(pool)

cur.execute.assert_called_once()
ddl = cur.execute.call_args[0][0]
assert "CREATE TABLE IF NOT EXISTS paper_cache" in ddl
assert "discovered_urls" in ddl
assert "probe_miss_counts" in ddl
assert "poll_state" in ddl
assert "user_watchlist" in ddl
conn.commit.assert_called_once()
pool.putconn.assert_called_once_with(conn)


def test_init_db_putconn_even_when_execute_fails():
pool = MagicMock()
conn = MagicMock()
cur = MagicMock()
cm = MagicMock()
cm.__enter__.return_value = cur
cm.__exit__.return_value = None
conn.cursor.return_value = cm
pool.getconn.return_value = conn
cur.execute.side_effect = RuntimeError("DDL failed")

with pytest.raises(RuntimeError, match="DDL failed"):
init_db(pool)

pool.putconn.assert_called_once_with(conn)
15 changes: 15 additions & 0 deletions tests/test_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,18 @@ def test_other_path_returns_404(self, health_url):
with pytest.raises(urllib.error.HTTPError) as exc_info:
urllib.request.urlopen(f"{health_url}/notfound")
assert exc_info.value.code == 404

def test_iso_probe_flag_follows_config_settings(self, health_url):
import paperscout.config as cfg

original = cfg.settings.enable_iso_probe
try:
cfg.settings.enable_iso_probe = False
data = json.loads(urllib.request.urlopen(f"{health_url}/health").read())
assert data["iso_probe_enabled"] is False

cfg.settings.enable_iso_probe = True
data = json.loads(urllib.request.urlopen(f"{health_url}/health").read())
assert data["iso_probe_enabled"] is True
finally:
cfg.settings.enable_iso_probe = original
32 changes: 32 additions & 0 deletions tests/test_init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Tests for paperscout package metadata (__version__)."""

from __future__ import annotations

import importlib
import importlib.metadata
from unittest.mock import patch

import pytest

import paperscout


@pytest.fixture(autouse=True)
def restore_paperscout_module():
yield
importlib.reload(paperscout)


def test_version_uses_installed_metadata():
with patch.object(importlib.metadata, "version", return_value="9.9.9-test"):
importlib.reload(paperscout)
assert paperscout.__version__ == "9.9.9-test"


def test_version_fallback_when_package_not_found():
def _missing(_name: str):
raise importlib.metadata.PackageNotFoundError()

with patch.object(importlib.metadata, "version", side_effect=_missing):
importlib.reload(paperscout)
assert paperscout.__version__ == "0.0.0-dev"
142 changes: 142 additions & 0 deletions tests/test_message_queue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Tests for paperscout.scout.MessageQueue (Slack chat.postMessage worker)."""

from __future__ import annotations

import threading
from unittest.mock import MagicMock, patch

import pytest
from slack_sdk.errors import SlackApiError

from paperscout.scout import MessageQueue


def _slack_error(status: int, headers: dict | None = None) -> SlackApiError:
resp = MagicMock()
resp.status_code = status
resp.headers = headers if headers is not None else {}
return SlackApiError("slack error", resp)


class TestMessageQueueDirect:
"""Exercise ``_throttle`` / ``_send_with_retry`` without starting the daemon thread."""

def test_send_success_updates_last_send(self):
app = MagicMock()
mq = MessageQueue(app)
with patch.object(mq, "_throttle"):
mq._send_with_retry("C1", "hello", {})
app.client.chat_postMessage.assert_called_once_with(
channel="C1",
text="hello",
unfurl_links=False,
unfurl_media=False,
)

def test_send_forwards_extra_kwargs(self):
app = MagicMock()
mq = MessageQueue(app)
with patch.object(mq, "_throttle"):
mq._send_with_retry("C1", "x", {"thread_ts": "99.9"})
app.client.chat_postMessage.assert_called_once_with(
channel="C1",
text="x",
unfurl_links=False,
unfurl_media=False,
thread_ts="99.9",
)

def test_429_retries_then_success(self):
app = MagicMock()
app.client.chat_postMessage.side_effect = [
_slack_error(429, {"Retry-After": "2"}),
None,
]
mq = MessageQueue(app)
sleeps: list[float] = []

with patch.object(mq, "_throttle"):
with patch("paperscout.scout.time.sleep", side_effect=sleeps.append):
mq._send_with_retry("C1", "hi", {})

assert app.client.chat_postMessage.call_count == 2
assert sleeps == [2.0]

def test_429_default_retry_after_when_header_missing(self):
app = MagicMock()
app.client.chat_postMessage.side_effect = [
_slack_error(429, {}),
None,
]
mq = MessageQueue(app)
sleeps: list[float] = []

with patch.object(mq, "_throttle"):
with patch("paperscout.scout.time.sleep", side_effect=sleeps.append):
mq._send_with_retry("C1", "hi", {})

assert sleeps == [5.0]

def test_non_429_slack_error_stops(self):
app = MagicMock()
app.client.chat_postMessage.side_effect = _slack_error(500)
mq = MessageQueue(app)

with patch.object(mq, "_throttle"):
mq._send_with_retry("C1", "hi", {})

assert app.client.chat_postMessage.call_count == 1

def test_generic_exception_stops(self):
app = MagicMock()
app.client.chat_postMessage.side_effect = RuntimeError("network down")
mq = MessageQueue(app)

with patch.object(mq, "_throttle"):
mq._send_with_retry("C1", "hi", {})

assert app.client.chat_postMessage.call_count == 1

def test_throttle_sleeps_when_within_one_second(self):
app = MagicMock()
mq = MessageQueue(app)
mq._last_send["C1"] = 1000.0

sleeps: list[float] = []

with patch("paperscout.scout.time.monotonic", return_value=1000.4):
with patch("paperscout.scout.time.sleep", side_effect=sleeps.append):
mq._throttle("C1")

assert len(sleeps) == 1
assert sleeps[0] == pytest.approx(0.6, rel=1e-3)

def test_throttle_no_sleep_when_idle(self):
app = MagicMock()
mq = MessageQueue(app)
mq._last_send["C1"] = 0.0

sleeps: list[float] = []

with patch("paperscout.scout.time.monotonic", return_value=5000.0):
with patch("paperscout.scout.time.sleep", side_effect=sleeps.append):
mq._throttle("C1")

assert sleeps == []


class TestMessageQueueThreaded:
def test_enqueue_processed_by_background_thread(self):
app = MagicMock()
mq = MessageQueue(app)
done = threading.Event()

def side_effect(**kwargs):
done.set()

app.client.chat_postMessage.side_effect = side_effect

mq.start()
mq.enqueue("D123", "queued message")
assert done.wait(timeout=5.0), "chat_postMessage was not invoked in time"
app.client.chat_postMessage.assert_called()
18 changes: 18 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,21 @@ def test_paper_default_fields():
assert p.long_link == ""
assert p.github_url == ""
assert p.issues == []


@pytest.mark.parametrize(
"pid,exp_prefix,exp_num,exp_rev",
[
("P0001R0", "P", 1, 0),
("p0001r0", "P", 1, 0),
("D2300R10", "D", 2300, 10),
("N4950", "N", 4950, None),
("CWG123", "CWG", 123, None),
("garbage", "", None, None),
],
)
def test_paper_id_prefix_number_revision(pid, exp_prefix, exp_num, exp_rev):
p = Paper(id=pid)
assert p.prefix == exp_prefix
assert p.number == exp_num
assert p.revision == exp_rev
19 changes: 19 additions & 0 deletions tests/test_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,25 @@ def test_empty_to_empty(self):
result = diff_snapshots({}, {})
assert result.new_papers == [] and result.updated_papers == []

@pytest.mark.parametrize(
"field,new_val",
[
("title", "New Title"),
("author", "New Author"),
("date", "2025-01-01"),
("long_link", "https://new.example/paper.pdf"),
],
)
def test_updated_paper_detected_single_field(self, field, new_val):
base = dict(title="T", author="A", date="2024-01-01", long_link="")
old_kw = dict(base)
new_kw = dict(base)
new_kw[field] = new_val
old_p = Paper(id="P2300R10", **old_kw)
new_p = Paper(id="P2300R10", **new_kw)
result = diff_snapshots({"P2300R10": old_p}, {"P2300R10": new_p})
assert len(result.updated_papers) == 1


# ── PollResult ────────────────────────────────────────────────────────────────

Expand Down
Loading
Loading