from datetime import UTC, datetime, timedelta import os from tempfile import mkstemp 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 async def make_store() -> SqliteStore: """Create an isolated temporary SqliteStore instance with schema.""" SqliteStore._reset_singleton() fd, db_path = mkstemp(prefix="ytptube-sqlite-store-", suffix=".db") os.close(fd) store = SqliteStore.get_instance(db_path=db_path) 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_returns_valid_sessionmaker() -> None: """Test that sessionmaker() returns a working async_sessionmaker.""" SqliteStore._reset_singleton() store = SqliteStore.get_instance(db_path=":memory:") # 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=":memory:") 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=":memory:") 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_persists_download_skipped_flag(): 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_returns_count_and_bulk_path(): 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_get_many_by_ids_and_status_and_bulk_delete_by_status(): 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_out_of_range_returns_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_returns_none_without_kwargs(): 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_raise_without_key_or_url(): store = await make_store() with pytest.raises(KeyError): await store.exists("queue") with pytest.raises(KeyError): await store.get("queue") await store.close()