@@ -403,5 +403,78 @@ def test_merge_show_entries_combines_show_rating_with_season_removal(self) -> No
403403 )
404404
405405
406+ class _WatchedFakeSession :
407+ """Fakes just enough of AsyncSession for _import_watched: an empty
408+ existing-watch-events query, plus recording every WatchEvent added."""
409+
410+ def __init__ (self ) -> None :
411+ self .added : list = []
412+
413+ async def execute (self , statement ):
414+ return SimpleNamespace (all = lambda : [])
415+
416+ def begin_nested (self ):
417+ return self
418+
419+ async def __aenter__ (self ):
420+ return self
421+
422+ async def __aexit__ (self , exc_type , exc , traceback ):
423+ return False
424+
425+ def add (self , obj ):
426+ self .added .append (obj )
427+
428+
429+ class ImportWatchedSkipsShowRollupTests (unittest .IsolatedAsyncioTestCase ):
430+ async def test_shows_entries_are_not_imported_as_watch_events (self ) -> None :
431+ """Regression test: MDBList's /sync/watched "shows" entries are rollup
432+ wrappers whose watched_at just mirrors the show's most recently
433+ watched episode — they carry no per-episode data of their own.
434+ Importing them as standalone watch events created a bogus
435+ series-level WatchEvent for every watched show, alongside the real
436+ episode-level one, and could collide with an unrelated movie that
437+ happens to share the same TMDB id (movies and shows are separate
438+ TMDB id namespaces)."""
439+ from routers .mdblist import _import_watched
440+
441+ seen_kinds : list [str ] = []
442+
443+ async def fake_resolve_media (db , kind , entry , api_key , external_cache ):
444+ seen_kinds .append (kind )
445+ if kind == "movies" :
446+ return SimpleNamespace (id = 1 )
447+ if kind == "episodes" :
448+ return SimpleNamespace (id = 2 )
449+ return SimpleNamespace (id = 999 ) # would only happen on regression
450+
451+ payload = {
452+ "movies" : [{"ids" : {"tmdb" : 100 }, "watched_at" : "2026-08-01T00:00:00Z" }],
453+ "shows" : [
454+ {"ids" : {"tmdb" : 32726 }, "last_watched_at" : "2026-08-01T17:37:45Z" }
455+ ],
456+ "episodes" : [
457+ {
458+ "episode" : {"season" : 12 , "number" : 1 },
459+ "show" : {"ids" : {"tmdb" : 32726 }},
460+ "last_watched_at" : "2026-08-01T17:37:45Z" ,
461+ }
462+ ],
463+ }
464+ stats = {"watched" : 0 , "skipped" : 0 , "errors" : 0 }
465+ db = _WatchedFakeSession ()
466+
467+ with patch ("routers.mdblist._resolve_media" , side_effect = fake_resolve_media ):
468+ changed = await _import_watched (
469+ db , user_id = 35 , payload = payload , api_key = None , external_cache = {}, stats = stats
470+ )
471+
472+ self .assertEqual (seen_kinds , ["movies" , "episodes" ])
473+ self .assertEqual ({obj .media_id for obj in db .added }, {1 , 2 })
474+ self .assertEqual (stats ["watched" ], 2 )
475+ self .assertEqual (stats ["skipped" ], 1 )
476+ self .assertEqual (changed , {1 , 2 })
477+
478+
406479if __name__ == "__main__" :
407480 unittest .main ()
0 commit comments