haiku.rag/tests/ingester/test_revision_round_trip.py

109 lines
3.9 KiB
Python

"""Regression: every source's revision must round-trip from FetchResult →
document.metadata → sync_state, so the next sweep can recognise the file
as unchanged. Catches the FS-specific bug where revision was lost in the
pipeline and every periodic sweep re-enqueued every file forever.
"""
from pathlib import Path
import pytest
from haiku.rag.client import HaikuRAG
from haiku.rag.ingester.sources.base import SourceEventKind
from haiku.rag.ingester.sources.fs import FSSource
@pytest.fixture(scope="module")
def vcr_cassette_dir():
return str(Path(__file__).parent.parent / "cassettes" / "test_revision_round_trip")
@pytest.mark.asyncio
@pytest.mark.vcr()
async def test_fs_ingest_writes_source_revision_to_metadata(temp_db_path, tmp_path):
file_path = tmp_path / "doc.md"
file_path.write_text("hello")
expected_revision = str(file_path.stat().st_mtime_ns)
async with HaikuRAG(temp_db_path, create=True) as client:
doc = await client.create_document_from_source(file_path)
assert doc.metadata["source_revision"] == expected_revision
@pytest.mark.asyncio
@pytest.mark.vcr()
async def test_fs_second_sweep_emits_unchanged_after_ingest(temp_db_path, tmp_path):
"""The full round-trip: ingest a file, build a sync_state-shaped snapshot
from document.metadata, hand it to FSSource.discover() — must see
UNCHANGED, not UPSERT. This is exactly what the periodic poller does."""
file_path = tmp_path / "doc.md"
file_path.write_text("hello")
async with HaikuRAG(temp_db_path, create=True) as client:
doc = await client.create_document_from_source(file_path)
assert doc.uri is not None
snapshot = {doc.uri: doc.metadata["source_revision"]}
src = FSSource(root=tmp_path)
kinds: list[SourceEventKind] = []
async for event in src.discover(since=snapshot):
kinds.append(event.kind)
assert kinds == [SourceEventKind.UNCHANGED]
@pytest.mark.asyncio
@pytest.mark.vcr()
async def test_fs_second_sweep_emits_upsert_when_file_changes(temp_db_path, tmp_path):
"""Counterpart to the unchanged test: a file modified after ingest still
triggers UPSERT. Ensures the round-trip doesn't accidentally over-skip."""
file_path = tmp_path / "doc.md"
file_path.write_text("hello")
async with HaikuRAG(temp_db_path, create=True) as client:
doc = await client.create_document_from_source(file_path)
# Bump mtime by writing different content; snapshot still has the old revision.
assert doc.uri is not None
snapshot = {doc.uri: doc.metadata["source_revision"]}
file_path.write_text("hello world")
# st_mtime_ns has nanosecond resolution; a write_text always advances it
# on any sane filesystem, but assert anyway to make the intent explicit.
assert str(file_path.stat().st_mtime_ns) != doc.metadata["source_revision"]
src = FSSource(root=tmp_path)
kinds: list[SourceEventKind] = []
async for event in src.discover(since=snapshot):
kinds.append(event.kind)
assert kinds == [SourceEventKind.UPSERT]
@pytest.mark.asyncio
@pytest.mark.vcr()
async def test_fs_head_short_circuit_skips_fetch_for_unchanged_revision(
temp_db_path, tmp_path, monkeypatch
):
"""The one-shot (add-src) path: if the existing doc has source_revision
and HEAD reports the same value, fetch must not run."""
file_path = tmp_path / "doc.md"
file_path.write_text("hello")
async with HaikuRAG(temp_db_path, create=True) as client:
first = await client.create_document_from_source(file_path)
fetch_calls: list[str] = []
original_fetch = FSSource.fetch
async def _track_fetch(self: FSSource, uri: str): # type: ignore[no-untyped-def]
fetch_calls.append(uri)
return await original_fetch(self, uri)
monkeypatch.setattr(FSSource, "fetch", _track_fetch)
second = await client.create_document_from_source(file_path)
assert second.id == first.id
assert fetch_calls == []