"""Unit tests for anidb_sync module — token parsing, XML processing, and route.""" import pytest from anidb_sync import _parse_mapping_token, _process_xml, sync_state # --------------------------------------------------------------------------- # _parse_mapping_token tests # --------------------------------------------------------------------------- def test_parse_token_valid(): result = _parse_mapping_token("1-2") assert result == (1, 2) def test_parse_token_with_spaces(): result = _parse_mapping_token(" 3-7 ") assert result == (3, 7) @pytest.mark.parametrize("token", ["bad", "1-2-3", ""]) def test_parse_token_malformed_returns_none(token): assert _parse_mapping_token(token) is None def test_parse_token_non_numeric_returns_none(): assert _parse_mapping_token("a-b") is None def test_parse_token_zero_values(): result = _parse_mapping_token("0-0") assert result == (0, 0) # --------------------------------------------------------------------------- # _process_xml tests # --------------------------------------------------------------------------- VALID_XML = b""" 1-1;2-2;3-3; """ MISSING_TVDBID_XML = b""" 1-1; """ # Bleach, copied verbatim from anime-list.xml (tvdbid 74796, anidbid 2369). # Every one of these mapping elements has EMPTY text: the episode pairs live # in the start/end/offset attributes. Issue #205 — the parser read only the # text, so a series mapped this way produced no rows at all and absolute-order # search fell back to plain S/E numbering. RANGE_XML = b""" ;2-99;3-2;4-0; """ def _mapping_for(season, episode, tvdb_id=74796): from db.repositories.anidb import AnidbRepository return AnidbRepository().get_anidb_absolute(tvdb_id=tvdb_id, season=season, episode=episode) def test_process_xml_expands_range_mapping(app_ctx): """A mapping carrying start/end/offset must produce one row per episode. Issue #205: Bleach S02E01 has to resolve to AniDB absolute episode 21. """ result = _process_xml(RANGE_XML, app_ctx) assert result["error"] is None with app_ctx.app_context(): assert _mapping_for(2, 1) == 21 assert _mapping_for(2, 21) == 41 # offset defaults to 0 when the attribute is absent assert _mapping_for(1, 1) == 1 assert _mapping_for(1, 20) == 20 assert _mapping_for(3, 1) == 42 def test_process_xml_range_and_explicit_tokens_both_apply(app_ctx): """Text tokens next to a range are not lost — the two are additive.""" both = b""" ;9-7; """ result = _process_xml(both, app_ctx) assert result["error"] is None with app_ctx.app_context(): assert _mapping_for(1, 1, tvdb_id=4242) == 5 assert _mapping_for(1, 2, tvdb_id=4242) == 6 assert _mapping_for(1, 7, tvdb_id=4242) == 9 def test_process_xml_range_rejects_absurd_and_negative_results(app_ctx): """A malformed range must not flood the table or write episode <= 0.""" bad = b""" """ result = _process_xml(bad, app_ctx) assert result["error"] is None assert result["mappings_upserted"] == 0 with app_ctx.app_context(): assert _mapping_for(1, 1, tvdb_id=4243) is None assert _mapping_for(1, 1, tvdb_id=4244) is None assert _mapping_for(1, 1, tvdb_id=4245) is None def test_one_failing_row_does_not_kill_the_rest_of_the_sync(app_ctx, monkeypatch): """A row the database refuses must cost that row, not the whole run. RC 2026-09-09: the first failure left the SQLAlchemy session rolled back, so every later upsert failed instantly with "This Session's transaction has been rolled back". 40 rows were written and 10,129 failed -- in one second, because a poisoned session fails fast. The summary still said "AniDB sync complete" and counted only the successes. """ from db.repositories.anidb import AnidbRepository real_upsert = AnidbRepository.upsert_mapping calls = {"n": 0} def flaky(self, **kwargs): calls["n"] += 1 if calls["n"] == 1: raise RuntimeError("UPDATE statement expected to update 1 row(s); 2 were matched") return real_upsert(self, **kwargs) monkeypatch.setattr(AnidbRepository, "upsert_mapping", flaky) result = _process_xml(RANGE_XML, app_ctx) assert result["error"] is None assert result["failed"] == 1, "the refused row must be counted, not swallowed" # Everything after the failure still has to land. assert result["mappings_upserted"] > 50 with app_ctx.app_context(): assert _mapping_for(2, 21) == 41 assert _mapping_for(3, 1) == 42 def test_failures_are_reported_not_only_counted(app_ctx, monkeypatch, caplog): """A run that mostly failed must not read as a success in the log.""" import logging from db.repositories.anidb import AnidbRepository def always_fails(self, **kwargs): raise RuntimeError("nope") monkeypatch.setattr(AnidbRepository, "upsert_mapping", always_fails) with caplog.at_level(logging.WARNING, logger="anidb_sync"): result = _process_xml(RANGE_XML, app_ctx) assert result["mappings_upserted"] == 0 assert result["failed"] > 0 assert any("failed" in r.message.lower() for r in caplog.records), ( "a sync where every write failed must say so above debug level" ) def test_process_xml_valid(app_ctx): """Valid XML with mappings should upsert at least one mapping.""" result = _process_xml(VALID_XML, app_ctx) assert result["error"] is None assert result["mappings_upserted"] >= 1 assert result["series_processed"] >= 1 def test_process_xml_malformed_returns_error(app_ctx): """Malformed XML should return a dict with an error key.""" result = _process_xml(b"<>", app_ctx) assert result["error"] is not None assert "XML" in result["error"] or "parse" in result["error"].lower() def test_process_xml_skips_missing_tvdbid(app_ctx): """Anime element with empty tvdbid should increment skipped counter.""" result = _process_xml(MISSING_TVDBID_XML, app_ctx) assert result["error"] is None assert result["skipped"] >= 1 # --------------------------------------------------------------------------- # Route: POST /api/v1/anidb-mapping/refresh → 409 when already running # --------------------------------------------------------------------------- def test_refresh_returns_409_when_running(client, monkeypatch): """If sync is already running, the refresh endpoint must return 409.""" import anidb_sync monkeypatch.setitem(anidb_sync.sync_state, "running", True) response = client.post("/api/v1/anidb-mapping/refresh") assert response.status_code == 409 data = response.get_json() assert data["success"] is False # Reset so other tests are not affected monkeypatch.setitem(anidb_sync.sync_state, "running", False)