import json import sqlite3 import shutil import tempfile import unittest from contextlib import closing from pathlib import Path from peach.migrations import plan, upgrade MIGRATIONS = Path(__file__).resolve().parents[1] / "migrations" class MigrationTests(unittest.TestCase): def test_reference_index_upgrade_preserves_rows_and_removes_only_legacy_duplicates(self): previous = self.root / 'previous-indexes' previous.mkdir() for path in MIGRATIONS.glob('*.sql'): if path.name < '0052_': shutil.copyfile(path, previous / path.name) upgrade(self.db, previous) with closing(sqlite3.connect(self.db)) as connection: connection.execute("INSERT INTO asset(id,location,path,name,medium,code) " "VALUES(1,'local','sample.mp4','sample.mp4','video','EXM-001')") for name, columns in [('ix_asset_name', 'name'), ('ix_asset_loc', 'location'), ('ix_asset_size', 'size'), ('ix_asset_hash', 'hash_kind,hash')]: connection.execute(f'CREATE INDEX {name} ON asset({columns})') connection.execute('CREATE INDEX ix_asset_unrelated ON asset(duration)') connection.executemany("INSERT INTO task_run(id,task_key,trigger,status,root_run_id,finished_at) " "VALUES(?,'sample','manual','succeeded',?,'now')", [(index, index) for index in range(1, 1501)]) connection.commit() asset = connection.execute('SELECT * FROM asset').fetchall() self.assertEqual([item.version for item in upgrade(self.db, MIGRATIONS)], ['0052', '0053', '0054']) with closing(sqlite3.connect(self.db)) as connection: self.assertEqual(connection.execute('SELECT * FROM asset').fetchall(), asset) self.assertEqual(connection.execute('SELECT count(*) FROM task_run').fetchone()[0], 1500) plan_rows = connection.execute('EXPLAIN QUERY PLAN SELECT id FROM task_run ' 'WHERE root_run_id=1').fetchall() self.assertFalse(any('SCAN task_run' in row[3] for row in plan_rows), plan_rows) names = {row[1] for row in connection.execute('PRAGMA index_list(asset)')} self.assertTrue(names.isdisjoint({'ix_asset_name', 'ix_asset_loc', 'ix_asset_size', 'ix_asset_hash'})) self.assertIn('ix_asset_unrelated', names) self.assertEqual(connection.execute('PRAGMA foreign_key_check').fetchall(), []) self.assertEqual(upgrade(self.db, MIGRATIONS), []) def test_playback_progress_upgrade_keeps_rows_and_stops_bumping_the_asset_revision(self): previous = self.root / 'previous-revision' previous.mkdir() for path in MIGRATIONS.glob('*.sql'): if path.name < '0054_': shutil.copyfile(path, previous / path.name) upgrade(self.db, previous) with closing(sqlite3.connect(self.db)) as connection: connection.execute("INSERT INTO asset(id,location,path,name,medium,play_count) " "VALUES(1,'local','a.mp4','a.mp4','video',2)") connection.commit() asset = connection.execute('SELECT * FROM asset').fetchall() self.assertEqual([item.version for item in upgrade(self.db, MIGRATIONS)], ['0054']) def revision(connection): return connection.execute("SELECT n FROM ledger_revision WHERE tbl='asset'").fetchone()[0] with closing(sqlite3.connect(self.db)) as connection, connection: self.assertEqual(connection.execute('SELECT * FROM asset').fetchall(), asset) before = revision(connection) connection.execute("UPDATE asset SET play_seconds=12,leave_ratio=0.2,max_reached=0.2," "seek_count=1,last_played=1 WHERE id=1") self.assertEqual(revision(connection), before) connection.execute("UPDATE asset SET play_count=3,last_played=2 WHERE id=1") self.assertEqual(revision(connection), before + 1) connection.execute("UPDATE asset SET rating=80 WHERE id=1") self.assertEqual(revision(connection), before + 2) def test_foreign_key_failure_rolls_back_data_schema_and_migration_record(self): directory = self.root / 'boundary-migrations' directory.mkdir() (directory / '9000_boundary.sql').write_text( 'CREATE TABLE parent(id INTEGER PRIMARY KEY);' 'CREATE TABLE child(id INTEGER PRIMARY KEY,parent_id INTEGER REFERENCES parent(id));' 'INSERT INTO parent VALUES(1);INSERT INTO child VALUES(1,1);', encoding='utf-8') upgrade(self.db, directory) (directory / '9001_invalid.sql').write_text( '-- peach:foreign_keys=off\nCREATE TABLE introduced(id INTEGER);DELETE FROM parent;', encoding='utf-8') with self.assertRaisesRegex(RuntimeError, '违约'): upgrade(self.db, directory) with closing(sqlite3.connect(self.db)) as connection: self.assertEqual(connection.execute('SELECT id FROM parent').fetchall(), [(1,)]) self.assertEqual(connection.execute('PRAGMA foreign_key_check').fetchall(), []) self.assertIsNone(connection.execute("SELECT name FROM sqlite_schema WHERE name='introduced'").fetchone()) self.assertEqual([item.version for item in plan(self.db, directory)[1]], ['9001']) def test_review_history_upgrade_preserves_legacy_decisions(self): prior = self.root / 'prior' prior.mkdir() for path in MIGRATIONS.glob('*.sql'): if path.name < '0050_': shutil.copyfile(path, prior / path.name) upgrade(self.db, prior) with closing(sqlite3.connect(self.db)) as connection: connection.executemany('INSERT INTO review_decision(category,item_key,status,reviewer,note,updated_at) VALUES(?,?,?,?,?,?)', [('creator_tags', state, state, '本人', '旧记录', 'old') for state in ('approved', 'rejected', 'skipped')]) connection.commit() before = connection.execute('SELECT * FROM review_decision ORDER BY item_key').fetchall() self.assertEqual([item.version for item in upgrade(self.db, MIGRATIONS)], ['0050', '0051', '0052', '0053', '0054']) self.assertEqual(connection.execute('SELECT * FROM review_decision ORDER BY item_key').fetchall(), before) self.assertEqual(connection.execute('SELECT COUNT(*) FROM review_decision_history').fetchone()[0], 0) def revision(): return connection.execute("SELECT n FROM ledger_revision WHERE tbl='review_decision_history'").fetchone()[0] self.assertEqual(revision(), 0) connection.execute("INSERT INTO review_decision_history(category,item_key,status,reviewer,note,decided_at,restored_at) VALUES('creator_tags','kept','skipped','本人','旧记录','old','now')") self.assertEqual(revision(), 1) connection.execute("UPDATE review_decision_history SET note='保留原文' WHERE item_key='kept'") self.assertEqual(revision(), 2) connection.execute("DELETE FROM review_decision_history WHERE item_key='kept'") self.assertEqual(revision(), 3) connection.commit() self.assertEqual(connection.execute('PRAGMA integrity_check').fetchone()[0], 'ok') self.assertEqual(connection.execute('PRAGMA foreign_key_check').fetchall(), []) self.assertEqual(upgrade(self.db, MIGRATIONS), []) def test_task_reference_migration_preserves_rows_and_existing_links(self): prior = self.root.resolve() / 'prior' prior.mkdir() for path in MIGRATIONS.glob('*.sql'): if path.name < '0049_': shutil.copyfile(path, prior / path.name) upgrade(self.db, prior) with closing(sqlite3.connect(self.db)) as connection: connection.executescript(""" INSERT INTO task_run(id,task_key,trigger,status,finished_at,parent_run_id,root_run_id,result_summary,error) VALUES(10,'parent','manual','succeeded','t',NULL,10,'{}',NULL), (11,'child','manual','succeeded','t',10,10,'{"changed":1}',NULL), (12,'child','manual','failed','t',100,100,'{}','保留错误'), (13,'child','manual','pending',NULL,100,10,'{}',NULL), (14,'child','manual','running',NULL,11,100,'{}',NULL), (15,'standalone','manual','succeeded','t',NULL,NULL,'{}',NULL); INSERT INTO asset(id,location,path,medium) VALUES(1,'local','sample.mp4','video'); INSERT INTO asset_tag(asset_id,tag,source) VALUES(1,'示例','user'); """) columns = [row[1] for row in connection.execute('PRAGMA table_info(task_run)') if row[1] not in ('parent_run_id', 'root_run_id')] query = f"SELECT {','.join(columns)} FROM task_run ORDER BY id" tasks = connection.execute(query).fetchall() assets = connection.execute('SELECT * FROM asset').fetchall() tags = connection.execute('SELECT * FROM asset_tag').fetchall() self.assertEqual(len(connection.execute('PRAGMA foreign_key_check').fetchall()), 4) self.assertEqual([m.version for m in upgrade(self.db, MIGRATIONS)], ['0049', '0050', '0051', '0052', '0053', '0054']) self.assertEqual(connection.execute('PRAGMA foreign_key_check').fetchall(), []) self.assertEqual(connection.execute('PRAGMA integrity_check').fetchone()[0], 'ok') self.assertEqual(connection.execute('SELECT id,parent_run_id,root_run_id FROM task_run ORDER BY id').fetchall(), [(10,None,10),(11,10,10),(12,None,None),(13,None,10),(14,11,None),(15,None,None)]) self.assertEqual(connection.execute(query).fetchall(), tasks) self.assertEqual(connection.execute('SELECT * FROM asset').fetchall(), assets) self.assertEqual(connection.execute('SELECT * FROM asset_tag').fetchall(), tags) self.assertEqual(upgrade(self.db, MIGRATIONS), []) def test_missing_database_status_does_not_create_a_file(self): self.assertTrue(plan(self.db, MIGRATIONS)[1]) self.assertFalse(self.db.exists()) def test_disposal_reason_migration_preserves_assets_tags_and_unknown_reasons(self): prior = self.root.resolve() / 'prior' prior.mkdir() for path in MIGRATIONS.glob('*.sql'): if path.name < '0046_': shutil.copyfile(path, prior / path.name) upgrade(self.db, prior) connection = sqlite3.connect(self.db) try: connection.execute("INSERT INTO asset(id,location,path,medium,disposal) VALUES(1,'local','sample.mp4','video','trash')") connection.execute("INSERT INTO asset_tag(asset_id,tag,source) VALUES(1,'示例','user')") connection.commit() self.assertEqual([migration.version for migration in upgrade(self.db, MIGRATIONS)], ['0046', '0047', '0048', '0049', '0050', '0051', '0052', '0053', '0054']) self.assertEqual(connection.execute("SELECT disposal,disposal_reason FROM asset WHERE id=1").fetchone(), ('trash', None)) self.assertEqual(connection.execute("SELECT tag,source FROM asset_tag WHERE asset_id=1").fetchall(), [('示例', 'user')]) self.assertEqual(connection.execute("SELECT count(*) FROM asset").fetchone()[0], 1) finally: connection.close() def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.root = Path(self.tmp.name) self.db = self.root / "ledger.db" def tearDown(self): self.tmp.cleanup() def test_empty_database_upgrades_and_is_idempotent(self): sqlite3.connect(self.db).close() backup = self.root / "before.db" done = upgrade(self.db, MIGRATIONS, backup) self.assertEqual([m.version for m in done], [f'{number:04d}' for number in range(55)]) self.assertTrue(backup.exists()) con = sqlite3.connect(self.db) tables = {row[0] for row in con.execute( "SELECT name FROM sqlite_schema WHERE type='table'" )} versions = [row[0] for row in con.execute( "SELECT version FROM schema_migration ORDER BY version" )] con.close() self.assertTrue({"asset", "profile", "media_binding", "activity_event", "provider_profile", "entity", "entity_alias", "entity_external_ref", "asset_entity", "entity_link", "entity_search_term", "watch_queue", "asset_preference", "asset_quality_goal", "playlist", "playlist_item", "asset_tag_preference", "asset_search", "follow_playback", "genre_decision", "asset_subtitle", "task_run", "performer_profile", "code_sample_image", "entity_classification", "entity_identity_link", "seed_pack_history", "schema_migration"} <= tables) self.assertEqual(versions, [f'{number:04d}' for number in range(55)]) self.assertEqual(upgrade(self.db, MIGRATIONS), []) self.assertEqual(plan(self.db, MIGRATIONS)[1], []) def test_old_schema_columns_are_reconciled(self): con = sqlite3.connect(self.db) con.executescript(""" CREATE TABLE asset( id INTEGER PRIMARY KEY, location TEXT NOT NULL, path TEXT NOT NULL, name TEXT, medium TEXT, size INTEGER, mtime TEXT, hash_kind TEXT, hash TEXT, creator TEXT, series TEXT, code TEXT, duration REAL, width INTEGER, height INTEGER, vcodec TEXT, fps REAL, has_audio INTEGER, ctx_length TEXT, ctx_orient TEXT, ctx_quality TEXT, ctx_pace TEXT, ctx_people TEXT, play_count INTEGER DEFAULT 0, last_played TEXT, rating INTEGER, o_count INTEGER, watch_ratio REAL, source_id INTEGER, stash_scene_id INTEGER, snapshot_path TEXT, first_seen TEXT, last_seen TEXT, UNIQUE(location,path)); CREATE TABLE asset_tag(asset_id INTEGER,tag TEXT,confidence REAL,source TEXT, UNIQUE(asset_id,tag)); """) con.close() upgrade(self.db, MIGRATIONS) con = sqlite3.connect(self.db) columns = {row[1] for row in con.execute("PRAGMA table_info(asset)")} con.close() self.assertTrue({"studio", "feedback", "disposal", "leave_ratio", "play_seconds", "feedback_at", "seek_count", "max_reached", "release_date", "catalog_title", "original_title"} <= columns) def test_checksum_change_is_rejected(self): sqlite3.connect(self.db).close() upgrade(self.db, MIGRATIONS) con = sqlite3.connect(self.db) con.execute("UPDATE schema_migration SET checksum='changed' WHERE version='0001'") con.commit() con.close() with self.assertRaises(RuntimeError): plan(self.db, MIGRATIONS) def test_missing_migration_directory_is_rejected_instead_of_reported_empty(self): """migration 目录不存在时拒绝,不要把空账本当成迁移到位的账本。 `pip install .`(非 editable)只装 `src/` 下的包,仓库根的 `migrations/` 不进 wheel。目录缺失若被读成「0 个迁移」,`peach init` 会打印成功并留下 0 字节账本, `peach migrate status` 还跟着报 0 pending。 """ absent = self.root / "no-such-migrations" with self.assertRaises(FileNotFoundError) as caught: plan(self.db, absent) self.assertIn(str(absent), str(caught.exception)) with self.assertRaises(FileNotFoundError): upgrade(self.db, absent) self.assertFalse(self.db.exists()) def test_empty_migration_directory_reports_nothing_to_apply(self): empty = self.root / "empty-migrations" empty.mkdir() self.assertEqual(upgrade(self.db, empty), []) def test_entity_migration_backfills_flattened_relations(self): base_migrations = self.root / "base-migrations" base_migrations.mkdir() for name in ("0000_legacy_schema.sql", "0001_core_boundaries.sql"): shutil.copyfile(MIGRATIONS / name, base_migrations / name) sqlite3.connect(self.db).close() upgrade(self.db, base_migrations) con = sqlite3.connect(self.db) con.executescript(""" INSERT INTO asset(id,location,path,name,medium,creator,studio,series) VALUES(1,'local','one.mp4','one.mp4','video','Owner A','Studio A','Series A'); INSERT INTO asset_tag(asset_id,tag,confidence,source) VALUES(1,'演员:Performer A',1.0,'stash:performer'), (1,'Tag A',0.9,'stash:tag'); """) con.close() upgrade(self.db, MIGRATIONS) con = sqlite3.connect(self.db) entities = set(con.execute("SELECT kind,canonical_name FROM entity")) relations = set(con.execute( "SELECT e.kind,ae.role,ae.source FROM asset_entity ae " "JOIN entity e ON e.id=ae.entity_id WHERE ae.asset_id=1" )) con.close() self.assertTrue({('performer','Performer A'),('tag','Tag A'), ('studio','Studio A'),('creator','Owner A'), ('series','Series A')} <= entities) self.assertTrue({('performer','performer','stash:performer'), ('tag','tag','stash:tag'),('studio','studio','legacy:asset'), ('creator','creator','legacy:asset'), ('series','series','legacy:asset')} <= relations) def test_late_legacy_tags_are_backfilled_by_0005(self): base_migrations = self.root / "base-migrations" base_migrations.mkdir() for name in ( "0000_legacy_schema.sql", "0001_core_boundaries.sql", "0002_canonical_entities.sql", "0003_entity_pages_and_watch_queue.sql", "0004_asset_search_fts.sql", ): shutil.copyfile(MIGRATIONS / name, base_migrations / name) sqlite3.connect(self.db).close() upgrade(self.db, base_migrations) con = sqlite3.connect(self.db) con.executescript(""" INSERT INTO asset(id,location,path,name,medium) VALUES(1,'local','late.mp4','late.mp4','video'); INSERT INTO asset_tag(asset_id,tag,confidence,source) VALUES(1,'足交',0.6,'vision_creator'), (1,'演员:Late Performer',1.0,'stash:performer'); """) con.close() upgrade(self.db, MIGRATIONS) con = sqlite3.connect(self.db) relations = set(con.execute( "SELECT e.kind,e.canonical_name,ae.role,ae.source,ae.confidence " "FROM asset_entity ae JOIN entity e ON e.id=ae.entity_id " "WHERE ae.asset_id=1" )) search_entities = con.execute( "SELECT entities FROM asset_search WHERE asset_id=1" ).fetchone()[0] con.close() self.assertIn(('tag', '足交', 'tag', 'vision_creator', 0.6), relations) self.assertIn(('performer', 'Late Performer', 'performer', 'stash:performer', 1.0), relations) self.assertIn('足交', search_entities) self.assertIn('Late Performer', search_entities) def test_structural_creator_cleanup_keeps_real_creator(self): base_migrations = self.root / "base-migrations" base_migrations.mkdir() for path in sorted(MIGRATIONS.glob("*.sql")): if not path.name.startswith(("0007_", "0009_")): shutil.copyfile(path, base_migrations / path.name) sqlite3.connect(self.db).close() upgrade(self.db, base_migrations) connection = sqlite3.connect(self.db) connection.executescript(""" INSERT INTO asset(id,location,path,name,medium,creator) VALUES(1,'115','B:/xxr/门槛/one.mp4','one.mp4','video','门槛'); INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at) VALUES(101,'creator','门槛','门槛','now','now'), (102,'creator','Actual Creator','actual creator','now','now'); INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence) VALUES(1,101,'creator','legacy:asset',0.8), (1,102,'creator','reviewed',1.0); """) connection.close() upgrade(self.db, MIGRATIONS) connection = sqlite3.connect(self.db) self.assertEqual(connection.execute( "SELECT creator FROM asset WHERE id=1" ).fetchone()[0], "Actual Creator") self.assertEqual(connection.execute( "SELECT e.canonical_name FROM asset_entity ae JOIN entity e ON e.id=ae.entity_id " "WHERE ae.asset_id=1 AND e.kind='creator'" ).fetchall(), [("Actual Creator",)]) connection.close() def test_asce_cleanup_removes_only_false_creator_board_assertions(self): base_migrations = self.root / "base-migrations" base_migrations.mkdir() for path in sorted(MIGRATIONS.glob("*.sql")): if not path.name.startswith("0009_"): shutil.copyfile(path, base_migrations / path.name) sqlite3.connect(self.db).close() upgrade(self.db, base_migrations) connection = sqlite3.connect(self.db) connection.executescript(""" INSERT INTO asset(id,location,path,name,medium,creator) VALUES(1,'115','B:/kkg/asce/one.mp4','one.mp4','video','asce'); INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at) VALUES(101,'creator','asce','asce','now','now'), (102,'tag','制服','制服','now','now'), (103,'tag','原创','原创','now','now'); INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence) VALUES(1,101,'creator','legacy:asset',0.8), (1,102,'tag','vision_creator',0.6), (1,103,'tag','name',0.9); INSERT INTO asset_tag(asset_id,tag,confidence,source) VALUES(1,'制服',0.6,'vision_creator'),(1,'原创',0.9,'name'); """) connection.close() upgrade(self.db, MIGRATIONS) connection = sqlite3.connect(self.db) self.assertIsNone(connection.execute( "SELECT creator FROM asset WHERE id=1" ).fetchone()[0]) self.assertEqual(connection.execute( "SELECT tag,source FROM asset_tag WHERE asset_id=1" ).fetchall(), [("原创", "name")]) self.assertEqual(connection.execute( "SELECT e.canonical_name,ae.source FROM asset_entity ae " "JOIN entity e ON e.id=ae.entity_id WHERE ae.asset_id=1" ).fetchall(), [("原创", "name")]) connection.close() def test_folder_attribution_corrections_are_narrow_and_evidence_backed(self): base_migrations = self.root / "base-migrations" base_migrations.mkdir() for path in sorted(MIGRATIONS.glob("*.sql")): if not path.name.startswith("0010_"): shutil.copyfile(path, base_migrations / path.name) sqlite3.connect(self.db).close() upgrade(self.db, base_migrations) connection = sqlite3.connect(self.db) connection.executescript(""" INSERT INTO asset(id,location,path,name,medium,creator) VALUES(1,'115','B:\\云下载\\足交仙人\\feet of Suzyq (1).mp4','one.mp4','video','足交仙人'), (2,'115','B:\\MVP\\捅主任\\TokyoDolls\\32.mp4','32.mp4','video','捅主任'), (3,'115','B:\\创作者\\捅主任\\real.mp4','real.mp4','video','捅主任'); INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at) VALUES(101,'creator','足交仙人','足交仙人','now','now'), (102,'creator','捅主任','捅主任','now','now'); INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence) VALUES(1,101,'creator','legacy:asset',0.8), (2,102,'creator','legacy:asset',0.8), (3,102,'creator','legacy:asset',0.8); """) connection.close() upgrade(self.db, MIGRATIONS) connection = sqlite3.connect(self.db) self.assertEqual(connection.execute( "SELECT id,creator FROM asset ORDER BY id" ).fetchall(), [(1, "suzuq"), (2, None), (3, "捅主任")]) relations = connection.execute( "SELECT ae.asset_id,e.canonical_name,ae.source,ae.confidence " "FROM asset_entity ae JOIN entity e ON e.id=ae.entity_id " "WHERE e.kind='creator' ORDER BY ae.asset_id" ).fetchall() self.assertEqual(relations, [(1, "suzuq", "user:watermark", 1.0), (3, "捅主任", "legacy:asset", 0.8)]) self.assertEqual(connection.execute( "SELECT alias,source,confidence FROM entity_alias " "WHERE entity_id=(SELECT id FROM entity WHERE normalized_name='suzuq')" ).fetchall(), [("Suzyq", "filename", 0.95)]) connection.close() def test_fts_tracks_asset_entity_alias_and_search_term_changes(self): sqlite3.connect(self.db).close() upgrade(self.db, MIGRATIONS) connection = sqlite3.connect(self.db) connection.executescript(""" INSERT INTO asset(id,location,path,name,medium,code) VALUES(1,'local','one.mp4','Prestige sample.mp4','video','ABW-001'); INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at) VALUES(1001,'performer','高桥千凛','高桥千凛','now','now'); INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence) VALUES(1,1001,'performer','test',1.0); INSERT INTO entity_alias(entity_id,alias,normalized_alias,source,confidence) VALUES(1001,'Takachi Chisato','takachi chisato','test',1.0); INSERT INTO entity_search_term(entity_id,term,purpose,source,created_at) VALUES(1001,'private release keyword','source_lookup','test','now'); """) for query in ('"Prestige"', '"高桥千"', '"Takachi"', '"release keyword"'): self.assertEqual(connection.execute( "SELECT asset_id FROM asset_search WHERE asset_search MATCH ?", (query,) ).fetchall(), [(1,)]) connection.execute("UPDATE asset SET name='Renamed title.mp4' WHERE id=1") self.assertEqual(connection.execute( "SELECT asset_id FROM asset_search WHERE asset_search MATCH ?", ('"Renamed"',) ).fetchall(), [(1,)]) self.assertEqual(connection.execute( "SELECT asset_id FROM asset_search WHERE asset_search MATCH ?", ('"Prestige"',) ).fetchall(), []) connection.execute( "UPDATE asset SET catalog_title='Official catalog title'," "original_title='Original catalog title' WHERE id=1" ) for query in ('"Official catalog"', '"Original catalog"'): self.assertEqual(connection.execute( "SELECT asset_id FROM asset_search WHERE asset_search MATCH ?", (query,) ).fetchall(), [(1,)]) connection.close() def test_rule34_source_case_duplicates_merge_without_losing_item_state(self): base_migrations = self.root / "base-migrations" base_migrations.mkdir() for path in sorted(MIGRATIONS.glob("*.sql")): # 历史基线停在 0037,follow_playback 建表与重建随 0020/0021 统一应用。 # 0038 起的版本号触发器依赖这些表,留在后续的完整迁移链中。 if path.name < "0038_" and not path.name.startswith(("0020_", "0021_", "0022_", "0024_")): shutil.copyfile(path, base_migrations / path.name) sqlite3.connect(self.db).close() upgrade(self.db, base_migrations) connection = sqlite3.connect(self.db) connection.executescript(""" INSERT INTO asset(id,location,path,name,medium) VALUES(1,'online','https://example.test/1','saved','video'); INSERT INTO follow_source( id,provider,ref,label,url,semantics,metadata_json,created_at,updated_at ) VALUES (10,'rule34xxx','lazyprocrastinator','lazyprocrastinator', 'https://rule34.xxx/?tags=lazyprocrastinator','work','{}','1','1'), (11,'rule34xxx','LazyProcrastinator','LazyProcrastinator', 'https://rule34.xxx/?tags=LazyProcrastinator','work','{}','2','2'); INSERT INTO follow_item( source_id,external_id,title,published_precision,release_key,variant_kind, status,asset_id,metadata_json,first_seen_at,last_seen_at ) VALUES (10,'same','same','exact','same','main','ignored',NULL,'{}','1','2'), (10,'lower-only','lower','exact','lower','main','new',NULL,'{}','1','2'), (11,'same','same','exact','same','main','saved',1,'{}','2','3'), (11,'upper-only','upper','exact','upper','main','seen',NULL,'{}','2','3'); """) connection.close() upgrade(self.db, MIGRATIONS) connection = sqlite3.connect(self.db) sources = connection.execute( "SELECT id,ref,url FROM follow_source WHERE provider='rule34xxx'" ).fetchall() items = connection.execute( "SELECT external_id,status,asset_id FROM follow_item ORDER BY external_id" ).fetchall() connection.close() self.assertEqual(sources, [(10, "lazyprocrastinator", "https://rule34.xxx/?tags=lazyprocrastinator")]) self.assertEqual(items, [ ("lower-only", "new", None), ("same", "saved", 1), ("upper-only", "seen", None), ]) FK_SEED_SQL = r""" INSERT INTO profile(id,user_id,name,is_default,settings_json,created_at,updated_at) VALUES('p2','local','Second',0,'{}','1','1'); INSERT INTO asset(id,location,path,name,medium,disposal) VALUES (1,'local','R:\Media\alpha.mp4','Alpha title.mp4','video',NULL), (2,'local','R:\Media\beta.mp4','Beta title.mp4','video','trash'); INSERT INTO asset_tag(asset_id,tag,confidence,source) VALUES (1,'巨乳',1.0,'name'),(1,'制服',0.8,'vision'),(2,'巨乳',1.0,'r18'); INSERT INTO media_binding( asset_id,backend,external_id,priority,metadata_json,last_synced_at) VALUES(1,'stash','101',100,'{}','1'); INSERT INTO activity_event(id,asset_id,profile_id,kind,occurred_at,value_json,source) VALUES(1,1,'local-default','play','1','{}','web'), (2,2,'p2','play','2','{}','web'); INSERT INTO asset_tag_preference(profile_id,asset_id,normalized_tag,hidden,updated_at) VALUES('local-default',1,'制服',1,'1'),('p2',2,'巨乳',1,'1'); INSERT INTO watch_queue(profile_id,asset_id,added_at,source) VALUES('local-default',1,'1','web'); INSERT INTO asset_preference(profile_id,asset_id,liked,updated_at) VALUES('p2',1,1,'1'); INSERT INTO asset_quality_goal(profile_id,asset_id,wanted,updated_at) VALUES('p2',1,1,'1'); INSERT INTO playlist(id,profile_id,name,source_kind,source_seed_asset_id, current_asset_id,created_at,updated_at) VALUES(1,'local-default','Mix','mix',1,1,'1','1'); INSERT INTO follow_source( id,provider,ref,label,url,semantics,metadata_json,created_at,updated_at) VALUES(10,'rule34xxx','someone','someone', 'https://rule34.xxx/?tags=someone','work','{}','1','1'); INSERT INTO follow_item( id,source_id,external_id,title,published_precision,release_key,variant_kind, status,asset_id,metadata_json,first_seen_at,last_seen_at) VALUES(100,10,'x','X','exact','x','main','new',NULL,'{}','1','1'); INSERT INTO follow_playback( follow_item_id,profile_id,play_count,play_seconds,max_reached,last_played) VALUES(100,'local-default',2,10,0.5,1.0),(100,'p2',1,5,0.2,2.0); """ # 0024 声明的删除行为。profile.user_id 是唯一保留 NO ACTION 的外键:即时外键下 # NO ACTION 已等于 RESTRICT,而重建 profile 会让 DROP TABLE 的隐式 DELETE 真的 # 级联删掉 profile 私有状态。判据写在 0024 的头部注释里。 EXPECTED_DELETE_RULES = { ("asset_tag", "asset_id"): ("asset", "CASCADE"), ("media_binding", "asset_id"): ("asset", "CASCADE"), ("activity_event", "asset_id"): ("asset", "CASCADE"), ("activity_event", "profile_id"): ("profile", "SET NULL"), ("asset_tag_preference", "profile_id"): ("profile", "CASCADE"), ("follow_playback", "profile_id"): ("profile", "CASCADE"), ("asset_subtitle", "asset_id"): ("asset", "CASCADE"), } NO_ACTION_ALLOWED = {("profile", "user_id")} class AssetFieldOwnerColumnTests(unittest.TestCase): """0029:真相字段的归属列与乐观并发计数。""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name).resolve() self.db = self.root / "ledger.db" sqlite3.connect(self.db).close() upgrade(self.db, MIGRATIONS) def test_asset_gains_field_owners_and_a_revision_counter(self): connection = sqlite3.connect(self.db) self.addCleanup(connection.close) columns = {row[1]: (row[2], row[3], row[4]) for row in connection.execute("PRAGMA table_info(asset)")} self.assertEqual(columns["field_owners"][0], "TEXT") declaration, not_null, default = columns["mutation_revision"] self.assertEqual(declaration, "INTEGER") self.assertEqual((not_null, default), (1, "0")) def test_existing_rows_start_unowned_at_revision_zero(self): """归属不回填:谁写的答不出来时,留空比猜一个写入者诚实。""" connection = sqlite3.connect(self.db) self.addCleanup(connection.close) connection.execute( "INSERT INTO asset(id,location,path,name,medium,studio) " "VALUES(1,'local','R:\\\\a.mp4','a.mp4','video','某厂牌')") row = connection.execute( "SELECT field_owners,mutation_revision FROM asset WHERE id=1").fetchone() self.assertEqual(row, (None, 0)) def test_sqlite_has_the_json_functions_the_owner_map_needs(self): """`json_extract` / `json_patch` 是归属写入唯一的实现手段,先确认它们在。""" connection = sqlite3.connect(self.db) self.addCleanup(connection.close) merged = connection.execute( "SELECT json_patch('{\"a\":\"scan:filename\"}','{\"b\":\"user:manual\"}')" ).fetchone()[0] self.assertEqual(json.loads(merged), {"a": "scan:filename", "b": "user:manual"}) self.assertEqual(connection.execute( "SELECT json_extract('{\"studio\":\"user:manual\"}','$.\"studio\"')" ).fetchone()[0], "user:manual") class ForeignKeyDeleteRuleTests(unittest.TestCase): """0024:补齐缺失的 ON DELETE,重建时不丢行、不丢索引与触发器。""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() # 用 addCleanup 而不是 tearDown:清理按后进先出跑,连接一定先于临时目录关闭。 self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name).resolve() self.db = self.root / "ledger.db" def _migrations_below(self, upper: str): """只含版本号小于 `upper` 的迁移目录。 按上界取,不按「排除 0024」取补集:补集会把后来新增的每一个版本都算进这份 「到 0023 为止」的目录,而 0025 与 0024 无关,照样装得上。这一组用例盯的是 0024 那次重建,多装一个版本就会把它新增的表和索引算成 0024 的账。 """ directory = self.root / f"below-{upper}" directory.mkdir() for path in sorted(MIGRATIONS.glob("*.sql")): if path.name < upper: shutil.copyfile(path, directory / path.name) return directory def _upgrade_through_0023(self): sqlite3.connect(self.db).close() upgrade(self.db, self._migrations_below("0024")) def _seed(self): connection = sqlite3.connect(self.db) connection.executescript(FK_SEED_SQL) connection.commit() connection.close() def _open(self, foreign_keys=False): connection = sqlite3.connect(self.db) connection.isolation_level = None # 断言失败也要先关连接,否则 Windows 上 tearDown 删不掉临时库。 self.addCleanup(connection.close) if foreign_keys: connection.execute("PRAGMA foreign_keys=ON") return connection @staticmethod def _counts(connection): # asset_search 的影子表另行核对;schema_migration 本次必然 +1。 names = [row[0] for row in connection.execute( "SELECT name FROM sqlite_schema WHERE type='table' " "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'asset_search%' " "AND name<>'schema_migration' ORDER BY name" )] return {name: connection.execute( f'SELECT count(*) FROM "{name}"').fetchone()[0] for name in names} @staticmethod def _objects(connection, kind): return {row[0] for row in connection.execute( "SELECT name FROM sqlite_schema WHERE type=? AND sql IS NOT NULL", (kind,) )} @staticmethod def _search_rows(connection): return sorted(row[0] for row in connection.execute( "SELECT asset_id FROM asset_search")) @staticmethod def _delete_rules(connection): rules = {} tables = [row[0] for row in connection.execute( "SELECT name FROM sqlite_schema WHERE type='table' " "AND name NOT LIKE 'sqlite_%' ORDER BY name" )] for table in tables: for row in connection.execute(f'PRAGMA foreign_key_list("{table}")'): rules[(table, row[3])] = (row[2], row[6]) return rules def test_upgrade_keeps_rows_indexes_triggers_and_search(self): self._upgrade_through_0023() self._seed() connection = self._open() before_counts = self._counts(connection) before_indexes = self._objects(connection, "index") before_triggers = self._objects(connection, "trigger") before_views = self._objects(connection, "view") before_search = self._search_rows(connection) connection.close() self.assertEqual(before_counts["asset_tag"], 3) self.assertEqual(before_counts["follow_playback"], 2) self.assertEqual( [m.version for m in upgrade(self.db, self._migrations_below("0025"))], ["0024"]) connection = self._open() self.assertEqual(self._counts(connection), before_counts) self.assertEqual(self._objects(connection, "trigger"), before_triggers) self.assertEqual(self._objects(connection, "view"), before_views) after_indexes = self._objects(connection, "index") self.assertEqual(before_indexes - after_indexes, set()) self.assertEqual(after_indexes - before_indexes, {"idx_asset_disposal_id", "idx_asset_tag_source_asset"}) self.assertEqual(self._search_rows(connection), before_search) self.assertEqual(connection.execute( "SELECT asset_id FROM asset_search WHERE asset_search MATCH ?", ('"Alpha"',) ).fetchall(), [(1,)]) self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), []) self.assertEqual(connection.execute("PRAGMA integrity_check").fetchone()[0], "ok") connection.close() def test_every_foreign_key_declares_a_delete_rule(self): self._upgrade_through_0023() upgrade(self.db, MIGRATIONS) connection = self._open() rules = self._delete_rules(connection) connection.close() for key, expected in EXPECTED_DELETE_RULES.items(): self.assertEqual(rules.get(key), expected, key) undeclared = {key for key, value in rules.items() if value[1] == "NO ACTION"} self.assertEqual(undeclared, NO_ACTION_ALLOWED) def test_deleting_asset_cascades_to_its_dependent_rows(self): self._upgrade_through_0023() self._seed() upgrade(self.db, MIGRATIONS) connection = self._open(foreign_keys=True) connection.execute("DELETE FROM asset WHERE id=1") self.assertEqual(connection.execute( "SELECT asset_id,tag FROM asset_tag ORDER BY asset_id,tag").fetchall(), [(2, "巨乳")]) for table in ("media_binding", "watch_queue", "asset_preference", "asset_quality_goal"): self.assertEqual(connection.execute( f"SELECT count(*) FROM {table} WHERE asset_id=1").fetchone()[0], 0, table) self.assertEqual(connection.execute( "SELECT id FROM activity_event ORDER BY id").fetchall(), [(2,)]) self.assertEqual(connection.execute( "SELECT profile_id,asset_id FROM asset_tag_preference").fetchall(), [("p2", 2)]) self.assertEqual(connection.execute( "SELECT source_seed_asset_id,current_asset_id FROM playlist").fetchall(), [(None, None)]) self.assertEqual(self._search_rows(connection), [2]) self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), []) connection.close() def test_deleting_profile_keeps_activity_history_without_owner(self): self._upgrade_through_0023() self._seed() upgrade(self.db, MIGRATIONS) connection = self._open(foreign_keys=True) connection.execute("DELETE FROM profile WHERE id='p2'") self.assertEqual(connection.execute( "SELECT follow_item_id,profile_id FROM follow_playback").fetchall(), [(100, "local-default")]) self.assertEqual(connection.execute( "SELECT profile_id,asset_id FROM asset_tag_preference").fetchall(), [("local-default", 1)]) # 行为历史保留,只丢归属。 self.assertEqual(connection.execute( "SELECT id,asset_id,profile_id FROM activity_event ORDER BY id").fetchall(), [(1, 1, "local-default"), (2, 2, None)]) self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), []) connection.close() def test_deleting_app_user_with_profiles_is_still_refused(self): self._upgrade_through_0023() self._seed() upgrade(self.db, MIGRATIONS) connection = self._open(foreign_keys=True) with self.assertRaises(sqlite3.IntegrityError): connection.execute("DELETE FROM app_user WHERE id='local'") self.assertEqual(connection.execute( "SELECT count(*) FROM app_user WHERE id='local'").fetchone()[0], 1) connection.close() def test_orphan_rows_abort_the_migration_instead_of_being_dropped(self): self._upgrade_through_0023() self._seed() connection = self._open() connection.execute( "INSERT INTO asset_tag(asset_id,tag,confidence,source) " "VALUES(999,'orphan',1.0,'name')") connection.close() with self.assertRaises(sqlite3.IntegrityError): upgrade(self.db, MIGRATIONS) connection = self._open() self.assertEqual(connection.execute( "SELECT max(version) FROM schema_migration").fetchone()[0], "0023") self.assertEqual(connection.execute( "SELECT count(*) FROM asset_tag WHERE asset_id=999").fetchone()[0], 1) self.assertEqual(connection.execute( "SELECT count(*) FROM asset_tag").fetchone()[0], 4) self.assertEqual(self._delete_rules(connection).get(("asset_tag", "asset_id")), None) connection.close() def test_new_indexes_are_chosen_by_their_justifying_queries(self): self._upgrade_through_0023() self._seed() upgrade(self.db, MIGRATIONS) connection = self._open() trash = " ".join(row[3] for row in connection.execute( "EXPLAIN QUERY PLAN SELECT count(*) FROM asset a WHERE a.disposal='trash'")) self.assertIn("idx_asset_disposal_id", trash) sources = " ".join(row[3] for row in connection.execute( "EXPLAIN QUERY PLAN SELECT source,count(*),count(DISTINCT asset_id) " "FROM asset_tag GROUP BY source ORDER BY count(*) DESC")) self.assertIn("idx_asset_tag_source_asset", sources) connection.close() class AssetSubtitleTableTests(unittest.TestCase): """0027:字幕 sidecar 表的形状由 schema 自己守住。""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name).resolve() self.db = self.root / "ledger.db" sqlite3.connect(self.db).close() upgrade(self.db, MIGRATIONS) self.connection = sqlite3.connect(self.db) self.connection.isolation_level = None self.addCleanup(self.connection.close) self.connection.execute( "INSERT INTO asset(id,location,path,name,medium,first_seen,last_seen) " r"VALUES(1,'local','R:\media\x\a.mp4','a.mp4','video','2026-09-01','2026-09-01')") def _insert(self, **overrides): row = {"asset_id": 1, "location": "local", "path": r"R:\media\x\a.srt", "name": "a.srt", "language": "zh-Hans", "format": "srt", "size": 40, "mtime": "2026-09-01", "pairing": "exact", "first_seen": "2026-09-01", "last_seen": "2026-09-01"} row.update(overrides) columns = ",".join(row) self.connection.execute( f"INSERT INTO asset_subtitle({columns}) " f"VALUES({','.join('?' * len(row))})", tuple(row.values())) def test_the_same_path_can_only_be_registered_once_per_location(self): self._insert() with self.assertRaises(sqlite3.IntegrityError): self._insert(name="别名.srt") def test_an_unpaired_row_must_declare_itself_orphan(self): self._insert(asset_id=None, pairing="orphan") with self.assertRaises(sqlite3.IntegrityError): self._insert(asset_id=None, pairing="exact", path=r"R:\media\x\b.srt") def test_an_unknown_pairing_name_is_refused(self): with self.assertRaises(sqlite3.IntegrityError): self._insert(pairing="差不多") def test_deleting_the_asset_takes_its_subtitle_rows(self): self._insert() self.connection.execute("PRAGMA foreign_keys=ON") self.connection.execute("DELETE FROM asset WHERE id=1") self.assertEqual(self.connection.execute( "SELECT count(*) FROM asset_subtitle").fetchone()[0], 0) self.assertEqual( self.connection.execute("PRAGMA foreign_key_check").fetchall(), []) def test_the_index_carries_the_listing_query(self): plan = " ".join(row[3] for row in self.connection.execute( "EXPLAIN QUERY PLAN SELECT id,name FROM asset_subtitle " "WHERE asset_id=1 ORDER BY name COLLATE NOCASE, id")) self.assertIn("idx_asset_subtitle_asset", plan) class TaskRunTableTests(unittest.TestCase): """0028:任务中心那张表的约束由数据库守,不靠服务层自觉。""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.db = Path(self.tmp.name).resolve() / "ledger.db" sqlite3.connect(self.db).close() upgrade(self.db, MIGRATIONS) self.connection = sqlite3.connect(self.db) self.addCleanup(self.connection.close) def _insert(self, **fields): fields.setdefault("task_key", "demo") fields.setdefault("trigger", "manual") fields.setdefault("status", "running") columns = ",".join(fields) marks = ",".join("?" * len(fields)) cursor = self.connection.execute( f"INSERT INTO task_run({columns}) VALUES({marks})", list(fields.values())) self.connection.commit() return cursor.lastrowid def test_one_active_run_per_mutex_key(self): self._insert(mutex_key="follow-check", started_at="2026-09-11T00:00:00.000Z") with self.assertRaises(sqlite3.IntegrityError): self._insert(mutex_key="follow-check", started_at="2026-09-11T00:01:00.000Z") def test_finished_runs_release_the_mutex_key_without_losing_it(self): """终态行不占索引位,但 `mutex_key` 留着——「上次是谁挡的」还查得到。""" for _ in range(3): self._insert(mutex_key="follow-check", status="succeeded", finished_at="2026-09-11T00:00:00.000Z") self._insert(mutex_key="follow-check", started_at="2026-09-11T00:02:00.000Z") kept = self.connection.execute( "SELECT count(*) FROM task_run WHERE mutex_key='follow-check'").fetchone() self.assertEqual(kept[0], 4) def test_runs_without_a_mutex_key_never_block_each_other(self): for _ in range(3): self._insert(started_at="2026-09-11T00:00:00.000Z") self.assertEqual(self.connection.execute( "SELECT count(*) FROM task_run WHERE status='running'").fetchone()[0], 3) def test_terminal_status_and_finished_at_agree(self): with self.assertRaises(sqlite3.IntegrityError): self._insert(status="succeeded") with self.assertRaises(sqlite3.IntegrityError): self._insert(status="running", finished_at="2026-09-11T00:00:00.000Z") def test_unknown_status_and_trigger_are_rejected(self): with self.assertRaises(sqlite3.IntegrityError): self._insert(status="done", finished_at="2026-09-11T00:00:00.000Z") with self.assertRaises(sqlite3.IntegrityError): self._insert(trigger="webhook") def test_recent_runs_of_one_task_use_their_index(self): plan_rows = " ".join(row[3] for row in self.connection.execute( "EXPLAIN QUERY PLAN SELECT id FROM task_run WHERE task_key='demo' " "ORDER BY id DESC LIMIT 20")) self.assertIn("idx_task_run_key_recent", plan_rows) class ExternalRefUniquenessTests(unittest.TestCase): """0032:一位实体在一个站点可以有多个页面,一个页面仍只属于一位实体。""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.db = Path(self.tmp.name).resolve() / "ledger.db" sqlite3.connect(self.db).close() upgrade(self.db, MIGRATIONS) self.connection = sqlite3.connect(self.db) self.addCleanup(self.connection.close) for entity_id, name in ((10, "釈アリス"), (11, "天川そら")): self.connection.execute( "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)" " VALUES(?,'performer',?,?,'t','t')", (entity_id, name, name)) self.connection.commit() def _ref(self, entity_id: int, external_id: str): self.connection.execute( "INSERT INTO entity_external_ref(entity_id,provider,external_kind,external_id)" " VALUES(?,'javdb','performer',?)", (entity_id, external_id)) self.connection.commit() def test_one_performer_can_hold_two_pages_on_the_same_site(self): self._ref(10, "d45k9") self._ref(10, "ZX5z7") self.assertEqual(self.connection.execute( "SELECT count(*) FROM entity_external_ref WHERE entity_id=10").fetchone()[0], 2) def test_the_same_page_cannot_be_claimed_twice(self): self._ref(10, "d45k9") with self.assertRaises(sqlite3.IntegrityError): self._ref(11, "d45k9") with self.assertRaises(sqlite3.IntegrityError): self._ref(10, "d45k9") def test_deleting_the_performer_takes_every_page_with_it(self): self._ref(10, "d45k9") self._ref(10, "ZX5z7") self.connection.execute("PRAGMA foreign_keys=ON") self.connection.execute("DELETE FROM entity WHERE id=10") self.connection.commit() self.assertEqual(self.connection.execute( "SELECT count(*) FROM entity_external_ref").fetchone()[0], 0) class OrphanSourceRowsTests(unittest.TestCase): """0039:删来源时没跟着走的子表行被清掉,还有主人的原样留下。""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name).resolve() self.db = self.root / "ledger.db" below = self.root / "below-0039" below.mkdir() for path in sorted(MIGRATIONS.glob("*.sql")): if path.name < "0039": shutil.copyfile(path, below / path.name) sqlite3.connect(self.db).close() upgrade(self.db, below) connection = sqlite3.connect(self.db) # 和服务的连接一样不开外键,才造得出生产上那种孤儿行。 connection.executescript(""" INSERT INTO follow_source(id,provider,ref,label,url,created_at,updated_at) VALUES(1,'rule34xxx','kept','kept','https://x.test/kept','t','t'); INSERT INTO follow_item(id,source_id,external_id,title,release_key,first_seen_at,last_seen_at) VALUES(10,1,'a','kept','a','t','t'), (11,106,'b','orphan','b','t','t'); INSERT INTO follow_playback(follow_item_id,play_count) VALUES(10,1),(11,1),(12,1); INSERT INTO feed_source(id,kind,url,created_at) VALUES(1,'javdb_actor','https://j.test/1','t'); INSERT INTO feed_item(id,source_id,item_key,first_seen) VALUES(20,1,'k','t'),(21,9,'k','t'); INSERT INTO feed_discovery(id,code,source_id,discovered_at) VALUES(30,'ABC-001',1,'t'),(31,'ABC-002',9,'t'); """) connection.commit() connection.close() def test_rows_of_removed_sources_are_cleared(self): upgrade(self.db, MIGRATIONS) connection = sqlite3.connect(self.db) self.addCleanup(connection.close) self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), []) self.assertEqual([row[0] for row in connection.execute("SELECT id FROM follow_item")], [10]) self.assertEqual([row[0] for row in connection.execute( "SELECT follow_item_id FROM follow_playback")], [10]) self.assertEqual([row[0] for row in connection.execute("SELECT id FROM feed_item")], [20]) # 发现过的作品是「这个番号还没入库」,不随订阅消失,只断开来源。 self.assertEqual(connection.execute( "SELECT id,source_id FROM feed_discovery ORDER BY id").fetchall(), [(30, 1), (31, None)]) if __name__ == "__main__": unittest.main()