Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """Unit tests for core_lib.pipeline.DataSourcePipeline orchestration.""" | |
| from __future__ import annotations | |
| import asyncio | |
| import os | |
| from typing import AsyncIterator | |
| import pytest | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from core_lib.db.engine import get_async_engine, get_sync_engine | |
| from core_lib.pipeline import DataSourcePipeline, PipelineContext, RawRow | |
| def _isolate_engines(monkeypatch, tmp_path): | |
| """Each test gets a clean in-memory(ish) sqlite engine. Caching is reset.""" | |
| db_path = tmp_path / "pipeline_test.db" | |
| monkeypatch.setenv("DATABASE_URL", f"sqlite:///{db_path}") | |
| monkeypatch.delenv("NEON_DATABASE_URL_DEV", raising=False) | |
| monkeypatch.delenv("NEON_DATABASE_URL", raising=False) | |
| get_sync_engine.cache_clear() | |
| get_async_engine.cache_clear() | |
| # Force re-creation of the session factories that capture the engine | |
| import core_lib.db.session as s | |
| s._sync_factory = None | |
| s._async_factory = None | |
| yield | |
| get_sync_engine.cache_clear() | |
| get_async_engine.cache_clear() | |
| s._sync_factory = None | |
| s._async_factory = None | |
| class _Row(RawRow): | |
| value: int | |
| def _make_pipeline(raws: list[dict], on_load=None): | |
| class _P(DataSourcePipeline[_Row]): | |
| source = "test_src" | |
| batch_size = 2 | |
| row_schema = _Row | |
| async def extract(self, ctx: PipelineContext) -> AsyncIterator[dict]: | |
| for r in raws: | |
| yield r | |
| async def load_batch( | |
| self, session: AsyncSession, rows: list[_Row], ctx: PipelineContext | |
| ) -> None: | |
| if on_load is not None: | |
| await on_load(session, rows, ctx) | |
| return _P() | |
| def _good(i: int) -> dict: | |
| return { | |
| "source": "test_src", | |
| "source_version": "v1", | |
| "natural_key": f"k:{i}", | |
| "value": i, | |
| } | |
| def test_run_extracts_validates_and_loads_all_rows(): | |
| seen_batches: list[list[_Row]] = [] | |
| async def load(session, rows, ctx): | |
| seen_batches.append(list(rows)) | |
| p = _make_pipeline([_good(i) for i in range(5)], on_load=load) | |
| run = asyncio.run(p.run()) | |
| assert run.extracted == 5 | |
| assert run.validated == 5 | |
| assert run.rejected == 0 | |
| assert run.loaded == 5 | |
| assert run.finished_at is not None | |
| assert run.errors == [] | |
| # batch_size=2 → batches of [2, 2, 1] | |
| assert [len(b) for b in seen_batches] == [2, 2, 1] | |
| assert seen_batches[0][0].value == 0 | |
| def test_validation_rejects_bad_rows_without_failing_run(): | |
| bad = {"source": "test_src", "source_version": "v1", "natural_key": "x", "value": "not-an-int"} | |
| p = _make_pipeline([_good(0), bad, _good(1)]) | |
| run = asyncio.run(p.run()) | |
| assert run.extracted == 3 | |
| assert run.validated == 2 | |
| assert run.rejected == 1 | |
| assert run.loaded == 2 | |
| assert run.errors == [] | |
| def test_load_batch_failure_propagates_and_records_error(): | |
| async def boom(session, rows, ctx): | |
| raise RuntimeError("intentional load failure") | |
| p = _make_pipeline([_good(i) for i in range(3)], on_load=boom) | |
| with pytest.raises(RuntimeError, match="intentional load failure"): | |
| asyncio.run(p.run()) | |
| def test_empty_extract_yields_zero_loaded(): | |
| p = _make_pipeline([]) | |
| run = asyncio.run(p.run()) | |
| assert run.extracted == 0 | |
| assert run.validated == 0 | |
| assert run.loaded == 0 | |
| assert run.errors == [] | |