11import json
22
33import pytest
4+ from sqlalchemy import event
45from sqlmodel import Session , SQLModel , create_engine
56
67from module .database .bangumi import BangumiDatabase
1213engine = create_engine ("sqlite://" , echo = False )
1314
1415
16+ @event .listens_for (engine , "connect" )
17+ def _enable_foreign_keys (dbapi_conn , connection_record ):
18+ """匹配生产环境行为:启用 SQLite 外键约束。"""
19+ cursor = dbapi_conn .cursor ()
20+ cursor .execute ("PRAGMA foreign_keys=ON" )
21+ cursor .close ()
22+
23+
24+ def _ensure_bangumi (session , bangumi_id : int ):
25+ """确保 bangumi 表中存在指定 id 的记录,满足外键约束。"""
26+ if session .get (Bangumi , bangumi_id ) is None :
27+ session .add (Bangumi (
28+ id = bangumi_id ,
29+ official_title = f"Stub Anime { bangumi_id } " ,
30+ title_raw = f"Stub { bangumi_id } " ,
31+ group_name = "TestGroup" ,
32+ dpi = "1080p" ,
33+ source = "Web" ,
34+ subtitle = "CHT" ,
35+ rss_link = f"stub_{ bangumi_id } " ,
36+ ))
37+ session .commit ()
38+
39+
1540@pytest .fixture
1641def db_session ():
1742 SQLModel .metadata .create_all (engine )
@@ -189,6 +214,9 @@ def test_torrent_with_bangumi_id(db_session):
189214 """Test torrent with bangumi_id for offset lookup."""
190215 db = TorrentDatabase (db_session )
191216
217+ # 父记录满足外键约束
218+ _ensure_bangumi (db_session , 42 )
219+
192220 # Create torrent linked to a bangumi
193221 torrent = Torrent (
194222 name = "[SubGroup] Test Anime - 04 [1080p].mkv" ,
@@ -445,6 +473,7 @@ class TestDeleteByBangumiId:
445473
446474 def test_deletes_matching_torrents (self , db_session ):
447475 db = TorrentDatabase (db_session )
476+ _ensure_bangumi (db_session , 10 )
448477 for i in range (3 ):
449478 db .add (Torrent (name = f"torrent_{ i } " , url = f"https://example.com/{ i } " , bangumi_id = 10 ))
450479 assert len (db .search_all ()) == 3
@@ -455,6 +484,8 @@ def test_deletes_matching_torrents(self, db_session):
455484
456485 def test_leaves_other_bangumi_torrents (self , db_session ):
457486 db = TorrentDatabase (db_session )
487+ _ensure_bangumi (db_session , 20 )
488+ _ensure_bangumi (db_session , 30 )
458489 db .add (Torrent (name = "keep" , url = "https://example.com/keep" , bangumi_id = 20 ))
459490 db .add (Torrent (name = "delete" , url = "https://example.com/delete" , bangumi_id = 30 ))
460491
@@ -466,6 +497,7 @@ def test_leaves_other_bangumi_torrents(self, db_session):
466497
467498 def test_no_match_returns_zero (self , db_session ):
468499 db = TorrentDatabase (db_session )
500+ _ensure_bangumi (db_session , 5 )
469501 db .add (Torrent (name = "unrelated" , url = "https://example.com/1" , bangumi_id = 5 ))
470502
471503 count = db .delete_by_bangumi_id (999 )
@@ -474,6 +506,7 @@ def test_no_match_returns_zero(self, db_session):
474506
475507 def test_skips_null_bangumi_id (self , db_session ):
476508 db = TorrentDatabase (db_session )
509+ _ensure_bangumi (db_session , 7 )
477510 db .add (Torrent (name = "orphan" , url = "https://example.com/orphan" , bangumi_id = None ))
478511 db .add (Torrent (name = "target" , url = "https://example.com/target" , bangumi_id = 7 ))
479512
@@ -486,6 +519,7 @@ def test_skips_null_bangumi_id(self, db_session):
486519 def test_check_new_finds_urls_after_cleanup (self , db_session ):
487520 """Core scenario: after deleting torrent records, check_new should treat those URLs as new."""
488521 db = TorrentDatabase (db_session )
522+ _ensure_bangumi (db_session , 42 )
489523 db .add (Torrent (name = "ep01" , url = "https://mikan.me/t/001" , bangumi_id = 42 ))
490524 db .add (Torrent (name = "ep02" , url = "https://mikan.me/t/002" , bangumi_id = 42 ))
491525
@@ -579,3 +613,99 @@ def test_match_list_with_aliases(db_session):
579613 unmatched = db .match_list (torrents , "rss2" )
580614 assert len (unmatched ) == 1
581615 assert unmatched [0 ].name == "[OtherGroup] Different Anime - 01.mkv"
616+
617+
618+ # ============================================================
619+ # RSS Foreign Key Constraint Tests
620+ # ============================================================
621+
622+
623+ class TestRSSDeleteWithTorrents :
624+ """Regression tests: deleting RSSItem must cascade-delete referencing torrents."""
625+
626+ def test_delete_rss_with_torrents (self , db_session ):
627+ """删除 RSSItem 时应自动清除引用它的 torrent 记录。"""
628+ rss_db = RSSDatabase (db_session )
629+ torrent_db = TorrentDatabase (db_session )
630+
631+ # 创建 RSS 和关联的 torrent
632+ rss = RSSItem (url = "https://mikanani.me/RSS/test" , name = "Test RSS" )
633+ rss_db .add (rss )
634+
635+ torrent_db .add (Torrent (name = "ep01" , url = "https://example.com/1" , rss_id = rss .id ))
636+ torrent_db .add (Torrent (name = "ep02" , url = "https://example.com/2" , rss_id = rss .id ))
637+ # 不关联此 RSS 的 torrent
638+ torrent_db .add (Torrent (name = "other" , url = "https://example.com/3" , rss_id = None ))
639+
640+ assert len (torrent_db .search_rss (rss .id )) == 2
641+
642+ # 删除 RSS(不应报外键错误)
643+ result = rss_db .delete (rss .id )
644+ assert result is True
645+
646+ # RSS 和关联 torrent 都应被删除
647+ assert rss_db .search_id (rss .id ) is None
648+ assert len (torrent_db .search_rss (rss .id )) == 0
649+ # 无关 torrent 不受影响
650+ assert len (torrent_db .search_all ()) == 1
651+
652+ def test_delete_rss_without_torrents (self , db_session ):
653+ """删除没有关联 torrent 的 RSSItem 应正常工作。"""
654+ rss_db = RSSDatabase (db_session )
655+
656+ rss = RSSItem (url = "https://mikanani.me/RSS/empty" , name = "Empty RSS" )
657+ rss_db .add (rss )
658+
659+ result = rss_db .delete (rss .id )
660+ assert result is True
661+ assert rss_db .search_id (rss .id ) is None
662+
663+ def test_delete_rss_cascades_in_transaction (self , db_session ):
664+ """验证删除操作在同一事务中完成,要么全成功要么全回滚。"""
665+ rss_db = RSSDatabase (db_session )
666+ torrent_db = TorrentDatabase (db_session )
667+
668+ rss = RSSItem (url = "https://mikanani.me/RSS/tx" , name = "TX Test" )
669+ rss_db .add (rss )
670+
671+ for i in range (5 ):
672+ torrent_db .add (
673+ Torrent (name = f"ep{ i :02d} " , url = f"https://example.com/{ i } " , rss_id = rss .id )
674+ )
675+
676+ assert len (torrent_db .search_rss (rss .id )) == 5
677+
678+ rss_db .delete (rss .id )
679+
680+ # 全部清理干净
681+ assert rss_db .search_id (rss .id ) is None
682+ assert len (torrent_db .search_rss (rss .id )) == 0
683+
684+ def test_delete_all_rss_with_torrents (self , db_session ):
685+ """delete_all 应删除所有 RSS 及其关联 torrent。"""
686+ rss_db = RSSDatabase (db_session )
687+ torrent_db = TorrentDatabase (db_session )
688+
689+ rss1 = RSSItem (url = "https://mikanani.me/RSS/a" , name = "RSS A" )
690+ rss2 = RSSItem (url = "https://mikanani.me/RSS/b" , name = "RSS B" )
691+ rss_db .add (rss1 )
692+ rss_db .add (rss2 )
693+
694+ torrent_db .add (Torrent (name = "t1" , url = "https://example.com/1" , rss_id = rss1 .id ))
695+ torrent_db .add (Torrent (name = "t2" , url = "https://example.com/2" , rss_id = rss2 .id ))
696+ # rss_id 为 None 的 torrent 应不受影响
697+ torrent_db .add (Torrent (name = "orphan" , url = "https://example.com/3" , rss_id = None ))
698+
699+ rss_db .delete_all ()
700+
701+ assert len (rss_db .search_all ()) == 0
702+ # 只剩 rss_id 为 None 的
703+ remaining = torrent_db .search_all ()
704+ assert len (remaining ) == 1
705+ assert remaining [0 ].name == "orphan"
706+
707+ def test_delete_nonexistent_rss (self , db_session ):
708+ """删除不存在的 RSS 不应报错。"""
709+ rss_db = RSSDatabase (db_session )
710+ result = rss_db .delete (999 )
711+ assert result is True
0 commit comments