"""Unit tests for the cyclic ingest wrapper — no DB, no real HTTP, no real Ollama/Anthropic/Paperless. Every stage function (paperless_adapter.run, chunk_embed.run, summarize.run_summarize, summarize.run_embed_summaries) and probe_ollama/fetch_embed_backlog are monkeypatched with fakes; only cyclic_ingest's own orchestration, predicates, and .prom rendering are under test.""" from __future__ import annotations import sys import pytest from documents_ingest import cyclic_ingest from documents_ingest.cyclic_ingest import ( PROM_METRIC_HELP, _adapter_failed, _chunk_embed_failed, _embed_summaries_failed, _read_prev_metric, _summarize_failed, build_metrics, main, probe_ollama, render_prom, run_cyclic, write_prom_atomic, ) # --------------------------------------------------------------------------- # Per-stage failure predicates — pinned against each job's own main() exit check # --------------------------------------------------------------------------- class TestAdapterFailed: def test_balanced_no_errors_passes(self): stats = {"fetched": 3, "already_in_db": 1, "inserted": 2, "errors": 0} assert _adapter_failed(stats) is False def test_any_error_fails(self): stats = {"fetched": 3, "already_in_db": 1, "inserted": 1, "errors": 1} assert _adapter_failed(stats) is True def test_imbalance_fails_even_with_zero_errors(self): stats = {"fetched": 3, "already_in_db": 1, "inserted": 1, "errors": 0} assert _adapter_failed(stats) is True class TestChunkEmbedFailed: def _stats(self, **overrides): stats = { "documents_fetched": 2, "empty_content": 0, "documents_chunked": 2, "chunks_total": 4, "chunks_already_embedded": 0, "chunks_inserted": 4, "chunks_junk_flagged": 0, "chunks_conflict_skipped": 0, "chunks_errors": 0, } stats.update(overrides) return stats def test_balanced_passes(self): assert _chunk_embed_failed(self._stats()) is False def test_chunks_errors_fails(self): assert _chunk_embed_failed(self._stats(chunks_errors=1, chunks_inserted=3)) is True def test_conflict_skipped_fails(self): assert _chunk_embed_failed(self._stats(chunks_conflict_skipped=1, chunks_inserted=3)) is True def test_doc_balance_break_fails(self): assert _chunk_embed_failed(self._stats(documents_fetched=99)) is True class TestSummarizeFailed: def _stats(self, **overrides): stats = { "documents_fetched": 2, "duplicates_skipped": 0, "no_active_chunks": 0, "already_summarized": 0, "summarized": 2, "llm_errors": 0, } stats.update(overrides) return stats def test_balanced_passes(self): assert _summarize_failed(self._stats()) is False def test_llm_errors_fails(self): assert _summarize_failed(self._stats(llm_errors=1, summarized=1)) is True def test_imbalance_fails(self): assert _summarize_failed(self._stats(documents_fetched=5)) is True class TestEmbedSummariesFailed: def test_balanced_passes(self): assert _embed_summaries_failed({"summaries_fetched": 2, "embedded": 2, "errors": 0}) is False def test_errors_fails(self): assert _embed_summaries_failed({"summaries_fetched": 2, "embedded": 1, "errors": 1}) is True def test_imbalance_fails(self): assert _embed_summaries_failed({"summaries_fetched": 5, "embedded": 2, "errors": 0}) is True # --------------------------------------------------------------------------- # probe_ollama — reachability probe, must never raise # --------------------------------------------------------------------------- class _FakeProbeResponse: def __init__(self, status): self.status = status async def __aenter__(self): return self async def __aexit__(self, *exc): return False class _FakeProbeSession: def __init__(self, status=200, raise_on_get=None): self._status = status self._raise_on_get = raise_on_get async def __aenter__(self): return self async def __aexit__(self, *exc): return False def get(self, url): if self._raise_on_get is not None: raise self._raise_on_get return _FakeProbeResponse(self._status) class TestProbeOllama: async def test_200_is_up(self, monkeypatch): monkeypatch.setattr( "documents_ingest.cyclic_ingest.aiohttp.ClientSession", lambda *a, **kw: _FakeProbeSession(status=200), ) assert await probe_ollama("http://solaria:11434") is True async def test_non_200_is_down(self, monkeypatch): monkeypatch.setattr( "documents_ingest.cyclic_ingest.aiohttp.ClientSession", lambda *a, **kw: _FakeProbeSession(status=500), ) assert await probe_ollama("http://solaria:11434") is False async def test_connection_error_is_down_not_raised(self, monkeypatch): monkeypatch.setattr( "documents_ingest.cyclic_ingest.aiohttp.ClientSession", lambda *a, **kw: _FakeProbeSession(raise_on_get=ConnectionError("refused")), ) assert await probe_ollama("http://solaria:11434") is False # --------------------------------------------------------------------------- # run_cyclic — sequencing, Ollama-offline tolerance, stage isolation # --------------------------------------------------------------------------- ADAPTER_OK = {"fetched": 1, "already_in_db": 0, "inserted": 1, "source_mail_linked": 0, "empty_content": 0, "errors": 0} CHUNK_OK = {"documents_fetched": 1, "empty_content": 0, "documents_chunked": 1, "chunks_total": 1, "chunks_already_embedded": 0, "chunks_inserted": 1, "chunks_junk_flagged": 0, "chunks_conflict_skipped": 0, "chunks_errors": 0, "embed_calls": 1, "embed_seconds_total": 0.1} SUMMARIZE_OK = {"documents_fetched": 1, "duplicates_skipped": 0, "no_active_chunks": 0, "already_summarized": 0, "summarized": 1, "llm_errors": 0, "documents_mapreduce": 0, "tags_truncated": 0} EMBED_SUMMARIES_OK = {"summaries_fetched": 1, "embedded": 1, "errors": 0} def _patch_stages(monkeypatch, *, ollama_up=True, adapter=None, chunk=None, summarize=None, embed_summaries=None, backlog=0): calls = {"adapter": 0, "chunk_embed": 0, "summarize": 0, "embed_summaries": 0} async def _fake_probe(url, timeout=5.0): return ollama_up async def _fake_adapter_run(**kwargs): calls["adapter"] += 1 return adapter if adapter is not None else dict(ADAPTER_OK) async def _fake_chunk_run(**kwargs): calls["chunk_embed"] += 1 if chunk is not None and isinstance(chunk, Exception): raise chunk return chunk if chunk is not None else dict(CHUNK_OK) async def _fake_run_summarize(**kwargs): calls["summarize"] += 1 if summarize is not None and isinstance(summarize, Exception): raise summarize return summarize if summarize is not None else dict(SUMMARIZE_OK) async def _fake_run_embed_summaries(**kwargs): calls["embed_summaries"] += 1 return embed_summaries if embed_summaries is not None else dict(EMBED_SUMMARIES_OK) async def _fake_backlog(dsn): return backlog monkeypatch.setattr(cyclic_ingest, "probe_ollama", _fake_probe) monkeypatch.setattr(cyclic_ingest.paperless_adapter, "run", _fake_adapter_run) monkeypatch.setattr(cyclic_ingest.chunk_embed, "run", _fake_chunk_run) monkeypatch.setattr(cyclic_ingest.summarize, "run_summarize", _fake_run_summarize) monkeypatch.setattr(cyclic_ingest.summarize, "run_embed_summaries", _fake_run_embed_summaries) monkeypatch.setattr(cyclic_ingest, "fetch_embed_backlog", _fake_backlog) return calls class TestRunCyclic: async def test_all_stages_succeed_ollama_up(self, monkeypatch): calls = _patch_stages(monkeypatch, ollama_up=True) result = await run_cyclic( dsn="dsn", paperless_url="http://paperless", paperless_token="tok", anthropic_api_key="key", apply=True, ) assert result["failed"] is False assert result["ollama_up"] is True assert calls == {"adapter": 1, "chunk_embed": 1, "summarize": 1, "embed_summaries": 1} assert result["chunk_embed"]["skipped"] is False assert result["embed_summaries"]["skipped"] is False assert result["embed_backlog"] == 0 async def test_ollama_down_skips_embed_stages_not_a_failure(self, monkeypatch): calls = _patch_stages(monkeypatch, ollama_up=False) result = await run_cyclic( dsn="dsn", paperless_url="http://paperless", paperless_token="tok", anthropic_api_key="key", apply=True, ) assert result["failed"] is False assert result["ollama_up"] is False # chunk_embed and embed_summaries must never even be invoked when Ollama is down. assert calls["chunk_embed"] == 0 assert calls["embed_summaries"] == 0 assert calls["adapter"] == 1 assert calls["summarize"] == 1 assert result["chunk_embed"]["skipped"] is True assert result["chunk_embed"]["stats"] is None assert result["embed_summaries"]["skipped"] is True async def test_adapter_failure_does_not_skip_later_stages(self, monkeypatch): broken_adapter = dict(ADAPTER_OK, errors=1) calls = _patch_stages(monkeypatch, ollama_up=True, adapter=broken_adapter) result = await run_cyclic( dsn="dsn", paperless_url="http://paperless", paperless_token="tok", anthropic_api_key="key", apply=True, ) assert result["failed"] is True assert result["adapter"]["failed"] is True # every other stage still ran despite the adapter failing this tick. assert calls == {"adapter": 1, "chunk_embed": 1, "summarize": 1, "embed_summaries": 1} assert result["chunk_embed"]["failed"] is False assert result["summarize"]["failed"] is False assert result["embed_summaries"]["failed"] is False async def test_chunk_embed_exception_is_isolated(self, monkeypatch): calls = _patch_stages(monkeypatch, ollama_up=True, chunk=RuntimeError("ollama 500")) result = await run_cyclic( dsn="dsn", paperless_url="http://paperless", paperless_token="tok", anthropic_api_key="key", apply=True, ) assert result["failed"] is True assert result["chunk_embed"]["failed"] is True assert "ollama 500" in result["chunk_embed"]["error"] # summarize + embed_summaries still ran despite chunk_embed raising. assert calls["summarize"] == 1 assert calls["embed_summaries"] == 1 assert result["summarize"]["failed"] is False async def test_stats_mismatch_marks_stage_failed_without_exception(self, monkeypatch): imbalanced_summarize = dict(SUMMARIZE_OK, documents_fetched=99) _patch_stages(monkeypatch, ollama_up=True, summarize=imbalanced_summarize) result = await run_cyclic( dsn="dsn", paperless_url="http://paperless", paperless_token="tok", anthropic_api_key="key", apply=True, ) assert result["failed"] is True assert result["summarize"]["failed"] is True assert result["summarize"]["error"] is None # no exception, just a failed predicate # --------------------------------------------------------------------------- # .prom rendering + carry-forward # --------------------------------------------------------------------------- class TestRenderProm: def test_includes_help_and_type_for_every_metric(self): metrics = {name: 1 for name in PROM_METRIC_HELP} content = render_prom(metrics) for name in PROM_METRIC_HELP: assert f"# HELP {name} " in content assert f"# TYPE {name} gauge" in content assert f"\n{name} 1\n" in content def test_omits_metrics_not_present(self): content = render_prom({"kb_ingest_last_run_timestamp": 123.0}) assert "kb_ingest_last_run_timestamp 123.0" in content assert "kb_ingest_embed_backlog" not in content class TestWriteAndReadPromFile: def test_write_then_read_prev_metric(self, tmp_path): path = tmp_path / "kb-ingest.prom" content = render_prom({"kb_ingest_last_success_timestamp": 1700000000.0}) write_prom_atomic(path, content) assert path.read_text() == content assert list(tmp_path.iterdir()) == [path] # no leftover .tmp file assert _read_prev_metric(path, "kb_ingest_last_success_timestamp") == 1700000000.0 assert _read_prev_metric(path, "kb_ingest_missing_metric") is None def test_read_prev_metric_missing_file_returns_none(self, tmp_path): assert _read_prev_metric(tmp_path / "nope.prom", "kb_ingest_last_success_timestamp") is None class TestBuildMetrics: def _result(self, failed, ollama_up=True, embed_backlog=0): return { "ollama_up": ollama_up, "adapter": {"stats": dict(ADAPTER_OK), "failed": False, "skipped": False, "error": None}, "chunk_embed": {"stats": dict(CHUNK_OK), "failed": False, "skipped": False, "error": None}, "summarize": {"stats": dict(SUMMARIZE_OK), "failed": False, "skipped": False, "error": None}, "embed_summaries": {"stats": dict(EMBED_SUMMARIES_OK), "failed": False, "skipped": False, "error": None}, "embed_backlog": embed_backlog, "failed": failed, } def test_success_sets_last_success_to_now(self, tmp_path): prom_path = tmp_path / "kb-ingest.prom" metrics = build_metrics(self._result(failed=False), now_ts=1000.0, prom_path=prom_path) assert metrics["kb_ingest_last_success_timestamp"] == 1000.0 assert metrics["kb_ingest_last_exit_code"] == 0 assert metrics["kb_ingest_documents_inserted"] == 1 assert metrics["kb_ingest_embed_skipped"] == 0 def test_failure_carries_forward_previous_success(self, tmp_path): prom_path = tmp_path / "kb-ingest.prom" write_prom_atomic(prom_path, render_prom({"kb_ingest_last_success_timestamp": 500.0})) metrics = build_metrics(self._result(failed=True), now_ts=1000.0, prom_path=prom_path) assert metrics["kb_ingest_last_success_timestamp"] == 500.0 assert metrics["kb_ingest_last_run_timestamp"] == 1000.0 assert metrics["kb_ingest_last_exit_code"] == 1 def test_failure_with_no_previous_file_defaults_to_zero(self, tmp_path): prom_path = tmp_path / "kb-ingest.prom" metrics = build_metrics(self._result(failed=True), now_ts=1000.0, prom_path=prom_path) assert metrics["kb_ingest_last_success_timestamp"] == 0.0 def test_ollama_down_sets_embed_skipped(self, tmp_path): result = self._result(failed=False, ollama_up=False) result["chunk_embed"] = {"stats": None, "failed": False, "skipped": True, "error": None} metrics = build_metrics(result, now_ts=1000.0, prom_path=tmp_path / "x.prom") assert metrics["kb_ingest_embed_skipped"] == 1 assert metrics["kb_ingest_chunks_inserted"] == 0 def test_embed_backlog_none_is_omitted(self, tmp_path): metrics = build_metrics( self._result(failed=False, embed_backlog=None), now_ts=1000.0, prom_path=tmp_path / "x.prom", ) assert "kb_ingest_embed_backlog" not in metrics # --------------------------------------------------------------------------- # main() — CLI-level guardrails and exit-code propagation # --------------------------------------------------------------------------- class TestMainGuards: def test_missing_dsn_exits_1(self, monkeypatch): monkeypatch.setattr(sys, "argv", ["documents-ingest-cyclic", "--paperless-token", "tok"]) monkeypatch.delenv("KB_DSN", raising=False) with pytest.raises(SystemExit) as exc: main() assert exc.value.code == 1 def test_missing_paperless_token_exits_1(self, monkeypatch): monkeypatch.setattr(sys, "argv", ["documents-ingest-cyclic", "--dsn", "dsn"]) monkeypatch.delenv("PAPERLESS_API_TOKEN", raising=False) with pytest.raises(SystemExit) as exc: main() assert exc.value.code == 1 def test_apply_without_anthropic_key_exits_1(self, monkeypatch): monkeypatch.setattr(sys, "argv", [ "documents-ingest-cyclic", "--dsn", "dsn", "--paperless-token", "tok", "--apply", ]) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) with pytest.raises(SystemExit) as exc: main() assert exc.value.code == 1 class TestMainExitCodePropagation: def test_exit_0_on_success(self, monkeypatch, tmp_path): _patch_stages(monkeypatch, ollama_up=True) monkeypatch.setattr(sys, "argv", [ "documents-ingest-cyclic", "--dsn", "dsn", "--paperless-token", "tok", "--anthropic-api-key", "key", "--prom-path", str(tmp_path / "kb-ingest.prom"), ]) with pytest.raises(SystemExit) as exc: main() assert exc.value.code == 0 assert (tmp_path / "kb-ingest.prom").exists() def test_exit_1_on_stage_failure(self, monkeypatch, tmp_path): _patch_stages(monkeypatch, ollama_up=True, adapter=dict(ADAPTER_OK, errors=1)) monkeypatch.setattr(sys, "argv", [ "documents-ingest-cyclic", "--dsn", "dsn", "--paperless-token", "tok", "--anthropic-api-key", "key", "--prom-path", str(tmp_path / "kb-ingest.prom"), ]) with pytest.raises(SystemExit) as exc: main() assert exc.value.code == 1 def test_exit_0_when_ollama_down_apply_still_true(self, monkeypatch, tmp_path): _patch_stages(monkeypatch, ollama_up=False) monkeypatch.setattr(sys, "argv", [ "documents-ingest-cyclic", "--dsn", "dsn", "--paperless-token", "tok", "--anthropic-api-key", "key", "--apply", "--prom-path", str(tmp_path / "kb-ingest.prom"), ]) with pytest.raises(SystemExit) as exc: main() assert exc.value.code == 0 prom_content = (tmp_path / "kb-ingest.prom").read_text() assert "kb_ingest_embed_skipped 1" in prom_content