"""实体链接安装:已复核的结果必须能进库,不合法的必须留下拒绝理由。"""
import importlib.util
import json
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
import httpx
REPO = Path(__file__).resolve().parents[1]
#: 停放页的最小形态,照 `crusegroup.net` 2026-09-26 实测的结构手写:200、标题
#: `Redirecting...`、正文一段把访客送去停放平台路由的脚本,响应头里带着平台主机名。
PARKED_PAGE = (b'
Redirecting...'
b'')
PARKED_HEADERS = {"permissions-policy": 'ch-ua=(self "https://*.parklogic.com")'}
def load_module(name: str = "install_entity_links"):
sys.path.insert(0, str(REPO / "src"))
spec = importlib.util.spec_from_file_location(name, REPO / "scripts" / f"{name}.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
SCHEMA = """
CREATE TABLE entity(id INTEGER PRIMARY KEY, kind TEXT, canonical_name TEXT);
CREATE TABLE entity_alias(entity_id INTEGER, alias TEXT);
CREATE TABLE entity_link(
id INTEGER PRIMARY KEY,
entity_id INTEGER NOT NULL REFERENCES entity(id) ON DELETE CASCADE,
link_kind TEXT NOT NULL CHECK(link_kind IN ('official','social','catalog','source_reference')),
label TEXT NOT NULL, url TEXT NOT NULL, hostname TEXT,
is_sensitive INTEGER NOT NULL DEFAULT 0 CHECK(is_sensitive IN (0,1)),
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
UNIQUE(entity_id,url));
"""
class InstallTests(unittest.TestCase):
def setUp(self):
self.module = load_module()
self.tmp = Path(tempfile.mkdtemp())
self.db = self.tmp / "ledger.db"
self.connection = sqlite3.connect(self.db)
self.connection.executescript(SCHEMA)
self.connection.executemany(
"INSERT INTO entity VALUES(?,?,?)",
[(1, "studio", "MOODYZ"), (2, "studio", "Prestige"), (3, "performer", "立花美涼")])
self.connection.execute("INSERT INTO entity_alias VALUES(?,?)", (2, "プレステージ"))
self.connection.commit()
def tearDown(self):
self.connection.close()
def row(self, **kw):
base = {"entity_id": "", "kind": "studio", "name": "MOODYZ", "link_kind": "official",
"label": "官方网站", "url": "https://moodyz.com/", "evidence": "标题自述厂牌名"}
base.update(kw)
return base
def test_a_reviewed_link_reaches_the_ledger_with_its_provenance(self):
"""装进去的不只是 URL,还有它凭什么被采信。
没有 provenance 的话,半年后没人能判断这条链接是人工看图确认的还是脚本猜的,
于是整表都只能重查一遍——那等于这次复核白做。
"""
planned = self.module.plan(self.connection, [self.row()])
self.assertEqual([p["action"] for p in planned], ["insert"])
self.assertEqual(self.module.install(self.connection, planned, "review.csv"), 1)
stored = self.connection.execute(
"SELECT entity_id,link_kind,label,url,hostname,metadata_json FROM entity_link"
).fetchone()
self.assertEqual(stored[:5], (1, "official", "官方网站", "https://moodyz.com/", "moodyz.com"))
metadata = json.loads(stored[5])
self.assertEqual(metadata["source"], "review.csv")
self.assertEqual(metadata["evidence"], "标题自述厂牌名")
self.assertIn("installed_at", metadata)
def test_running_twice_does_not_duplicate(self):
"""复核表会被反复重跑;第二遍必须是零写入而不是第二行。"""
planned = self.module.plan(self.connection, [self.row()])
self.module.install(self.connection, planned, "review.csv")
again = self.module.plan(self.connection, [self.row()])
self.assertEqual([p["action"] for p in again], ["skip"])
self.assertIn("已存在", again[0]["reason"])
self.assertEqual(self.module.install(self.connection, again, "review.csv"), 0)
self.assertEqual(
self.connection.execute("SELECT count(*) FROM entity_link").fetchone()[0], 1)
def test_automatic_link_keeps_its_revert_batch(self):
planned = self.module.plan(self.connection, [self.row()])
self.module.install(self.connection, planned, 'auto:company-profile', batch='batch1')
metadata = json.loads(self.connection.execute('SELECT metadata_json FROM entity_link').fetchone()[0])
self.assertEqual((metadata['source'],metadata['batch']), ('auto:company-profile','batch1'))
def test_a_label_edited_in_the_review_table_reaches_the_ledger_on_rerun(self):
"""label 是资料页上的链接文字,复核表是它的出处:表里改了字,重跑就对齐,不另建一行。"""
self.module.install(self.connection, self.module.plan(self.connection, [self.row()]),
"review.csv")
again = self.module.plan(self.connection, [self.row(label="官网存档(2022-05)")])
self.assertEqual([p["action"] for p in again], ["relabel"])
self.assertEqual(self.module.install(self.connection, again, "review.csv"), 0)
self.assertEqual(self.module.relabel(self.connection, again), 1)
self.assertEqual(self.connection.execute("SELECT label FROM entity_link").fetchall(),
[("官网存档(2022-05)",)])
def test_a_url_without_scheme_does_not_become_a_second_row(self):
"""复核表是人和脚本混写的,`moodyz.com` 和 `https://moodyz.com/` 都会出现。
不归一的话同一个站会绕过 UNIQUE(entity_id,url) 建出两行,资料页上就并排出现
两条一模一样的链接。
"""
self.module.install(self.connection, self.module.plan(self.connection, [self.row()]),
"review.csv")
planned = self.module.plan(self.connection, [self.row(url="moodyz.com/")])
self.assertEqual(planned[0]["action"], "skip")
def test_the_old_host_of_a_renamed_site_does_not_become_a_second_row(self):
"""目录型来源抄的是 2023 年改名前的写法,写入端要把它收成现主机。
`UNIQUE(entity_id,url)` 和「已存在,跳过」都只认字面,
`twitter.com/` 与 `x.com/` 在它们眼里是两条链接。minnano-av
一批 710 条里就有 170 条是这个形态,全写进去等于把 `normalize_link_hosts.py`
清掉的那 295 条重新长回来,而资料页会并排显示两枚一样的 X 图标。
"""
self.module.install(
self.connection,
self.module.plan(self.connection, [self.row(url="https://x.com/kouzaisaki")]),
"review.csv")
planned = self.module.plan(
self.connection, [self.row(url="https://twitter.com/kouzaisaki")])
self.assertEqual(planned[0]["action"], "skip")
self.assertIn("已存在", planned[0]["reason"])
self.assertEqual(
self.connection.execute("SELECT count(*) FROM entity_link").fetchone()[0], 1)
def test_a_renamed_host_is_stored_in_its_current_form(self):
"""收成现主机是写进库里,不只是拿来比对——否则库里仍是旧写法。"""
self.module.install(
self.connection,
self.module.plan(self.connection, [self.row(url="https://twitter.com/kouzaisaki")]),
"review.csv")
stored = self.connection.execute("SELECT url FROM entity_link").fetchone()[0]
self.assertEqual(stored, "https://x.com/kouzaisaki")
def test_an_entity_can_be_matched_by_alias(self):
planned = self.module.plan(self.connection, [self.row(name="プレステージ")])
self.assertEqual(planned[0]["entity_id"], 2)
self.assertEqual(planned[0]["reason"], "按别名")
def test_an_unknown_entity_is_reported_not_silently_dropped(self):
"""静默丢行会让「装了 N 条」和「表里多了 N 条」对不上,而没人知道差在哪。"""
planned = self.module.plan(self.connection, [self.row(name="不存在的厂牌")])
self.assertEqual(planned[0]["action"], "skip")
self.assertIn("找不到", planned[0]["reason"])
def test_an_invalid_link_kind_is_refused_before_sqlite_raises(self):
"""表上有 CHECK 约束,但让它抛出来会中断整批。提前拦下才能继续装其余的。"""
planned = self.module.plan(self.connection, [self.row(link_kind="blog")])
self.assertEqual(planned[0]["action"], "skip")
self.assertIn("link_kind", planned[0]["reason"])
def test_a_non_http_url_is_refused(self):
for bad in ("javascript:alert(1)", "ftp://example.com/"):
planned = self.module.plan(self.connection, [self.row(url=bad)])
self.assertEqual(planned[0]["action"], "skip", bad)
self.assertIn("URL", planned[0]["reason"])
def test_a_link_without_a_label_is_refused(self):
"""资料页拿 label 当链接文字;空 label 会渲染成一个点不到的空链接。"""
planned = self.module.plan(self.connection, [self.row(label=" ")])
self.assertEqual(planned[0]["action"], "skip")
self.assertIn("label", planned[0]["reason"])
def test_apply_without_backup_refuses_and_writes_nothing(self):
"""真实账本写入必须带备份;缺备份要在读输入之前就停。"""
code = self.module.main(["--db", str(self.db), "--input", str(self.tmp / "x.csv"),
"--apply"])
self.assertEqual(code, 2)
self.assertEqual(
self.connection.execute("SELECT count(*) FROM entity_link").fetchone()[0], 0)
def test_dry_run_is_the_default_and_touches_nothing(self):
source = self.tmp / "review.csv"
source.write_text(
"entity_id,kind,name,link_kind,label,url,evidence\n"
",studio,MOODYZ,official,官方网站,https://moodyz.com/,标题自述\n",
encoding="utf-8-sig")
# `--no-check` 不只是提速:测试不许发网络请求,否则一次断网就变成红灯。
self.assertEqual(self.module.main(
["--db", str(self.db), "--input", str(source), "--no-check"]), 0)
self.assertEqual(
self.connection.execute("SELECT count(*) FROM entity_link").fetchone()[0], 0)
def test_a_link_that_does_not_open_never_reaches_the_ledger(self):
"""这条守的是一次真实事故。
首批 703 条链接一条都没验就装进了账本,事后逐条测发现 289 条 official 里有
107 条打不开(84 个 404、18 个 502),37% 是死的。上游给什么就存什么,等于把
minnano-av 几年前的快照当成现在的事实——T-POWERS 改过站,`/official/talent/X`
早已 404,而资料页上它看起来和好链接一模一样。
门槛放在安装器而不是各个采集器里:这里是所有来源进入账本的唯一入口。
"""
planned = self.module.plan(self.connection, [
self.row(url="https://dead.example/gone"),
self.row(name="Prestige", url="https://alive.example/"),
])
self.assertEqual([p["action"] for p in planned], ["insert", "insert"])
self.module.check_links(
planned, probe=lambda url: (url == "https://alive.example/", "HTTP 404"))
self.assertEqual([p["action"] for p in planned], ["skip", "insert"])
self.assertIn("打不开", planned[0]["reason"])
self.assertIn("404", planned[0]["reason"])
self.assertEqual(self.module.install(self.connection, planned, "review.csv"), 1)
def test_dead_links_already_in_the_ledger_are_found_for_pruning(self):
"""
链接还会随时间烂掉——事务所改版、艺人解约、博客注销——所以这条路要留着复用,
不是一次性的清理脚本。
"""
for url in ("https://alive.example/", "https://dead.example/gone"):
planned = self.module.plan(self.connection, [self.row(url=url)])
self.module.install(self.connection, planned, "seed.csv")
self.assertEqual(
self.connection.execute("SELECT count(*) FROM entity_link").fetchone()[0], 2)
dead = self.module.dead_links(
self.connection, probe=lambda url: (url == "https://alive.example/", "HTTP 404"))
self.assertEqual([d["url"] for d in dead], ["https://dead.example/gone"])
self.assertEqual(dead[0]["entity"], "MOODYZ")
self.assertEqual(dead[0]["note"], "HTTP 404")
def test_only_a_gone_status_counts_as_proof_that_a_link_is_dead(self):
"""取不到不等于没了,这两件事只有 404/410 能划清。按「非 200 就删」会把这些好链接一起删掉。
这和取证失败时写 `未取得` 而不是写结论是同一条规矩。
"""
for note in ("HTTP 404", "HTTP 410"):
self.assertTrue(self.module.is_gone(note), note)
for note in ("HTTP 403", "HTTP 400", "HTTP 500", "HTTP 502",
"取不到:ConnectError", "取不到:ReadTimeout", "可打开"):
self.assertFalse(self.module.is_gone(note), note)
def test_the_reachability_gate_does_not_re_probe_rows_already_skipped(self):
"""已经因为别的原因跳过的行不必再请求一次——那是白费一次往返。"""
planned = self.module.plan(self.connection, [self.row(name="不存在的厂牌")])
self.assertEqual(planned[0]["action"], "skip")
probed = []
def probe(url):
probed.append(url)
return True, ""
self.module.check_links(planned, probe=probe)
self.assertEqual(probed, [])
class ResolvesTests(unittest.TestCase):
"""可达性探测:抖动要重试,状态码是一次成局。"""
class Client:
"""`httpx.Client` 的替身,按剧本逐次抛错、返回状态码或返回整个响应。"""
def __init__(self, script):
self.script = script
def __call__(self, **kwargs):
return self
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def get(self, url, headers=None):
step = self.script.pop(0)
if isinstance(step, Exception):
raise step
status, body, response_headers = step if isinstance(step, tuple) else (step, b"", {})
return httpx.Response(status, content=body, headers=response_headers,
request=httpx.Request("GET", url))
def setUp(self):
# `time` 和 `httpx` 是进程共享的真模块,替身必须还回去:这一组用例先跑完,
# 后面每个测试的 sleep 就都成了 list.append。
self.module = load_module()
self.slept = []
for holder, name in ((self.module.time, "sleep"), (self.module.httpx, "Client")):
self.addCleanup(setattr, holder, name, getattr(holder, name))
self.module.time.sleep = self.slept.append
def use(self, *script):
self.module.httpx.Client = self.Client(list(script))
def test_a_blip_on_the_first_try_is_not_a_conclusion(self):
"""这条出口的 TLS 大约三次断一次,一次失败就判死会把好站写成死链。"""
self.use(ConnectionError("SSL: UNEXPECTED_EOF_WHILE_READING"), 200)
self.assertEqual(self.module.resolves("https://eltra.jp/"), (True, "可打开"))
self.assertEqual(len(self.slept), 1)
def test_a_status_code_is_answered_at_once(self):
"""404 重试三次还是 404,多敲两次只是浪费对方的带宽。"""
self.use(404, 200)
self.assertEqual(self.module.resolves("https://x.jp/gone"), (False, "HTTP 404"))
self.assertEqual(self.slept, [])
def test_every_try_failing_keeps_the_reason(self):
"""全程取不到时说明必须留着:那不是「页面没了」,下一步是换个时间复查。"""
self.use(*[ConnectionError("boom")] * 3)
ok, note = self.module.resolves("https://down.jp/")
self.assertFalse(ok)
self.assertIn("ConnectionError", note)
self.assertFalse(self.module.is_gone(note), "取不到不等于确证没了")
def test_a_parked_domain_is_gone_even_though_it_answers_200(self):
"""事务所注销、域名被停放平台接走之后,页面照样 200,内容已不是这家公司。"""
self.use((200, PARKED_PAGE, PARKED_HEADERS))
ok, note = self.module.resolves("https://www.crusegroup.net/model/221")
self.assertFalse(ok)
self.assertIn("parklogic.com", note)
self.assertTrue(self.module.is_gone(note))
self.assertEqual(self.slept, [], "停放页是站点的回答,不是抖动,不必重试")
def test_a_parked_domain_behind_an_invalid_certificate_is_gone(self):
"""停放平台没有原主的证书,HTTPS 当场失败;同一地址走 http 才看得见停放页。"""
certificate = ConnectionError("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
self.use(certificate, (200, PARKED_PAGE, PARKED_HEADERS))
ok, note = self.module.resolves("https://crusegroup.net/model/316")
self.assertFalse(ok)
self.assertIn("证书", note)
self.assertTrue(self.module.is_gone(note))
def test_an_invalid_certificate_alone_is_not_proof(self):
"""证书链不全的真站也报同一个错;http 那一页不是停放页就只算取不到。"""
certificate = ConnectionError("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
self.use(*[certificate, (200, b"T-POWERS", {})] * 3)
ok, note = self.module.resolves("https://www.t-powers.co.jp/")
self.assertFalse(ok)
self.assertFalse(self.module.is_gone(note))
def test_an_ordinary_page_that_links_elsewhere_still_opens(self):
self.use((200, b"ARMX", {}))
self.assertEqual(self.module.resolves("https://arm-p.com/models/"), (True, "可打开"))
def test_pruning_can_be_limited_to_one_site(self):
"""清一家注销事务所的链接时,库里别的死链不跟着一起删。"""
connection = sqlite3.connect(":memory:")
connection.executescript(SCHEMA)
connection.execute("INSERT INTO entity VALUES(1,'performer','山岸逢花')")
connection.executemany(
"INSERT INTO entity_link(entity_id,link_kind,label,url,created_at,updated_at) "
"VALUES(1,'official',?,?,'2026-01-01','2026-01-01')",
[("Cruse Group", "https://www.crusegroup.net/model/221"),
("Cruse Group", "https://crusegroup.net/"),
("别的事务所", "https://other.example/gone"),
("存档", "https://web.archive.org/web/2015/http://z-earth2.crusegroup.net/")])
probed = []
dead = self.module.dead_links(
connection, host="crusegroup.net",
probe=lambda url: probed.append(url) or (False, "HTTP 404"))
self.assertEqual(probed, ["https://www.crusegroup.net/model/221",
"https://crusegroup.net/"])
self.assertEqual(len(dead), 2)
def test_pruning_marks_a_retired_performers_link_and_deletes_the_rest(self):
"""已隐退女优的事务所页没了,留成失效标记;还在活动的人照旧删,已标记的不再去敲。"""
tmp = Path(tempfile.mkdtemp())
db = tmp / "ledger.db"
with sqlite3.connect(db) as connection:
connection.executescript(SCHEMA + "CREATE TABLE performer_profile("
"entity_id INTEGER PRIMARY KEY, active_until INTEGER);")
connection.executemany("INSERT INTO entity VALUES(?,'performer',?)",
[(1, "东条苍"), (2, "神宫寺")])
connection.execute("INSERT INTO performer_profile VALUES(1, 2021)")
connection.executemany(
"INSERT INTO entity_link(id,entity_id,link_kind,label,url,created_at,updated_at) "
"VALUES(?,?,'official','Cruse Group',?,'2026-01-01','2026-01-01')",
[(1, 1, "https://crusegroup.net/model/316"),
(2, 2, "https://www.crusegroup.net/model/295")])
probed = []
self.module.resolves = lambda url: probed.append(url) or (False, "HTTP 404")
code = self.module.main(["--db", str(db), "--prune-dead", "--host", "crusegroup.net",
"--apply", "--backup", str(tmp / "backup.db")])
self.assertEqual(code, 0)
with sqlite3.connect(db) as connection:
rows = connection.execute("SELECT id, metadata_json FROM entity_link").fetchall()
self.assertEqual([row[0] for row in rows], [1])
self.assertEqual(json.loads(rows[0][1])["gone"]["note"], "HTTP 404")
probed.clear()
self.assertEqual(self.module.main(["--db", str(db), "--prune-dead"]), 0)
self.assertEqual(probed, [], "已标记失效的链接不再重验")
with sqlite3.connect(db) as connection:
connection.executemany(
"INSERT INTO entity_link(entity_id,link_kind,label,url,created_at,updated_at) "
"VALUES(?,?,'x',?,'2026-01-01','2026-01-01')",
[(1, "official", "https://hanaya-project.co.jp/model/aoi/"),
(1, "social", "https://x.com/aoi"), (2, "official", "https://arm-p.com/models/")])
self.assertEqual(self.module.main(["--db", str(db), "--prune-dead", "--retired"]), 0)
self.assertEqual(probed, ["https://hanaya-project.co.jp/model/aoi/"],
"--retired 只查已隐退女优的官网链接")
class NormalizeHostsTests(unittest.TestCase):
"""twitter.com → x.com 的主机归一:改写要保真,撞上已有的新写法要删而不是炸。
2026-09-02 实测账本:295 条 `twitter.com` + 1 条 `www.twitter.com`,67 条 `x.com`,
其中 6 个实体两种写法都有。这些用例就是那张计划表的缩影。
"""
def setUp(self):
self.module = load_module("normalize_link_hosts")
self.tmp = Path(tempfile.mkdtemp())
self.db = self.tmp / "ledger.db"
self.connection = sqlite3.connect(self.db)
self.connection.row_factory = sqlite3.Row
self.connection.executescript(SCHEMA)
self.connection.executemany(
"INSERT INTO entity VALUES(?,?,?)",
[(1, "performer", "立花美涼"), (2, "performer", "河北彩花")])
self.connection.commit()
def tearDown(self):
self.connection.close()
def link(self, entity_id, url, label="X @h", metadata="{}"):
host = url.split("/")[2]
self.connection.execute(
"INSERT INTO entity_link(entity_id,link_kind,label,url,hostname,metadata_json,"
"created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)",
(entity_id, "social", label, url, host, metadata, "2026-01-01", "2026-01-01"))
self.connection.commit()
return self.connection.execute("SELECT last_insert_rowid()").fetchone()[0]
def count(self):
return self.connection.execute("SELECT count(*) FROM entity_link").fetchone()[0]
def test_only_listed_hosts_are_touched_and_the_path_is_kept(self):
"""只换主机名,handle 大小写、查询和片段都是用户复核过的,原样留着。"""
self.assertEqual(self.module.target_url("http://twitter.com/Abc_D?s=1#x"),
"https://x.com/Abc_D?s=1#x")
self.assertEqual(self.module.target_url("https://mobile.twitter.com/abc"), "https://x.com/abc")
for untouched in ("https://x.com/abc", "https://instagram.com/abc", "https://twitter.co.jp/a"):
self.assertEqual(self.module.target_url(untouched), "", untouched)
def test_a_rewrite_keeps_the_row_and_only_changes_url_and_hostname(self):
"""改写不是删了再插:id、label、metadata(provenance)全都要留在原行上。"""
link_id = self.link(1, "https://twitter.com/tachibana", metadata='{"source":"a.csv"}')
planned = self.module.plan(self.connection)
self.assertEqual([(p["id"], p["action"]) for p in planned], [(link_id, "rewrite")])
self.assertEqual(self.module.apply_plan(self.connection, planned), (1, 0))
row = self.connection.execute(
"SELECT id,label,url,hostname,metadata_json,created_at,updated_at FROM entity_link").fetchone()
self.assertEqual(tuple(row)[:5], (link_id, "X @h", "https://x.com/tachibana", "x.com",
'{"source":"a.csv"}'))
self.assertEqual(row["created_at"], "2026-01-01")
self.assertNotEqual(row["updated_at"], "2026-01-01")
def test_an_entity_that_already_has_the_new_form_drops_the_old_row(self):
"""`UNIQUE(entity_id,url)` 不允许并存;留下的那行承载同一个 handle,计划表要点名它。"""
keeper = self.link(1, "https://x.com/tachibana")
old = self.link(1, "https://twitter.com/tachibana")
planned = self.module.plan(self.connection)
self.assertEqual([(p["id"], p["action"]) for p in planned], [(old, "drop")])
self.assertIn(f"#{keeper}", planned[0]["reason"])
self.assertEqual(self.module.apply_plan(self.connection, planned), (0, 1))
self.assertEqual([r[0] for r in self.connection.execute("SELECT id FROM entity_link")], [keeper])
def test_two_old_forms_of_one_handle_resolve_within_the_same_run(self):
"""同一实体 `twitter.com/a` 和 `mobile.twitter.com/a` 都映到一处:先改写的占位,后来的删。
不记录本轮已占用的地址,第二条会规划成 rewrite,写入时撞 UNIQUE 让整批回滚。
"""
first = self.link(1, "https://twitter.com/a")
second = self.link(1, "https://mobile.twitter.com/a")
planned = self.module.plan(self.connection)
self.assertEqual([(p["id"], p["action"]) for p in planned],
[(first, "rewrite"), (second, "drop")])
self.assertEqual(self.module.apply_plan(self.connection, planned), (1, 1))
self.assertEqual(self.count(), 1)
def test_the_same_handle_on_two_entities_is_not_a_collision(self):
self.link(1, "https://twitter.com/shared")
self.link(2, "https://x.com/shared")
planned = self.module.plan(self.connection)
self.assertEqual([p["action"] for p in planned], ["rewrite"])
def test_a_second_run_finds_nothing_to_do(self):
self.link(1, "https://twitter.com/a")
self.link(2, "https://x.com/b")
self.module.apply_plan(self.connection, self.module.plan(self.connection))
self.connection.commit()
self.assertEqual(self.module.plan(self.connection), [])
def test_apply_without_backup_refuses_and_writes_nothing(self):
self.link(1, "https://twitter.com/a")
self.assertEqual(self.module.main(["--database", str(self.db), "--apply"]), 2)
self.assertEqual(self.connection.execute(
"SELECT url FROM entity_link").fetchone()[0], "https://twitter.com/a")
def test_dry_run_is_the_default_and_touches_nothing(self):
self.link(1, "https://twitter.com/a")
self.assertEqual(self.module.main(["--database", str(self.db)]), 0)
self.assertEqual(self.connection.execute(
"SELECT url FROM entity_link").fetchone()[0], "https://twitter.com/a")
def test_apply_with_backup_writes_and_the_backup_holds_the_old_state(self):
self.link(1, "https://twitter.com/a")
self.link(1, "https://x.com/b")
self.link(1, "https://twitter.com/b")
backup = self.tmp / "bak" / "ledger.db"
self.assertEqual(self.module.main(
["--database", str(self.db), "--apply", "--backup", str(backup)]), 0)
self.assertEqual(sorted(r[0] for r in self.connection.execute("SELECT url FROM entity_link")),
["https://x.com/a", "https://x.com/b"])
saved = sqlite3.connect(backup)
self.assertEqual(saved.execute("SELECT count(*) FROM entity_link").fetchone()[0], 3)
saved.close()
if __name__ == "__main__":
unittest.main()