from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, patch import pytest from sqlalchemy import text from app.library.ItemDTO import ItemDTO from app.library.operations import Operation from app.library.sqlite_store import SqliteStore from app.tests.helpers import make_in_memory_db_path async def make_store() -> SqliteStore: """Create an isolated named in-memory SqliteStore instance with schema.""" SqliteStore._reset_singleton() store = SqliteStore.get_instance(db_path=make_in_memory_db_path("sqlite-store")) await store.get_connection() assert store._engine is not None, "Engine should be initialized after _ensure_conn" return store def make_item(idx: int, *, status: str = "finished", cli: str = "", download_skipped: bool = False) -> ItemDTO: return ItemDTO( id=f"vid{idx}", title=f"Video {idx}", url=f"https://example.com/{idx}", folder="/downloads", status=status, cli=cli, download_skipped=download_skipped, ) @pytest.mark.asyncio async def test_sessionmaker_ready() -> None: """Test that sessionmaker() returns a working async_sessionmaker.""" SqliteStore._reset_singleton() store = SqliteStore.get_instance(db_path=make_in_memory_db_path("sessionmaker")) # Ensure connection is initialized await store.get_connection() # Get sessionmaker sessionmaker = store.sessionmaker() assert sessionmaker is not None, "Sessionmaker should not be None after connection initialization" # Verify we can create a session async with sessionmaker() as session: assert session is not None, "Session should be created successfully" await store.close() @pytest.mark.asyncio async def test_sessionmaker_raises_before_init() -> None: """Test that sessionmaker() raises error before connection initialization.""" SqliteStore._reset_singleton() store = SqliteStore.get_instance(db_path=make_in_memory_db_path("sessionmaker-before-init")) with pytest.raises(RuntimeError, match="Database connection not initialized"): store.sessionmaker() await store.close() @pytest.mark.asyncio async def test_sqlalchemy_engine_disposed_on_close() -> None: """Test that SQLAlchemy engine is properly disposed on close.""" SqliteStore._reset_singleton() store = SqliteStore.get_instance(db_path=make_in_memory_db_path("engine-close")) await store.get_connection() assert store._engine is not None, "Engine should be created" assert store._sessionmaker is not None, "Sessionmaker should be created" await store.close() assert store._engine is None, "Engine should be None after close" assert store._sessionmaker is None, "Sessionmaker should be None after close" @pytest.mark.asyncio async def test_named_in_memory_databases_are_isolated() -> None: SqliteStore._reset_singleton() first = SqliteStore.get_instance(db_path=":memory:named-a") await first.get_connection() await first.execute_raw( 'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)', ( "first", "queue", "https://example.com/a", '{"id":"a","title":"A","url":"https://example.com/a","folder":"/downloads","status":"finished"}', "2024-01-01 00:00:00", ), ) await first.close() SqliteStore._reset_singleton() second = SqliteStore.get_instance(db_path=":memory:named-b") await second.get_connection() rows = await second.fetch_raw('SELECT "id" FROM "history" WHERE "id" = ?', ("first",)) assert rows == [] await second.close() @pytest.mark.asyncio async def test_enqueue_upsert_and_fetch_saved(): store = await make_store() item = make_item(1) await store.enqueue_upsert("queue", item) await store.flush() saved = await store.fetch_saved("queue") assert len(saved) == 1 key, loaded = saved[0] assert key == item._id assert loaded.title == item.title await store.close() @pytest.mark.asyncio async def test_enqueue_upsert_skipped(): store = await make_store() item = make_item(2, download_skipped=True) await store.enqueue_upsert("history", item) await store.flush() saved = await store.fetch_saved("history") assert len(saved) == 1 _, loaded = saved[0] assert item.download_skipped is True assert loaded.download_skipped is True await store.close() @pytest.mark.asyncio async def test_delete_and_bulk_delete(): store = await make_store() items = [make_item(i) for i in range(3)] for itm in items: await store.enqueue_upsert("queue", itm) await store.flush() await store.enqueue_delete("queue", items[0]._id) await store.enqueue_bulk_delete("queue", [items[1]._id]) await store.flush() remaining = await store.fetch_saved("queue") assert len(remaining) == 1 assert remaining[0][0] == items[2]._id await store.close() @pytest.mark.asyncio async def test_count_with_status_filters(): store = await make_store() finished_items = [make_item(i, status="finished") for i in range(2)] pending_items = [make_item(i + 10, status="pending") for i in range(3)] skipped_items = [make_item(i + 20, status="skip") for i in range(2)] for itm in finished_items + pending_items + skipped_items: await store.enqueue_upsert("history", itm) await store.flush() assert await store.count("history", status_filter="finished") == 2 assert await store.count("history", status_filter="pending") == 3 assert await store.count("history", status_filter="finished,skip") == 4 assert await store.count("history", status_filter="!finished") == 5 assert await store.count("history", status_filter="!finished,!skip") == 3 await store.close() @pytest.mark.asyncio async def test_paginate_order_and_bounds(): store = await make_store() base = datetime(2024, 1, 1, tzinfo=UTC) _list: list[ItemDTO] = [] for i in range(15): itm = make_item(i) encoded = itm.json() created_at = (base + timedelta(minutes=i)).strftime("%Y-%m-%d %H:%M:%S") _list.append(itm) await store._conn.execute( text( 'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (:id, :type, :url, :data, :created_at)' ), {"id": itm._id, "type": "history", "url": itm.url, "data": encoded, "created_at": created_at}, ) await store._conn.commit() items, total, page, total_pages = await store.paginate("history", page=1, per_page=5, order="ASC") assert total == 15 assert page == 1 assert total_pages == 3 assert items[0][0] == _list[0]._id items_desc, *_ = await store.paginate("history", page=1, per_page=3, order="DESC") assert len(items_desc) == 3 assert items_desc[0][0] == _list[14]._id await store.close() @pytest.mark.asyncio async def test_get_by_id(): store = await make_store() item = make_item(99) await store.enqueue_upsert("queue", item) await store.flush() assert item._id == (await store.get_by_id("queue", item._id))._id await store.close() @pytest.mark.asyncio async def test_shutdown_closes_worker_queue(): store = await make_store() await store.enqueue_upsert("queue", make_item(99)) await store.shutdown() assert store._queue is None await store.close() @pytest.mark.asyncio async def test_enqueue_delete_removes_row(): store = await make_store() item = make_item(5) await store.enqueue_upsert("queue", item) await store.flush() await store.enqueue_delete("queue", item._id) await store.flush() rows = await store.fetch_saved("queue") assert rows == [] await store.close() @pytest.mark.asyncio async def test_enqueue_bulk_delete(): store = await make_store() items = [make_item(i) for i in range(3)] for itm in items: await store.enqueue_upsert("history", itm) await store.flush() await store.enqueue_bulk_delete("history", [items[0]._id, items[1]._id]) await store.flush() remaining = await store.fetch_saved("history") assert len(remaining) == 1 assert remaining[0][0] == items[2]._id # direct bulk_delete should report affected rows count = await store.bulk_delete("history", [items[2]._id]) assert count == 1 await store.close() @pytest.mark.asyncio async def test_ids_status_bulk_delete(): store = await make_store() finished = [make_item(i, status="finished") for i in range(2)] pending = [make_item(i + 10, status="pending") for i in range(2)] skipped = [make_item(i + 20, status="skip") for i in range(2)] for item in finished + pending + skipped: await store.enqueue_upsert("history", item) await store.flush() by_ids = await store.get_many_by_ids("history", [pending[1]._id, finished[0]._id]) assert [item_id for item_id, _ in by_ids] == [pending[1]._id, finished[0]._id] by_status = await store.get_many_by_status("history", "finished") assert {item_id for item_id, _ in by_status} == {finished[0]._id, finished[1]._id} completed = await store.get_many_by_status("history", "finished,skip") assert {item_id for item_id, _ in completed} == { finished[0]._id, finished[1]._id, skipped[0]._id, skipped[1]._id, } deleted = await store.bulk_delete_by_status("history", "finished") assert deleted == 2 remaining = await store.fetch_saved("history") assert {item_id for item_id, _ in remaining} == { pending[0]._id, pending[1]._id, skipped[0]._id, skipped[1]._id, } await store.close() @pytest.mark.asyncio async def test_paginate_last_page(): store = await make_store() for i in range(7): await store.enqueue_upsert("history", make_item(i)) await store.flush() items, total, page, total_pages = await store.paginate("history", page=10, per_page=3, order="DESC") assert total == 7 assert total_pages == 3 assert page == 3 assert len(items) == 1 await store.close() @pytest.mark.asyncio async def test_get_item_no_filters(): store = await make_store() await store.enqueue_upsert("queue", make_item(1)) await store.flush() result = await store.get_item("queue") assert result is None await store.close() @pytest.mark.asyncio async def test_get_item_matches_conditions(): store = await make_store() first = make_item(1, status="finished") second = make_item(2, status="pending") await store.enqueue_upsert("queue", first) await store.enqueue_upsert("queue", second) await store.flush() # equality match match_equal = await store.get_item("queue", title=first.title) assert match_equal is not None assert match_equal._id == first._id # NOT_EQUAL should find the second item match_not_equal = await store.get_item("queue", status=(Operation.NOT_EQUAL, "finished")) assert match_not_equal is not None assert match_not_equal._id == second._id # CONTAIN should find first created (first) match_contain = await store.get_item("queue", title=(Operation.CONTAIN, "Video")) assert match_contain is not None assert match_contain._id == first._id # No match returns None no_match = await store.get_item("queue", title=(Operation.CONTAIN, "does-not-exist")) assert no_match is None await store.close() @pytest.mark.asyncio async def test_on_shutdown_closes_connection(): store = await make_store() await store.enqueue_upsert("queue", make_item(1)) await store.flush() await store.on_shutdown(None) assert store._conn is None # Note: on_shutdown already closes the connection, so no need to call close() again @pytest.mark.asyncio async def test_exists_and_get_by_key_and_url(): store = await make_store() item = make_item(42) await store.enqueue_upsert("queue", item) await store.flush() assert await store.exists("queue", key=item._id) is True assert await store.exists("queue", url=item.url) is True fetched_by_key = await store.get("queue", key=item._id) assert fetched_by_key is not None assert fetched_by_key._id == item._id fetched_by_url = await store.get("queue", url=item.url) assert fetched_by_url is not None assert fetched_by_url._id == item._id assert await store.exists("queue", key="missing") is False assert await store.get("queue", key="missing") is None await store.close() @pytest.mark.asyncio async def test_exists_and_get_require_lookup(): store = await make_store() with pytest.raises(KeyError): await store.exists("queue") with pytest.raises(KeyError): await store.get("queue") await store.close()