全部 auth 类 fixture 从 HTTP register 改为 DB 直接创建,绕过 6次/小时 IP 限速 (conftest、test_literature、test_subscriptions、test_verification、test_auth、 test_approvals、test_notifications、test_user_settings 共 8 个文件)。 同步修复 europe_pmc 解析、admin pipeline、ai_summary、email_service、 security/permissions 等共 21 个文件的断言和适配问题。
391 lines
12 KiB
Python
391 lines
12 KiB
Python
"""Feed engine service tests"""
|
|
|
|
import uuid
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
def _make_sub_row(user_id, tag_id, match_mode):
|
|
"""Create a mock SQLAlchemy Row that supports both attribute access and
|
|
tuple unpacking, matching how the feed_engine iterates user_data."""
|
|
row = MagicMock()
|
|
row.user_id = user_id
|
|
row.tag_id = tag_id
|
|
row.match_mode = match_mode
|
|
row.__iter__.return_value = iter([user_id, tag_id, match_mode])
|
|
return row
|
|
|
|
|
|
def _make_count_row(user_id, total):
|
|
"""Mock row for user subscription count query."""
|
|
row = MagicMock()
|
|
row.user_id = user_id
|
|
row.total = total
|
|
row.__iter__.return_value = iter([user_id, total])
|
|
return row
|
|
|
|
|
|
def _make_existing_row(user_id, literature_id):
|
|
"""Mock row for existing feed check query."""
|
|
row = MagicMock()
|
|
row.user_id = user_id
|
|
row.literature_id = literature_id
|
|
row.__iter__.return_value = iter([user_id, literature_id])
|
|
return row
|
|
|
|
|
|
def _make_empty_result():
|
|
"""Mock result that iterates to empty list (for dismissed tags / existing feeds)."""
|
|
res = MagicMock()
|
|
res.__iter__.return_value = iter([])
|
|
return res
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_tags():
|
|
"""No tags for the literature -> returns 0 immediately"""
|
|
db = AsyncMock(spec=AsyncSession)
|
|
lit_id = uuid.uuid4()
|
|
|
|
mock_tags = MagicMock()
|
|
mock_tags.__iter__.return_value = iter([])
|
|
db.execute.return_value = mock_tags
|
|
|
|
from app.services.feed_engine import generate_feeds_for_literature
|
|
|
|
result = await generate_feeds_for_literature(db, lit_id)
|
|
|
|
assert result == 0
|
|
assert db.execute.call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tags_no_subscriptions():
|
|
"""Tags exist but no active subscriptions -> returns 0"""
|
|
db = AsyncMock(spec=AsyncSession)
|
|
lit_id = uuid.uuid4()
|
|
tag_id = uuid.uuid4()
|
|
|
|
mock_tags = MagicMock()
|
|
mock_tags.__iter__.return_value = iter([(tag_id, True)])
|
|
|
|
mock_subs = MagicMock()
|
|
mock_subs.all.return_value = []
|
|
|
|
db.execute.side_effect = [mock_tags, mock_subs]
|
|
|
|
from app.services.feed_engine import generate_feeds_for_literature
|
|
|
|
result = await generate_feeds_for_literature(db, lit_id)
|
|
|
|
assert result == 0
|
|
assert db.execute.call_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standard_mode_priority_must_read():
|
|
"""Standard mode, 3+ tags with major -> must_read priority"""
|
|
db = AsyncMock(spec=AsyncSession)
|
|
lit_id = uuid.uuid4()
|
|
uid = uuid.uuid4()
|
|
tag_ids = [uuid.uuid4() for _ in range(3)]
|
|
|
|
mock_tags = MagicMock()
|
|
mock_tags.__iter__.return_value = iter([(tag_ids[0], True), (tag_ids[1], True), (tag_ids[2], True)])
|
|
|
|
rows = [_make_sub_row(uid, tid, "standard") for tid in tag_ids]
|
|
mock_subs = MagicMock()
|
|
mock_subs.all.return_value = rows
|
|
|
|
mock_total = MagicMock()
|
|
mock_total.__iter__.return_value = iter([_make_count_row(uid, 3)])
|
|
|
|
mock_existing = MagicMock()
|
|
mock_existing.__iter__.return_value = iter([])
|
|
|
|
db.execute.side_effect = [mock_tags, mock_subs, _make_empty_result(), mock_total, mock_existing]
|
|
|
|
from app.services.feed_engine import generate_feeds_for_literature
|
|
|
|
result = await generate_feeds_for_literature(db, lit_id)
|
|
|
|
assert result == 1
|
|
assert db.add.call_count == 1
|
|
added_feed = db.add.call_args[0][0]
|
|
assert added_feed.user_id == uid
|
|
assert added_feed.literature_id == lit_id
|
|
assert added_feed.priority == "must_read"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standard_mode_priority_recommended():
|
|
"""Standard mode, 2 tags with 1 major -> recommended priority"""
|
|
db = AsyncMock(spec=AsyncSession)
|
|
lit_id = uuid.uuid4()
|
|
uid = uuid.uuid4()
|
|
tag_id_1 = uuid.uuid4()
|
|
tag_id_2 = uuid.uuid4()
|
|
|
|
mock_tags = MagicMock()
|
|
mock_tags.__iter__.return_value = iter([(tag_id_1, True), (tag_id_2, False)])
|
|
|
|
rows = [
|
|
_make_sub_row(uid, tag_id_1, "standard"),
|
|
_make_sub_row(uid, tag_id_2, "standard"),
|
|
]
|
|
mock_subs = MagicMock()
|
|
mock_subs.all.return_value = rows
|
|
|
|
mock_total = MagicMock()
|
|
mock_total.__iter__.return_value = iter([_make_count_row(uid, 2)])
|
|
|
|
mock_existing = MagicMock()
|
|
mock_existing.__iter__.return_value = iter([])
|
|
|
|
db.execute.side_effect = [mock_tags, mock_subs, _make_empty_result(), mock_total, mock_existing]
|
|
|
|
from app.services.feed_engine import generate_feeds_for_literature
|
|
|
|
result = await generate_feeds_for_literature(db, lit_id)
|
|
|
|
assert result == 1
|
|
added_feed = db.add.call_args[0][0]
|
|
assert added_feed.priority == "recommended"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standard_mode_priority_related():
|
|
"""Standard mode, single non-major tag -> related priority"""
|
|
db = AsyncMock(spec=AsyncSession)
|
|
lit_id = uuid.uuid4()
|
|
uid = uuid.uuid4()
|
|
tag_id = uuid.uuid4()
|
|
|
|
mock_tags = MagicMock()
|
|
mock_tags.__iter__.return_value = iter([(tag_id, False)])
|
|
|
|
mock_subs = MagicMock()
|
|
mock_subs.all.return_value = [_make_sub_row(uid, tag_id, "standard")]
|
|
|
|
mock_total = MagicMock()
|
|
mock_total.__iter__.return_value = iter([_make_count_row(uid, 1)])
|
|
|
|
mock_existing = MagicMock()
|
|
mock_existing.__iter__.return_value = iter([])
|
|
|
|
db.execute.side_effect = [mock_tags, mock_subs, _make_empty_result(), mock_total, mock_existing]
|
|
|
|
from app.services.feed_engine import generate_feeds_for_literature
|
|
|
|
result = await generate_feeds_for_literature(db, lit_id)
|
|
|
|
assert result == 1
|
|
added_feed = db.add.call_args[0][0]
|
|
assert added_feed.priority == "related"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loose_mode_must_read():
|
|
"""Loose mode, 3+ tags -> must_read priority"""
|
|
db = AsyncMock(spec=AsyncSession)
|
|
lit_id = uuid.uuid4()
|
|
uid = uuid.uuid4()
|
|
tag_ids = [uuid.uuid4() for _ in range(3)]
|
|
|
|
mock_tags = MagicMock()
|
|
mock_tags.__iter__.return_value = iter([(tag_ids[0], False), (tag_ids[1], False), (tag_ids[2], True)])
|
|
|
|
rows = [_make_sub_row(uid, tid, "loose") for tid in tag_ids]
|
|
mock_subs = MagicMock()
|
|
mock_subs.all.return_value = rows
|
|
|
|
mock_total = MagicMock()
|
|
mock_total.__iter__.return_value = iter([_make_count_row(uid, 3)])
|
|
|
|
mock_existing = MagicMock()
|
|
mock_existing.__iter__.return_value = iter([])
|
|
|
|
db.execute.side_effect = [mock_tags, mock_subs, _make_empty_result(), mock_total, mock_existing]
|
|
|
|
from app.services.feed_engine import generate_feeds_for_literature
|
|
|
|
result = await generate_feeds_for_literature(db, lit_id)
|
|
|
|
assert result == 1
|
|
added_feed = db.add.call_args[0][0]
|
|
assert added_feed.priority == "must_read"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loose_mode_recommended():
|
|
"""Loose mode, 1 tag -> recommended priority"""
|
|
db = AsyncMock(spec=AsyncSession)
|
|
lit_id = uuid.uuid4()
|
|
uid = uuid.uuid4()
|
|
tag_id = uuid.uuid4()
|
|
|
|
mock_tags = MagicMock()
|
|
mock_tags.__iter__.return_value = iter([(tag_id, True)])
|
|
|
|
mock_subs = MagicMock()
|
|
mock_subs.all.return_value = [_make_sub_row(uid, tag_id, "loose")]
|
|
|
|
mock_total = MagicMock()
|
|
mock_total.__iter__.return_value = iter([_make_count_row(uid, 1)])
|
|
|
|
mock_existing = MagicMock()
|
|
mock_existing.__iter__.return_value = iter([])
|
|
|
|
db.execute.side_effect = [mock_tags, mock_subs, _make_empty_result(), mock_total, mock_existing]
|
|
|
|
from app.services.feed_engine import generate_feeds_for_literature
|
|
|
|
result = await generate_feeds_for_literature(db, lit_id)
|
|
|
|
assert result == 1
|
|
added_feed = db.add.call_args[0][0]
|
|
assert added_feed.priority == "recommended"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_strict_mode_partial_match_skips():
|
|
"""Strict mode, not all subscribed tags match -> feed is skipped"""
|
|
db = AsyncMock(spec=AsyncSession)
|
|
lit_id = uuid.uuid4()
|
|
uid = uuid.uuid4()
|
|
tag_id_1 = uuid.uuid4()
|
|
|
|
# Literature has only tag_id_1 (major)
|
|
mock_tags = MagicMock()
|
|
mock_tags.__iter__.return_value = iter([(tag_id_1, True)])
|
|
|
|
# User has 1 matching subscription but 2 total active subscriptions
|
|
mock_subs = MagicMock()
|
|
mock_subs.all.return_value = [_make_sub_row(uid, tag_id_1, "strict")]
|
|
|
|
mock_total = MagicMock()
|
|
mock_total.__iter__.return_value = iter([_make_count_row(uid, 2)])
|
|
|
|
# existing_feeds query runs before the user loop, must be provided
|
|
mock_existing = MagicMock()
|
|
mock_existing.__iter__.return_value = iter([])
|
|
|
|
db.execute.side_effect = [mock_tags, mock_subs, _make_empty_result(), mock_total, mock_existing]
|
|
|
|
from app.services.feed_engine import generate_feeds_for_literature
|
|
|
|
result = await generate_feeds_for_literature(db, lit_id)
|
|
|
|
assert result == 0
|
|
assert db.add.call_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_strict_mode_all_match_creates_feed():
|
|
"""Strict mode, all subscribed tags match -> feed created with recommended priority"""
|
|
db = AsyncMock(spec=AsyncSession)
|
|
lit_id = uuid.uuid4()
|
|
uid = uuid.uuid4()
|
|
tag_ids = [uuid.uuid4() for _ in range(2)]
|
|
|
|
mock_tags = MagicMock()
|
|
mock_tags.__iter__.return_value = iter([(tag_ids[0], True), (tag_ids[1], False)])
|
|
|
|
rows = [_make_sub_row(uid, tid, "strict") for tid in tag_ids]
|
|
mock_subs = MagicMock()
|
|
mock_subs.all.return_value = rows
|
|
|
|
mock_total = MagicMock()
|
|
mock_total.__iter__.return_value = iter([_make_count_row(uid, 2)])
|
|
|
|
mock_existing = MagicMock()
|
|
mock_existing.__iter__.return_value = iter([])
|
|
|
|
db.execute.side_effect = [mock_tags, mock_subs, _make_empty_result(), mock_total, mock_existing]
|
|
|
|
from app.services.feed_engine import generate_feeds_for_literature
|
|
|
|
result = await generate_feeds_for_literature(db, lit_id)
|
|
|
|
assert result == 1
|
|
added_feed = db.add.call_args[0][0]
|
|
# strict fallback: major_cnt >= 2 -> must_read, else recommended
|
|
# 1 major (tag_ids[0]) -> recommended
|
|
assert added_feed.priority == "recommended"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_feed_prevents_duplicate():
|
|
"""Feed already exists for (user, literature) -> skip duplicate"""
|
|
db = AsyncMock(spec=AsyncSession)
|
|
lit_id = uuid.uuid4()
|
|
uid = uuid.uuid4()
|
|
tag_id = uuid.uuid4()
|
|
|
|
mock_tags = MagicMock()
|
|
mock_tags.__iter__.return_value = iter([(tag_id, True)])
|
|
|
|
mock_subs = MagicMock()
|
|
mock_subs.all.return_value = [_make_sub_row(uid, tag_id, "standard")]
|
|
|
|
mock_total = MagicMock()
|
|
mock_total.__iter__.return_value = iter([_make_count_row(uid, 1)])
|
|
|
|
mock_existing = MagicMock()
|
|
mock_existing.__iter__.return_value = iter([_make_existing_row(uid, lit_id)])
|
|
|
|
db.execute.side_effect = [mock_tags, mock_subs, _make_empty_result(), mock_total, mock_existing]
|
|
|
|
from app.services.feed_engine import generate_feeds_for_literature
|
|
|
|
result = await generate_feeds_for_literature(db, lit_id)
|
|
|
|
assert result == 0
|
|
assert db.add.call_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_users():
|
|
"""Multiple users subscribed -> feeds created for each"""
|
|
db = AsyncMock(spec=AsyncSession)
|
|
lit_id = uuid.uuid4()
|
|
uid_1 = uuid.uuid4()
|
|
uid_2 = uuid.uuid4()
|
|
tag_id = uuid.uuid4()
|
|
|
|
mock_tags = MagicMock()
|
|
mock_tags.__iter__.return_value = iter([(tag_id, True)])
|
|
|
|
mock_subs = MagicMock()
|
|
mock_subs.all.return_value = [
|
|
_make_sub_row(uid_1, tag_id, "standard"),
|
|
_make_sub_row(uid_2, tag_id, "standard"),
|
|
]
|
|
|
|
mock_total = MagicMock()
|
|
mock_total.__iter__.return_value = iter([
|
|
_make_count_row(uid_1, 1),
|
|
_make_count_row(uid_2, 1),
|
|
])
|
|
|
|
mock_existing = MagicMock()
|
|
mock_existing.__iter__.return_value = iter([])
|
|
|
|
db.execute.side_effect = [mock_tags, mock_subs, _make_empty_result(), mock_total, mock_existing]
|
|
|
|
from app.services.feed_engine import generate_feeds_for_literature
|
|
|
|
result = await generate_feeds_for_literature(db, lit_id)
|
|
|
|
assert result == 2
|
|
assert db.add.call_count == 2
|
|
|
|
|
|
__all__ = []
|