Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e
This commit is contained in:
commit
7ca726caf2
659 files changed
+98171
No files matched your search
@@ -0,0 +1,482 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.answers.dependencies import get_answer_application, get_answer_principal
|
||||
from ragcore.api.v1.answers.service import (
|
||||
AnswerEventType,
|
||||
AnswerQuery,
|
||||
AnswerService,
|
||||
)
|
||||
from ragcore.api.v1.context.service import ContextExecutionRequest, ContextExecutionResult
|
||||
from ragcore.api.v1.search.service import SearchQuery
|
||||
from ragcore.application.retrieval.authorization import RetrievalAuthorizationService
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.citations import Citation
|
||||
from ragcore.domain.control import Application, Role, Workspace
|
||||
from ragcore.main import create_app
|
||||
from ragcore.retrieval.context import ContextCandidate
|
||||
|
||||
WORKSPACE_ID = UUID("00000000-0000-0000-0000-000000000001")
|
||||
SPACE_ID = UUID("00000000-0000-0000-0000-000000000002")
|
||||
|
||||
EVIDENCE_EXCERPT = (
|
||||
"The quarterly maintenance window for the payment gateway runs every "
|
||||
"second Sunday from midnight to four in the morning Central European time."
|
||||
)
|
||||
|
||||
|
||||
class FixtureContextExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
candidate: ContextCandidate | None,
|
||||
*,
|
||||
degraded: bool = False,
|
||||
degradation_codes: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
self.candidate = candidate
|
||||
self.degraded = degraded
|
||||
self.degradation_codes = degradation_codes
|
||||
self.calls: list[ContextExecutionRequest] = []
|
||||
|
||||
async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult:
|
||||
self.calls.append(request)
|
||||
if self.candidate is None:
|
||||
return ContextExecutionResult(
|
||||
degraded=self.degraded, degradation_codes=self.degradation_codes
|
||||
)
|
||||
citation = self.candidate.citation
|
||||
refreshed = Citation.create(
|
||||
workspace_id=citation.workspace_id,
|
||||
space_id=citation.space_id,
|
||||
document_id=citation.document_id,
|
||||
document_version_id=citation.document_version_id,
|
||||
chunk_id=citation.chunk_id,
|
||||
parent_chunk_id=citation.parent_chunk_id,
|
||||
location=citation.location,
|
||||
excerpt=citation.excerpt,
|
||||
source_uri=citation.source_uri,
|
||||
acquired_at=citation.acquired_at,
|
||||
retrieval_run_id=request.retrieval_run_id,
|
||||
index_alias=citation.index_alias,
|
||||
profile_version=citation.profile_version,
|
||||
)
|
||||
return ContextExecutionResult(
|
||||
candidates=(replace(self.candidate, citation=refreshed),),
|
||||
degraded=self.degraded,
|
||||
degradation_codes=self.degradation_codes,
|
||||
)
|
||||
|
||||
|
||||
_CITATION_ID = re.compile(r"citation_ids=([0-9a-fA-F-]{36})")
|
||||
|
||||
|
||||
class FixtureGenerator:
|
||||
"""``None`` in ``responses`` means: build a valid answer citing whichever
|
||||
citation id the prompt's evidence fence actually carries -- the real id
|
||||
depends on the request's own (unpredictable) retrieval_run_id, so it
|
||||
cannot be precomputed by the test itself.
|
||||
"""
|
||||
|
||||
def __init__(self, responses: list[str | None] | None = None) -> None:
|
||||
self.responses: list[str | None] = list(responses if responses is not None else [None])
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
|
||||
async def generate(self, *, prompt: str, generation_profile: str, spaces) -> str:
|
||||
self.calls.append(tuple(space.slug for space in spaces))
|
||||
if not self.responses:
|
||||
raise AssertionError("generator was called more times than expected")
|
||||
response = self.responses.pop(0)
|
||||
if response is not None:
|
||||
return response
|
||||
match = _CITATION_ID.search(prompt)
|
||||
assert match is not None, "prompt did not contain a citation id fence"
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "answerable",
|
||||
"answer": (
|
||||
"The quarterly maintenance window for the payment gateway runs "
|
||||
"every second Sunday from midnight to four in the morning."
|
||||
),
|
||||
"citations": [match.group(1)],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class HangingGenerator:
|
||||
def __init__(self) -> None:
|
||||
self.started = asyncio.Event()
|
||||
self.cancelled = False
|
||||
|
||||
async def generate(self, *, prompt: str, generation_profile: str, spaces) -> str:
|
||||
self.started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
self.cancelled = True
|
||||
raise
|
||||
return "unreachable"
|
||||
|
||||
|
||||
class FixtureInputs:
|
||||
def __init__(self, workspace: Workspace, principal: Principal, space_id: UUID) -> None:
|
||||
self.workspace = workspace
|
||||
self.principal = principal
|
||||
self.space_id = space_id
|
||||
|
||||
async def load(self, principal: Principal):
|
||||
space = self.workspace.create_knowledge_space(
|
||||
id=self.space_id, name="Answers space", slug="answers-space"
|
||||
)
|
||||
return type(
|
||||
"Inputs",
|
||||
(),
|
||||
{
|
||||
"spaces": (space,),
|
||||
"grants": (),
|
||||
"user_grants": (
|
||||
self.workspace.grant_user(
|
||||
id=uuid4(),
|
||||
user_id=principal.id,
|
||||
knowledge_space=space,
|
||||
role=Role.EDITOR,
|
||||
),
|
||||
),
|
||||
"application": Application(
|
||||
id=uuid4(),
|
||||
workspace_id=self.workspace.id,
|
||||
name="Answers application",
|
||||
default_knowledge_space_ids=frozenset({self.space_id}),
|
||||
),
|
||||
},
|
||||
)()
|
||||
|
||||
|
||||
def _candidate(excerpt: str = EVIDENCE_EXCERPT) -> ContextCandidate:
|
||||
citation = Citation.create(
|
||||
workspace_id=WORKSPACE_ID,
|
||||
space_id=SPACE_ID,
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=uuid4(),
|
||||
parent_chunk_id=None,
|
||||
location={"page_start": 1},
|
||||
excerpt=excerpt,
|
||||
source_uri="https://example.test/doc.md",
|
||||
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
|
||||
retrieval_run_id=uuid4(),
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
return ContextCandidate(citation, excerpt, 1, 1.0)
|
||||
|
||||
|
||||
def _fixture(
|
||||
*,
|
||||
candidate: ContextCandidate | None,
|
||||
generator: object,
|
||||
degraded: bool = False,
|
||||
degradation_codes: tuple[str, ...] = (),
|
||||
) -> tuple[object, Principal, UUID, FixtureContextExecutor]:
|
||||
workspace = Workspace(WORKSPACE_ID, "Answers workspace", "answers-workspace")
|
||||
space_id = SPACE_ID
|
||||
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR}))
|
||||
executor = FixtureContextExecutor(
|
||||
candidate, degraded=degraded, degradation_codes=degradation_codes
|
||||
)
|
||||
service = AnswerService(
|
||||
authorization=RetrievalAuthorizationService(),
|
||||
inputs=FixtureInputs(workspace, principal, space_id),
|
||||
context_executor=executor,
|
||||
generator=generator,
|
||||
)
|
||||
api = create_app()
|
||||
api.dependency_overrides[get_answer_principal] = lambda: principal
|
||||
api.dependency_overrides[get_answer_application] = lambda: service
|
||||
return api, principal, space_id, executor
|
||||
|
||||
|
||||
def test_openapi_and_runtime_expose_answer_operation() -> None:
|
||||
candidate = _candidate()
|
||||
api, _, _, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
|
||||
document = yaml.safe_load(
|
||||
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
|
||||
)
|
||||
assert set(api.openapi()["paths"]["/v1/answers"]) == {"post"}
|
||||
assert "post" in document["paths"]["/v1/answers"]
|
||||
|
||||
|
||||
def test_grounded_answer_returns_citations_and_claims() -> None:
|
||||
candidate = _candidate()
|
||||
generator = FixtureGenerator()
|
||||
api, _, space_id, executor = _fixture(candidate=candidate, generator=generator)
|
||||
|
||||
response = TestClient(api).post(
|
||||
"/v1/answers",
|
||||
json={"query": "when is the maintenance window?", "requested_space_ids": [str(space_id)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.json()
|
||||
body = response.json()
|
||||
# A single contributing document assembles as "partially_answerable"
|
||||
# (ContextAssembler's own document-diversity heuristic); generation is
|
||||
# still expected to proceed for any status other than not_answerable.
|
||||
assert body["answerability"] == "partially_answerable"
|
||||
assert "maintenance window" in body["answer"]
|
||||
assert body["citations"]
|
||||
assert body["claims"]
|
||||
assert all(claim["citation_ids"] for claim in body["claims"])
|
||||
assert executor.calls[0].scope.effective_space_ids == frozenset({space_id})
|
||||
|
||||
|
||||
def test_reranker_failure_degrades_but_still_returns_a_grounded_answer() -> None:
|
||||
"""M8-01: a reranker that 404s or times out must not turn found evidence
|
||||
into a not_answerable, 0-citation response -- it degrades to the fused
|
||||
ranking, and the caller can see that happened via degraded=true."""
|
||||
|
||||
candidate = _candidate()
|
||||
generator = FixtureGenerator()
|
||||
api, _, space_id, _ = _fixture(
|
||||
candidate=candidate,
|
||||
generator=generator,
|
||||
degraded=True,
|
||||
degradation_codes=("rerank_rejected",),
|
||||
)
|
||||
|
||||
response = TestClient(api).post(
|
||||
"/v1/answers",
|
||||
json={"query": "when is the maintenance window?", "requested_space_ids": [str(space_id)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.json()
|
||||
body = response.json()
|
||||
assert body["answerability"] != "not_answerable"
|
||||
assert body["citations"]
|
||||
assert body["degraded"] is True
|
||||
assert "rerank_rejected" in body["degradation_reasons"]
|
||||
|
||||
|
||||
def test_search_authorization_is_reused_and_narrows_to_effective_spaces() -> None:
|
||||
candidate = _candidate()
|
||||
generator = FixtureGenerator()
|
||||
api, _, _, executor = _fixture(candidate=candidate, generator=generator)
|
||||
|
||||
forbidden_space = uuid4()
|
||||
response = TestClient(api).post(
|
||||
"/v1/answers",
|
||||
json={"query": "anything", "requested_space_ids": [str(forbidden_space)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["answerability"] == "not_answerable"
|
||||
assert body["answer"] == ""
|
||||
assert executor.calls == []
|
||||
assert generator.calls == []
|
||||
|
||||
|
||||
def test_not_answerable_context_never_calls_the_generator() -> None:
|
||||
generator = FixtureGenerator()
|
||||
api, _, space_id, executor = _fixture(candidate=None, generator=generator)
|
||||
|
||||
response = TestClient(api).post(
|
||||
"/v1/answers",
|
||||
json={"query": "anything", "requested_space_ids": [str(space_id)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["answerability"] == "not_answerable"
|
||||
assert body["answer"] == ""
|
||||
assert body["claims"] == []
|
||||
assert len(executor.calls) == 1
|
||||
assert generator.calls == []
|
||||
|
||||
|
||||
def test_invalid_generated_output_is_rejected_not_silently_accepted() -> None:
|
||||
candidate = _candidate()
|
||||
generator = FixtureGenerator(["not json at all", "still not json"])
|
||||
api, _, space_id, _ = _fixture(candidate=candidate, generator=generator)
|
||||
|
||||
response = TestClient(api).post(
|
||||
"/v1/answers",
|
||||
json={
|
||||
"query": "when is the maintenance window?",
|
||||
"requested_space_ids": [str(space_id)],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
# ClaimCitationValidator's own retry loop exhausted both attempts and
|
||||
# raises its stable "exhausted" code, not a silently-degraded answer.
|
||||
assert response.json()["code"] == "VALIDATION_RETRIES_EXHAUSTED"
|
||||
assert len(generator.calls) == 2
|
||||
|
||||
|
||||
def test_debug_output_requires_admin_capability() -> None:
|
||||
candidate = _candidate()
|
||||
api, _, space_id, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
|
||||
|
||||
response = TestClient(api).post(
|
||||
"/v1/answers",
|
||||
json={
|
||||
"query": "anything",
|
||||
"requested_space_ids": [str(space_id)],
|
||||
"include_debug": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["code"] == "DEBUG_NOT_ALLOWED"
|
||||
|
||||
|
||||
def test_missing_answer_service_is_503_problem_details() -> None:
|
||||
candidate = _candidate()
|
||||
api, _, _, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
|
||||
# create_app() always wires a real answer_application now; popping the
|
||||
# dependency override alone would just fall through to that real service
|
||||
# instead of exercising get_answer_application's own missing-state
|
||||
# fallback, so the state attribute itself has to be genuinely absent.
|
||||
api.dependency_overrides.pop(get_answer_application)
|
||||
del api.state.answer_application
|
||||
|
||||
response = TestClient(api).post("/v1/answers", json={"query": "anything"})
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["code"] == "ANSWERS_UNAVAILABLE"
|
||||
|
||||
|
||||
def test_missing_query_is_a_validation_error() -> None:
|
||||
candidate = _candidate()
|
||||
api, _, _, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
|
||||
|
||||
response = TestClient(api).post("/v1/answers", json={})
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["code"] == "VALIDATION_ERROR"
|
||||
|
||||
|
||||
def test_sse_stream_emits_metadata_before_done_in_order() -> None:
|
||||
candidate = _candidate()
|
||||
generator = FixtureGenerator()
|
||||
api, _, space_id, _ = _fixture(candidate=candidate, generator=generator)
|
||||
|
||||
with TestClient(api).stream(
|
||||
"POST",
|
||||
"/v1/answers",
|
||||
json={
|
||||
"query": "when is the maintenance window?",
|
||||
"requested_space_ids": [str(space_id)],
|
||||
"stream": True,
|
||||
},
|
||||
) as response:
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/event-stream")
|
||||
raw = response.read().decode()
|
||||
|
||||
frames = [frame for frame in raw.strip().split("\n\n") if frame]
|
||||
assert len(frames) == 2
|
||||
|
||||
def _parse(frame: str) -> tuple[str, dict]:
|
||||
lines = frame.splitlines()
|
||||
event_line = next(line for line in lines if line.startswith("event: "))
|
||||
data_line = next(line for line in lines if line.startswith("data: "))
|
||||
return event_line[len("event: ") :], json.loads(data_line[len("data: ") :])
|
||||
|
||||
first_type, first_data = _parse(frames[0])
|
||||
second_type, second_data = _parse(frames[1])
|
||||
|
||||
assert first_type == AnswerEventType.RETRIEVAL_METADATA.value
|
||||
assert first_data["answerability"] == "partially_answerable"
|
||||
assert "effective_space_ids" in first_data
|
||||
|
||||
assert second_type == AnswerEventType.DONE.value
|
||||
assert second_data["answerability"] == "partially_answerable"
|
||||
assert second_data["citations"]
|
||||
assert "maintenance window" in second_data["answer"]
|
||||
|
||||
|
||||
def test_sse_stream_reports_a_terminal_error_event_on_invalid_output() -> None:
|
||||
candidate = _candidate()
|
||||
generator = FixtureGenerator(["not json", "still not json"])
|
||||
api, _, space_id, _ = _fixture(candidate=candidate, generator=generator)
|
||||
|
||||
with TestClient(api).stream(
|
||||
"POST",
|
||||
"/v1/answers",
|
||||
json={
|
||||
"query": "when is the maintenance window?",
|
||||
"requested_space_ids": [str(space_id)],
|
||||
"stream": True,
|
||||
},
|
||||
) as response:
|
||||
assert response.status_code == 200
|
||||
raw = response.read().decode()
|
||||
|
||||
frames = [frame for frame in raw.strip().split("\n\n") if frame]
|
||||
assert len(frames) == 2
|
||||
assert "event: retrieval_metadata" in frames[0]
|
||||
assert "event: error" in frames[1]
|
||||
assert '"validation_retries_exhausted"' in frames[1]
|
||||
|
||||
|
||||
async def test_disconnect_cancels_provider_work() -> None:
|
||||
candidate = _candidate()
|
||||
generator = HangingGenerator()
|
||||
workspace = Workspace(WORKSPACE_ID, "Answers workspace", "answers-workspace")
|
||||
space_id = SPACE_ID
|
||||
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR}))
|
||||
executor = FixtureContextExecutor(candidate)
|
||||
service = AnswerService(
|
||||
authorization=RetrievalAuthorizationService(),
|
||||
inputs=FixtureInputs(workspace, principal, space_id),
|
||||
context_executor=executor,
|
||||
generator=generator,
|
||||
)
|
||||
query = AnswerQuery(
|
||||
query=SearchQuery(
|
||||
query="when is the maintenance window?", requested_space_ids=(space_id,)
|
||||
)
|
||||
)
|
||||
authorized = await service.authorize(principal, query)
|
||||
stream = service.answer_stream(principal, query, authorized)
|
||||
|
||||
first_event = await stream.__anext__()
|
||||
assert first_event.type is AnswerEventType.RETRIEVAL_METADATA
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
await asyncio.wait_for(stream.__anext__(), timeout=0.2)
|
||||
|
||||
assert generator.cancelled is True
|
||||
|
||||
|
||||
def test_answer_response_schema_matches_runtime_openapi() -> None:
|
||||
candidate = _candidate()
|
||||
api, _, _, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
|
||||
static = yaml.safe_load(
|
||||
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
|
||||
)["components"]["schemas"]
|
||||
runtime = api.openapi()["components"]["schemas"]
|
||||
|
||||
assert static["AnswerClaim"]["required"] == runtime["AnswerClaim"]["required"]
|
||||
assert static["AnswerResponse"]["required"] == runtime["AnswerResponse"]["required"]
|
||||
|
||||
def _resolved_required(schemas: dict, ref: dict) -> set[str]:
|
||||
name = ref["$ref"].rsplit("/", 1)[-1]
|
||||
return set(schemas[name].get("required", ()))
|
||||
|
||||
static_citation_ref = static["AnswerResponse"]["properties"]["citations"]["items"]
|
||||
runtime_citation_ref = runtime["AnswerResponse"]["properties"]["citations"]["items"]
|
||||
assert _resolved_required(static, static_citation_ref) == _resolved_required(
|
||||
runtime, runtime_citation_ref
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.context.dependencies import get_context_application, get_context_principal
|
||||
from ragcore.api.v1.context.service import (
|
||||
ContextExecutionRequest,
|
||||
ContextExecutionResult,
|
||||
ContextService,
|
||||
)
|
||||
from ragcore.application.retrieval.authorization import RetrievalAuthorizationService
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.citations import Citation
|
||||
from ragcore.domain.control import Application, Role, Workspace
|
||||
from ragcore.main import create_app
|
||||
from ragcore.retrieval.context import ContextCandidate
|
||||
|
||||
|
||||
class FixtureInputs:
|
||||
def __init__(self, workspace: Workspace, space_id: UUID, principal: Principal) -> None:
|
||||
self.workspace = workspace
|
||||
self.space_id = space_id
|
||||
self.principal = principal
|
||||
|
||||
async def load(self, principal: Principal):
|
||||
space = self.workspace.create_knowledge_space(
|
||||
id=self.space_id,
|
||||
name="Context space",
|
||||
slug="context-space",
|
||||
)
|
||||
return type(
|
||||
"Inputs",
|
||||
(),
|
||||
{
|
||||
"spaces": (space,),
|
||||
"grants": (),
|
||||
"user_grants": (
|
||||
self.workspace.grant_user(
|
||||
id=uuid4(),
|
||||
user_id=principal.id,
|
||||
knowledge_space=space,
|
||||
role=Role.EDITOR,
|
||||
),
|
||||
),
|
||||
"application": Application(
|
||||
id=uuid4(),
|
||||
workspace_id=self.workspace.id,
|
||||
name="Context application",
|
||||
default_knowledge_space_ids=frozenset({self.space_id}),
|
||||
),
|
||||
},
|
||||
)()
|
||||
|
||||
|
||||
class FixtureExecutor:
|
||||
def __init__(self, candidate: ContextCandidate) -> None:
|
||||
self.candidate = candidate
|
||||
self.calls: list[ContextExecutionRequest] = []
|
||||
self.fail = False
|
||||
self.degraded = False
|
||||
self.degradation_codes: tuple[str, ...] = ()
|
||||
|
||||
async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult:
|
||||
self.calls.append(request)
|
||||
if self.fail:
|
||||
raise RuntimeError("provider secret must not leak")
|
||||
citation = self.candidate.citation
|
||||
refreshed = Citation.create(
|
||||
workspace_id=citation.workspace_id,
|
||||
space_id=citation.space_id,
|
||||
document_id=citation.document_id,
|
||||
document_version_id=citation.document_version_id,
|
||||
chunk_id=citation.chunk_id,
|
||||
parent_chunk_id=citation.parent_chunk_id,
|
||||
location=citation.location,
|
||||
excerpt=citation.excerpt,
|
||||
source_uri=citation.source_uri,
|
||||
acquired_at=citation.acquired_at,
|
||||
retrieval_run_id=request.retrieval_run_id,
|
||||
index_alias=citation.index_alias,
|
||||
profile_version=citation.profile_version,
|
||||
)
|
||||
return ContextExecutionResult(
|
||||
candidates=(replace(self.candidate, citation=refreshed),),
|
||||
degraded=self.degraded,
|
||||
degradation_codes=self.degradation_codes,
|
||||
)
|
||||
|
||||
|
||||
def _fixture() -> tuple[object, Principal, UUID, ContextCandidate, FixtureExecutor]:
|
||||
workspace = Workspace(uuid4(), "Context workspace", "context-workspace")
|
||||
space_id = uuid4()
|
||||
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR}))
|
||||
citation = Citation.create(
|
||||
workspace_id=workspace.id,
|
||||
space_id=space_id,
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=uuid4(),
|
||||
parent_chunk_id=None,
|
||||
location={"page_start": 1},
|
||||
excerpt="Context evidence",
|
||||
source_uri="https://example.test/context.md",
|
||||
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
|
||||
retrieval_run_id=uuid4(),
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
candidate = ContextCandidate(citation, "Context evidence", 1, 1.0)
|
||||
executor = FixtureExecutor(candidate)
|
||||
service = ContextService(
|
||||
authorization=RetrievalAuthorizationService(),
|
||||
inputs=FixtureInputs(workspace, space_id, principal),
|
||||
executor=executor,
|
||||
)
|
||||
api = create_app()
|
||||
api.dependency_overrides[get_context_principal] = lambda: principal
|
||||
api.dependency_overrides[get_context_application] = lambda: service
|
||||
return api, principal, space_id, candidate, executor
|
||||
|
||||
|
||||
def test_openapi_and_runtime_expose_context_operation() -> None:
|
||||
api, _, _, _, _ = _fixture()
|
||||
document = yaml.safe_load(
|
||||
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
|
||||
)
|
||||
|
||||
assert set(api.openapi()["paths"]["/v1/context"]) == {"post"}
|
||||
assert "post" in document["paths"]["/v1/context"]
|
||||
|
||||
|
||||
def test_context_preserves_citations_and_budget() -> None:
|
||||
api, _, space_id, _, executor = _fixture()
|
||||
response = TestClient(api).post(
|
||||
"/v1/context",
|
||||
json={
|
||||
"query": "context",
|
||||
"requested_space_ids": [str(space_id)],
|
||||
"max_context_tokens": 256,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.json()
|
||||
body = response.json()
|
||||
assert body["blocks"][0]["citation_ids"] == [body["citations"][0]["id"]]
|
||||
assert body["citations"][0]["excerpt"] in body["blocks"][0]["text"]
|
||||
assert body["token_count"] <= 256
|
||||
assert executor.calls[0].scope.effective_space_ids == frozenset({space_id})
|
||||
|
||||
|
||||
def test_context_surfaces_degraded_retrieval() -> None:
|
||||
"""M8-01: a caller must be able to see that context was assembled from a
|
||||
degraded retrieval (e.g. reranker fallback), not just that citations
|
||||
happen to still be there."""
|
||||
|
||||
api, _, space_id, _, executor = _fixture()
|
||||
executor.degraded = True
|
||||
executor.degradation_codes = ("rerank_rejected",)
|
||||
|
||||
response = TestClient(api).post(
|
||||
"/v1/context",
|
||||
json={"query": "context", "requested_space_ids": [str(space_id)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.json()
|
||||
body = response.json()
|
||||
assert body["degraded"] is True
|
||||
assert "rerank_rejected" in body["degradation_reasons"]
|
||||
assert body["citations"]
|
||||
|
||||
|
||||
def test_context_cannot_expand_to_an_unauthorized_requested_space() -> None:
|
||||
api, _, _, _, executor = _fixture()
|
||||
response = TestClient(api).post(
|
||||
"/v1/context",
|
||||
json={"query": "context", "requested_space_ids": [str(uuid4())]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["blocks"] == []
|
||||
assert executor.calls == []
|
||||
|
||||
|
||||
def test_context_validation_errors_are_problem_details() -> None:
|
||||
api, _, _, _, _ = _fixture()
|
||||
response = TestClient(api).post(
|
||||
"/v1/context",
|
||||
json={"query": " ", "max_context_tokens": 256},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["code"] == "VALIDATION_ERROR"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["query"])
|
||||
def test_missing_required_context_values_are_rejected(field: str) -> None:
|
||||
api, _, _, _, _ = _fixture()
|
||||
payload = {"query": "context", "max_context_tokens": 256}
|
||||
payload.pop(field)
|
||||
response = TestClient(api).post("/v1/context", json=payload)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_unexpected_executor_failure_is_redacted_and_stable() -> None:
|
||||
api, _, space_id, _, executor = _fixture()
|
||||
executor.fail = True
|
||||
|
||||
response = TestClient(api).post(
|
||||
"/v1/context",
|
||||
json={"query": "context", "requested_space_ids": [str(space_id)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
body = response.json()
|
||||
assert body["code"] == "CONTEXT_UNAVAILABLE"
|
||||
assert "provider secret" not in response.text
|
||||
|
||||
def test_context_response_schema_matches_runtime_openapi() -> None:
|
||||
api, _, _, _, _ = _fixture()
|
||||
static = yaml.safe_load(
|
||||
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
|
||||
)["components"]["schemas"]
|
||||
runtime = api.openapi()["components"]["schemas"]
|
||||
|
||||
assert set(static["ContextRequest"]["properties"]) == set(
|
||||
runtime["ContextRequest"]["properties"]
|
||||
)
|
||||
assert static["ContextRequest"]["additionalProperties"] is False
|
||||
max_context_tokens = static["ContextRequest"]["properties"]["max_context_tokens"]
|
||||
assert {key: max_context_tokens[key] for key in ("type", "minimum", "maximum", "default")} == {
|
||||
"type": "integer",
|
||||
"minimum": 256,
|
||||
"maximum": 64000,
|
||||
"default": 8000,
|
||||
}
|
||||
assert static["ContextBlockResponse"]["required"] == runtime["ContextBlockResponse"]["required"]
|
||||
assert static["ContextBlockResponse"]["additionalProperties"] is False
|
||||
assert static["ContextResponse"]["required"] == runtime["ContextResponse"]["required"]
|
||||
assert static["ContextResponse"]["additionalProperties"] is False
|
||||
assert static["ContextResponse"]["properties"]["blocks"]["items"] == {
|
||||
"$ref": "#/components/schemas/ContextBlockResponse"
|
||||
}
|
||||
assert static["ContextResponse"]["properties"]["citations"]["items"] == {
|
||||
"$ref": "#/components/schemas/CitationResponse"
|
||||
}
|
||||
assert static["CitationResponse"]["required"] == runtime["CitationResponse"]["required"]
|
||||
assert static["CitationResponse"]["additionalProperties"] is False
|
||||
@@ -0,0 +1,278 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.control.dependencies import (
|
||||
get_control_application,
|
||||
get_credential_service,
|
||||
get_principal,
|
||||
)
|
||||
from ragcore.application.authorization import AuthorizationService
|
||||
from ragcore.application.control.api import ControlPlaneApplication
|
||||
from ragcore.application.identity.credentials import ServiceAccountCredentialService
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import (
|
||||
Application,
|
||||
Grant,
|
||||
KnowledgeSpace,
|
||||
Policy,
|
||||
Role,
|
||||
ServiceAccount,
|
||||
UserGrant,
|
||||
Workspace,
|
||||
)
|
||||
from ragcore.infrastructure.database.identity.memory import InMemoryCredentialRepository
|
||||
from ragcore.main import create_app
|
||||
|
||||
|
||||
class FakeControlRepository:
|
||||
def __init__(self, workspace: Workspace) -> None:
|
||||
self.workspace = workspace
|
||||
self.applications: list[Application] = []
|
||||
self.service_accounts: list[ServiceAccount] = []
|
||||
self.spaces: list[KnowledgeSpace] = []
|
||||
self.grants: list[Grant] = []
|
||||
self.policies: list[Policy] = []
|
||||
|
||||
async def get_workspace(self, workspace_id: UUID) -> Workspace | None:
|
||||
return self.workspace if workspace_id == self.workspace.id else None
|
||||
|
||||
async def create_application(self, entity: Application) -> Application:
|
||||
self.applications.append(entity)
|
||||
return entity
|
||||
|
||||
async def get_application(self, application_id: UUID, workspace_id: UUID) -> Application | None:
|
||||
return next(
|
||||
(
|
||||
item
|
||||
for item in self.applications
|
||||
if item.id == application_id and item.workspace_id == workspace_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
async def list_applications(self, workspace_id: UUID) -> tuple[Application, ...]:
|
||||
return tuple(item for item in self.applications if item.workspace_id == workspace_id)
|
||||
|
||||
async def create_service_account(self, entity: ServiceAccount) -> ServiceAccount:
|
||||
self.service_accounts.append(entity)
|
||||
return entity
|
||||
|
||||
async def get_service_account(
|
||||
self, service_account_id: UUID, workspace_id: UUID
|
||||
) -> ServiceAccount | None:
|
||||
return next(
|
||||
(
|
||||
item
|
||||
for item in self.service_accounts
|
||||
if item.id == service_account_id and item.workspace_id == workspace_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
async def get_service_account_by_id(self, service_account_id: UUID) -> ServiceAccount | None:
|
||||
return next((item for item in self.service_accounts if item.id == service_account_id), None)
|
||||
|
||||
async def create_space(self, entity: KnowledgeSpace) -> KnowledgeSpace:
|
||||
self.spaces.append(entity)
|
||||
return entity
|
||||
|
||||
async def list_spaces(self, workspace_id: UUID) -> tuple[KnowledgeSpace, ...]:
|
||||
return tuple(item for item in self.spaces if item.workspace_id == workspace_id)
|
||||
|
||||
async def create_grant(self, entity: Grant) -> Grant:
|
||||
self.grants.append(entity)
|
||||
return entity
|
||||
|
||||
async def create_user_grant(self, entity: UserGrant) -> UserGrant:
|
||||
self.grants.append(entity)
|
||||
return entity
|
||||
|
||||
async def create_policy(self, entity: Policy) -> Policy:
|
||||
self.policies.append(entity)
|
||||
return entity
|
||||
|
||||
async def list_policies(self, workspace_id: UUID) -> tuple[Policy, ...]:
|
||||
return tuple(item for item in self.policies if item.workspace_id == workspace_id)
|
||||
|
||||
|
||||
def _fixture():
|
||||
workspace = Workspace(uuid4(), "Contract workspace", "contract-workspace")
|
||||
principal = Principal(
|
||||
uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.WORKSPACE_ADMIN})
|
||||
)
|
||||
repository = FakeControlRepository(workspace)
|
||||
service = ControlPlaneApplication(repository, AuthorizationService())
|
||||
credential_repository = InMemoryCredentialRepository()
|
||||
credential_service = ServiceAccountCredentialService(
|
||||
credential_repository,
|
||||
"x" * 32,
|
||||
repository.get_service_account_by_id,
|
||||
)
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_principal] = lambda: principal
|
||||
app.dependency_overrides[get_control_application] = lambda: service
|
||||
app.dependency_overrides[get_credential_service] = lambda: credential_service
|
||||
return app, repository, principal
|
||||
|
||||
|
||||
def test_openapi_and_runtime_expose_same_control_operations() -> None:
|
||||
app, _, _ = _fixture()
|
||||
document = yaml.safe_load(
|
||||
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
|
||||
)
|
||||
documented = {
|
||||
path: set(operations)
|
||||
for path, operations in document["paths"].items()
|
||||
if path.startswith("/v1/") and "control" in str(document["paths"][path])
|
||||
}
|
||||
runtime = {
|
||||
path: set(operations)
|
||||
for path, operations in app.openapi()["paths"].items()
|
||||
if path.startswith("/v1/") and "control" in str(operations)
|
||||
}
|
||||
assert set(runtime) == set(documented)
|
||||
assert all(
|
||||
{method.lower() for method in methods} == documented[path]
|
||||
for path, methods in runtime.items()
|
||||
)
|
||||
|
||||
|
||||
def test_missing_authentication_is_problem_details() -> None:
|
||||
app, _, _ = _fixture()
|
||||
app.dependency_overrides.pop(get_principal)
|
||||
response = TestClient(app).get("/v1/applications")
|
||||
assert response.status_code == 401
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
assert response.json()["code"] == "AUTHENTICATION_REQUIRED"
|
||||
|
||||
|
||||
def test_missing_idempotency_key_is_problem_details() -> None:
|
||||
app, _, _ = _fixture()
|
||||
response = TestClient(app).post(
|
||||
"/v1/applications",
|
||||
json={"slug": "missing-key", "display_name": "Missing key"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
assert response.json()["code"] == "VALIDATION_ERROR"
|
||||
|
||||
|
||||
def test_pagination_and_invalid_cursor_error_code() -> None:
|
||||
app, repository, principal = _fixture()
|
||||
client = TestClient(app)
|
||||
headers = {"Idempotency-Key": "pagination-key-0001"}
|
||||
for slug in ("first-app", "second-app"):
|
||||
response = client.post(
|
||||
"/v1/applications",
|
||||
json={"slug": slug, "display_name": slug, "scopes": ["search"]},
|
||||
headers=headers | {"Idempotency-Key": f"{slug}-idempotency"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
first = client.get("/v1/applications?limit=1")
|
||||
assert first.status_code == 200
|
||||
assert len(first.json()["items"]) == 1
|
||||
assert first.json()["next_cursor"] == "1"
|
||||
second = client.get(f"/v1/applications?limit=1&cursor={first.json()['next_cursor']}")
|
||||
assert second.status_code == 200
|
||||
assert len(second.json()["items"]) == 1
|
||||
invalid = client.get("/v1/applications?cursor=not-a-cursor")
|
||||
assert invalid.status_code == 400
|
||||
assert invalid.json()["code"] == "INVALID_CURSOR"
|
||||
assert len(repository.applications) == 2
|
||||
assert principal.workspace_id == repository.workspace.id
|
||||
|
||||
|
||||
def test_idempotency_replay_and_conflict_do_not_duplicate() -> None:
|
||||
app, repository, _ = _fixture()
|
||||
client = TestClient(app)
|
||||
headers = {"Idempotency-Key": "stable-create-key-01"}
|
||||
payload = {"slug": "stable-app", "display_name": "Stable app", "scopes": ["search"]}
|
||||
first = client.post("/v1/applications", json=payload, headers=headers)
|
||||
replay = client.post("/v1/applications", json=payload, headers=headers)
|
||||
conflict = client.post(
|
||||
"/v1/applications",
|
||||
json={"slug": "other-app", "display_name": "Other app"},
|
||||
headers=headers,
|
||||
)
|
||||
assert first.status_code == 201
|
||||
assert replay.status_code == 200
|
||||
assert replay.json()["id"] == first.json()["id"]
|
||||
assert conflict.status_code == 409
|
||||
assert conflict.json()["code"] == "IDEMPOTENCY_KEY_REUSED"
|
||||
assert len(repository.applications) == 1
|
||||
|
||||
|
||||
def test_operation_policy_and_credentials_are_enforced() -> None:
|
||||
app, repository, principal = _fixture()
|
||||
client = TestClient(app)
|
||||
create = client.post(
|
||||
"/v1/applications",
|
||||
json={"slug": "credential-app", "display_name": "Credential app", "scopes": ["search"]},
|
||||
headers={"Idempotency-Key": "application-create-key"},
|
||||
)
|
||||
assert create.status_code == 201
|
||||
application_id = create.json()["id"]
|
||||
credential = client.post(
|
||||
f"/v1/applications/{application_id}/credentials",
|
||||
json={"name": "runtime", "scopes": ["search"]},
|
||||
headers={"Idempotency-Key": "credential-create-key"},
|
||||
)
|
||||
assert credential.status_code == 201
|
||||
assert credential.json()["token"].startswith("rc_sa_")
|
||||
assert len(repository.service_accounts) == 1
|
||||
replay = client.post(
|
||||
f"/v1/applications/{application_id}/credentials",
|
||||
json={"name": "runtime", "scopes": ["search"]},
|
||||
headers={"Idempotency-Key": "credential-create-key"},
|
||||
)
|
||||
assert replay.status_code == 200
|
||||
assert replay.json()["id"] == credential.json()["id"]
|
||||
policy = client.post(
|
||||
"/v1/policies",
|
||||
json={"operation": "answer", "required_scopes": ["search"]},
|
||||
headers={"Idempotency-Key": "policy-create-key"},
|
||||
)
|
||||
assert policy.status_code == 201
|
||||
assert client.get("/v1/policies").status_code == 200
|
||||
assert principal.roles == frozenset({Role.WORKSPACE_ADMIN})
|
||||
|
||||
|
||||
def test_non_admin_cannot_manage_spaces() -> None:
|
||||
app, _, principal = _fixture()
|
||||
denied = Principal(
|
||||
principal.id, principal.workspace_id, PrincipalType.HUMAN, frozenset({Role.VIEWER})
|
||||
)
|
||||
app.dependency_overrides[get_principal] = lambda: denied
|
||||
response = TestClient(app).post(
|
||||
"/v1/knowledge-spaces",
|
||||
json={"slug": "private", "display_name": "Private", "data_policy": "local_only"},
|
||||
headers={"Idempotency-Key": "space-create-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["code"] == "RESOURCE_NOT_FOUND"
|
||||
|
||||
def test_user_grant_is_workspace_authorized():
|
||||
app, repository, principal = _fixture()
|
||||
client = TestClient(app)
|
||||
space = client.post(
|
||||
"/v1/knowledge-spaces",
|
||||
json={"slug": "user-space", "display_name": "User space", "data_policy": "local_only"},
|
||||
headers={"Idempotency-Key": "user-space-create-key"},
|
||||
)
|
||||
assert space.status_code == 201
|
||||
user_id = uuid4()
|
||||
grant = client.post(
|
||||
f"/v1/knowledge-spaces/{space.json()['id']}/grants",
|
||||
json={"principal_type": "user", "principal_id": str(user_id), "permissions": ["read"]},
|
||||
headers={"Idempotency-Key": "user-grant-create-key"},
|
||||
)
|
||||
assert grant.status_code == 201
|
||||
assert grant.json()["principal_type"] == "user"
|
||||
assert grant.json()["principal_id"] == str(user_id)
|
||||
assert len(repository.grants) == 1
|
||||
assert principal.workspace_id == repository.workspace.id
|
||||
@@ -0,0 +1,389 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.documents.dependencies import (
|
||||
get_documents_read_principal,
|
||||
get_documents_sync_principal,
|
||||
)
|
||||
from ragcore.application.authorization import AuthorizationService
|
||||
from ragcore.application.control.api import ControlPlaneApplication
|
||||
from ragcore.application.documents import (
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import Role
|
||||
from ragcore.main import create_app
|
||||
|
||||
|
||||
class FakeDocumentRepository:
|
||||
def __init__(self) -> None:
|
||||
self.documents: dict[UUID, DocumentRecord] = {}
|
||||
self.versions: dict[UUID, list[DocumentVersionRecord]] = {}
|
||||
self.retired: list[UUID] = []
|
||||
|
||||
def seed(self, document: DocumentRecord, *versions: DocumentVersionRecord) -> None:
|
||||
self.documents[document.id] = document
|
||||
self.versions[document.id] = list(versions)
|
||||
|
||||
async def get_document(self, document_id: UUID) -> DocumentRecord | None:
|
||||
return self.documents.get(document_id)
|
||||
|
||||
async def get_document_by_source(
|
||||
self, workspace_id: UUID, source_key: str, external_id: str
|
||||
) -> DocumentRecord | None:
|
||||
return next(
|
||||
(
|
||||
document
|
||||
for document in self.documents.values()
|
||||
if document.workspace_id == workspace_id
|
||||
and document.source_key == source_key
|
||||
and document.external_id == external_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
async def list_documents_by_source(
|
||||
self, workspace_id: UUID, source_id: UUID
|
||||
) -> tuple[DocumentRecord, ...]:
|
||||
return tuple(
|
||||
document
|
||||
for document in self.documents.values()
|
||||
if document.workspace_id == workspace_id and document.source_id == source_id
|
||||
)
|
||||
|
||||
async def list_versions(self, document_id: UUID) -> tuple[DocumentVersionRecord, ...]:
|
||||
return tuple(self.versions.get(document_id, ()))
|
||||
|
||||
async def list_searchable_versions(
|
||||
self, document_id: UUID
|
||||
) -> tuple[DocumentVersionRecord, ...]:
|
||||
document = self.documents[document_id]
|
||||
if document.source_deleted:
|
||||
return ()
|
||||
return tuple(
|
||||
version
|
||||
for version in self.versions.get(document_id, ())
|
||||
if version.state is DocumentVersionState.PUBLISHED
|
||||
)
|
||||
|
||||
async def mark_source_deleted(self, document_id: UUID) -> None:
|
||||
self.retired.append(document_id)
|
||||
document = self.documents[document_id]
|
||||
self.documents[document_id] = DocumentRecord(
|
||||
id=document.id,
|
||||
workspace_id=document.workspace_id,
|
||||
space_id=document.space_id,
|
||||
source_key=document.source_key,
|
||||
external_id=document.external_id,
|
||||
title=document.title,
|
||||
source_deleted=True,
|
||||
)
|
||||
|
||||
|
||||
def _document(workspace_id: UUID, *, source_key: str | None = None) -> DocumentRecord:
|
||||
return DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
space_id=uuid4(),
|
||||
source_key=source_key or str(uuid4()),
|
||||
external_id="wiki-page-1",
|
||||
title="Wiki page",
|
||||
)
|
||||
|
||||
|
||||
def _version(document_id: UUID, state: DocumentVersionState) -> DocumentVersionRecord:
|
||||
return DocumentVersionRecord(
|
||||
id=uuid4(),
|
||||
document_id=document_id,
|
||||
source_fingerprint="a" * 64,
|
||||
content_hash="b" * 64,
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version="prose-v1",
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
def _fixture():
|
||||
workspace_id = uuid4()
|
||||
principal = Principal(
|
||||
uuid4(), workspace_id, PrincipalType.SERVICE_ACCOUNT, frozenset({Role.EDITOR})
|
||||
)
|
||||
repository = FakeDocumentRepository()
|
||||
service = ControlPlaneApplication(
|
||||
repository=None, # type: ignore[arg-type]
|
||||
authorization=AuthorizationService(),
|
||||
document_repository=repository, # type: ignore[arg-type]
|
||||
)
|
||||
app = create_app()
|
||||
from ragcore.api.v1.control.dependencies import get_control_application
|
||||
|
||||
app.dependency_overrides[get_control_application] = lambda: service
|
||||
app.dependency_overrides[get_documents_read_principal] = lambda: principal
|
||||
app.dependency_overrides[get_documents_sync_principal] = lambda: principal
|
||||
return app, repository, principal
|
||||
|
||||
|
||||
def test_openapi_and_runtime_expose_same_document_and_job_operations() -> None:
|
||||
app, _, _ = _fixture()
|
||||
document = yaml.safe_load(
|
||||
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
|
||||
)
|
||||
for path in (
|
||||
"/v1/documents",
|
||||
"/v1/documents/{document_id}",
|
||||
"/v1/documents/{document_id}/versions",
|
||||
"/v1/documents/{document_id}/retire",
|
||||
):
|
||||
assert path in document["paths"], f"{path} missing from openapi.yaml"
|
||||
documented_methods = {method.lower() for method in document["paths"][path]}
|
||||
runtime_methods = {method.lower() for method in app.openapi()["paths"][path]}
|
||||
assert runtime_methods == documented_methods, path
|
||||
|
||||
|
||||
def test_list_documents_requires_a_source_id() -> None:
|
||||
"""source_id alone lists the identity's documents (see
|
||||
test_list_documents_by_source_returns_every_document_of_the_identity);
|
||||
no filter at all is still rejected, and an unknown source is an empty page."""
|
||||
app, _, _ = _fixture()
|
||||
response = TestClient(app).get("/v1/documents")
|
||||
assert response.status_code == 400
|
||||
assert response.json()["code"] == "MISSING_FILTER"
|
||||
unknown = TestClient(app).get(f"/v1/documents?source_id={uuid4()}")
|
||||
assert unknown.status_code == 200
|
||||
assert unknown.json() == {"items": [], "next_cursor": None}
|
||||
|
||||
|
||||
def test_list_documents_empty_when_no_match() -> None:
|
||||
app, _, _ = _fixture()
|
||||
response = TestClient(app).get(f"/v1/documents?source_id={uuid4()}&external_id=missing")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"items": [], "next_cursor": None}
|
||||
|
||||
|
||||
def test_list_documents_returns_the_active_content_sha256() -> None:
|
||||
app, repository, principal = _fixture()
|
||||
source_id = uuid4()
|
||||
document = _document(principal.workspace_id, source_key=str(source_id))
|
||||
published = _version(document.id, DocumentVersionState.PUBLISHED)
|
||||
repository.seed(document, published)
|
||||
|
||||
response = TestClient(app).get(
|
||||
f"/v1/documents?source_id={source_id}&external_id={document.external_id}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
[item] = response.json()["items"]
|
||||
assert item["id"] == str(document.id)
|
||||
assert item["source_id"] == str(source_id)
|
||||
assert item["status"] == "active"
|
||||
assert item["active_version"]["content_sha256"] == published.content_hash
|
||||
|
||||
|
||||
def test_list_documents_cross_workspace_is_empty_not_leaked() -> None:
|
||||
app, repository, _ = _fixture()
|
||||
source_id = uuid4()
|
||||
document = _document(uuid4(), source_key=str(source_id))
|
||||
repository.seed(document)
|
||||
|
||||
response = TestClient(app).get(
|
||||
f"/v1/documents?source_id={source_id}&external_id={document.external_id}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"] == []
|
||||
|
||||
|
||||
def test_get_document_by_id_not_found_is_problem_details() -> None:
|
||||
app, _, _ = _fixture()
|
||||
response = TestClient(app).get(f"/v1/documents/{uuid4()}")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["code"] == "DOCUMENT_NOT_FOUND"
|
||||
|
||||
|
||||
def test_get_document_versions_lists_every_state() -> None:
|
||||
app, repository, principal = _fixture()
|
||||
document = _document(principal.workspace_id)
|
||||
superseded = _version(document.id, DocumentVersionState.SUPERSEDED)
|
||||
published = _version(document.id, DocumentVersionState.PUBLISHED)
|
||||
repository.seed(document, superseded, published)
|
||||
|
||||
response = TestClient(app).get(f"/v1/documents/{document.id}/versions")
|
||||
|
||||
assert response.status_code == 200
|
||||
ids = {item["id"] for item in response.json()["items"]}
|
||||
assert ids == {str(superseded.id), str(published.id)}
|
||||
|
||||
|
||||
def test_retire_document_marks_source_deleted_and_is_idempotent() -> None:
|
||||
app, repository, principal = _fixture()
|
||||
document = _document(principal.workspace_id)
|
||||
repository.seed(document)
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.post(f"/v1/documents/{document.id}/retire")
|
||||
assert first.status_code == 200
|
||||
assert first.json()["status"] == "source_deleted"
|
||||
|
||||
second = client.post(f"/v1/documents/{document.id}/retire")
|
||||
assert second.status_code == 200
|
||||
assert repository.retired == [document.id]
|
||||
|
||||
|
||||
def test_retire_document_does_not_hard_delete() -> None:
|
||||
app, repository, principal = _fixture()
|
||||
document = _document(principal.workspace_id)
|
||||
repository.seed(document)
|
||||
|
||||
TestClient(app).post(f"/v1/documents/{document.id}/retire")
|
||||
|
||||
assert document.id in repository.documents
|
||||
assert repository.documents[document.id].source_deleted is True
|
||||
|
||||
|
||||
def test_missing_authentication_is_problem_details() -> None:
|
||||
app, _, _ = _fixture()
|
||||
app.dependency_overrides.pop(get_documents_read_principal)
|
||||
response = TestClient(app).get(f"/v1/documents?source_id={uuid4()}&external_id=x")
|
||||
assert response.status_code == 401
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
assert response.json()["code"] == "AUTHENTICATION_REQUIRED"
|
||||
|
||||
|
||||
def test_insufficient_role_is_forbidden_problem_details() -> None:
|
||||
app, repository, principal = _fixture()
|
||||
document = _document(principal.workspace_id)
|
||||
repository.seed(document)
|
||||
denied = Principal(
|
||||
principal.id,
|
||||
principal.workspace_id,
|
||||
PrincipalType.SERVICE_ACCOUNT,
|
||||
frozenset({Role.AUDITOR}),
|
||||
)
|
||||
app.dependency_overrides[get_documents_read_principal] = lambda: denied
|
||||
|
||||
response = TestClient(app).get(f"/v1/documents/{document.id}")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["code"] == "RESOURCE_NOT_FOUND"
|
||||
|
||||
|
||||
def test_reconcile_space_index_runs_the_scoped_reconciler_and_maps_errors() -> None:
|
||||
from ragcore.api.v1.documents.dependencies import get_space_index_reconciler
|
||||
from ragcore.application.indexes.space_reconcile import SpaceReconciliationReport
|
||||
from ragcore.domain.control import KnowledgeSpace
|
||||
|
||||
app, repository, _principal = _fixture()
|
||||
space_id = uuid4()
|
||||
|
||||
class FakeControlRepository:
|
||||
async def list_spaces(self, workspace_id: UUID):
|
||||
return (
|
||||
KnowledgeSpace(
|
||||
id=space_id,
|
||||
workspace_id=workspace_id,
|
||||
name="portfolio-public",
|
||||
slug="portfolio-public",
|
||||
),
|
||||
)
|
||||
|
||||
class FakeReconciler:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[UUID] = []
|
||||
|
||||
async def reconcile(self, space, documents):
|
||||
self.calls.append(space)
|
||||
return SpaceReconciliationReport(
|
||||
space_id=space,
|
||||
documents_total=3,
|
||||
documents_retired=1,
|
||||
searchable_versions=2,
|
||||
points_before=10,
|
||||
deleted_points=4,
|
||||
orphaned_documents=2,
|
||||
points_after=6,
|
||||
)
|
||||
|
||||
from ragcore.api.v1.control.dependencies import get_control_application
|
||||
|
||||
service = ControlPlaneApplication(
|
||||
repository=FakeControlRepository(), # type: ignore[arg-type]
|
||||
authorization=AuthorizationService(),
|
||||
document_repository=repository, # type: ignore[arg-type]
|
||||
)
|
||||
app.dependency_overrides[get_control_application] = lambda: service
|
||||
reconciler = FakeReconciler()
|
||||
app.dependency_overrides[get_space_index_reconciler] = lambda: reconciler
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(f"/v1/spaces/{space_id}/index/reconcile")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {
|
||||
"space_id": str(space_id),
|
||||
"documents_total": 3,
|
||||
"documents_retired": 1,
|
||||
"searchable_versions": 2,
|
||||
"points_before": 10,
|
||||
"deleted_points": 4,
|
||||
"orphaned_documents": 2,
|
||||
"points_after": 6,
|
||||
}
|
||||
assert reconciler.calls == [space_id]
|
||||
|
||||
missing = client.post(f"/v1/spaces/{uuid4()}/index/reconcile")
|
||||
assert missing.status_code == 404
|
||||
assert missing.json()["code"] == "SPACE_NOT_FOUND"
|
||||
|
||||
app.dependency_overrides[get_space_index_reconciler] = lambda: None
|
||||
unavailable = client.post(f"/v1/spaces/{space_id}/index/reconcile")
|
||||
assert unavailable.status_code == 503
|
||||
assert unavailable.json()["code"] == "INDEX_UNAVAILABLE"
|
||||
|
||||
|
||||
def test_openapi_documents_the_reconcile_endpoint() -> None:
|
||||
app, _, _ = _fixture()
|
||||
document = yaml.safe_load(
|
||||
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
|
||||
)
|
||||
path = "/v1/spaces/{space_id}/index/reconcile"
|
||||
assert path in document["paths"]
|
||||
assert {m.lower() for m in app.openapi()["paths"][path]} == {"post"}
|
||||
|
||||
|
||||
def test_list_documents_by_source_returns_every_document_of_the_identity() -> None:
|
||||
app, repository, principal = _fixture()
|
||||
source_id = uuid4()
|
||||
first = _document(principal.workspace_id, source_key=str(source_id))
|
||||
second = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=principal.workspace_id,
|
||||
space_id=first.space_id,
|
||||
source_key=str(source_id),
|
||||
external_id="wiki-page-2",
|
||||
title="Second page",
|
||||
source_deleted=True,
|
||||
)
|
||||
other = _document(principal.workspace_id) # another source identity, must not appear
|
||||
repository.seed(first, _version(first.id, DocumentVersionState.PUBLISHED))
|
||||
repository.seed(second, _version(second.id, DocumentVersionState.PUBLISHED))
|
||||
repository.seed(other, _version(other.id, DocumentVersionState.PUBLISHED))
|
||||
|
||||
response = TestClient(app).get(f"/v1/documents?source_id={source_id}")
|
||||
assert response.status_code == 200, response.text
|
||||
items = response.json()["items"]
|
||||
assert {item["external_id"] for item in items} == {"wiki-page-1", "wiki-page-2"}
|
||||
by_id = {item["external_id"]: item for item in items}
|
||||
assert by_id["wiki-page-1"]["status"] == "active"
|
||||
assert by_id["wiki-page-1"]["active_version"]["source_fingerprint"] == "a" * 64
|
||||
assert by_id["wiki-page-2"]["status"] == "source_deleted"
|
||||
assert by_id["wiki-page-2"]["active_version"] is None
|
||||
|
||||
missing = TestClient(app).get("/v1/documents")
|
||||
assert missing.status_code == 400
|
||||
assert missing.json()["code"] == "MISSING_FILTER"
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.jobs.dependencies import get_job_service, get_jobs_sync_principal
|
||||
from ragcore.application.jobs import JobService
|
||||
from ragcore.application.jobs.models import Job
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import Role
|
||||
from ragcore.main import create_app
|
||||
|
||||
|
||||
class FakeJobRepository:
|
||||
def __init__(self) -> None:
|
||||
self.jobs: dict[UUID, Job] = {}
|
||||
|
||||
async def enqueue(self, job: Job) -> Job:
|
||||
self.jobs[job.id] = job
|
||||
return job
|
||||
|
||||
async def get(self, job_id: UUID) -> Job | None:
|
||||
return self.jobs.get(job_id)
|
||||
|
||||
async def get_owned(self, job_id: UUID, *, workspace_id: UUID | None) -> Job | None:
|
||||
job = self.jobs.get(job_id)
|
||||
if job is None:
|
||||
return None
|
||||
if workspace_id is not None and job.workspace_id != workspace_id:
|
||||
return None
|
||||
return job
|
||||
|
||||
|
||||
def _job(*, workspace_id: UUID | None = None, **overrides: object) -> Job:
|
||||
payload: dict[str, object] = {"blob_digest": "a" * 64}
|
||||
if workspace_id is not None:
|
||||
payload["workspace_id"] = str(workspace_id)
|
||||
defaults: dict[str, object] = dict(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
job_key=f"job-{uuid4()}",
|
||||
operation="ingest_upload",
|
||||
target_key="blob:abc",
|
||||
profile_version="upload-v1",
|
||||
payload=payload,
|
||||
available_at=datetime.now(UTC),
|
||||
trace_id="trace",
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return Job(**defaults) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _fixture():
|
||||
workspace_id = uuid4()
|
||||
principal = Principal(
|
||||
uuid4(), workspace_id, PrincipalType.SERVICE_ACCOUNT, frozenset({Role.EDITOR})
|
||||
)
|
||||
repository = FakeJobRepository()
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_job_service] = lambda: JobService(repository) # type: ignore[arg-type]
|
||||
app.dependency_overrides[get_jobs_sync_principal] = lambda: principal
|
||||
return app, repository, principal
|
||||
|
||||
|
||||
def test_openapi_and_runtime_expose_same_job_operations() -> None:
|
||||
app, _, _ = _fixture()
|
||||
document = yaml.safe_load(
|
||||
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
|
||||
)
|
||||
assert "/v1/jobs/{job_id}" in document["paths"]
|
||||
documented = {method.lower() for method in document["paths"]["/v1/jobs/{job_id}"]}
|
||||
runtime = {method.lower() for method in app.openapi()["paths"]["/v1/jobs/{job_id}"]}
|
||||
assert runtime == documented
|
||||
|
||||
|
||||
def test_get_job_resolves_the_upload_status_url() -> None:
|
||||
app, repository, principal = _fixture()
|
||||
job = _job(workspace_id=principal.workspace_id)
|
||||
asyncio.run(repository.enqueue(job))
|
||||
|
||||
response = TestClient(app).get(f"/v1/jobs/{job.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["id"] == str(job.id)
|
||||
assert body["operation"] == "ingest_upload"
|
||||
assert body["status"] == "ready"
|
||||
assert body["attempt_count"] == 0
|
||||
assert body["max_attempts"] == 3
|
||||
|
||||
|
||||
def test_get_job_unknown_id_is_404_problem_details() -> None:
|
||||
app, _, _ = _fixture()
|
||||
response = TestClient(app).get(f"/v1/jobs/{uuid4()}")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["code"] == "JOB_NOT_FOUND"
|
||||
|
||||
|
||||
def test_get_job_cross_workspace_is_404_not_leaked() -> None:
|
||||
app, repository, _ = _fixture()
|
||||
job = _job(workspace_id=uuid4())
|
||||
asyncio.run(repository.enqueue(job))
|
||||
|
||||
response = TestClient(app).get(f"/v1/jobs/{job.id}")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["code"] == "JOB_NOT_FOUND"
|
||||
|
||||
|
||||
def test_get_job_without_ownership_is_404_for_a_workspace_scoped_caller() -> None:
|
||||
"""A job with no owner is nobody's, not everyone's.
|
||||
|
||||
This route used to accept any job whose payload simply lacked a
|
||||
``workspace_id`` -- a fail-open default that handed unowned work to
|
||||
whichever tenant asked for it first. Ownership is a column now, and an
|
||||
absent owner (a legacy row that migration 0018 would not guess at) reads
|
||||
exactly like a job that does not exist.
|
||||
"""
|
||||
|
||||
app, repository, _ = _fixture()
|
||||
job = _job(workspace_id=None)
|
||||
asyncio.run(repository.enqueue(job))
|
||||
|
||||
response = TestClient(app).get(f"/v1/jobs/{job.id}")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["code"] == "JOB_NOT_FOUND"
|
||||
|
||||
|
||||
def test_missing_authentication_is_problem_details() -> None:
|
||||
app, _, _ = _fixture()
|
||||
app.dependency_overrides.pop(get_jobs_sync_principal)
|
||||
response = TestClient(app).get(f"/v1/jobs/{uuid4()}")
|
||||
assert response.status_code == 401
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
assert response.json()["code"] == "AUTHENTICATION_REQUIRED"
|
||||
@@ -0,0 +1,278 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.search.dependencies import get_search_application, get_search_principal
|
||||
from ragcore.api.v1.search.service import (
|
||||
AuthorizationInputs,
|
||||
SearchExecutionRequest,
|
||||
SearchExecutionResult,
|
||||
SearchHit,
|
||||
SearchService,
|
||||
)
|
||||
from ragcore.application.retrieval.authorization import RetrievalAuthorizationService
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.citations import Citation
|
||||
from ragcore.domain.control import Application, Role, Workspace
|
||||
from ragcore.main import create_app
|
||||
from ragcore.retrieval.ranking import FusedCandidate
|
||||
from ragcore.retrieval.search.models import (
|
||||
Candidate,
|
||||
CandidateBackendKind,
|
||||
CandidateProvenance,
|
||||
)
|
||||
|
||||
|
||||
class FixtureExecutor:
|
||||
def __init__(self, hits: tuple[SearchHit, ...] = (), **metadata: object) -> None:
|
||||
self.hits = hits
|
||||
self.metadata = metadata
|
||||
self.calls: list[SearchExecutionRequest] = []
|
||||
|
||||
async def execute(self, request: SearchExecutionRequest) -> SearchExecutionResult:
|
||||
self.calls.append(request)
|
||||
hits = tuple(
|
||||
replace(
|
||||
hit,
|
||||
citation=Citation.create(
|
||||
workspace_id=hit.citation.workspace_id,
|
||||
space_id=hit.citation.space_id,
|
||||
document_id=hit.citation.document_id,
|
||||
document_version_id=hit.citation.document_version_id,
|
||||
chunk_id=hit.citation.chunk_id,
|
||||
parent_chunk_id=hit.citation.parent_chunk_id,
|
||||
location=hit.citation.location,
|
||||
excerpt=hit.citation.excerpt,
|
||||
source_uri=hit.citation.source_uri,
|
||||
acquired_at=hit.citation.acquired_at,
|
||||
retrieval_run_id=request.retrieval_run_id,
|
||||
index_alias=hit.citation.index_alias,
|
||||
profile_version=hit.citation.profile_version,
|
||||
),
|
||||
)
|
||||
for hit in self.hits
|
||||
)
|
||||
return SearchExecutionResult(hits=hits, **self.metadata)
|
||||
|
||||
|
||||
class FixtureInputs:
|
||||
def __init__(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
principal: Principal,
|
||||
space_id: UUID,
|
||||
*,
|
||||
application: Application | None = None,
|
||||
) -> None:
|
||||
self.workspace = workspace
|
||||
self.principal = principal
|
||||
self.space_id = space_id
|
||||
self.application = application
|
||||
|
||||
async def load(self, principal: Principal) -> AuthorizationInputs:
|
||||
return AuthorizationInputs(
|
||||
spaces=(self.workspace.create_knowledge_space(
|
||||
id=self.space_id,
|
||||
name="Search space",
|
||||
slug="search-space",
|
||||
),),
|
||||
user_grants=(
|
||||
self.workspace.grant_user(
|
||||
id=uuid4(),
|
||||
user_id=principal.id,
|
||||
knowledge_space=self.workspace.create_knowledge_space(
|
||||
id=self.space_id,
|
||||
name="Search space",
|
||||
slug="search-space",
|
||||
),
|
||||
role=Role.VIEWER,
|
||||
),
|
||||
),
|
||||
application=self.application,
|
||||
)
|
||||
|
||||
|
||||
def _fixture(
|
||||
*,
|
||||
text: str = "Exact evidence excerpt.",
|
||||
query: str = "exact",
|
||||
metadata: dict[str, object] | None = None,
|
||||
) -> tuple[object, Principal, UUID, SearchHit, FixtureExecutor]:
|
||||
workspace = Workspace(uuid4(), "Search workspace", "search-workspace")
|
||||
space_id = uuid4()
|
||||
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.VIEWER}))
|
||||
document_id, version_id, chunk_id = uuid4(), uuid4(), uuid4()
|
||||
citation = Citation.create(
|
||||
workspace_id=workspace.id,
|
||||
space_id=space_id,
|
||||
document_id=document_id,
|
||||
document_version_id=version_id,
|
||||
chunk_id=chunk_id,
|
||||
parent_chunk_id=None,
|
||||
location={"page_start": 2, "page_end": 2, "section": "Runbook"},
|
||||
excerpt=text,
|
||||
source_uri="https://example.test/runbook.md",
|
||||
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
|
||||
retrieval_run_id=uuid4(),
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
candidate = Candidate(
|
||||
point_id=uuid4(),
|
||||
score=0.91,
|
||||
rank=1,
|
||||
backend=CandidateBackendKind.DENSE,
|
||||
provenance=CandidateProvenance(
|
||||
workspace_id=workspace.id,
|
||||
space_id=space_id,
|
||||
document_id=document_id,
|
||||
document_version_id=version_id,
|
||||
chunk_id=chunk_id,
|
||||
source_id=uuid4(),
|
||||
parent_chunk_id=None,
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="nl",
|
||||
trust_level="trusted_internal",
|
||||
),
|
||||
)
|
||||
hit = SearchHit(
|
||||
fused=FusedCandidate(candidate, 1 / 61, 1, None),
|
||||
text=text,
|
||||
title="Runbook",
|
||||
citation=citation,
|
||||
dense_score=0.91,
|
||||
)
|
||||
app = Application(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace.id,
|
||||
name="Search application",
|
||||
default_knowledge_space_ids=frozenset({space_id}),
|
||||
)
|
||||
executor = FixtureExecutor((hit,), **(metadata or {}))
|
||||
service = SearchService(
|
||||
authorization=RetrievalAuthorizationService(),
|
||||
inputs=FixtureInputs(workspace, principal, space_id, application=app),
|
||||
executor=executor,
|
||||
)
|
||||
api = create_app()
|
||||
api.dependency_overrides[get_search_principal] = lambda: principal
|
||||
api.dependency_overrides[get_search_application] = lambda: service
|
||||
return api, principal, space_id, hit, executor
|
||||
|
||||
|
||||
def test_openapi_and_runtime_expose_search_operation() -> None:
|
||||
api, _, _, _, _ = _fixture()
|
||||
document = yaml.safe_load(
|
||||
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
|
||||
)
|
||||
assert set(api.openapi()["paths"]["/v1/search"]) == {"post"}
|
||||
assert "post" in document["paths"]["/v1/search"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", ["exact", "semantic"])
|
||||
def test_exact_and_semantic_fixtures_return_citations(query: str) -> None:
|
||||
api, _, space_id, _, executor = _fixture(query=query)
|
||||
response = TestClient(api).post(
|
||||
"/v1/search",
|
||||
json={"query": query, "requested_space_ids": [str(space_id)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["effective_space_ids"] == [str(space_id)]
|
||||
assert body["results"][0]["citation"]["excerpt_sha256"]
|
||||
assert executor.calls[0].scope.effective_space_ids == frozenset({space_id})
|
||||
|
||||
|
||||
def test_requested_spaces_cannot_expand_effective_scope() -> None:
|
||||
api, _, _, _, executor = _fixture()
|
||||
forbidden_space = uuid4()
|
||||
response = TestClient(api).post(
|
||||
"/v1/search",
|
||||
json={"query": "exact", "requested_space_ids": [str(forbidden_space)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["effective_space_ids"] == []
|
||||
assert response.json()["results"] == []
|
||||
assert executor.calls == []
|
||||
|
||||
|
||||
def test_degraded_metadata_is_stable() -> None:
|
||||
api, _, space_id, _, _ = _fixture(
|
||||
metadata={
|
||||
"degraded": True,
|
||||
"degradation_codes": ("backend_timeout",),
|
||||
"timings_ms": {"dense": 12.5, "sparse": 5.0},
|
||||
}
|
||||
)
|
||||
response = TestClient(api).post(
|
||||
"/v1/search",
|
||||
json={"query": "exact", "requested_space_ids": [str(space_id)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["degraded"] is True
|
||||
assert response.json()["degradation_codes"] == ["backend_timeout"]
|
||||
assert response.json()["timings_ms"] == {"dense": 12.5, "sparse": 5.0}
|
||||
|
||||
|
||||
def test_invalid_filter_duplicates_return_problem_details() -> None:
|
||||
api, _, _, _, _ = _fixture()
|
||||
response = TestClient(api).post(
|
||||
"/v1/search",
|
||||
json={"query": "exact", "filters": {"languages": ["nl", "nl"]}},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["code"] == "VALIDATION_ERROR"
|
||||
|
||||
|
||||
def test_missing_search_service_is_503_problem_details() -> None:
|
||||
api, principal, _, _, _ = _fixture()
|
||||
# create_app() always wires a real search_application now; popping the
|
||||
# dependency override alone would just fall through to that real service
|
||||
# instead of exercising get_search_application's own missing-state
|
||||
# fallback, so the state attribute itself has to be genuinely absent.
|
||||
api.dependency_overrides.pop(get_search_application)
|
||||
del api.state.search_application
|
||||
response = TestClient(api).post("/v1/search", json={"query": "exact"})
|
||||
assert response.status_code == 503
|
||||
assert response.json()["code"] == "SEARCH_UNAVAILABLE"
|
||||
assert principal.workspace_id
|
||||
def test_debug_output_requires_admin_capability() -> None:
|
||||
api, _, space_id, _, _ = _fixture()
|
||||
response = TestClient(api).post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "exact",
|
||||
"requested_space_ids": [str(space_id)],
|
||||
"include_debug": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["code"] == "DEBUG_NOT_ALLOWED"
|
||||
|
||||
|
||||
def test_mismatched_profile_provenance_is_rejected() -> None:
|
||||
api, _, space_id, _, _ = _fixture()
|
||||
response = TestClient(api).post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "exact",
|
||||
"profile": "other-v1",
|
||||
"requested_space_ids": [str(space_id)],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["code"] == "UNAUTHORIZED_OR_MISMATCHED_RESULT"
|
||||
Reference in new issue
Block a user