Release govoplan-docs v0.1.23: unify help discovery and batch semantic reads
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-09-08 01:32:36 +02:00
parent 1ae23b4e9d
commit cbe434de02
18 changed files with 842 additions and 81 deletions
+397
View File
@@ -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()