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

655 lines
27 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.
"""
整理历史表的查询行为。
整理历史同时承担三个职责:查重(这个文件整理过没有)、溯源(这个媒体库文件是从哪
来的)、统计。查重误判会重复整理或永久漏件——挂载故障那一类问题最终就落在这张表上;
溯源查错会让「重新整理」把不相干的文件搬走。
"""
import time as _time
import pytest
from app.db.models import transferhistory as transferhistory_module
from app.db.models.transferhistory import TransferHistory
from app.schemas.types import MediaSource, MediaType
TMDB = str(MediaSource.TMDB)
@pytest.fixture(autouse=True)
def _track(db):
"""
把整理历史表纳入用例级回收。
"""
db.watermark(TransferHistory)
def _hist(title: str = "片名", src: str = None, dest: str = None,
src_storage: str = "local", dest_storage: str = "local",
mode: str = "move", status: bool = True, mtype: str = None,
year: str = "2026", media_id: str = "6001", seasons: str = None,
episodes: str = None, date: str = "2026-08-13 10:00:00",
download_hash: str = None) -> TransferHistory:
"""
构造一条整理历史。
"""
return TransferHistory(src=src or f"/downloads/{title}.mkv", src_storage=src_storage,
dest=dest or f"/media/{title}.mkv", dest_storage=dest_storage,
mode=mode, type=mtype or MediaType.TV.value, title=title,
year=year, media_source=TMDB, media_id=media_id,
seasons=seasons, episodes=episodes, status=status,
date=date, download_hash=download_hash, files=[])
# --------------------------------------------------------------------------- #
# 按路径溯源
# --------------------------------------------------------------------------- #
def test_get_by_src_scopes_by_storage(db):
"""
按源路径查询可限定存储,不限定时取主键最大的那条。
同一路径在不同存储下是不同文件;不限定存储会把本地盘的记录当成网盘的。
表上有 (src, src_storage) 唯一约束,所以「最新一条」只在跨存储时才有分歧。
"""
db.add(_hist("本地", src="/data/same.mkv", src_storage="local"))
latest = db.add(_hist("网盘", src="/data/same.mkv", src_storage="alist"))
assert TransferHistory.get_by_src(db.session, "/data/same.mkv",
storage="alist").title == "网盘"
assert TransferHistory.get_by_src(db.session, "/data/same.mkv",
storage="local").title == "本地"
assert TransferHistory.get_by_src(db.session, "/data/same.mkv").id == latest.id
assert TransferHistory.get_by_src(db.session, "/data/none.mkv") is None
def test_get_success_by_src_matches_path_verbatim(db):
"""
成功记录按源路径原样精确匹配,不做归一化。
蓝光原盘目录的记录带尾斜杠,归一化后反而匹配不到自己。
"""
db.add(_hist("原盘", src="/data/BDMV/", status=True),
_hist("失败的", src="/data/fail.mkv", status=False))
assert TransferHistory.get_success_by_src(db.session, "/data/BDMV/").title == "原盘"
assert TransferHistory.get_success_by_src(db.session, "/data/BDMV") is None
assert TransferHistory.get_success_by_src(db.session, "/data/fail.mkv") is None
def test_get_success_by_src_can_scope_by_storage(db):
"""
成功记录查询同样支持按源存储收窄。
"""
db.add(_hist("本地", src="/data/x.mkv", src_storage="local"),
_hist("网盘", src="/data/x.mkv", src_storage="alist"))
assert TransferHistory.get_success_by_src(db.session, "/data/x.mkv",
storage="alist").title == "网盘"
def test_get_by_dest_scopes_by_storage_and_takes_newest(db):
"""
按目标路径查询用于从媒体库反查来源,可限定目标存储并取最新一条。
"""
db.add(_hist("旧", src="/data/d1.mkv", dest="/media/same.mkv", dest_storage="local"))
newest = db.add(_hist("新", src="/data/d2.mkv", dest="/media/same.mkv",
dest_storage="local"))
db.add(_hist("别的存储", src="/data/d3.mkv", dest="/media/same.mkv",
dest_storage="alist"))
assert TransferHistory.get_by_dest(db.session, "/media/same.mkv",
storage="local").id == newest.id
assert TransferHistory.get_by_dest(db.session, "/media/same.mkv",
storage="alist").title == "别的存储"
assert TransferHistory.get_by_dest(db.session, "/media/none.mkv") is None
def test_list_success_by_src_normalizes_the_path(db):
"""
列举成功记录时对入参路径做归一化(反斜杠、尾斜杠)。
Windows 侧传来的路径带反斜杠,不归一化会一条都匹配不到。
"""
db.add(_hist("目录", src="/data/dir"))
for probe in ("/data/dir", "/data/dir/", "\\data\\dir"):
assert [h.title for h in TransferHistory.list_success_by_src(db.session, probe)] == ["目录"]
def test_list_success_by_src_recursive_matches_children_only(db):
"""
递归模式匹配目录自身及其子项,但不能误伤同前缀的兄弟目录。
直接用 `like("前缀%")` 会把 /data/dir2 也算成 /data/dir 的子项,
重新整理时会把邻居目录一起搬走。
"""
db.add(_hist("自身", src="/data/dir"),
_hist("子项", src="/data/dir/a.mkv"),
_hist("兄弟", src="/data/dir2/b.mkv"))
titles = {h.title for h in TransferHistory.list_success_by_src(
db.session, "/data/dir", recursive=True)}
assert titles == {"自身", "子项"}
def test_list_success_by_src_escapes_like_wildcards(db):
"""
路径中的 % 与 _ 必须被转义,否则会被当成通配符匹配到别的目录。
"""
db.add(_hist("含下划线", src="/data/a_b/x.mkv"),
_hist("被误伤", src="/data/axb/y.mkv"))
titles = {h.title for h in TransferHistory.list_success_by_src(
db.session, "/data/a_b", recursive=True)}
assert titles == {"含下划线"}
def test_list_success_move_by_dest_only_returns_move_mode(db):
"""
从媒体库现址发起重新整理时只认「移动」模式的记录。
硬链接/复制的源文件仍在原处,把它们算作可回溯的移动记录会导致重复整理。
"""
db.add(_hist("移动", src="/data/m.mkv", dest="/media/m.mkv", mode="move"),
_hist("硬链", src="/data/l.mkv", dest="/media/l.mkv", mode="link"),
_hist("移动失败", src="/data/f.mkv", dest="/media/f.mkv", mode="move",
status=False))
titles = {h.title for h in TransferHistory.list_success_move_by_dest(db.session, "/media/m.mkv")}
assert titles == {"移动"}
assert TransferHistory.list_success_move_by_dest(db.session, "/media/l.mkv") == []
assert TransferHistory.list_success_move_by_dest(db.session, "/media/f.mkv") == []
def test_list_success_move_by_dest_recursive_matches_children_only(db):
"""
目标侧的递归匹配同样不能误伤同前缀的兄弟目录。
"""
db.add(_hist("自身", src="/data/w1.mkv", dest="/media/show", mode="move"),
_hist("子项", src="/data/w2.mkv", dest="/media/show/s01.mkv", mode="move"),
_hist("兄弟", src="/data/w3.mkv", dest="/media/show2/s01.mkv", mode="move"))
titles = {h.title for h in TransferHistory.list_success_move_by_dest(
db.session, "/media/show", recursive=True)}
assert titles == {"自身", "子项"}
def test_replace_by_src_keeps_one_record_per_source(db):
"""
同一源路径在同一存储中只保留最新一条记录,且不波及其他存储。
先删后插是在同一事务内完成的:旧的「查一条再删一条」在遗留重复数据下会留下
脏记录,查重随后就会命中旧行。
"""
db.add(_hist("旧一", src="/data/dup.mkv", src_storage="local"),
_hist("别的存储", src="/data/dup.mkv", src_storage="alist"))
TransferHistory.replace_by_src(db.session, src="/data/dup.mkv", src_storage="local",
dest="/media/new.mkv", type=MediaType.TV.value,
title="新记录", status=True, date="2026-08-13 12:00:00")
local_rows = TransferHistory.list_success_by_src(db.session, "/data/dup.mkv",
storage="local")
assert [h.title for h in local_rows] == ["新记录"]
assert TransferHistory.get_by_src(db.session, "/data/dup.mkv",
storage="alist").title == "别的存储"
def test_replace_by_src_defaults_storage_to_local(db):
"""
未指定源存储时按 local 处理——历史数据没有这一列,缺省值必须与写入侧一致。
"""
created = TransferHistory.replace_by_src(db.session, src="/data/nostorage.mkv",
type=MediaType.TV.value, title="无存储",
status=True, date="2026-08-13 12:00:00")
assert created.src_storage == "local"
# --------------------------------------------------------------------------- #
# 按 hash / 身份查询
# --------------------------------------------------------------------------- #
def test_hash_lookups_return_single_and_all(db):
"""
按下载 hash 既可取单条也可取全部,未命中时分别是 None 与空列表。
"""
db.add(_hist("文件一", download_hash="th-1", src="/data/1.mkv"),
_hist("文件二", download_hash="th-1", src="/data/2.mkv"))
assert TransferHistory.get_by_hash(db.session, "th-1") is not None
assert len(TransferHistory.list_by_hash(db.session, "th-1")) == 2
assert TransferHistory.get_by_hash(db.session, "th-none") is None
assert TransferHistory.list_by_hash(db.session, "th-none") == []
def test_get_by_media_identity_requires_matching_type(db):
"""
按媒体身份查询时类型必须参与匹配,否则电影会命中同 ID 的剧集记录。
"""
db.add(_hist("剧集", media_id="6100", mtype=MediaType.TV.value))
assert TransferHistory.get_by_media_identity(
db.session, MediaSource.TMDB, "6100", MediaType.TV.value) is not None
assert TransferHistory.get_by_media_identity(
db.session, MediaSource.TMDB, "6100", MediaType.MOVIE.value) is None
def test_update_download_hash_writes_only_the_target_row(db):
"""
补写下载 hash 只影响指定的那一条记录。
"""
target = db.add(_hist("目标", src="/data/t.mkv"))
db.add(_hist("其他", src="/data/o.mkv"))
TransferHistory.update_download_hash(db.session, historyid=target.id,
download_hash="new-hash")
assert TransferHistory.get_by_src(db.session, "/data/t.mkv").download_hash == "new-hash"
assert TransferHistory.get_by_src(db.session, "/data/o.mkv").download_hash is None
@pytest.mark.parametrize("season,episode,dest,expected", [
("S01", "E01", "/media/s1e1.mkv", {"季集"}),
("S01", None, None, {"季集", "整季"}),
(None, None, None, {"季集", "整季", "另一季"}),
])
def test_list_by_media_identity_narrows_by_season_episode_and_dest(
db, season, episode, dest, expected):
"""
按媒体身份查询时季、集、目标路径逐级收窄。
收窄失效会让「这一集整理过没有」误判成整季都整理过,剩余剧集被永久跳过。
"""
db.add(_hist("季集", src="/data/e1.mkv", seasons="S01", episodes="E01",
dest="/media/s1e1.mkv"),
_hist("整季", src="/data/s1.mkv", seasons="S01", episodes=None,
dest="/media/s1.mkv"),
_hist("另一季", src="/data/s2.mkv", seasons="S02", episodes=None,
dest="/media/s2.mkv"))
got = TransferHistory.list_by(db.session, mtype=MediaType.TV.value,
media_source=MediaSource.TMDB, media_id="6001",
season=season, episode=episode, dest=dest)
assert {h.title for h in got} == expected
def test_list_by_media_identity_uses_dest_for_movies(db):
"""
电影没有季集,靠目标路径区分不同版本;给出目标路径即须生效。
"""
db.add(_hist("4K 版", src="/data/m4k.mkv", mtype=MediaType.MOVIE.value,
media_id="6200", dest="/media/movie-4k.mkv"),
_hist("1080 版", src="/data/m1080.mkv", mtype=MediaType.MOVIE.value,
media_id="6200", dest="/media/movie-1080.mkv"))
got = TransferHistory.list_by(db.session, mtype=MediaType.MOVIE.value,
media_source=MediaSource.TMDB, media_id="6200",
dest="/media/movie-4k.mkv")
assert [h.title for h in got] == ["4K 版"]
def test_list_by_falls_back_to_title_and_year(db):
"""
没有媒体身份时退回「标题 + 年份」,服务于识别失败的历史数据。
"""
db.add(_hist("回退标题", src="/data/fb.mkv", media_id="6300", year="2020",
seasons="S01"))
assert [h.title for h in TransferHistory.list_by(
db.session, title="回退标题", year="2020", match_all_versions=True)] == ["回退标题"]
assert [h.title for h in TransferHistory.list_by(
db.session, title="回退标题", year="2020", season="S01")] == ["回退标题"]
assert TransferHistory.list_by(db.session, title="回退标题", year="1999") == []
@pytest.mark.parametrize("dest", [None, ""])
@pytest.mark.parametrize("use_identity", [True, False])
def test_list_by_unknown_movie_path_does_not_match_other_copies(db, dest, use_identity):
"""
电影路径未知时不能按媒体身份或标题年份命中仍在使用的副本。
"""
db.add(_hist("同一电影", mtype=MediaType.MOVIE.value, dest="/media/active.mkv"))
criteria = {"media_source": MediaSource.TMDB, "media_id": "6001"} if use_identity else {
"title": "同一电影", "year": "2026",
}
assert TransferHistory.list_by(
db.session, mtype=MediaType.MOVIE.value, dest=dest, **criteria,
) == []
def test_list_by_all_movie_versions_requires_explicit_opt_in(db):
"""
明确查询全部版本时才可省略电影目标路径,给出路径后仍须精确匹配。
"""
db.add(_hist("4K", mtype=MediaType.MOVIE.value, dest="/media/4k.mkv"),
_hist("1080", mtype=MediaType.MOVIE.value, dest="/media/1080.mkv"))
criteria = {"mtype": MediaType.MOVIE.value, "media_source": MediaSource.TMDB,
"media_id": "6001", "match_all_versions": True}
assert {h.title for h in TransferHistory.list_by(db.session, **criteria)} == {"4K", "1080"}
assert [h.title for h in TransferHistory.list_by(
db.session, dest="/media/4k.mkv", **criteria,
)] == ["4K"]
def test_list_by_title_without_type_or_path_is_not_full_match(db):
"""
缺少类型的标题年份回退查询同样需要显式允许全部版本。
"""
db.add(_hist("同一电影", mtype=MediaType.MOVIE.value))
assert TransferHistory.list_by(db.session, title="同一电影", year="2026") == []
def test_transferhistory_oper_preserves_movie_path_guard_and_opt_in(db):
"""
插件兼容 Oper 应传递路径保护和显式全版本参数。
"""
from app.db.oper.transferhistory import TransferHistoryOper
db.add(_hist("同一电影", mtype=MediaType.MOVIE.value, dest="/media/active.mkv"))
oper = TransferHistoryOper(db.session)
criteria = {"mtype": MediaType.MOVIE.value, "media_source": MediaSource.TMDB,
"media_id": "6001"}
assert oper.get_by(dest="", **criteria) == []
assert oper.get_by(dest="/media/deleted.mkv", **criteria) == []
assert [h.title for h in oper.get_by(match_all_versions=True, **criteria)] == ["同一电影"]
def test_list_by_supports_type_season_and_dest_prefix(db):
"""
媒体服务器 webhook 缺少远端身份时,按「类型 + 季 + 目标路径前缀」查询。
这是该场景下唯一能定位记录的路径,丢了会让 webhook 触发的刮削全部落空。
"""
db.add(_hist("剧集一", src="/data/wh1.mkv", mtype=MediaType.TV.value, seasons="S01",
dest="/media/Show/Season 01/e01.mkv"),
_hist("别的剧", src="/data/wh2.mkv", mtype=MediaType.TV.value, seasons="S01",
dest="/media/Other/Season 01/e01.mkv"))
got = TransferHistory.list_by(db.session, mtype=MediaType.TV.value, season="S01",
dest="/media/Show/")
assert [h.title for h in got] == ["剧集一"]
def test_list_by_without_usable_criteria_returns_empty(db):
"""
条件不足以定位时返回空列表,不能退化成返回全表。
"""
db.add(_hist("任意"))
assert TransferHistory.list_by(db.session) == []
assert TransferHistory.list_by(db.session, title="只有标题") == []
# --------------------------------------------------------------------------- #
# 列表、计数与统计
# --------------------------------------------------------------------------- #
def test_list_by_page_filters_status_and_supports_unbounded_count(db):
"""
分页可按成功状态收窄;count 为负表示不分页返回全部。
负数这条约定是「导出全部历史」依赖的,当成普通 limit 处理会返回空。
"""
db.add(_hist("成功一", src="/data/ok1.mkv", status=True, date="2026-08-13 10:00:01"),
_hist("成功二", src="/data/ok2.mkv", status=True, date="2026-08-13 10:00:02"),
_hist("失败", src="/data/ng.mkv", status=False, date="2026-08-13 10:00:03"))
assert [h.title for h in TransferHistory.list_by_page(db.session, page=1, count=1,
status=True)] == ["成功二"]
assert {h.title for h in TransferHistory.list_by_page(db.session, count=-1,
status=False)} == {"失败"}
assert len(TransferHistory.list_by_page(db.session, count=-1)) >= 3
def test_list_by_title_searches_title_source_and_destination(db):
"""
标题检索同时匹配标题、源路径与目标路径,且大小写不敏感。
只匹配标题会让用户按文件名搜不到任何记录。
"""
db.add(_hist("Alpha", src="/downloads/zzz.mkv", dest="/media/zzz.mkv"),
_hist("Beta", src="/downloads/AlphaFile.mkv", dest="/media/beta.mkv"),
_hist("Gamma", src="/downloads/g.mkv", dest="/media/alpha-dir/g.mkv"))
titles = {h.title for h in TransferHistory.list_by_title(db.session, "alpha", count=-1)}
assert titles == {"Alpha", "Beta", "Gamma"}
def test_list_by_title_wildcard_mode_takes_the_pattern_verbatim(db):
"""
通配模式下调用方自带 % 通配符,不再额外包裹。
"""
db.add(_hist("PrefixMatch", src="/downloads/p.mkv", dest="/media/p.mkv"),
_hist("NoPrefix", src="/downloads/n.mkv", dest="/media/n.mkv"))
titles = {h.title for h in TransferHistory.list_by_title(
db.session, "Prefix%", count=-1, wildcard=True)}
assert titles == {"PrefixMatch"}
def test_list_by_title_can_filter_status(db):
"""
检索结果同样支持按成功状态收窄。
"""
db.add(_hist("SearchOk", status=True, src="/downloads/ok.mkv"),
_hist("SearchFail", status=False, src="/downloads/fail.mkv"))
assert [h.title for h in TransferHistory.list_by_title(
db.session, "SearchFail", count=-1, status=False)] == ["SearchFail"]
def test_list_by_title_matches_async_twin(db):
"""
同步与异步的标题检索必须返回同一批记录。
"""
db.add(_hist("ParallelSearch", src="/downloads/par.mkv"))
sync_titles = [h.title for h in TransferHistory.list_by_title(
db.session, "ParallelSearch", count=-1)]
async_titles = [h.title for h in db.run_async_session(
lambda session: TransferHistory.async_list_by_title(
session, title="ParallelSearch", count=-1
)
)]
assert sync_titles == async_titles
def test_count_and_count_by_title_match_async_twins(db):
"""
计数与带条件计数的同步、异步结果必须一致——分页总数由它决定,
对不上就会出现「翻到最后一页是空的」。
"""
db.add(_hist("CountMe", status=True, src="/downloads/c1.mkv", dest="/media/c1.mkv"),
_hist("CountMe", status=False, src="/downloads/c2.mkv", dest="/media/c2.mkv"))
assert TransferHistory.count(db.session) == db.run_async_session(
TransferHistory.async_count
)
assert TransferHistory.count(db.session, status=True) == \
db.run_async_session(
lambda session: TransferHistory.async_count(session, status=True)
)
assert TransferHistory.count_by_title(db.session, "CountMe") == 2
assert TransferHistory.count_by_title(db.session, "CountMe", status=False) == 1
assert TransferHistory.count_by_title(db.session, "CountMe") == \
db.run_async_session(
lambda session: TransferHistory.async_count_by_title(
session, title="CountMe"
)
)
def test_statistic_groups_by_day_within_the_window(db):
"""
统计按日期分组,且只统计窗口内的记录。
"""
today = _time.strftime("%Y-%m-%d", _time.localtime())
db.add(_hist("今天一", src="/data/t1.mkv", date=f"{today} 10:00:00"),
_hist("今天二", src="/data/t2.mkv", date=f"{today} 11:00:00"),
_hist("很久以前", src="/data/t3.mkv", date="2000-01-01 10:00:00"))
rows = dict(TransferHistory.statistic(db.session, days=7))
assert rows.get(today, 0) >= 2
assert "2000-01-01" not in rows
def test_statistic_includes_the_window_start_boundary(db, frozen_now):
"""
统计窗口是闭区间起点(``date >= 起点``),正好落在起点的记录必须计入,同步异步一致。
起点由「调用时刻 - N 天」现算,不冻结时钟就摆不到边界上;上面那条用例用的是「今天」
与 2000 年两个极端值,起点比较符改成 ``>`` 照样绿。
"""
now = frozen_now(transferhistory_module)
window_start = _time.strftime("%Y-%m-%d %H:%M:%S", _time.localtime(now - 86400 * 7))
boundary_day = window_start[:10]
db.add(_hist("窗口起点上", src="/data/bstat.mkv", date=window_start))
rows = dict(TransferHistory.statistic(db.session, days=7))
async_rows = dict(db.run_async_session(
lambda session: TransferHistory.async_statistic(session, days=7)
))
assert rows.get(boundary_day, 0) == 1
assert async_rows.get(boundary_day, 0) == 1
def test_list_by_date_returns_newest_first(db):
"""
按时间查询返回该时间之后的记录,按主键倒序。
"""
db.add(_hist("旧", src="/data/od.mkv", date="2026-08-01 10:00:00"),
_hist("新", src="/data/nd.mkv", date="2026-08-12 10:00:00"))
titles = [h.title for h in TransferHistory.list_by_date(db.session, "2026-08-05")]
assert titles == ["新"]
def test_list_by_date_excludes_the_row_exactly_at_the_boundary(db):
"""
取的是「该时刻之后」的记录(``date > date``),正好等于该时刻的那条不算在内。
上面那条用例两侧数据各离边界四天与七天,比较符放宽成 ``>=`` 一样绿;
这里把行压在边界上,让开闭区间之差可观测。
"""
boundary = "2026-08-05 00:00:00"
db.add(_hist("边界上", src="/data/bd0.mkv", date=boundary),
_hist("边界后一秒", src="/data/bd1.mkv", date="2026-08-05 00:00:01"))
titles = [h.title for h in TransferHistory.list_by_date(db.session, boundary)]
assert titles == ["边界后一秒"]
def test_delete_before_is_batched_and_keeps_recent(db):
"""
历史清理分批执行且不碰保留期内的记录。
"""
for index in range(4):
db.add(_hist(f"old-{index}", src=f"/data/old{index}.mkv",
date=f"2026-01-01 10:00:0{index}"))
db.add(_hist("recent", src="/data/recent.mkv", date="2026-08-13 10:00:00"))
assert TransferHistory.delete_before(db.session, before_time="2026-08-01", limit=2) == 2
assert TransferHistory.delete_before(db.session, before_time="2026-08-01", limit=100) == 2
assert TransferHistory.delete_before(db.session, before_time="2026-08-01", limit=100) == 0
assert TransferHistory.get_by_src(db.session, "/data/recent.mkv") is not None
def test_delete_before_preserves_current_failed_task_history(db):
"""
过期维护不得删除当前失败 pending 映射使用的历史。
"""
durable = _hist(
"durable-old",
src="/data/durable-old.mkv",
date="2026-01-01 10:00:00",
status=False,
)
durable.transfer_task_id = "task-durable-old"
durable.transfer_settlement_revision = 1
db.add(durable)
assert TransferHistory.delete_before(
db.session,
before_time="2026-08-01",
limit=100,
) == 0
assert TransferHistory.get_by_transfer_task_id(
db.session,
task_id="task-durable-old",
) is not None
def test_upsert_durable_projection_advances_to_new_same_source_task(db):
"""
同源历史只表达最新任务投影,旧任务重放身份由独立回执持有。
"""
durable = _hist(
"old-task",
src="/data/reused.mkv",
date="2026-08-01 10:00:00",
)
durable.transfer_task_id = "old-task"
durable.transfer_settlement_revision = 1
db.add(durable)
projected = TransferHistory.upsert_by_transfer_task_id(
db.session,
task_id="new-task",
settlement_revision=1,
retain_task_mapping=False,
payload={
"src": "/data/reused.mkv",
"src_storage": "local",
"status": True,
},
)
assert TransferHistory.get_by_transfer_task_id(
db.session,
task_id="old-task",
) is None
assert TransferHistory.get_by_transfer_task_id(
db.session,
task_id="new-task",
) is None
assert projected is durable
assert projected.transfer_task_id is None
assert projected.transfer_settlement_revision is None
def test_delete_before_keeps_the_row_exactly_at_the_boundary(db):
"""
保留时间点上的整理历史属于「保留期内」,不能被清理(``date < before_time``)。
整理历史被删掉就等于丢失溯源,同一文件会被重新整理一次;
上面那条用例的数据离水位半年,``<`` 写成 ``<=`` 完全不可观测。
"""
boundary = "2026-05-01 00:00:00"
db.add(_hist("边界上", src="/data/bdel0.mkv", date=boundary),
_hist("边界前一秒", src="/data/bdel1.mkv", date="2026-04-30 23:59:59"))
assert TransferHistory.delete_before(db.session, before_time=boundary, limit=100) == 1
assert TransferHistory.get_by_src(db.session, "/data/bdel0.mkv") is not None
assert TransferHistory.get_by_src(db.session, "/data/bdel1.mkv") is None