- domain : StoreKind.NEO4J ajouté à l'enum - service : validation config par kind étendue (Neo4j requires non-empty index_name) - frontend : Neo4jConfigForm (index_name + database optionnelle), dispatcher StoreConfigForm, dropdown kind, i18n FR/EN - tests service : 2 cas Neo4j (create OK + missing index_name 422) Auth (URI/user/password) reste pilotée par les variables d'environnement globales (cf. infra/neo4j.py) — la config par store ne porte que les paramètres routables (index, database).
283 lines
9.2 KiB
Python
283 lines
9.2 KiB
Python
"""Tests for StoreService — CRUD + validations (#251)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from domain.models import Document, DocumentStoreLink
|
|
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
|
|
from services.store_service import (
|
|
StoreConflictError,
|
|
StoreNotFoundError,
|
|
StoreService,
|
|
StoreValidationError,
|
|
)
|
|
|
|
|
|
@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 service():
|
|
return StoreService(SqliteStoreRepository(), SqliteDocumentStoreLinkRepository())
|
|
|
|
|
|
VALID_OS_CONFIG = {"index_name": "rh-corpus-v3"}
|
|
|
|
|
|
class TestCreate:
|
|
async def test_create_ok(self, service):
|
|
store = await service.create_store(
|
|
name="rh-corpus",
|
|
slug="rh-corpus",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config=VALID_OS_CONFIG,
|
|
)
|
|
assert store.id
|
|
assert store.slug == "rh-corpus"
|
|
|
|
async def test_slug_normalized_to_lowercase(self, service):
|
|
store = await service.create_store(
|
|
name="RH",
|
|
slug="RH-Corpus",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config=VALID_OS_CONFIG,
|
|
)
|
|
assert store.slug == "rh-corpus"
|
|
|
|
async def test_create_rejects_duplicate_slug(self, service):
|
|
await service.create_store(
|
|
name="rh",
|
|
slug="rh",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config=VALID_OS_CONFIG,
|
|
)
|
|
with pytest.raises(StoreConflictError):
|
|
await service.create_store(
|
|
name="rh-2",
|
|
slug="rh",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config=VALID_OS_CONFIG,
|
|
)
|
|
|
|
async def test_create_rejects_duplicate_name(self, service):
|
|
await service.create_store(
|
|
name="rh",
|
|
slug="rh-1",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config=VALID_OS_CONFIG,
|
|
)
|
|
with pytest.raises(StoreConflictError):
|
|
await service.create_store(
|
|
name="rh",
|
|
slug="rh-2",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config=VALID_OS_CONFIG,
|
|
)
|
|
|
|
async def test_create_rejects_bad_slug(self, service):
|
|
with pytest.raises(StoreValidationError):
|
|
await service.create_store(
|
|
name="rh",
|
|
slug="RH Corpus",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config=VALID_OS_CONFIG,
|
|
)
|
|
|
|
async def test_create_rejects_missing_index_name(self, service):
|
|
with pytest.raises(StoreValidationError):
|
|
await service.create_store(
|
|
name="rh",
|
|
slug="rh",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config={},
|
|
)
|
|
|
|
async def test_create_neo4j_ok(self, service):
|
|
store = await service.create_store(
|
|
name="kg",
|
|
slug="kg",
|
|
kind=StoreKind.NEO4J,
|
|
embedder="bge-m3",
|
|
config={"index_name": "chunks-vec", "database": "neo4j"},
|
|
)
|
|
assert store.kind is StoreKind.NEO4J
|
|
assert store.config["index_name"] == "chunks-vec"
|
|
|
|
async def test_create_neo4j_rejects_missing_index_name(self, service):
|
|
with pytest.raises(StoreValidationError):
|
|
await service.create_store(
|
|
name="kg",
|
|
slug="kg",
|
|
kind=StoreKind.NEO4J,
|
|
embedder="bge-m3",
|
|
config={},
|
|
)
|
|
|
|
async def test_create_default_clears_others(self, service):
|
|
# The seeded 'default' store starts as is_default=True.
|
|
new_default = await service.create_store(
|
|
name="new",
|
|
slug="new",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config={"index_name": "new"},
|
|
is_default=True,
|
|
)
|
|
all_stores = await service.list_stores()
|
|
defaults = [s for s in all_stores if s.is_default]
|
|
assert len(defaults) == 1
|
|
assert defaults[0].slug == new_default.slug
|
|
|
|
|
|
class TestRead:
|
|
async def test_get_by_slug_404(self, service):
|
|
with pytest.raises(StoreNotFoundError):
|
|
await service.get_by_slug("missing")
|
|
|
|
async def test_list_includes_seeded_default(self, service):
|
|
stores = await service.list_stores()
|
|
slugs = [s.slug for s in stores]
|
|
assert "default" in slugs
|
|
|
|
async def test_list_counts_linked_documents(self, service):
|
|
# Seed a doc + a link to the seeded default store.
|
|
doc_repo = SqliteDocumentRepository()
|
|
await doc_repo.insert(Document(id="d-1", filename="t.pdf", storage_path="/tmp/t.pdf"))
|
|
await SqliteDocumentStoreLinkRepository().upsert(
|
|
DocumentStoreLink(
|
|
id="l-1",
|
|
document_id="d-1",
|
|
store_id="default",
|
|
state=DocumentStoreLinkState.INGESTED,
|
|
)
|
|
)
|
|
views = await service.list_stores()
|
|
default_view = next(v for v in views if v.slug == "default")
|
|
assert default_view.document_count == 1
|
|
|
|
|
|
class TestUpdate:
|
|
async def test_partial_update(self, service):
|
|
await service.create_store(
|
|
name="rh",
|
|
slug="rh",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config=VALID_OS_CONFIG,
|
|
)
|
|
updated = await service.update_store("rh", embedder="bge-large")
|
|
assert updated.embedder == "bge-large"
|
|
# Other fields untouched.
|
|
assert updated.name == "rh"
|
|
|
|
async def test_update_rename_slug(self, service):
|
|
await service.create_store(
|
|
name="rh",
|
|
slug="rh",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config=VALID_OS_CONFIG,
|
|
)
|
|
updated = await service.update_store("rh", new_slug="rh-v2")
|
|
assert updated.slug == "rh-v2"
|
|
with pytest.raises(StoreNotFoundError):
|
|
await service.get_by_slug("rh")
|
|
|
|
async def test_update_rejects_slug_collision(self, service):
|
|
await service.create_store(
|
|
name="a",
|
|
slug="a",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config={"index_name": "a"},
|
|
)
|
|
await service.create_store(
|
|
name="b",
|
|
slug="b",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config={"index_name": "b"},
|
|
)
|
|
with pytest.raises(StoreConflictError):
|
|
await service.update_store("a", new_slug="b")
|
|
|
|
async def test_update_promote_default_demotes_others(self, service):
|
|
await service.create_store(
|
|
name="rh",
|
|
slug="rh",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config=VALID_OS_CONFIG,
|
|
)
|
|
await service.update_store("rh", is_default=True)
|
|
defaults = [s for s in await service.list_stores() if s.is_default]
|
|
assert len(defaults) == 1
|
|
assert defaults[0].slug == "rh"
|
|
|
|
async def test_update_rejects_invalid_config(self, service):
|
|
await service.create_store(
|
|
name="rh",
|
|
slug="rh",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config=VALID_OS_CONFIG,
|
|
)
|
|
with pytest.raises(StoreValidationError):
|
|
await service.update_store("rh", config={})
|
|
|
|
|
|
class TestDelete:
|
|
async def test_delete_ok(self, service):
|
|
await service.create_store(
|
|
name="tmp",
|
|
slug="tmp",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config={"index_name": "tmp"},
|
|
)
|
|
await service.delete_store("tmp")
|
|
with pytest.raises(StoreNotFoundError):
|
|
await service.get_by_slug("tmp")
|
|
|
|
async def test_delete_seeded_default_refused(self, service):
|
|
with pytest.raises(StoreConflictError):
|
|
await service.delete_store("default")
|
|
|
|
async def test_delete_with_links_refused(self, service):
|
|
store = await service.create_store(
|
|
name="rh",
|
|
slug="rh",
|
|
kind=StoreKind.OPENSEARCH,
|
|
embedder="bge-m3",
|
|
config=VALID_OS_CONFIG,
|
|
)
|
|
doc_repo = SqliteDocumentRepository()
|
|
await doc_repo.insert(Document(id="d-1", filename="t.pdf", storage_path="/tmp/t.pdf"))
|
|
await SqliteDocumentStoreLinkRepository().upsert(
|
|
DocumentStoreLink(
|
|
id="l-1",
|
|
document_id="d-1",
|
|
store_id=store.id,
|
|
state=DocumentStoreLinkState.INGESTED,
|
|
)
|
|
)
|
|
with pytest.raises(StoreConflictError):
|
|
await service.delete_store("rh")
|