fix(delete): prune in-memory SerieList cache after delete

delete_series() removed the row from the database and the folder
from disk, but never evicted the entry from SerieList.keyDict — the
in-memory cache that list_series_with_filters() reads from via
SeriesApp.list.GetList(). As a result /api/anime kept returning the
deleted series on every page reload (the 'Beyblade Burst still listed
after delete' bug).

Changes:
* Add SerieList.remove(key) so the cache has a proper eviction API.
* In delete_series(), call it (or fall back to keyDict.pop) after a
  successful DB delete.
* Broadcast a broader series_list_changed event so any client that
  missed the specific series_deleted event can re-sync by re-fetching
  /api/anime. Front-end: new SERIES_LIST_CHANGED constant, handler
  that triggers SeriesManager.reloadSeries().
* Two new regression tests: one asserting the in-memory cache is
  pruned, one asserting the broader broadcast fires.
This commit is contained in:
2026-09-04 20:16:31 +02:00
parent 62b4ca5ffc
commit 35a733d36f
7 changed files with 249 additions and 3 deletions

View File

@@ -646,6 +646,111 @@ class TestDeleteSeriesService:
db_delete_mock.assert_not_called()
assert result.success is False
# ------------------------------------------------------------------
# In-memory keyDict cache eviction
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_series_evicts_in_memory_cache(
self, anime_service, mock_series_app
):
"""After a successful DB delete, the in-memory SerieList.keyDict
entry for that series must be removed.
The /api/anime list endpoint reads from SeriesApp.list.keyDict
via list_series_with_filters(). If the cache is not pruned, the
deleted series keeps appearing in the listing on every page
reload — exactly the Beyblade Burst bug.
"""
from src.server.database.SerieList import SerieList
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "beyblade-burst-rise"
mock_series.name = "Beyblade Burst Rise"
mock_series.folder = "Beyblade Burst Rise (2016)"
mock_series.id = 487
# Use a real SerieList instance — the same type the production
# code mutates — so the eviction actually exercises the real
# remove() method (a MagicMock would just return more mocks).
real_list = SerieList(str(mock_series_app.directory_to_search))
real_list.keyDict["beyblade-burst-rise"] = mock_series
mock_series_app.list = real_list
anime_service._websocket_service = MagicMock()
anime_service._websocket_service.broadcast_series_deleted = AsyncMock()
anime_service._websocket_service.broadcast_series_list_changed = AsyncMock()
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
), patch(
"src.server.database.service.AnimeSeriesService.delete",
new_callable=AsyncMock,
return_value=True,
):
result = await anime_service.delete_series(
key="beyblade-burst-rise",
delete_database=True,
delete_folder=False,
)
assert result.success is True
assert "beyblade-burst-rise" not in real_list.keyDict, (
"In-memory cache still contains the deleted series — "
"/api/anime will keep returning it after a page reload"
)
@pytest.mark.asyncio
async def test_delete_series_broadcasts_series_list_changed(
self, anime_service, mock_series_app
):
"""A stronger ``series_list_changed`` broadcast fires after delete
so any connected client can re-sync without relying on the more
specific ``series_deleted`` event reaching them.
"""
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Test Series"
mock_series.folder = "Test Series (2023)"
mock_series.id = 1
mock_series_app.list.keyDict = {"test-key": mock_series}
anime_service._websocket_service = MagicMock()
anime_service._websocket_service.broadcast_series_deleted = AsyncMock()
anime_service._websocket_service.broadcast_series_list_changed = AsyncMock()
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
), patch(
"src.server.database.service.AnimeSeriesService.delete",
new_callable=AsyncMock,
return_value=True,
):
await anime_service.delete_series(
key="test-key",
delete_database=True,
delete_folder=False,
)
anime_service._websocket_service.broadcast_series_list_changed.assert_called_once()
# ------------------------------------------------------------------
# Orphan folder recovery (DB row gone, folder still on disk)
# ------------------------------------------------------------------