Introduces the data layer for multi-store ingestion. Documents can now live in multiple stores, each with its own Ingested/Stale/Failed state. The doc-level lifecycle (#202) becomes the aggregate over all per-store links, computed by a pure domain function. Domain - Store entity (name, slug, kind, embedder, config, is_default) - DocumentStoreLink entity with mark_ingested / mark_stale / mark_failed helpers - StoreKind and DocumentStoreLinkState enums - aggregate_lifecycle(): pure function — Failed > Stale > Ingested > fallback (the doc's pre-link Uploaded/Parsed/Chunked state) Persistence - New tables 'stores' and 'document_store_links' with the right indexes (doc_id, store_id, state) and a UNIQUE (doc, store) on the link - Default 'opensearch' store seeded idempotently in init_db, embedder pulled from DEFAULT_EMBEDDER (fallback bge-m3) - SqliteStoreRepository (find_by_slug, find_by_id, get_default, …) - SqliteDocumentStoreLinkRepository with ON CONFLICT … DO UPDATE upsert Ports - StoreRepository and DocumentStoreLinkRepository protocols added Tests - 14 new tests: seed idempotency, insert/find round-trips, UNIQUE constraint, cascade delete with the document, every link state round-trips, aggregation rule with all branches Refs #203
190 lines
6.3 KiB
Python
190 lines
6.3 KiB
Python
"""Tests for the Store and DocumentStoreLink repositories (#203)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
|
|
from domain.models import Document, DocumentStoreLink, Store
|
|
from domain.value_objects import DocumentStoreLinkState, StoreKind
|
|
from persistence.database import init_db
|
|
from persistence.document_repo import SqliteDocumentRepository
|
|
from persistence.document_store_link_repo import SqliteDocumentStoreLinkRepository
|
|
from persistence.store_repo import SqliteStoreRepository
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def setup_db(monkeypatch, tmp_path):
|
|
db_path = str(tmp_path / "test.db")
|
|
monkeypatch.setattr("persistence.database.DB_PATH", db_path)
|
|
await init_db()
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def store_repo():
|
|
return SqliteStoreRepository()
|
|
|
|
|
|
@pytest.fixture
|
|
def link_repo():
|
|
return SqliteDocumentStoreLinkRepository()
|
|
|
|
|
|
@pytest.fixture
|
|
def document_repo():
|
|
return SqliteDocumentRepository()
|
|
|
|
|
|
class TestStoreRepo:
|
|
async def test_default_store_seeded_on_init(self, store_repo):
|
|
"""init_db() must seed exactly one `default` store on a fresh DB."""
|
|
default = await store_repo.get_default()
|
|
assert default is not None
|
|
assert default.slug == "default"
|
|
assert default.is_default is True
|
|
assert default.kind == StoreKind.OPENSEARCH
|
|
|
|
async def test_seed_is_idempotent(self, store_repo):
|
|
"""Re-running init_db() must not create a second default store."""
|
|
await init_db()
|
|
await init_db()
|
|
all_stores = await store_repo.find_all()
|
|
slugs = [s.slug for s in all_stores]
|
|
assert slugs.count("default") == 1
|
|
|
|
async def test_insert_and_find_by_slug(self, store_repo):
|
|
store = Store(
|
|
id="s-rh",
|
|
name="rh-corpus-v3",
|
|
slug="rh-corpus-v3",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config={"index_name": "rh-corpus-v3"},
|
|
)
|
|
await store_repo.insert(store)
|
|
|
|
found = await store_repo.find_by_slug("rh-corpus-v3")
|
|
assert found is not None
|
|
assert found.embedder == "bge-m3"
|
|
assert found.config == {"index_name": "rh-corpus-v3"}
|
|
|
|
async def test_find_all_orders_default_first(self, store_repo):
|
|
await store_repo.insert(
|
|
Store(
|
|
id="s-rh",
|
|
name="rh",
|
|
slug="rh",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
)
|
|
)
|
|
await store_repo.insert(
|
|
Store(
|
|
id="s-legal",
|
|
name="legal",
|
|
slug="legal",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
)
|
|
)
|
|
all_stores = await store_repo.find_all()
|
|
assert all_stores[0].slug == "default" # seeded default comes first
|
|
|
|
|
|
class TestDocumentStoreLinkRepo:
|
|
async def _seed_doc(self, document_repo, doc_id: str = "doc-1") -> Document:
|
|
doc = Document(id=doc_id, filename="t.pdf", storage_path="/tmp/t.pdf")
|
|
await document_repo.insert(doc)
|
|
return doc
|
|
|
|
async def test_upsert_creates_then_updates(self, link_repo, document_repo):
|
|
await self._seed_doc(document_repo)
|
|
link = DocumentStoreLink(
|
|
id="l-1",
|
|
document_id="doc-1",
|
|
store_id="default",
|
|
state=DocumentStoreLinkState.INGESTED,
|
|
)
|
|
await link_repo.upsert(link)
|
|
|
|
# Update same (doc, store) — no second row.
|
|
link.mark_ingested(
|
|
hash_="abc123",
|
|
at=datetime(2026, 4, 29, 12, tzinfo=UTC),
|
|
run_id="run-1",
|
|
)
|
|
await link_repo.upsert(link)
|
|
|
|
all_for_doc = await link_repo.find_for_document("doc-1")
|
|
assert len(all_for_doc) == 1
|
|
assert all_for_doc[0].chunkset_hash == "abc123"
|
|
assert all_for_doc[0].last_run_id == "run-1"
|
|
|
|
async def test_unique_constraint_on_doc_store_pair(self, link_repo, document_repo):
|
|
"""Two links with the same (doc, store) collapse to one via upsert."""
|
|
await self._seed_doc(document_repo)
|
|
await link_repo.upsert(
|
|
DocumentStoreLink(
|
|
id="l-1",
|
|
document_id="doc-1",
|
|
store_id="default",
|
|
state=DocumentStoreLinkState.INGESTED,
|
|
)
|
|
)
|
|
await link_repo.upsert(
|
|
DocumentStoreLink(
|
|
id="l-2",
|
|
document_id="doc-1",
|
|
store_id="default",
|
|
state=DocumentStoreLinkState.STALE,
|
|
)
|
|
)
|
|
rows = await link_repo.find_for_document("doc-1")
|
|
assert len(rows) == 1
|
|
assert rows[0].state == DocumentStoreLinkState.STALE
|
|
|
|
async def test_find_one_returns_none_when_absent(self, link_repo, document_repo):
|
|
await self._seed_doc(document_repo)
|
|
assert await link_repo.find_one("doc-1", "default") is None
|
|
|
|
async def test_cascade_delete_when_doc_removed(self, link_repo, document_repo):
|
|
await self._seed_doc(document_repo)
|
|
await link_repo.upsert(
|
|
DocumentStoreLink(
|
|
id="l-1",
|
|
document_id="doc-1",
|
|
store_id="default",
|
|
state=DocumentStoreLinkState.INGESTED,
|
|
)
|
|
)
|
|
await document_repo.delete("doc-1")
|
|
rows = await link_repo.find_for_document("doc-1")
|
|
assert rows == []
|
|
|
|
async def test_state_round_trips(self, link_repo, document_repo):
|
|
await self._seed_doc(document_repo)
|
|
for state in DocumentStoreLinkState:
|
|
link = DocumentStoreLink(
|
|
id=f"l-{state.value}",
|
|
document_id="doc-1",
|
|
store_id=f"store-{state.value}",
|
|
state=state,
|
|
)
|
|
# Need a store row for FK; seed minimal stores.
|
|
from persistence.store_repo import SqliteStoreRepository
|
|
|
|
await SqliteStoreRepository().insert(
|
|
Store(
|
|
id=f"store-{state.value}",
|
|
name=f"s-{state.value}",
|
|
slug=f"s-{state.value}",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
)
|
|
)
|
|
await link_repo.upsert(link)
|
|
found = await link_repo.find_one("doc-1", f"store-{state.value}")
|
|
assert found is not None
|
|
assert found.state == state
|