Files
MoviePilot/tests/test_search_session.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

276 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
订阅搜索检查点的任务租约、版本 CAS 与凭据排除回归。
"""
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import create_engine, update
from sqlalchemy.orm import sessionmaker
from app.application.search.session import encode_search_state, torrent_snapshot
from app.db.adapters.searchsession import TransactionalSearchSessionRepository
from app.db.base import Base
from app.db.models.subscriptionsearch import SubscriptionSearchTask
from app.domain.context import TorrentInfo
@pytest.fixture
def repository(tmp_path):
"""
创建隔离 SQLite 搜索库与有效任务租约,并在测试后释放引擎。
"""
engine = create_engine(f"sqlite:///{tmp_path / 'search.db'}")
Base.metadata.create_all(engine)
now = datetime.now(timezone.utc).isoformat()
expiry = (datetime.now(timezone.utc) + timedelta(minutes=15)).isoformat()
with sessionmaker(bind=engine)() as session:
session.add(SubscriptionSearchTask(task_id="task", batch_id="batch", subscription_id=1,
source="manual", position=0, state="running", created_at=now, updated_at=now,
lease_token="old", lease_expires_at=expiry))
session.commit()
yield TransactionalSearchSessionRepository(sessionmaker(bind=engine)), engine
engine.dispose()
def _set_lease(engine, token):
with sessionmaker(bind=engine)() as session:
session.execute(update(SubscriptionSearchTask).values(lease_token=token))
session.commit()
def test_only_current_task_lease_and_version_can_advance(repository):
"""
只有当前任务租约与检查点版本匹配时才允许推进。
"""
repo, engine = repository
assert repo.create(task_id="task", payload="{}", task_lease="wrong") is None
first = repo.create(task_id="task", payload='{"page":0}', task_lease="old")
saved = repo.save(snapshot=first, payload='{"page":1}', task_lease="old")
assert saved.version == 1
# 迟到 worker 持有旧版本,不能覆盖新进度。
assert repo.save(snapshot=first, payload='{"page":999}', task_lease="old") is None
# 队列重新派发后旧租约立即失效,新租约无需等待即可继续。
_set_lease(engine, "new")
assert repo.save(snapshot=saved, payload='{"page":2}', task_lease="old") is None
assert repo.save(snapshot=saved, payload='{"page":2}', task_lease="new").version == 2
assert repo.get(task_id="task").payload == '{"page":2}'
def test_cancel_request_stops_checkpoint_writes(repository):
"""
任务收到取消请求后停止写入检查点。
"""
repo, engine = repository
first = repo.create(task_id="task", payload="{}", task_lease="old")
with sessionmaker(bind=engine)() as session:
session.execute(update(SubscriptionSearchTask).values(cancel_requested=1))
session.commit()
assert repo.save(snapshot=first, payload="{}", task_lease="old") is None
def test_checkpoint_excludes_credentials_and_indirect_download_recipe():
"""
检查点不保存凭据或可间接还原下载凭据的请求配方。
"""
torrent = TorrentInfo(site=1, title="Show", site_cookie="cookie-secret",
enclosure="[encoded-api-key]https://api.example/download",
page_url="https://site.example/details.php?id=23")
text = encode_search_state(torrent_snapshot(torrent))
assert "cookie-secret" not in text
assert "encoded-api-key" not in text
assert '"enclosure": null' in text
assert 'details.php?id=23' in text
def test_unrecognized_download_query_is_not_persisted():
"""
无法确认安全性的下载查询参数不得持久化。
"""
torrent = TorrentInfo(enclosure="https://site.example/download?id=1&passkey=secret")
assert torrent_snapshot(torrent)["enclosure"] is None
torrent.enclosure = "https://site.example/ticket/opaque-private-capability"
assert torrent_snapshot(torrent)["enclosure"] is None
@pytest.mark.parametrize("system_stop,cancelled,expected", [(True, False, "requeued"), (False, True, "cancelled")])
def test_partial_submission_does_not_finish_an_interrupted_search_round(system_stop, cancelled, expected):
"""
只提交部分目标不足以把中断的搜索轮次标记为完成。
"""
from types import SimpleNamespace
from unittest.mock import Mock
from app.application.subscription.observability import finish_returned_search_task
queue = Mock()
execution = SimpleNamespace(download_started=True, incremental_search=True, scan_finished=False,
is_expired=lambda: False)
_subscription, state, _reason = finish_returned_search_task(queue=queue, task_id="task", lease_token="lease",
subscription_id=1, execution_context=execution, system_stopped=system_stop, cancel_requested=cancelled)
assert state == expected
queue.finish_task.assert_not_called()
queue.release_task.assert_called_once()
def test_finished_round_removes_its_checkpoint_only_with_the_current_lease_and_version(repository):
"""
完成轮次时仅允许当前租约与版本删除检查点。
"""
repo, _ = repository
first = repo.create(task_id="task", payload="{}", task_lease="old")
repo.delete(snapshot=first, task_lease="stale")
assert repo.get(task_id="task") is not None
repo.delete(snapshot=first, task_lease="old")
assert repo.get(task_id="task") is None
def test_stale_worker_cannot_delete_progress_saved_after_lease_handover(repository):
# 旧执行者持有 version=0 快照;租约交接后新执行者保存 version=1,旧执行者随后删除不得生效。
"""
租约交接后的旧工作任务不能删除新持有者保存的进度。
"""
repo, engine = repository
stale = repo.create(task_id="task", payload='{"page":0}', task_lease="old")
_set_lease(engine, "new")
fresh = repo.save(snapshot=stale, payload='{"page":1}', task_lease="new")
assert fresh.version == 1
repo.delete(snapshot=stale, task_lease="old")
assert repo.get(task_id="task").version == 1
# 即使旧执行者碰巧持有新租约,旧版本也删不掉新版本。
repo.delete(snapshot=stale, task_lease="new")
assert repo.get(task_id="task").version == 1
def _queue(tmp_path):
from app.db.adapters.subscriptionsearch import TransactionalSubscriptionSearchRepository
engine = create_engine(f"sqlite:///{tmp_path / 'lifecycle.db'}")
Base.metadata.create_all(engine)
return TransactionalSubscriptionSearchRepository(sessionmaker(bind=engine)), engine
@pytest.mark.parametrize("state", ["completed", "failed", "cancelled", "skipped"])
def test_terminal_task_releases_its_checkpoint(tmp_path, state):
# 同一订阅之后的搜索是新任务,终态任务的检查点不会再被续用。
"""
任务进入终态后释放关联检查点。
"""
queue, engine = _queue(tmp_path)
queue.enqueue(subscription_ids=(1,), source="timer", priority=10)
task = queue.claim_next(owner="worker")
assert queue.sessions.create(task_id=task.task_id, payload="{}", task_lease=task.lease_token)
assert queue.finish_task(task_id=task.task_id, lease_token=task.lease_token, state=state, error=None)
assert queue.sessions.get(task_id=task.task_id) is None
engine.dispose()
def test_deferred_task_keeps_checkpoint_until_batch_is_cancelled(tmp_path):
"""
延后执行的任务保留检查点,直到所属批次取消。
"""
queue, engine = _queue(tmp_path)
enqueued = queue.enqueue(subscription_ids=(1,), source="timer", priority=10)
task = queue.claim_next(owner="worker")
queue.sessions.create(task_id=task.task_id, payload="{}", task_lease=task.lease_token)
assert queue.defer_task(task_id=task.task_id, lease_token=task.lease_token,
available_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
phase="waiting_site", message=None)
assert queue.sessions.get(task_id=task.task_id) is not None
# 排队中的任务被整批取消时直接进入终态,检查点同步删除。
assert queue.request_cancel(enqueued.batch.batch_id)
assert queue.sessions.get(task_id=task.task_id) is None
engine.dispose()
def test_checkpoints_not_updated_for_fourteen_days_are_purged(repository):
"""
十四天未更新的孤立检查点会被清理。
"""
from app.db.models.searchsession import SearchSession
repo, engine = repository
now = datetime.now(timezone.utc)
with sessionmaker(bind=engine)() as session:
for task_id, days in (("orphan", 15), ("recent", 13)):
session.add(SearchSession(task_id=task_id, version=0, payload="{}",
updated_at=(now - timedelta(days=days)).isoformat(timespec="seconds")))
session.commit()
assert repo.create(task_id="task", payload="{}", task_lease="old")
assert repo.get(task_id="orphan") is None
assert repo.get(task_id="recent") is not None
@pytest.mark.parametrize("enclosure", [
"https://site.example/download/0123456789abcdef0123456789abcdef?id=1",
"https://site.example/dl/AbCdEfGh12345678IjKlMnOp9.torrent?id=1",
])
def test_passkey_in_download_path_is_not_persisted(enclosure):
"""
下载路径中包含 passkey 时不保存该地址。
"""
assert torrent_snapshot(TorrentInfo(enclosure=enclosure))["enclosure"] is None
def test_readable_page_paths_are_still_persisted():
"""
可安全读取的详情页和下载路径仍允许持久化。
"""
torrent = TorrentInfo(page_url="https://site.example/torrents/1234567890123456789",
enclosure="https://site.example/download.php?id=1&https=1")
snapshot = torrent_snapshot(torrent)
assert snapshot["page_url"] == torrent.page_url
assert snapshot["enclosure"] == torrent.enclosure
def test_internal_candidate_id_is_not_exported():
"""
内部候选标识不进入公开种子字典,并保持旧构造参数兼容。
"""
torrent = TorrentInfo(site=1, title="Show")
torrent.search_resource_id = "internal"
assert "search_resource_id" not in torrent.to_dict()
assert TorrentInfo(1).site == 1
def test_late_first_create_after_lease_handover_cannot_fail_the_new_owner(repository):
# 审查复现的交错:旧执行者在租约交接后才插入首个检查点,新执行者此前读到的是空。
"""
租约交接后迟到的首次创建不能覆盖或影响新持有者。
"""
from app.db.models.searchsession import SearchSession
repo, engine = repository
_set_lease(engine, "new")
# 租约条件在插入语句内校验:旧执行者交接后的首次创建不落库。
assert repo.create(task_id="task", payload='{"by":"old"}', task_lease="old") is None
assert repo.get(task_id="task") is None
# 即便旧行已经写入(如交接前已通过校验的迟到事务),新执行者也以版本递增接管,不因唯一键失败。
with sessionmaker(bind=engine)() as session:
session.add(SearchSession(task_id="task", version=0, payload='{"by":"old"}',
updated_at=datetime.now(timezone.utc).isoformat(timespec="seconds")))
session.commit()
stale = repo.get(task_id="task")
owned = repo.create(task_id="task", payload='{"by":"new"}', task_lease="new")
assert (owned.version, owned.payload) == (1, '{"by":"new"}')
# 旧执行者既不能再创建覆盖,也不能用旧版本推进或删除。
assert repo.create(task_id="task", payload='{"by":"old"}', task_lease="old") is None
assert repo.save(snapshot=stale, payload='{"by":"old"}', task_lease="old") is None
repo.delete(snapshot=stale, task_lease="old")
assert repo.get(task_id="task").payload == '{"by":"new"}'
def test_first_create_statement_compiles_for_postgresql():
"""
首次创建检查点使用 PostgreSQL 可编译的冲突忽略语句。
"""
from sqlalchemy import literal, select
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import insert
from app.db.models.searchsession import SearchSession
statement = insert(SearchSession).from_select(
["task_id", "version", "payload", "updated_at"], select(literal("t"), literal(0), literal("{}"), literal("now")),
).on_conflict_do_nothing(index_elements=[SearchSession.task_id])
assert "ON CONFLICT (task_id) DO NOTHING" in str(statement.compile(dialect=postgresql.dialect()))