Release govoplan-docs v0.1.23: unify help discovery and batch semantic reads
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillRequest,
|
||||
SearchResourceReference,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_docs.backend.db.models import (
|
||||
SemanticDocumentationEntry,
|
||||
SemanticDocumentationRevision,
|
||||
)
|
||||
from govoplan_docs.backend.search_source import (
|
||||
PROVIDER_ID,
|
||||
RESOURCE_TYPE,
|
||||
SemanticDocumentationSearchSource,
|
||||
)
|
||||
from govoplan_docs.backend.semantic_service import (
|
||||
SemanticDocumentationError,
|
||||
current_revision,
|
||||
list_semantic_entries,
|
||||
prefetch_semantic_revisions,
|
||||
semantic_entry_payload,
|
||||
)
|
||||
from test_semantic_documentation import (
|
||||
_content,
|
||||
_principal,
|
||||
_Registry,
|
||||
_SubjectProvider,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def select_queries(engine):
|
||||
statements: list[str] = []
|
||||
|
||||
def observe(_connection, _cursor, statement, _parameters, _context, _many):
|
||||
if statement.lstrip().upper().startswith("SELECT"):
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(engine, "before_cursor_execute", observe)
|
||||
try:
|
||||
yield statements
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", observe)
|
||||
|
||||
|
||||
class SemanticReadEfficiencyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
SemanticDocumentationEntry.__table__,
|
||||
SemanticDocumentationRevision.__table__,
|
||||
],
|
||||
)
|
||||
self.provider = _SubjectProvider()
|
||||
self.registry = _Registry(self.provider)
|
||||
self.principal = _principal("author")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.engine.dispose()
|
||||
|
||||
def seed(self, count: int) -> None:
|
||||
with Session(self.engine) as session:
|
||||
for index in range(count):
|
||||
identifier = f"entry-{index:04}"
|
||||
session.add(
|
||||
SemanticDocumentationEntry(
|
||||
id=identifier,
|
||||
tenant_id="tenant-1",
|
||||
subject_stable_key="same-subject",
|
||||
subject_module_id="forms",
|
||||
subject_kind="form",
|
||||
subject_id="form-1",
|
||||
locale=f"de-{index:03}",
|
||||
lifecycle_state="draft",
|
||||
current_revision=2,
|
||||
current_revision_id=f"draft-{index:04}",
|
||||
published_revision_id=f"published-{index:04}",
|
||||
created_by="author",
|
||||
updated_by="author",
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
for revision, state, title in (
|
||||
(1, "published", f"Published {index}"),
|
||||
(2, "draft", f"Unpublished secret {index}"),
|
||||
):
|
||||
session.add(
|
||||
SemanticDocumentationRevision(
|
||||
id=f"{state}-{index:04}",
|
||||
tenant_id="tenant-1",
|
||||
entry_id=identifier,
|
||||
revision=revision,
|
||||
lifecycle_state=state,
|
||||
action="publish" if revision == 1 else "save",
|
||||
change_reason="Fixture",
|
||||
content=_content(title),
|
||||
content_hash=f"hash-{state}-{index}",
|
||||
authored_by="author",
|
||||
search_text=title,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def test_reader_and_editor_batching_preserve_payloads_with_two_queries_for_forty_entries(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(40)
|
||||
for editor in (False, True):
|
||||
with self.subTest(editor=editor):
|
||||
with (
|
||||
Session(self.engine) as session,
|
||||
select_queries(self.engine) as ordinary_queries,
|
||||
):
|
||||
entries = list_semantic_entries(session, self.principal)
|
||||
ordinary = [
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
self.principal,
|
||||
entry=entry,
|
||||
editor=editor,
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
with (
|
||||
Session(self.engine) as session,
|
||||
select_queries(self.engine) as batch_queries,
|
||||
):
|
||||
entries = list_semantic_entries(session, self.principal)
|
||||
revisions = prefetch_semantic_revisions(
|
||||
session, self.principal, entries=entries, editor=editor
|
||||
)
|
||||
batched = [
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
self.principal,
|
||||
entry=entry,
|
||||
editor=editor,
|
||||
revisions=revisions,
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
if not editor:
|
||||
self.assertTrue(
|
||||
all(
|
||||
item.lifecycle_state == "published"
|
||||
for item in revisions.values()
|
||||
)
|
||||
)
|
||||
self.assertTrue(all(item["pending_draft"] for item in batched))
|
||||
self.assertTrue(
|
||||
all(
|
||||
"Unpublished" not in item["content"]["title"]
|
||||
for item in batched
|
||||
)
|
||||
)
|
||||
self.assertEqual(ordinary, batched)
|
||||
self.assertEqual(81 if editor else 41, len(ordinary_queries))
|
||||
self.assertEqual(2, len(batch_queries))
|
||||
|
||||
def test_revision_prefetch_chunks_large_collections(self) -> None:
|
||||
self.seed(401)
|
||||
with Session(self.engine) as session, select_queries(self.engine) as queries:
|
||||
entries = list_semantic_entries(session, self.principal)
|
||||
revisions = prefetch_semantic_revisions(
|
||||
session, self.principal, entries=entries, editor=False
|
||||
)
|
||||
self.assertEqual(401, len(revisions))
|
||||
self.assertEqual(
|
||||
3, len(queries), "One entry query and two bounded revision batches."
|
||||
)
|
||||
|
||||
def test_search_authorization_batches_rows_without_weakening_owner_audience_or_revision_checks(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(40)
|
||||
requests = tuple(
|
||||
SearchAuthorizationRequest(
|
||||
reference=SearchResourceReference(
|
||||
tenant_id="tenant-1",
|
||||
module_id="docs",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=f"entry-{index:04}",
|
||||
),
|
||||
source_revision=f"published-{index:04}",
|
||||
)
|
||||
for index in range(40)
|
||||
)
|
||||
source = SemanticDocumentationSearchSource(self.registry)
|
||||
with Session(self.engine) as session, select_queries(self.engine) as queries:
|
||||
decisions = source.authorize(session, self.principal, requests=requests)
|
||||
self.assertTrue(all(decisions.values()))
|
||||
self.assertEqual(2, len(queries))
|
||||
self.provider.denied_accounts.add(self.principal.account_id)
|
||||
with Session(self.engine) as session:
|
||||
self.assertFalse(
|
||||
any(
|
||||
source.authorize(
|
||||
session, self.principal, requests=requests
|
||||
).values()
|
||||
)
|
||||
)
|
||||
self.provider.denied_accounts.clear()
|
||||
stale = SearchAuthorizationRequest(
|
||||
reference=requests[0].reference, source_revision="old-publication"
|
||||
)
|
||||
with Session(self.engine) as session:
|
||||
self.assertFalse(
|
||||
source.authorize(session, self.principal, requests=(stale,))[
|
||||
stale.reference.key
|
||||
]
|
||||
)
|
||||
|
||||
def test_audience_denial_precedes_provider_work_and_foreign_entries_do_not_load_revisions(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(1)
|
||||
with Session(self.engine) as session:
|
||||
revision = session.get(SemanticDocumentationRevision, "published-0000")
|
||||
revision.content = {
|
||||
**revision.content,
|
||||
"classification": "restricted",
|
||||
"audience": ["account:other"],
|
||||
}
|
||||
session.commit()
|
||||
with (
|
||||
Session(self.engine) as session,
|
||||
patch.object(
|
||||
self.provider, "resolve_subject", wraps=self.provider.resolve_subject
|
||||
) as resolve,
|
||||
):
|
||||
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session, self.registry, self.principal, entry=entry, editor=False
|
||||
)
|
||||
)
|
||||
resolve.assert_not_called()
|
||||
with select_queries(self.engine) as queries:
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
_principal("other", tenant_id="tenant-other"),
|
||||
entry=entry,
|
||||
editor=False,
|
||||
)
|
||||
)
|
||||
self.assertEqual([], queries)
|
||||
|
||||
def test_cross_tenant_or_cross_entry_revision_references_fail_closed_in_reading_and_indexing(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(2)
|
||||
source = SemanticDocumentationSearchSource(self.registry)
|
||||
publication_event = PlatformEvent(
|
||||
type="docs.semantic.published",
|
||||
module_id="docs",
|
||||
tenant=EventTenantRef(id="tenant-1"),
|
||||
resource=EventObjectRef(type=RESOURCE_TYPE, id="entry-0000"),
|
||||
)
|
||||
with Session(self.engine) as session:
|
||||
changes = source.index_changes_for_event(
|
||||
session, event=publication_event, delivery_key="valid-fixture"
|
||||
)
|
||||
self.assertEqual("upsert", changes[0].kind)
|
||||
self.assertEqual("published-0000", changes[0].document.source_revision)
|
||||
for corruption in ("tenant", "entry"):
|
||||
with self.subTest(corruption=corruption), Session(self.engine) as session:
|
||||
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
||||
revision = session.get(SemanticDocumentationRevision, "published-0000")
|
||||
if corruption == "tenant":
|
||||
revision.tenant_id = "tenant-other"
|
||||
else:
|
||||
revision.tenant_id = "tenant-1"
|
||||
revision.entry_id = "entry-0001"
|
||||
revision.revision = 3
|
||||
session.commit()
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
self.principal,
|
||||
entry=entry,
|
||||
editor=False,
|
||||
)
|
||||
)
|
||||
page = SemanticDocumentationSearchSource(self.registry).backfill(
|
||||
session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
rebuild_id="fixture",
|
||||
limit=100,
|
||||
),
|
||||
)
|
||||
self.assertNotIn(
|
||||
"entry-0000", {document.resource_id for document in page.documents}
|
||||
)
|
||||
changes = source.index_changes_for_event(
|
||||
session,
|
||||
event=publication_event,
|
||||
delivery_key=f"invalid-{corruption}",
|
||||
)
|
||||
self.assertEqual("delete", changes[0].kind)
|
||||
self.assertIsNone(changes[0].document)
|
||||
|
||||
def test_published_reader_does_not_depend_on_unavailable_draft_and_editor_still_validates_it(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(1)
|
||||
with Session(self.engine) as session:
|
||||
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
||||
draft = session.get(
|
||||
SemanticDocumentationRevision, entry.current_revision_id
|
||||
)
|
||||
draft.tenant_id = "tenant-other"
|
||||
session.commit()
|
||||
self.assertEqual(
|
||||
"Published 0",
|
||||
semantic_entry_payload(
|
||||
session, self.registry, self.principal, entry=entry, editor=False
|
||||
)["content"]["title"],
|
||||
)
|
||||
with self.assertRaises(SemanticDocumentationError):
|
||||
current_revision(session, entry)
|
||||
|
||||
def test_unpublished_revision_pointer_cannot_promote_a_draft_into_reading_or_search(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(1)
|
||||
with Session(self.engine) as session:
|
||||
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
||||
entry.published_revision_id = entry.current_revision_id
|
||||
session.commit()
|
||||
revisions = prefetch_semantic_revisions(
|
||||
session, self.principal, entries=(entry,), editor=False
|
||||
)
|
||||
self.assertEqual({}, revisions)
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
self.principal,
|
||||
entry=entry,
|
||||
editor=False,
|
||||
revisions=revisions,
|
||||
)
|
||||
)
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session, self.registry, self.principal, entry=entry, editor=False
|
||||
)
|
||||
)
|
||||
page = SemanticDocumentationSearchSource(self.registry).backfill(
|
||||
session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
rebuild_id="fixture",
|
||||
limit=100,
|
||||
),
|
||||
)
|
||||
self.assertEqual((), page.documents)
|
||||
changes = SemanticDocumentationSearchSource(
|
||||
self.registry
|
||||
).index_changes_for_event(
|
||||
session,
|
||||
delivery_key="draft-fixture",
|
||||
event=PlatformEvent(
|
||||
type="docs.semantic.published",
|
||||
module_id="docs",
|
||||
tenant=EventTenantRef(id="tenant-1"),
|
||||
resource=EventObjectRef(type=RESOURCE_TYPE, id=entry.id),
|
||||
),
|
||||
)
|
||||
self.assertEqual("delete", changes[0].kind)
|
||||
self.assertIsNone(changes[0].document)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user