docling-studio/document-parser/tests/test_store_repo.py
Pier-Jean Malandrino d81f36d899 feat(#203): per (document, store) ingestion state
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
2026-05-05 09:38:39 +02:00

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