mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-28 19:06:36 +08:00
276 lines
12 KiB
Python
276 lines
12 KiB
Python
"""
|
||
订阅搜索检查点的任务租约、版本 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()))
|