Étend le repo avec les méthodes manquantes pour le CRUD : - find_by_name : valider unicité name au create/update - update : remplace les champs mutables (id et created_at intacts) - clear_default_except : promotion mono-default lors d'un changement de default - delete : retourne True/False selon suppression effective Tests dédiés sur chacune des nouvelles méthodes.
260 lines
8.7 KiB
Python
260 lines
8.7 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
|
|
|
|
async def test_find_by_name(self, store_repo):
|
|
await store_repo.insert(
|
|
Store(
|
|
id="s-rh",
|
|
name="rh-corpus",
|
|
slug="rh-corpus",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
)
|
|
)
|
|
found = await store_repo.find_by_name("rh-corpus")
|
|
assert found is not None
|
|
assert found.slug == "rh-corpus"
|
|
assert await store_repo.find_by_name("missing") is None
|
|
|
|
async def test_update_replaces_mutable_fields(self, store_repo):
|
|
await store_repo.insert(
|
|
Store(
|
|
id="s-rh",
|
|
name="rh",
|
|
slug="rh",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config={"index_name": "rh"},
|
|
)
|
|
)
|
|
store = await store_repo.find_by_id("s-rh")
|
|
assert store is not None
|
|
store.embedder = "bge-large"
|
|
store.config = {"index_name": "rh-v2"}
|
|
store.name = "rh-v2"
|
|
await store_repo.update(store)
|
|
|
|
reloaded = await store_repo.find_by_id("s-rh")
|
|
assert reloaded is not None
|
|
assert reloaded.embedder == "bge-large"
|
|
assert reloaded.config == {"index_name": "rh-v2"}
|
|
assert reloaded.name == "rh-v2"
|
|
|
|
async def test_clear_default_except_promotes_single_winner(self, store_repo):
|
|
# Seeded default is the original; insert another candidate as default.
|
|
await store_repo.insert(
|
|
Store(
|
|
id="s-new",
|
|
name="new",
|
|
slug="new",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
is_default=True,
|
|
)
|
|
)
|
|
await store_repo.clear_default_except("s-new")
|
|
defaults = [s for s in await store_repo.find_all() if s.is_default]
|
|
assert len(defaults) == 1
|
|
assert defaults[0].id == "s-new"
|
|
|
|
async def test_delete_returns_true_when_removed(self, store_repo):
|
|
await store_repo.insert(
|
|
Store(
|
|
id="s-tmp",
|
|
name="tmp",
|
|
slug="tmp",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
)
|
|
)
|
|
assert await store_repo.delete("s-tmp") is True
|
|
assert await store_repo.find_by_id("s-tmp") is None
|
|
assert await store_repo.delete("s-tmp") is False
|
|
|
|
|
|
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
|