"""Unit tests for FileBackedQueueAdapter.""" from __future__ import annotations from datetime import UTC, datetime, timedelta from pathlib import Path import pytest from python_repositories.adapters.file_backed_queue_adapter import ( FileBackedQueueAdapter, ) from python_repositories.adapters.memory_queue_adapter import MemoryQueueAdapter from python_repositories.config.file_queue_config import FileQueueConfig from python_repositories.interfaces.queue_repository_interface import ( QueueRepositoryInterface, ) def _config(path: Path, **kwargs: object) -> FileQueueConfig: return FileQueueConfig(path=path, **kwargs) # type: ignore[arg-type] def test_implements_interface() -> None: assert issubclass(FileBackedQueueAdapter, QueueRepositoryInterface) def test_ops_require_connect(tmp_path: Path) -> None: queue = FileBackedQueueAdapter(config=_config(tmp_path / "q.jsonl")) with pytest.raises(RuntimeError, match="not connected"): queue.enqueue([{"id": 1}]) with pytest.raises(RuntimeError, match="not connected"): queue.dequeue_batch() with pytest.raises(RuntimeError, match="not connected"): queue.size() with pytest.raises(RuntimeError, match="not connected"): queue.evict_older_than(datetime.now(UTC)) def test_enqueue_dequeue_persists_and_compacts(tmp_path: Path) -> None: path = tmp_path / "items.jsonl" with FileBackedQueueAdapter(config=_config(path, dedup_keys=("id",))) as queue: queue.enqueue([{"id": 1}, {"id": 2}, {"id": 1}]) assert queue.size() == 2 assert path.is_file() lines = path.read_text(encoding="utf-8").strip().splitlines() assert len(lines) == 2 batch = queue.dequeue_batch(max_items=1) assert batch == [{"id": 1}] remaining = path.read_text(encoding="utf-8").strip().splitlines() assert len(remaining) == 1 assert queue.size() == 1 def test_replay_on_connect(tmp_path: Path) -> None: path = tmp_path / "items.jsonl" config = _config(path, dedup_keys=("id",), age_key="created_at", max_age_hours=24) now = datetime.now(UTC) with FileBackedQueueAdapter(config=config) as queue: queue.enqueue( [ {"id": 1, "created_at": now - timedelta(hours=1)}, {"id": 2, "created_at": now - timedelta(minutes=10)}, ] ) with FileBackedQueueAdapter(config=config) as queue: assert queue.size() == 2 assert [item["id"] for item in queue.dequeue_batch(max_items=10)] == [1, 2] def test_replay_filters_by_max_age(tmp_path: Path) -> None: path = tmp_path / "items.jsonl" now = datetime.now(UTC) path.write_text( "\n".join( [ f'{{"id": "old", "created_at": "{(now - timedelta(hours=30)).isoformat()}"}}', f'{{"id": "new", "created_at": "{(now - timedelta(hours=1)).isoformat()}"}}', ] ) + "\n", encoding="utf-8", ) config = _config(path, dedup_keys=("id",), age_key="created_at", max_age_hours=24) with FileBackedQueueAdapter(config=config) as queue: assert queue.size() == 1 assert queue.dequeue_batch(max_items=10)[0]["id"] == "new" def test_connect_skips_corrupt_and_invalid_lines(tmp_path: Path) -> None: path = tmp_path / "items.jsonl" path.write_text( "\n".join( [ "", "not-json", "[1, 2]", '{"id": 1}', '{"name": "missing-id"}', ] ) + "\n", encoding="utf-8", ) config = _config(path, dedup_keys=("id",)) with FileBackedQueueAdapter(config=config) as queue: assert queue.size() == 1 assert queue.dequeue_batch(max_items=10) == [{"id": 1}] def test_connect_skips_missing_or_invalid_age_on_replay(tmp_path: Path) -> None: path = tmp_path / "items.jsonl" now = datetime.now(UTC) path.write_text( "\n".join( [ '{"id": 1}', '{"id": 2, "created_at": "not-a-date"}', f'{{"id": 3, "created_at": "{now.isoformat()}"}}', ] ) + "\n", encoding="utf-8", ) config = _config(path, dedup_keys=("id",), age_key="created_at") with FileBackedQueueAdapter(config=config) as queue: assert queue.size() == 1 assert queue.dequeue_batch(max_items=10)[0]["id"] == 3 def test_connect_is_idempotent(tmp_path: Path) -> None: path = tmp_path / "items.jsonl" queue = FileBackedQueueAdapter(config=_config(path)) queue.connect() queue.enqueue([{"id": 1}]) queue.connect() assert queue.size() == 1 queue.disconnect() assert not queue.is_connected() def test_evict_compacts_file(tmp_path: Path) -> None: path = tmp_path / "items.jsonl" config = _config(path, dedup_keys=("id",), age_key="created_at", max_age_hours=24) now = datetime.now(UTC) with FileBackedQueueAdapter(config=config) as queue: queue.enqueue( [ {"id": 1, "created_at": now - timedelta(hours=2)}, {"id": 2, "created_at": now - timedelta(minutes=5)}, ] ) removed = queue.evict_older_than(now - timedelta(hours=1)) assert removed == 1 assert queue.size() == 1 lines = [line for line in path.read_text(encoding="utf-8").splitlines() if line] assert len(lines) == 1 def test_auto_evict_on_enqueue(tmp_path: Path) -> None: path = tmp_path / "items.jsonl" config = _config(path, dedup_keys=("id",), age_key="created_at", max_age_hours=1) now = datetime.now(UTC) with FileBackedQueueAdapter(config=config) as queue: queue.enqueue([{"id": 1, "created_at": now - timedelta(hours=2)}]) # Item is enqueued then immediately age-evicted. assert queue.size() == 0 def test_from_env_when_config_omitted( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: path = tmp_path / "from-env.jsonl" monkeypatch.setenv("FILE_QUEUE_PATH", str(path)) with FileBackedQueueAdapter() as queue: queue.enqueue([{"id": 1}]) assert queue.size() == 1 def test_injected_memory(tmp_path: Path) -> None: path = tmp_path / "items.jsonl" memory = MemoryQueueAdapter(dedup_keys=("id",)) config = _config(path, dedup_keys=("id",)) with FileBackedQueueAdapter(config=config, memory=memory) as queue: queue.enqueue([{"id": 1}]) assert memory.size() == 1 def test_empty_enqueue_and_dequeue_skip_compact(tmp_path: Path) -> None: path = tmp_path / "items.jsonl" with FileBackedQueueAdapter(config=_config(path)) as queue: queue.enqueue([]) assert queue.dequeue_batch(max_items=10) == [] assert queue.evict_older_than(datetime.now(UTC)) == 0 assert not path.exists() or path.read_text(encoding="utf-8") == "" def test_reconnect_replays_after_disconnect(tmp_path: Path) -> None: path = tmp_path / "items.jsonl" queue = FileBackedQueueAdapter(config=_config(path, dedup_keys=("id",))) queue.connect() queue.enqueue([{"id": 1}]) queue.disconnect() queue.connect() assert queue.size() == 1 queue.disconnect() def test_json_default_rejects_unsupported() -> None: from python_repositories.adapters.file_backed_queue_adapter import _json_default with pytest.raises(TypeError): _json_default(object()) def test_disconnect_ignores_fsync_oserror( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: path = tmp_path / "items.jsonl" queue = FileBackedQueueAdapter(config=_config(path)) queue.connect() def boom(_fd: int) -> None: raise OSError("fsync failed") monkeypatch.setattr( "python_repositories.adapters.file_backed_queue_adapter.os.fsync", boom, ) queue.disconnect() assert not queue.is_connected() def test_append_items_requires_open_handle(tmp_path: Path) -> None: path = tmp_path / "items.jsonl" queue = FileBackedQueueAdapter(config=_config(path)) queue.connect() queue._append_handle = None # noqa: SLF001 with pytest.raises(RuntimeError, match="append handle is not open"): queue._append_items([{"id": 1}]) # noqa: SLF001 queue._connected = False # noqa: SLF001