"""Unit tests for ApplicationService. Exercises every resolution path in get_by_country_code() and verifies the in-memory cache is populated and invalidated correctly. Uses an async SQLite in-memory database — no running MySQL is required. """ import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from sqlmodel import SQLModel from playlist_sync.models.application import Application from playlist_sync.services.application_service import ApplicationService TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest_asyncio.fixture(scope="function") async def db_engine(): engine = create_async_engine( TEST_DATABASE_URL, connect_args={"check_same_thread": False}, poolclass=StaticPool, ) async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) yield engine async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.drop_all) await engine.dispose() @pytest_asyncio.fixture(scope="function") async def session(db_engine): """Return a session pre-loaded with a standard set of Application rows.""" TestSession = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) apps = [ Application( id=1, name="US Market", spotify_region_code="US", active=True, fallback_application=False, global_push_application=False, workout_market=False, ), Application( id=2, name="UK Market", spotify_region_code="GB", active=True, fallback_application=False, global_push_application=False, workout_market=False, ), Application( id=3, name="Global Push", spotify_region_code="GLOBAL", active=True, fallback_application=False, global_push_application=True, workout_market=False, ), Application( id=4, name="Fallback", spotify_region_code="OTHER", active=True, fallback_application=True, global_push_application=False, workout_market=False, ), Application( id=5, name="Workout", spotify_region_code="WO", active=True, fallback_application=True, global_push_application=False, workout_market=True, ), Application( id=6, name="CA&C", spotify_region_code="CA&C", active=True, fallback_application=False, global_push_application=False, workout_market=False, ), Application( id=7, name="Inactive MX", spotify_region_code="MX", active=False, fallback_application=False, global_push_application=False, workout_market=False, ), ] async with TestSession() as s: for app in apps: s.add(app) await s.commit() # Each test gets a fresh session from the same engine ApplicationService.invalidate_cache() async with TestSession() as s: yield s ApplicationService.invalidate_cache() # --------------------------------------------------------------------------- # Resolution tests # --------------------------------------------------------------------------- async def test_get_by_country_code_global(session): """'global' should resolve to the GlobalPushApplication.""" svc = ApplicationService(session) app = await svc.get_by_country_code("global") assert app is not None assert app.global_push_application is True assert app.id == 3 async def test_get_by_country_code_global_case_insensitive(session): """'GLOBAL' and 'Global' should both resolve correctly.""" svc = ApplicationService(session) for variant in ("GLOBAL", "Global", "gLobal"): app = await svc.get_by_country_code(variant) assert app is not None and app.id == 3, f"Failed for variant '{variant}'" async def test_get_by_country_code_other(session): """'other' should resolve to the non-workout FallbackApplication.""" svc = ApplicationService(session) app = await svc.get_by_country_code("other") assert app is not None assert app.fallback_application is True assert app.workout_market is False assert app.id == 4 async def test_get_by_country_code_other_excludes_workout(session): """The workout fallback (id=5) must NOT be returned for 'other'.""" svc = ApplicationService(session) app = await svc.get_by_country_code("other") assert app is not None assert app.id != 5 async def test_get_by_country_code_numeric(session): """A numeric string should look up by application id.""" svc = ApplicationService(session) app = await svc.get_by_country_code("2") assert app is not None assert app.id == 2 assert app.spotify_region_code == "GB" async def test_get_by_country_code_string_exact(session): """An exact region code match should be returned.""" svc = ApplicationService(session) app = await svc.get_by_country_code("US") assert app is not None assert app.id == 1 async def test_get_by_country_code_string_case_insensitive(session): """Region code matching is case-insensitive ('us' should find 'US').""" svc = ApplicationService(session) app = await svc.get_by_country_code("us") assert app is not None assert app.id == 1 async def test_get_by_country_code_not_found(session): """An unknown country code should return None.""" svc = ApplicationService(session) app = await svc.get_by_country_code("ZZ") assert app is None async def test_get_by_country_code_empty_string(session): """An empty string should return None without raising.""" svc = ApplicationService(session) app = await svc.get_by_country_code("") assert app is None async def test_get_by_country_code_numeric_not_found(session): """A numeric string with no matching id should return None.""" svc = ApplicationService(session) app = await svc.get_by_country_code("9999") assert app is None # --------------------------------------------------------------------------- # get_fallback / get_global_push convenience methods # --------------------------------------------------------------------------- async def test_get_fallback(session): svc = ApplicationService(session) app = await svc.get_fallback() assert app is not None assert app.fallback_application is True assert app.workout_market is False async def test_get_global_push(session): svc = ApplicationService(session) app = await svc.get_global_push() assert app is not None assert app.global_push_application is True async def test_get_by_id_found(session): svc = ApplicationService(session) app = await svc.get_by_id(6) assert app is not None assert app.spotify_region_code == "CA&C" async def test_get_by_id_not_found(session): svc = ApplicationService(session) app = await svc.get_by_id(9999) assert app is None async def test_get_all_returns_all_rows(session): svc = ApplicationService(session) apps = await svc.get_all() assert len(apps) == 7 # --------------------------------------------------------------------------- # Cache behaviour # --------------------------------------------------------------------------- async def test_cache_is_populated_after_first_call(session): """After the first call the class-level cache should be non-empty.""" svc = ApplicationService(session) await svc.get_all() assert len(ApplicationService._cache) == 7 async def test_invalidate_cache_clears_data(session): svc = ApplicationService(session) await svc.get_all() assert ApplicationService._cache # populated ApplicationService.invalidate_cache() assert ApplicationService._cache == [] assert ApplicationService._cache_ts == 0.0 # --------------------------------------------------------------------------- # _bit_to_bool helper — MySQL BIT(1) coercion # --------------------------------------------------------------------------- class TestBitToBool: """Unit tests for _bit_to_bool helper in routes.py. MySQL BIT(1) columns are returned as bytes by aiomysql, not integers. SQLite tests use integers (0/1), so only these targeted tests exercise the bytes path. """ def setup_method(self): from playlist_sync.api import _bit_to_bool self._fn = _bit_to_bool def test_bytes_zero_is_false(self): assert self._fn(b"\x00") is False def test_bytes_one_is_true(self): assert self._fn(b"\x01") is True def test_empty_bytes_is_false(self): assert self._fn(b"") is False def test_bytearray_zero_is_false(self): assert self._fn(bytearray(b"\x00")) is False def test_bytearray_one_is_true(self): assert self._fn(bytearray(b"\x01")) is True def test_int_zero_is_false(self): assert self._fn(0) is False def test_int_one_is_true(self): assert self._fn(1) is True def test_bool_false(self): assert self._fn(False) is False def test_bool_true(self): assert self._fn(True) is True def test_none_is_false(self): assert self._fn(None) is False