Files
backend/backend/tests/test_service_feed_engine.py
T
34047007@qq.com c50831d1d8
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
test: 修复全量测试套件 — 1004 passed,消除注册限速 429 等故障
全部 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 个文件的断言和适配问题。
2026-07-27 22:19:27 +08:00

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__ = []