diff --git a/docs/spec/web-ui.md b/docs/spec/web-ui.md index 8de5bfc..38d5d2f 100644 --- a/docs/spec/web-ui.md +++ b/docs/spec/web-ui.md @@ -151,6 +151,27 @@ Zones 3–5 render with a top separator and a small uppercase section title, and The field tables below define each field's presence rule and rendering; the zone placement follows the mapping above. +### Detail card — what shows where, under what conditions + +The same card (`fragments/resource_detail.html`) renders on two surfaces: +- **Standalone `/uri/{uuid}` page** — permalink for any resource; no tree context. `in_pane=False` (shows the "Show in tree" link). +- **Tree right pane** — the selected node's detail beside the document tree. `in_pane=True`. Reached two ways: the SSR deep-link `/cftree/doc/{doc}/item/{id}` (`_render_tree_page`) and the HTMX fragment `/cftree/doc/{doc}/detail/{id}` (`_pane_fragment_response`). Both render identical content. + +Beyond the always-present fields, these **conditional / relational sections** appear per resource type and surface. All cross-tenant resolution is **public-only** (a private or nonexistent target tenant is dropped entirely — neither title, URI, link, nor count is surfaced; "case A"). Server-side builder: `web._detail_extras`. + +| Section | On which resource | Standalone `/uri/` page | Tree right pane | Conditions / rules | +|---|---|---|---|---| +| **Rubrics** list | CFDocument | ✓ | ✓ (also the tree's "Rubrics" section) | document has ≥1 CFRubric | +| **Related** (grouped associations) | CFItem | ✓ | ✓ | item is an association origin/destination. Each target classified: same-doc → in-pane nav; **other doc, same tenant** → tree-switch link + "Other framework" badge; **other public tenant** → that tenant's tree + "Other institution ↗" badge; **private/external** → hidden or external-link (http(s) only) | +| **Cross-doc hierarchy** (上位/下位 別FW) | CFItem | ✓ | ✓ | `isChildOf` parent/child lives in another framework. Same public/private cross-tenant rules as Related | +| **Referenced by (other institutions)** / 参照元(他機関) | CFItem | ✓ | ✓ | other-tenant associations whose destination is this item. **Public origins only** (private adopters never surfaced); current tenant excluded; excludes `isChildOf` | +| **Referring criteria** | CFItem | ✓ | ✓ | a CFRubricCriterion links to this item via `CFItemURI` | +| **Items setting this subject** / この分野を設定している項目 | CFSubject | ✓ **tenant-wide** (all docs) | ✓ **current document only** (案B) | items whose `subjectURI` references the subject. Top-20 + count + "Show more". Scope = `doc` presence (page None → tenant-wide; pane → that doc). `subject` string-only refs not matched | +| **Definitions** (Item Types / Concepts / Subjects / Licenses / Groupings) | CFDocument (tree) | — | ✓ | the lookups this document references (CFPackage `CFDefinitions` scope); each is a navigable tree node | +| **Effective license** | CFItem / CFDocument | ✓ | ✓ | item's own license, else inherited from document (labeled "from document") | + +**Pane-only invariant ("pane content ⟺ tree node"):** in the tree pane a selected **lookup** must be in the current document's Definitions, and a selected **rubric part** must belong to the current document — otherwise the pane route returns **404** (the standalone `/uri/` page has no such restriction; it resolves any resource). This is why a CFSubject is reachable in the pane only when the current document actually references it. + ### CFItem | Field | Required/Optional | Display | @@ -217,7 +238,13 @@ When `/uri/{uuid}` resolves to a lookup, show common + specific fields: | Specific fields | typeCode, hierarchyCode, licenseText, etc. (only when present) | | lastChangeDateTime | ISO 8601 | -**CFSubject — "Items setting this subject" (reverse lookup):** a CFSubject's card adds a section listing CFItems **in the same tenant** whose `subjectURI` references this subject (the reverse of CFItem → CFSubject). It shows the total count + the first page (top 20) inline (SSR), then a **"Show more"** button that appends the next page via HTMX from `GET /{tenant}/subject/{subject-id}/items?offset=&limit=` (`Cache-Control: public, max-age=86400`; the button is self-perpetuating — each click swaps it for the next page + a fresh button, like the lazy tree). Resolved server-side by `cf_item_repository.list_items_by_subject` / `count_items_by_subject` via the JSONB containment query `subject_uri @> '[{"identifier": ...}]'` (GIN-indexed: `ix_cf_items_subject_uri_gin`). Each row links to the item in its own document's tree. Scope notes: **within-tenant only** (a subject is tenant-owned; no cross-tenant/private gating); items that reference the subject only via the plain `subject` string (no `subjectURI`) are **not** matched; the reusable `fragments/subject_items.html` partial backs both the inline SSR and the fragment route (and is intended for reuse by a future search results list). The section is **not** shown in the tree right pane (the pane is document-scoped). Other lookups (CFItemType/Concept/License) do not yet have an equivalent reverse list. +**CFSubject — "Items setting this subject" (reverse lookup):** a CFSubject's card adds a section listing CFItems whose `subjectURI` references this subject (the reverse of CFItem → CFSubject). It shows the total count + the first page (top 20) inline (SSR), then a **"Show more"** button that appends the next page via HTMX from `GET /{tenant}/subject/{subject-id}/items?offset=&limit=[&doc=]` (`Cache-Control: public, max-age=86400`; the button is self-perpetuating — each click swaps it for the next page + a fresh button, like the lazy tree). Resolved server-side by `cf_item_repository.list_items_by_subject` / `count_items_by_subject` via the JSONB containment query `subject_uri @> '[{"identifier": ...}]'` (GIN-indexed: `ix_cf_items_subject_uri_gin`). Each row links to the item in its own document's tree. + +**Scope differs by surface (案B):** +- **Standalone `/uri/{subject}` page** — **tenant-wide**: every item in the tenant setting this subject, across all documents. (A subject is a tenant-owned lookup with no owning document, so the page has no document context — `doc` is None.) +- **Tree right pane** (subject selected via the current document's Definitions) — **restricted to the current document**: only that document's items. This keeps the pane consistent with its document-scoped tree (every listed item is a node in the same tree, so links never jump to another document), keeps the list small, and keeps the query cheap. The `doc` query param threads this scope through "Show more"; `subject_items.scope_doc` carries the document identifier (None = tenant-wide). The discriminator is simply whether `_detail_extras` receives a `doc` (pane) or not (page). + +Other scope notes: **within-tenant only** (no cross-tenant/private gating); items referencing the subject only via the plain `subject` string (no `subjectURI`) are **not** matched; the reusable `fragments/subject_items.html` partial backs both the inline SSR and the fragment route (and is intended for reuse by a future search results list). Other lookups (CFItemType/Concept/License) do not yet have an equivalent reverse list. ### CFRubric diff --git a/src/repositories/cf_item_repository.py b/src/repositories/cf_item_repository.py index 5a081ac..e3dd87b 100644 --- a/src/repositories/cf_item_repository.py +++ b/src/repositories/cf_item_repository.py @@ -112,6 +112,7 @@ async def list_items_by_subject( tenant_id: uuid.UUID, subject_identifier: str, *, + document_id: uuid.UUID | None = None, offset: int = 0, limit: int = 20, ) -> list[dict]: @@ -119,10 +120,16 @@ async def list_items_by_subject( Reverse lookup for the CFSubject detail page ("items setting this subject"). Matches via JSONB containment ``subject_uri @> '[{"identifier": }]'`` - (GIN-indexed). Tenant-scoped. Ordered by human_coding_scheme → full_statement - → identifier for a stable offset-pagination boundary. Returns + (GIN-indexed). Tenant-scoped. When ``document_id`` is given, additionally + restricts to that one document (used by the tree right pane, which is + document-scoped; the standalone /uri/ page passes None for the tenant-wide + list). Ordered by human_coding_scheme → full_statement → identifier for a + stable offset-pagination boundary. Returns ``[{identifier, human_coding_scheme, full_statement, doc_identifier, doc_title}]``. """ + conditions = [CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier)] + if document_id is not None: + conditions.append(CFItem.cf_document_id == document_id) rows = await session.execute( select( CFItem.identifier, @@ -132,7 +139,7 @@ async def list_items_by_subject( CFDocument.title, ) .join(CFDocument, CFItem.cf_document_id == CFDocument.id) - .where(CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier)) + .where(*conditions) .order_by( CFItem.human_coding_scheme.nullslast(), CFItem.full_statement, @@ -157,13 +164,16 @@ async def count_items_by_subject( session: AsyncSession, tenant_id: uuid.UUID, subject_identifier: str, + *, + document_id: uuid.UUID | None = None, ) -> int: - """Total CFItems in this tenant referencing the given subject (for the count - label). Call once on the SSR page; the "load more" fragment derives has_more - from a limit+1 fetch instead of recounting.""" - result = await session.execute( - select(func.count()) - .select_from(CFItem) - .where(CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier)) - ) + """Total CFItems referencing the given subject (for the count label). + Tenant-scoped; restricted to one document when ``document_id`` is given + (same scoping as ``list_items_by_subject``). Call once on the SSR page; the + "load more" fragment derives has_more from a limit+1 fetch instead of + recounting.""" + conditions = [CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier)] + if document_id is not None: + conditions.append(CFItem.cf_document_id == document_id) + result = await session.execute(select(func.count()).select_from(CFItem).where(*conditions)) return int(result.scalar_one()) diff --git a/src/routers/web.py b/src/routers/web.py index 6948efd..b93a590 100644 --- a/src/routers/web.py +++ b/src/routers/web.py @@ -415,8 +415,6 @@ async def _detail_extras( resource, doc=None, tree_index=None, - *, - include_subject_items: bool = True, ) -> dict: """Extra context the resource-detail card needs beyond the resource itself: rubrics (CFDocument), referring criteria + related groupings (CFItem). @@ -465,6 +463,7 @@ async def _detail_extras( "has_more": False, "next_offset": 0, "limit": SUBJECT_ITEMS_PAGE, + "scope_doc": None, } if resource_type == "CFDocument": rubrics = await cf_rubric_repository.list_by_document(session, resource.id) @@ -567,22 +566,28 @@ async def _detail_extras( related_other_tenant = await _resolve_cross_tenant(session, tenant_id, unresolved) hierarchy_upper, hierarchy_lower = await _cross_doc_hierarchy(session, tenant_id, resource, doc) incoming_refs = await _incoming_refs(session, tenant_id, resource) - elif resource_type == "CFSubject" and include_subject_items: - # Only the standalone /uri/ page lists "items setting this subject". The - # tree right pane is document-scoped, so pane callers pass - # include_subject_items=False — keeping it hidden there AND skipping the - # (potentially large) reverse-lookup query. + elif resource_type == "CFSubject": + # "Items setting this subject". Scope follows the surface (`doc`): + # - standalone /uri/ page: `doc` is None (a subject is a tenant-owned + # lookup with no owning document) → tenant-wide list. + # - tree right pane: `doc` is the current document → restrict to it, so + # the pane stays document-scoped (every listed item is a node in the + # current tree; no jumping to another doc, and the query is cheap). sid = str(resource.identifier) + scope_doc_id = doc.id if doc is not None else None rows = await cf_item_repository.list_items_by_subject( - session, tenant_id, sid, offset=0, limit=SUBJECT_ITEMS_PAGE + session, tenant_id, sid, document_id=scope_doc_id, offset=0, limit=SUBJECT_ITEMS_PAGE ) - total = await cf_item_repository.count_items_by_subject(session, tenant_id, sid) + total = await cf_item_repository.count_items_by_subject(session, tenant_id, sid, document_id=scope_doc_id) subject_items = { "rows": rows, "total": total, "has_more": total > len(rows), "next_offset": len(rows), "limit": SUBJECT_ITEMS_PAGE, + # Carried into the "load more" URL so pagination stays in the same + # scope (the pane keeps restricting to this document). + "scope_doc": str(doc.identifier) if doc is not None else None, } return { "rubrics": rubrics, @@ -765,6 +770,14 @@ async def _render_tree_page( hierarchy_upper: list[dict] = [] hierarchy_lower: list[dict] = [] incoming_refs: list[dict] = [] + subject_items: dict = { + "rows": [], + "total": 0, + "has_more": False, + "next_offset": 0, + "limit": SUBJECT_ITEMS_PAGE, + "scope_doc": None, + } # Which Definitions subgroup (if any) holds the selected node — so the # template can auto-open that section/subgroup on a direct load/reload. selected_def_group: str | None = None @@ -821,6 +834,13 @@ async def _render_tree_page( hierarchy_upper = extras["hierarchy_upper"] hierarchy_lower = extras["hierarchy_lower"] incoming_refs = extras["incoming_refs"] + elif pane_type == "CFSubject": + # Pane is document-scoped: list only this document's items that + # set the subject (passing `doc` scopes the query — see + # _detail_extras). The standalone /uri/ page shows the tenant-wide + # list (doc is None there). + extras = await _detail_extras(session, tenant_obj.id, "CFSubject", pane_resource, doc) + subject_items = extras["subject_items"] # Full-detail pane context (shared partial). `resource` is the # relationship-loaded selected resource, or the document by default. @@ -840,9 +860,9 @@ async def _render_tree_page( "hierarchy_upper": hierarchy_upper, "hierarchy_lower": hierarchy_lower, "incoming_refs": incoming_refs, - # The tree pane is document-scoped; a subject's cross-document "items - # setting this subject" list is shown only on the standalone /uri/ page. - "subject_items": {"rows": [], "total": 0, "has_more": False, "next_offset": 0, "limit": SUBJECT_ITEMS_PAGE}, + # Document-scoped in the pane (a CFSubject selected in this doc's tree + # lists only this document's items setting it); empty for non-subjects. + "subject_items": subject_items, } ctx = _detail_pane_context( pane_extras, @@ -1007,9 +1027,10 @@ async def _pane_fragment_response( """Render the shared full-detail card as a right-pane HTMX fragment.""" lang = _get_lang(request) t = get_translator(lang) - # in_pane: the tree pane is document-scoped, so suppress the CFSubject - # "items setting this subject" reverse list here (don't compute or render it). - extras = await _detail_extras(session, tenant_obj.id, resource_type, resource, doc, include_subject_items=False) + # Passing `doc` document-scopes the CFSubject "items setting this subject" + # list to the current tree (see _detail_extras); the standalone /uri/ page + # has no doc and shows the tenant-wide list. + extras = await _detail_extras(session, tenant_obj.id, resource_type, resource, doc) # in_pane=True → the redundant "Show in tree" link is hidden by the partial. ctx = _detail_pane_context( extras, @@ -1138,13 +1159,18 @@ async def subject_items_fragment( request: Request, offset: int = 0, limit: int = SUBJECT_ITEMS_PAGE, + doc: str | None = None, session: AsyncSession = Depends(get_session), ) -> HTMLResponse: """HTMX fragment: the next page of "items setting this subject", appended on the CFSubject detail page ("load more"). Mirrors the tree's lazy-load shape — returns the same `subject_items.html` markup (rows + a self-perpetuating button), so each click swaps the button for the next page + a fresh button. - Within-tenant only. has_more is derived from a limit+1 fetch (no recount).""" + Within-tenant only. has_more is derived from a limit+1 fetch (no recount). + + Optional `doc` keeps the pane's document scope across pagination: when given, + only that document's items are listed (matching the tree-pane view); omitted + on the standalone /uri/ page → tenant-wide.""" t = get_translator(_get_lang(request)) tenant_obj = await tenant_service.resolve_tenant(session, tenant) if tenant_obj is None: @@ -1159,10 +1185,23 @@ async def subject_items_fragment( if result is None or result.resource_type != "CFSubject": return _error_fragment(404, t("error_not_found")) + # Optional document scope (pane). Validate it's a document in this tenant. + scope_doc_id = None + scope_doc_ident = None + if doc is not None: + doc_uuid = _parse_uuid(doc) + if doc_uuid is None: + return _error_fragment(400, t("error_bad_request")) + scope_doc = await tree_service.get_document_for_tree(session, tenant_obj.id, doc_uuid) + if scope_doc is None: + return _error_fragment(404, t("error_document_not_found")) + scope_doc_id = scope_doc.id + scope_doc_ident = str(scope_doc.identifier) + offset = max(0, offset) limit = max(1, min(limit, SUBJECT_ITEMS_PAGE)) rows = await cf_item_repository.list_items_by_subject( - session, tenant_obj.id, str(subject_uuid), offset=offset, limit=limit + 1 + session, tenant_obj.id, str(subject_uuid), document_id=scope_doc_id, offset=offset, limit=limit + 1 ) has_more = len(rows) > limit rows = rows[:limit] @@ -1172,6 +1211,7 @@ async def subject_items_fragment( "limit": limit, "has_more": has_more, "subject_id": str(subject_uuid), + "scope_doc": scope_doc_ident, "tenant_url": _tenant_url_segment(tenant, tenant_obj), "t": t, } diff --git a/src/templates/fragments/resource_detail.html b/src/templates/fragments/resource_detail.html index 178558a..4a7fbbd 100644 --- a/src/templates/fragments/resource_detail.html +++ b/src/templates/fragments/resource_detail.html @@ -959,7 +959,7 @@

{{ t("rubric_levels") }}

{{ t("subject_items_count", count=subject_items.total|string) }}

diff --git a/src/templates/fragments/subject_items.html b/src/templates/fragments/subject_items.html index a9fce35..844b922 100644 --- a/src/templates/fragments/subject_items.html +++ b/src/templates/fragments/subject_items.html @@ -2,8 +2,10 @@ Used both as an SSR include on the CFSubject detail card and as the response of the /{tenant}/subject/{id}/items "load more" route (same markup, like tree_nodes.html). Context: rows, next_offset, limit, has_more, subject_id, - tenant_url, t. Each row links to the item in its own document's tree (a - subject has no document, so always the cross-document deep-link form). #} + tenant_url, t, scope_doc. Each row links to the item in its own document's + tree. `scope_doc` (a document identifier or None) is threaded into the + "load more" URL so pagination keeps the pane's document scope; None → + tenant-wide (standalone /uri/ page). #} {% for row in rows %}
  • diff --git a/tests/unit/test_subject_items.py b/tests/unit/test_subject_items.py index 2dc52c7..ecd66ff 100644 --- a/tests/unit/test_subject_items.py +++ b/tests/unit/test_subject_items.py @@ -260,35 +260,81 @@ async def test_unknown_subject_404(self, db_client, tenant: Tenant): assert resp.status_code == 404 -class TestPaneSuppression: - """The reverse list is for the standalone /uri/ page only — the tree right - pane is document-scoped and must NOT show it (and must NOT run the query).""" +def _make_document(tenant: Tenant, *, title: str = "Doc B") -> CFDocument: + ident = uuid.uuid4() + return CFDocument( + id=uuid.uuid4(), + tenant_id=tenant.id, + identifier=ident, + uri=f"https://example.com/uri/{ident}", + title=title, + creator="x", + language="ja", + last_change_date_time=_TS, + ) + - async def test_detail_extras_suppressed_when_in_pane( +class TestDocumentScope: + """案B: the standalone /uri/ page lists items tenant-wide; the tree right + pane lists only the CURRENT document's items setting the subject.""" + + async def test_repo_document_id_filters( self, db_session: AsyncSession, tenant: Tenant, sample_document: CFDocument ): subj = _make_subject(tenant) sid = str(subj.identifier) db_session.add(subj) - db_session.add(_make_item_with_subject(tenant, sample_document, sid, full_statement="ref item")) + db_session.add(_make_item_with_subject(tenant, sample_document, sid, full_statement="in doc A")) + doc_b = _make_document(tenant) + db_session.add(doc_b) + await db_session.flush() + db_session.add(_make_item_with_subject(tenant, doc_b, sid, full_statement="in doc B")) await db_session.flush() - # Pane path: include_subject_items=False → empty even though items exist. - suppressed = await _detail_extras(db_session, tenant.id, "CFSubject", subj, include_subject_items=False) - assert suppressed["subject_items"]["rows"] == [] - assert suppressed["subject_items"]["total"] == 0 + # Tenant-wide (document_id=None) → both. + all_rows = await cf_item_repository.list_items_by_subject(db_session, tenant.id, sid) + assert {r["full_statement"] for r in all_rows} == {"in doc A", "in doc B"} + assert await cf_item_repository.count_items_by_subject(db_session, tenant.id, sid) == 2 - # Standalone page path: default True → populated. - shown = await _detail_extras(db_session, tenant.id, "CFSubject", subj) - assert shown["subject_items"]["total"] == 1 - assert shown["subject_items"]["rows"][0]["full_statement"] == "ref item" + # Scoped to doc A → only A. + a_rows = await cf_item_repository.list_items_by_subject( + db_session, tenant.id, sid, document_id=sample_document.id + ) + assert {r["full_statement"] for r in a_rows} == {"in doc A"} + assert ( + await cf_item_repository.count_items_by_subject(db_session, tenant.id, sid, document_id=sample_document.id) + == 1 + ) - async def test_pane_fragment_hides_subject_items( + async def test_detail_extras_scopes_by_doc( + self, db_session: AsyncSession, tenant: Tenant, sample_document: CFDocument + ): + subj = _make_subject(tenant) + sid = str(subj.identifier) + db_session.add(subj) + db_session.add(_make_item_with_subject(tenant, sample_document, sid, full_statement="A item")) + doc_b = _make_document(tenant) + db_session.add(doc_b) + await db_session.flush() + db_session.add(_make_item_with_subject(tenant, doc_b, sid, full_statement="B item")) + await db_session.flush() + + # Standalone page (doc=None) → tenant-wide, no scope_doc. + page = await _detail_extras(db_session, tenant.id, "CFSubject", subj) + assert page["subject_items"]["total"] == 2 + assert page["subject_items"]["scope_doc"] is None + + # Pane (doc=sample_document) → only that doc's item, scope_doc set. + pane = await _detail_extras(db_session, tenant.id, "CFSubject", subj, sample_document) + assert pane["subject_items"]["total"] == 1 + assert pane["subject_items"]["rows"][0]["full_statement"] == "A item" + assert pane["subject_items"]["scope_doc"] == str(sample_document.identifier) + + async def test_pane_fragment_shows_doc_scoped( self, db_session: AsyncSession, db_client, tenant: Tenant, sample_document: CFDocument ): - # A subject referenced by an item in this doc becomes a tree-node - # (Definitions), so its detail fragment is reachable in the pane. The - # reverse-lookup section must NOT appear there. + # A subject referenced by an item in this doc is a tree node (Definitions), + # so its detail fragment renders in the pane — now WITH the doc-scoped list. subj = _make_subject(tenant, title="Pane Subject") sid = str(subj.identifier) db_session.add(subj) @@ -297,7 +343,56 @@ async def test_pane_fragment_hides_subject_items( resp = await db_client.get(f"/{tenant.id}/cftree/doc/{sample_document.identifier}/detail/{subj.identifier}") assert resp.status_code == 200 - assert "Pane Subject" in resp.text # the subject card itself renders - # ...but not its reverse list (no "show more" fragment link, no ref item). - assert f"/subject/{subj.identifier}/items" not in resp.text - assert "ref in pane" not in resp.text + assert "Pane Subject" in resp.text + assert "ref in pane" in resp.text # the doc's item IS listed in the pane now + + async def test_load_more_url_carries_doc_scope( + self, db_session: AsyncSession, db_client, tenant: Tenant, sample_document: CFDocument + ): + # >20 items in the doc → pane shows "load more"; its URL must keep &doc=. + subj = _make_subject(tenant, title="Big Subject") + sid = str(subj.identifier) + db_session.add(subj) + for i in range(25): + db_session.add( + _make_item_with_subject(tenant, sample_document, sid, hcs=f"{i:03d}", full_statement=f"big {i:03d}") + ) + await db_session.flush() + + resp = await db_client.get(f"/{tenant.id}/cftree/doc/{sample_document.identifier}/detail/{subj.identifier}") + assert resp.status_code == 200 + assert f"/subject/{subj.identifier}/items?offset=20" in resp.text + assert f"doc={sample_document.identifier}" in resp.text + + async def test_fragment_doc_scope_filters( + self, db_session: AsyncSession, db_client, tenant: Tenant, sample_document: CFDocument + ): + subj = _make_subject(tenant) + sid = str(subj.identifier) + db_session.add(subj) + db_session.add(_make_item_with_subject(tenant, sample_document, sid, full_statement="A only")) + doc_b = _make_document(tenant) + db_session.add(doc_b) + await db_session.flush() + db_session.add(_make_item_with_subject(tenant, doc_b, sid, full_statement="B only")) + await db_session.flush() + + # Fragment scoped to doc A → only A's item. + scoped = await db_client.get( + f"/{tenant.id}/subject/{subj.identifier}/items?offset=0&limit=20&doc={sample_document.identifier}" + ) + assert scoped.status_code == 200 + assert "A only" in scoped.text + assert "B only" not in scoped.text + + # No doc → tenant-wide. + wide = await db_client.get(f"/{tenant.id}/subject/{subj.identifier}/items?offset=0&limit=20") + assert "A only" in wide.text + assert "B only" in wide.text + + async def test_fragment_bad_doc_400(self, db_session: AsyncSession, db_client, tenant: Tenant): + subj = _make_subject(tenant) + db_session.add(subj) + await db_session.flush() + resp = await db_client.get(f"/{tenant.id}/subject/{subj.identifier}/items?doc=not-a-uuid") + assert resp.status_code == 400