Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e
This commit is contained in:
commit
7ca726caf2
659 files changed
+98171
No files matched your search
Whitespace-only changes.
@@ -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"
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Fixtures for the MCP contract tests.
|
||||
|
||||
Mirrors the same fixture-construction pattern as `tests/contract/api/**`
|
||||
and `tests/contract/sdk/python/**` (real `RetrievalAuthorizationService`,
|
||||
a fixture executor/generator standing in only for the retrieval
|
||||
backend/model provider) -- these tests exist to prove the MCP adapter's
|
||||
own auth/mapping/rate-limit layer, not to re-prove authorization/
|
||||
retrieval business logic already covered there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx2
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
from ragcore.api.v1.answers.service import AnswerService
|
||||
from ragcore.api.v1.context.service import (
|
||||
ContextExecutionRequest,
|
||||
ContextExecutionResult,
|
||||
ContextService,
|
||||
)
|
||||
from ragcore.api.v1.search.service import (
|
||||
AuthorizationInputs,
|
||||
SearchHit,
|
||||
SearchService,
|
||||
)
|
||||
from ragcore.application.identity.credentials import ServiceAccountCredentialService
|
||||
from ragcore.application.retrieval.authorization import RetrievalAuthorizationService
|
||||
from ragcore.domain.authorization import Principal
|
||||
from ragcore.domain.citations import Citation
|
||||
from ragcore.domain.control import Application, Role, Scope, ServiceAccount, Workspace
|
||||
from ragcore.infrastructure.database.identity import InMemoryCredentialRepository
|
||||
from ragcore.mcp.server import MCPDependencies, build_mcp_server
|
||||
from ragcore.retrieval.context import ContextCandidate
|
||||
from ragcore.retrieval.ranking import FusedCandidate
|
||||
from ragcore.retrieval.search.models import Candidate, CandidateBackendKind, CandidateProvenance
|
||||
|
||||
EVIDENCE_EXCERPT = "The refund window is thirty days from the original purchase date."
|
||||
CREDENTIAL_PEPPER = "test-credential-pepper-that-is-long-enough"
|
||||
|
||||
|
||||
class InMemoryDocumentRepository:
|
||||
def __init__(self) -> None:
|
||||
self._documents: dict[UUID, Any] = {}
|
||||
self._versions: dict[UUID, Any] = {}
|
||||
|
||||
def add_document(self, document: Any) -> None:
|
||||
self._documents[document.id] = document
|
||||
|
||||
def add_version(self, version: Any) -> None:
|
||||
self._versions[version.id] = version
|
||||
|
||||
async def get_document(self, document_id: UUID) -> Any:
|
||||
return self._documents.get(document_id)
|
||||
|
||||
async def get_version(self, version_id: UUID) -> Any:
|
||||
return self._versions.get(version_id)
|
||||
|
||||
async def list_searchable_versions(self, document_id: UUID) -> tuple[Any, ...]:
|
||||
from ragcore.application.documents.models import DocumentVersionState
|
||||
|
||||
return tuple(
|
||||
v
|
||||
for v in self._versions.values()
|
||||
if v.document_id == document_id and v.state is DocumentVersionState.PUBLISHED
|
||||
)
|
||||
|
||||
|
||||
class InMemoryCitationRepository:
|
||||
def __init__(self) -> None:
|
||||
self._citations: dict[UUID, Citation] = {}
|
||||
|
||||
def add(self, citation: Citation) -> None:
|
||||
self._citations[citation.id] = citation
|
||||
|
||||
async def get_authorized(
|
||||
self, citation_id: UUID, *, workspace_id: UUID, effective_space_ids: frozenset[UUID]
|
||||
) -> Citation | None:
|
||||
citation = self._citations.get(citation_id)
|
||||
if citation is None or citation.workspace_id != workspace_id:
|
||||
return None
|
||||
if citation.space_id not in effective_space_ids:
|
||||
return None
|
||||
return citation
|
||||
|
||||
|
||||
class _FixtureInputs:
|
||||
"""Loads the one space plus a real workspace-level `Grant` tying it to
|
||||
`application` -- `EffectiveSpaceResolver` requires an actual `Grant`
|
||||
row (not just `Application.default_knowledge_space_ids`, which is only
|
||||
consulted when the caller's own `requested_space_ids` is empty) to
|
||||
intersect an *explicit* request against for a SERVICE_ACCOUNT
|
||||
principal, exactly like a real workspace-level grant REST would use.
|
||||
"""
|
||||
|
||||
def __init__(self, workspace: Workspace, space: Any, application: Application) -> None:
|
||||
self.workspace = workspace
|
||||
self.space = space
|
||||
self.application = application
|
||||
self.grant = workspace.grant(
|
||||
id=uuid4(), application=application, knowledge_space=space, role=Role.EDITOR
|
||||
)
|
||||
|
||||
async def load(self, principal: Principal) -> Any:
|
||||
return type(
|
||||
"Inputs",
|
||||
(),
|
||||
{
|
||||
"spaces": (self.space,),
|
||||
"grants": (self.grant,),
|
||||
"user_grants": (),
|
||||
"application": self.application,
|
||||
},
|
||||
)()
|
||||
|
||||
|
||||
@dataclass
|
||||
class McpFixture:
|
||||
server: MCPServer
|
||||
deps: MCPDependencies
|
||||
workspace: Workspace
|
||||
application: Application
|
||||
space_id: UUID
|
||||
token: str
|
||||
document_repository: InMemoryDocumentRepository
|
||||
citation_repository: InMemoryCitationRepository
|
||||
|
||||
|
||||
def _citation(workspace_id: UUID, space_id: UUID) -> Citation:
|
||||
return 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": 4, "section": "Refunds"},
|
||||
excerpt=EVIDENCE_EXCERPT,
|
||||
source_uri="https://example.test/policy.md",
|
||||
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
|
||||
retrieval_run_id=uuid4(),
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
|
||||
|
||||
async def build_mcp_fixture(*, grant_scopes: set[Scope] | None = None) -> McpFixture:
|
||||
workspace = Workspace(uuid4(), "MCP workspace", "mcp-workspace")
|
||||
space_id = uuid4()
|
||||
space = workspace.create_knowledge_space(id=space_id, name="MCP space", slug="mcp-space")
|
||||
application = workspace.create_application(
|
||||
id=uuid4(),
|
||||
name="MCP client",
|
||||
slug="mcp-client",
|
||||
scopes=frozenset(
|
||||
grant_scopes
|
||||
or {
|
||||
Scope.SEARCH,
|
||||
Scope.CONTEXT,
|
||||
Scope.ANSWER,
|
||||
Scope.DOCUMENTS_READ,
|
||||
Scope.CITATIONS_READ,
|
||||
}
|
||||
),
|
||||
default_knowledge_space_ids=frozenset({space_id}),
|
||||
)
|
||||
service_account = ServiceAccount(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace.id,
|
||||
application_id=application.id,
|
||||
name="MCP service account",
|
||||
scopes=application.scopes,
|
||||
)
|
||||
|
||||
async def load_service_account(account_id: UUID) -> ServiceAccount | None:
|
||||
return service_account if account_id == service_account.id else None
|
||||
|
||||
credential_service = ServiceAccountCredentialService(
|
||||
InMemoryCredentialRepository(),
|
||||
CREDENTIAL_PEPPER,
|
||||
service_account_loader=load_service_account,
|
||||
)
|
||||
issued = await credential_service.issue(service_account, scopes=service_account.scopes)
|
||||
|
||||
citation = _citation(workspace.id, space_id)
|
||||
candidate = Candidate(
|
||||
point_id=uuid4(),
|
||||
score=0.87,
|
||||
rank=1,
|
||||
backend=CandidateBackendKind.DENSE,
|
||||
provenance=CandidateProvenance(
|
||||
workspace_id=workspace.id,
|
||||
space_id=space_id,
|
||||
document_id=citation.document_id,
|
||||
document_version_id=citation.document_version_id,
|
||||
chunk_id=citation.chunk_id,
|
||||
source_id=uuid4(),
|
||||
parent_chunk_id=None,
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="trusted_internal",
|
||||
),
|
||||
)
|
||||
hit = SearchHit(
|
||||
fused=FusedCandidate(candidate, 1 / 61, 1, None),
|
||||
text=EVIDENCE_EXCERPT,
|
||||
title="Refund policy",
|
||||
citation=citation,
|
||||
dense_score=0.87,
|
||||
)
|
||||
|
||||
class _SearchExecutor:
|
||||
async def execute(self, request: Any) -> Any:
|
||||
refreshed = 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,
|
||||
),
|
||||
)
|
||||
return type(
|
||||
"Result",
|
||||
(),
|
||||
{
|
||||
"hits": (refreshed,),
|
||||
"degraded": False,
|
||||
"degradation_codes": (),
|
||||
"timings_ms": {},
|
||||
},
|
||||
)()
|
||||
|
||||
inputs = _FixtureInputs(workspace, space, application)
|
||||
search_service = SearchService(
|
||||
authorization=RetrievalAuthorizationService(),
|
||||
inputs=inputs,
|
||||
executor=_SearchExecutor(),
|
||||
)
|
||||
|
||||
context_candidate = ContextCandidate(citation, EVIDENCE_EXCERPT, 1, 1.0)
|
||||
|
||||
class _ContextExecutor:
|
||||
async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult:
|
||||
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(context_candidate, citation=refreshed),)
|
||||
)
|
||||
|
||||
context_service = ContextService(
|
||||
authorization=RetrievalAuthorizationService(), inputs=inputs, executor=_ContextExecutor()
|
||||
)
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
citation_id_pattern = re.compile(r"citation_ids=([0-9a-fA-F-]{36})")
|
||||
|
||||
class _Generator:
|
||||
async def generate(self, *, prompt: str, generation_profile: str, spaces: Any) -> str:
|
||||
match = citation_id_pattern.search(prompt)
|
||||
assert match is not None
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "answerable",
|
||||
"answer": "The refund window is thirty days from purchase.",
|
||||
"citations": [match.group(1)],
|
||||
}
|
||||
)
|
||||
|
||||
answer_service = AnswerService(
|
||||
authorization=RetrievalAuthorizationService(),
|
||||
inputs=inputs,
|
||||
context_executor=_ContextExecutor(),
|
||||
generator=_Generator(),
|
||||
)
|
||||
|
||||
document_repository = InMemoryDocumentRepository()
|
||||
citation_repository = InMemoryCitationRepository()
|
||||
citation_repository.add(citation)
|
||||
|
||||
class _AuthorizationInputsForMcp:
|
||||
async def load(self, principal: Principal) -> AuthorizationInputs:
|
||||
loaded = await inputs.load(principal)
|
||||
return AuthorizationInputs(
|
||||
spaces=loaded.spaces,
|
||||
grants=loaded.grants,
|
||||
user_grants=loaded.user_grants,
|
||||
application=loaded.application,
|
||||
)
|
||||
|
||||
deps = MCPDependencies(
|
||||
search_application=search_service,
|
||||
context_application=context_service,
|
||||
answer_application=answer_service,
|
||||
authorization_inputs=_AuthorizationInputsForMcp(),
|
||||
credential_service=credential_service,
|
||||
citation_repository=citation_repository,
|
||||
document_repository=document_repository,
|
||||
)
|
||||
server = build_mcp_server(deps)
|
||||
|
||||
return McpFixture(
|
||||
server=server,
|
||||
deps=deps,
|
||||
workspace=workspace,
|
||||
application=application,
|
||||
space_id=space_id,
|
||||
token=issued.token,
|
||||
document_repository=document_repository,
|
||||
citation_repository=citation_repository,
|
||||
)
|
||||
|
||||
|
||||
_MCP_TEST_URL = "http://127.0.0.1:8765"
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def mcp_session(
|
||||
server: MCPServer, *, token: str | None = None
|
||||
) -> AsyncIterator[ClientSession]:
|
||||
"""A real streamable-HTTP MCP session over `httpx2.ASGITransport` -- genuine
|
||||
Starlette `Request`/header handling, no real TCP socket needed.
|
||||
|
||||
`ASGITransport` never runs the ASGI app's own lifespan protocol (that's
|
||||
normally what starts the session manager's task group when a real
|
||||
server like uvicorn owns the app), so `session_manager.run()` is
|
||||
entered explicitly here instead, mirroring what a real deployment's
|
||||
lifespan would do.
|
||||
"""
|
||||
|
||||
app = server.streamable_http_app(stateless_http=True)
|
||||
headers = {"authorization": f"Bearer {token}"} if token else {}
|
||||
async with (
|
||||
server.session_manager.run(),
|
||||
httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url=_MCP_TEST_URL, headers=headers
|
||||
) as http_client,
|
||||
streamable_http_client(f"{_MCP_TEST_URL}/mcp", http_client=http_client) as (read, write),
|
||||
ClientSession(read, write) as session,
|
||||
):
|
||||
await session.initialize()
|
||||
yield session
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from mcp_fixtures import build_mcp_fixture, mcp_session
|
||||
|
||||
from ragcore.application.audit.service import AuditEventService
|
||||
from ragcore.application.limits.models import RateLimitRule
|
||||
from ragcore.application.limits.rate_limiter import RateLimiter, RateLimitRegistry
|
||||
from ragcore.domain.control import Scope
|
||||
from ragcore.mcp.server import build_mcp_server
|
||||
|
||||
EXPECTED_TOOLS = {
|
||||
"rag_search",
|
||||
"rag_get_context",
|
||||
"rag_answer",
|
||||
"rag_list_spaces",
|
||||
"rag_get_document",
|
||||
"rag_get_citation",
|
||||
}
|
||||
|
||||
|
||||
async def test_tool_surface_is_exactly_the_agreed_read_only_tools() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
listed = await session.list_tools()
|
||||
|
||||
names = {tool.name for tool in listed.tools}
|
||||
assert names == EXPECTED_TOOLS
|
||||
for tool in listed.tools:
|
||||
assert tool.annotations is not None
|
||||
assert tool.annotations.read_only_hint is True
|
||||
assert tool.annotations.destructive_hint is False
|
||||
|
||||
|
||||
async def test_rag_search_returns_typed_results() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool(
|
||||
"rag_search", {"query": "refund", "spaces": [str(fixture.space_id)]}
|
||||
)
|
||||
|
||||
assert result.is_error is not True
|
||||
body = result.structured_content
|
||||
assert body["effective_space_ids"] == [str(fixture.space_id)]
|
||||
assert body["results"][0]["citation"]["excerpt_sha256"]
|
||||
|
||||
|
||||
async def test_rag_get_context_returns_typed_blocks() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool(
|
||||
"rag_get_context", {"query": "refund", "spaces": [str(fixture.space_id)]}
|
||||
)
|
||||
|
||||
assert result.is_error is not True
|
||||
body = result.structured_content
|
||||
assert body["citations"]
|
||||
assert body["blocks"][0]["citation_ids"]
|
||||
|
||||
|
||||
async def test_rag_answer_returns_grounded_answer() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool(
|
||||
"rag_answer", {"query": "refund window", "spaces": [str(fixture.space_id)]}
|
||||
)
|
||||
|
||||
assert result.is_error is not True
|
||||
body = result.structured_content
|
||||
assert body["answer"]
|
||||
assert body["citations"]
|
||||
assert all(claim["citation_ids"] for claim in body["claims"])
|
||||
|
||||
|
||||
async def test_rag_list_spaces_lists_only_granted_spaces() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool("rag_list_spaces", {})
|
||||
|
||||
assert result.is_error is not True
|
||||
spaces = result.structured_content["result"]
|
||||
assert [s["id"] for s in spaces] == [str(fixture.space_id)]
|
||||
|
||||
|
||||
async def test_rag_get_document_returns_the_published_version() -> None:
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from ragcore.application.documents.models import (
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
|
||||
fixture = await build_mcp_fixture()
|
||||
document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=fixture.workspace.id,
|
||||
space_id=fixture.space_id,
|
||||
source_key="policy.md",
|
||||
external_id="policy",
|
||||
title="Refund policy",
|
||||
)
|
||||
version = DocumentVersionRecord(
|
||||
id=uuid4(),
|
||||
document_id=document.id,
|
||||
source_fingerprint="fp",
|
||||
content_hash="hash",
|
||||
parser_version="v1",
|
||||
chunk_profile_version="v1",
|
||||
state=DocumentVersionState.PUBLISHED,
|
||||
created_at=datetime(2026, 8, 1, tzinfo=UTC),
|
||||
updated_at=datetime(2026, 8, 1, tzinfo=UTC),
|
||||
)
|
||||
fixture.document_repository.add_document(document)
|
||||
fixture.document_repository.add_version(version)
|
||||
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool("rag_get_document", {"document_id": str(document.id)})
|
||||
|
||||
assert result.is_error is not True
|
||||
body = result.structured_content
|
||||
assert body["id"] == str(document.id)
|
||||
assert body["version"]["id"] == str(version.id)
|
||||
|
||||
|
||||
async def test_rag_get_document_hides_a_document_outside_granted_spaces() -> None:
|
||||
from uuid import uuid4
|
||||
|
||||
from ragcore.application.documents.models import DocumentRecord
|
||||
|
||||
fixture = await build_mcp_fixture()
|
||||
other_space_document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=fixture.workspace.id,
|
||||
space_id=uuid4(),
|
||||
source_key="secret.md",
|
||||
external_id="secret",
|
||||
title="Not granted",
|
||||
)
|
||||
fixture.document_repository.add_document(other_space_document)
|
||||
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool(
|
||||
"rag_get_document", {"document_id": str(other_space_document.id)}
|
||||
)
|
||||
|
||||
assert result.is_error is True
|
||||
|
||||
|
||||
async def test_rag_get_citation_returns_a_granted_citation() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
(citation,) = fixture.citation_repository._citations.values()
|
||||
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool("rag_get_citation", {"citation_id": str(citation.id)})
|
||||
|
||||
assert result.is_error is not True
|
||||
assert result.structured_content["id"] == str(citation.id)
|
||||
|
||||
|
||||
async def test_rag_get_citation_hides_an_out_of_scope_citation() -> None:
|
||||
from uuid import uuid4
|
||||
|
||||
fixture = await build_mcp_fixture()
|
||||
outside = fixture.citation_repository
|
||||
from mcp_fixtures import _citation
|
||||
|
||||
stray = _citation(fixture.workspace.id, uuid4())
|
||||
outside.add(stray)
|
||||
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool("rag_get_citation", {"citation_id": str(stray.id)})
|
||||
|
||||
assert result.is_error is True
|
||||
|
||||
|
||||
async def test_no_token_is_rejected() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
async with mcp_session(fixture.server, token=None) as session:
|
||||
result = await session.call_tool("rag_search", {"query": "refund"})
|
||||
|
||||
assert result.is_error is True
|
||||
|
||||
|
||||
async def test_wrong_scope_token_is_rejected() -> None:
|
||||
fixture = await build_mcp_fixture(grant_scopes={Scope.SEARCH})
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool("rag_answer", {"query": "refund"})
|
||||
|
||||
assert result.is_error is True
|
||||
|
||||
|
||||
async def test_malformed_token_is_rejected() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
async with mcp_session(fixture.server, token="not-a-real-token") as session:
|
||||
result = await session.call_tool("rag_search", {"query": "refund"})
|
||||
|
||||
assert result.is_error is True
|
||||
|
||||
|
||||
async def test_rate_limit_is_enforced_via_the_shared_rate_limiter() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
# A default rule (keyed by operation only, no subject) applies to any
|
||||
# caller -- the same `RateLimitRegistry` primitive M6-02 built for REST,
|
||||
# reused unchanged here. `AuditEventService`'s repository is never
|
||||
# touched: only `set_override` (not used by this test) calls `.record()`.
|
||||
registry = RateLimitRegistry(
|
||||
default_rules={"mcp:rag_search": RateLimitRule(max_requests=1, window_seconds=60.0)},
|
||||
audit=AuditEventService(repository=None), # type: ignore[arg-type]
|
||||
)
|
||||
limiter = RateLimiter(registry=registry)
|
||||
limited_server = build_mcp_server(replace(fixture.deps, rate_limiter=limiter))
|
||||
|
||||
async with mcp_session(limited_server, token=fixture.token) as session:
|
||||
first = await session.call_tool(
|
||||
"rag_search", {"query": "refund", "spaces": [str(fixture.space_id)]}
|
||||
)
|
||||
second = await session.call_tool(
|
||||
"rag_search", {"query": "refund", "spaces": [str(fixture.space_id)]}
|
||||
)
|
||||
|
||||
assert first.is_error is not True
|
||||
assert second.is_error is True
|
||||
@@ -0,0 +1,125 @@
|
||||
"""One real MCP smoke flow: ragcore.mcp.main's own composition root, real
|
||||
Postgres and real Qdrant -- not just the fixture-backed protocol tests in
|
||||
test_mcp.py (which deliberately stay on fake repositories to prove the MCP
|
||||
adapter's own auth/mapping/rate-limit layer, not re-prove infrastructure
|
||||
wiring). This proves the actual production dependency graph
|
||||
(``build_dependencies``/``build_mcp_server``) authenticates a real bearer
|
||||
credential against real Postgres and serves a real tool call end to end.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp_fixtures import mcp_session
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.identity.credentials import ServiceAccountCredentialService
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.control import Scope, ServiceAccount, Workspace
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.control import ControlPlaneRepository
|
||||
from ragcore.infrastructure.database.identity.repository import PostgresCredentialRepository
|
||||
from ragcore.mcp.main import build_dependencies
|
||||
from ragcore.mcp.server import build_mcp_server
|
||||
from ragcore.platform.telemetry import configure_telemetry
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_TEST_PEPPER = "test-credential-pepper-that-is-long-enough-for-real"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy() -> asyncio.AbstractEventLoopPolicy:
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_real_composition_root_authenticates_and_serves_a_real_tool_call(
|
||||
db_engine: AsyncEngine,
|
||||
) -> None:
|
||||
session_factory = create_session_factory(db_engine)
|
||||
suffix = uuid4().hex[:8]
|
||||
|
||||
async with session_factory() as session, session.begin():
|
||||
control = ControlPlaneRepository(session)
|
||||
workspace = await control.create_workspace(
|
||||
Workspace(uuid4(), "MCP Real Root", f"mcp-real-root-{suffix}")
|
||||
)
|
||||
application = await control.create_application(
|
||||
workspace.create_application(
|
||||
id=uuid4(),
|
||||
name="MCP Real Root Client",
|
||||
slug=f"mcp-real-root-client-{suffix}",
|
||||
scopes=frozenset({Scope.SEARCH, Scope.CONTEXT, Scope.ANSWER}),
|
||||
)
|
||||
)
|
||||
service_account = await control.create_service_account(
|
||||
ServiceAccount(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace.id,
|
||||
application_id=application.id,
|
||||
name="MCP Real Root Service Account",
|
||||
scopes=application.scopes,
|
||||
)
|
||||
)
|
||||
credential_service = ServiceAccountCredentialService(
|
||||
PostgresCredentialRepository(session),
|
||||
_TEST_PEPPER,
|
||||
control.get_service_account_by_id,
|
||||
)
|
||||
issued = await credential_service.issue(service_account, scopes=service_account.scopes)
|
||||
|
||||
# Same pepper the credential above was issued with, so
|
||||
# build_dependencies' own session-scoped credential service (which reads
|
||||
# settings.api_token_pepper) can verify it for real.
|
||||
settings = Settings(env="test", api_token_pepper=_TEST_PEPPER)
|
||||
qdrant_client = AsyncQdrantClient(
|
||||
url=settings.qdrant_url, api_key=settings.qdrant_api_key.get_secret_value(), timeout=30
|
||||
)
|
||||
http_client = httpx.AsyncClient(follow_redirects=False)
|
||||
telemetry = configure_telemetry(settings, service_name="test-mcp")
|
||||
|
||||
try:
|
||||
deps = build_dependencies(
|
||||
settings,
|
||||
engine=db_engine,
|
||||
qdrant_client=qdrant_client,
|
||||
http_client=http_client,
|
||||
telemetry=telemetry,
|
||||
)
|
||||
server = build_mcp_server(deps)
|
||||
|
||||
async with mcp_session(server, token=issued.token) as mcp_client_session:
|
||||
listed = await mcp_client_session.list_tools()
|
||||
result = await mcp_client_session.call_tool("rag_list_spaces", {})
|
||||
finally:
|
||||
await http_client.aclose()
|
||||
await qdrant_client.close()
|
||||
telemetry.shutdown()
|
||||
|
||||
assert {tool.name for tool in listed.tools} == {
|
||||
"rag_search",
|
||||
"rag_get_context",
|
||||
"rag_answer",
|
||||
"rag_list_spaces",
|
||||
"rag_get_document",
|
||||
"rag_get_citation",
|
||||
}
|
||||
assert result.is_error is not True
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Fixture FastAPI apps for the SDK contract tests.
|
||||
|
||||
Mirrors the same fixture-construction pattern used by the existing
|
||||
`tests/contract/api/{search,context,answers}` contract tests (real
|
||||
`RetrievalAuthorizationService`, a fixture executor/generator standing in
|
||||
only for the retrieval backend/model provider) -- these tests exist to
|
||||
prove the SDK's HTTP layer (request shape, response typing, SSE parsing,
|
||||
Problem Details mapping, retries), not to re-prove authorization/retrieval
|
||||
business logic that is already covered there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from ragcore.api.v1.answers.dependencies import get_answer_application, get_answer_principal
|
||||
from ragcore.api.v1.answers.service import AnswerService
|
||||
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.api.v1.search.dependencies import get_search_application, get_search_principal
|
||||
from ragcore.api.v1.search.service import SearchHit, SearchQuery, 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.context import ContextCandidate
|
||||
from ragcore.retrieval.ranking import FusedCandidate
|
||||
from ragcore.retrieval.search.models import Candidate, CandidateBackendKind, CandidateProvenance
|
||||
|
||||
EVIDENCE_EXCERPT = "The refund window is thirty days from the original purchase date."
|
||||
|
||||
|
||||
class _FixtureInputs:
|
||||
def __init__(self, workspace: Workspace, space_id: UUID, space_name: str) -> None:
|
||||
self.workspace = workspace
|
||||
self.space_id = space_id
|
||||
self.space_name = space_name
|
||||
|
||||
async def load(self, principal: Principal) -> Any:
|
||||
space = self.workspace.create_knowledge_space(
|
||||
id=self.space_id, name=self.space_name, slug=self.space_name.lower().replace(" ", "-")
|
||||
)
|
||||
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=f"{self.space_name} application",
|
||||
default_knowledge_space_ids=frozenset({self.space_id}),
|
||||
),
|
||||
},
|
||||
)()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchFixture:
|
||||
app: FastAPI
|
||||
principal: Principal
|
||||
space_id: UUID
|
||||
calls: list[SearchQuery]
|
||||
|
||||
|
||||
def build_search_app() -> SearchFixture:
|
||||
workspace = Workspace(uuid4(), "SDK search workspace", "sdk-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": 4, "page_end": 4, "section": "Refunds"},
|
||||
excerpt=EVIDENCE_EXCERPT,
|
||||
source_uri="https://example.test/policy.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.87,
|
||||
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="en",
|
||||
trust_level="trusted_internal",
|
||||
),
|
||||
)
|
||||
hit = SearchHit(
|
||||
fused=FusedCandidate(candidate, 1 / 61, 1, None),
|
||||
text=EVIDENCE_EXCERPT,
|
||||
title="Refund policy",
|
||||
citation=citation,
|
||||
dense_score=0.87,
|
||||
)
|
||||
calls: list[SearchQuery] = []
|
||||
|
||||
class _Executor:
|
||||
async def execute(self, request: Any) -> Any:
|
||||
refreshed = 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,
|
||||
),
|
||||
)
|
||||
return type(
|
||||
"Result",
|
||||
(),
|
||||
{
|
||||
"hits": (refreshed,),
|
||||
"degraded": False,
|
||||
"degradation_codes": (),
|
||||
"timings_ms": {},
|
||||
},
|
||||
)()
|
||||
|
||||
application = Application(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace.id,
|
||||
name="SDK search application",
|
||||
default_knowledge_space_ids=frozenset({space_id}),
|
||||
)
|
||||
|
||||
class _Inputs(_FixtureInputs):
|
||||
async def load(self, principal: Principal) -> Any:
|
||||
base = await super().load(principal)
|
||||
return type(
|
||||
"Inputs",
|
||||
(),
|
||||
{
|
||||
"spaces": base.spaces,
|
||||
"grants": base.grants,
|
||||
"user_grants": base.user_grants,
|
||||
"application": application,
|
||||
},
|
||||
)()
|
||||
|
||||
service = SearchService(
|
||||
authorization=RetrievalAuthorizationService(),
|
||||
inputs=_Inputs(workspace, space_id, "SDK search space"),
|
||||
executor=_Executor(),
|
||||
)
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_search_principal] = lambda: principal
|
||||
app.dependency_overrides[get_search_application] = lambda: service
|
||||
return SearchFixture(app=app, principal=principal, space_id=space_id, calls=calls)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextFixture:
|
||||
app: FastAPI
|
||||
principal: Principal
|
||||
space_id: UUID
|
||||
|
||||
|
||||
def build_context_app() -> ContextFixture:
|
||||
workspace = Workspace(uuid4(), "SDK context workspace", "sdk-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=EVIDENCE_EXCERPT,
|
||||
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, EVIDENCE_EXCERPT, 1, 1.0)
|
||||
|
||||
class _Executor:
|
||||
async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult:
|
||||
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(candidate, citation=refreshed),))
|
||||
|
||||
service = ContextService(
|
||||
authorization=RetrievalAuthorizationService(),
|
||||
inputs=_FixtureInputs(workspace, space_id, "SDK context space"),
|
||||
executor=_Executor(),
|
||||
)
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_context_principal] = lambda: principal
|
||||
app.dependency_overrides[get_context_application] = lambda: service
|
||||
return ContextFixture(app=app, principal=principal, space_id=space_id)
|
||||
|
||||
|
||||
_CITATION_ID = re.compile(r"citation_ids=([0-9a-fA-F-]{36})")
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnswerFixture:
|
||||
app: FastAPI
|
||||
principal: Principal
|
||||
space_id: UUID
|
||||
|
||||
|
||||
def build_answer_app(*, generator_responses: list[str | None] | None = None) -> AnswerFixture:
|
||||
workspace = Workspace(uuid4(), "SDK answers workspace", "sdk-answers-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=EVIDENCE_EXCERPT,
|
||||
source_uri="https://example.test/answers.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, EVIDENCE_EXCERPT, 1, 1.0)
|
||||
|
||||
class _ContextExecutor:
|
||||
async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult:
|
||||
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(candidate, citation=refreshed),))
|
||||
|
||||
responses = list(generator_responses if generator_responses is not None else [None])
|
||||
|
||||
class _Generator:
|
||||
async def generate(self, *, prompt: str, generation_profile: str, spaces: Any) -> str:
|
||||
response = responses.pop(0) if responses else None
|
||||
if response is not None:
|
||||
return response
|
||||
match = _CITATION_ID.search(prompt)
|
||||
assert match is not None
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "answerable",
|
||||
"answer": "The refund window is thirty days from purchase.",
|
||||
"citations": [match.group(1)],
|
||||
}
|
||||
)
|
||||
|
||||
service = AnswerService(
|
||||
authorization=RetrievalAuthorizationService(),
|
||||
inputs=_FixtureInputs(workspace, space_id, "SDK answers space"),
|
||||
context_executor=_ContextExecutor(),
|
||||
generator=_Generator(),
|
||||
)
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_answer_principal] = lambda: principal
|
||||
app.dependency_overrides[get_answer_application] = lambda: service
|
||||
return AnswerFixture(app=app, principal=principal, space_id=space_id)
|
||||
|
||||
|
||||
def _serve_forever(server: uvicorn.Server, sock: socket.socket) -> None:
|
||||
# Deliberately not `server.run()`: that wraps `server.serve()` in
|
||||
# `asyncio.run(..., loop_factory=...)`, and uvicorn's own loop factory
|
||||
# (`uvicorn.loops.asyncio.asyncio_loop_factory`) hardcodes
|
||||
# `asyncio.ProactorEventLoop` on Windows -- which the real app's
|
||||
# lifespan (an async psycopg connection) refuses to run under, no
|
||||
# matter what global event-loop policy is set beforehand. Driving the
|
||||
# loop directly sidesteps uvicorn's own hardcoded choice.
|
||||
loop = asyncio.SelectorEventLoop() if sys.platform == "win32" else asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(server.serve(sockets=[sock]))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def run_app(app: FastAPI) -> Iterator[str]:
|
||||
"""Serve `app` over a real local TCP socket for the duration of the block.
|
||||
|
||||
`RAGCoreClient` (sync) needs this rather than `httpx.ASGITransport`:
|
||||
ASGITransport only implements httpx's *async* transport interface, so a
|
||||
sync `httpx.Client` can't use it at all (`AsyncRAGCoreClient` tests use
|
||||
ASGITransport directly instead, which is the supported combination).
|
||||
"""
|
||||
# Hand uvicorn the already-bound socket itself, rather than closing it
|
||||
# and passing the port number for uvicorn to rebind: closing and
|
||||
# rebinding is a real race (another process, or even this same
|
||||
# process's next bind, can win the port first), which is what
|
||||
# produced Windows `STARTUP_FAILURE`s here in practice.
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(128)
|
||||
port = sock.getsockname()[1]
|
||||
config = uvicorn.Config(app, log_level="warning")
|
||||
server = uvicorn.Server(config)
|
||||
thread = threading.Thread(target=_serve_forever, args=(server, sock), daemon=True)
|
||||
thread.start()
|
||||
deadline = time.monotonic() + 10
|
||||
while not server.started and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
try:
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
finally:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=10)
|
||||
with contextlib.suppress(OSError):
|
||||
sock.close()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AnswerFixture",
|
||||
"ContextFixture",
|
||||
"SearchFixture",
|
||||
"build_answer_app",
|
||||
"build_context_app",
|
||||
"build_search_app",
|
||||
"run_app",
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from ragcore_sdk import AnswerStreamEventType, RAGCoreClient
|
||||
|
||||
from conftest import build_answer_app, run_app
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _client(app: object) -> Iterator[RAGCoreClient]:
|
||||
with run_app(app) as base_url, RAGCoreClient(base_url=base_url, token="sdk-test-token") as client: # type: ignore[arg-type] # noqa: E501
|
||||
yield client
|
||||
|
||||
|
||||
def test_answer_sync_returns_typed_response() -> None:
|
||||
fixture = build_answer_app()
|
||||
with _client(fixture.app) as client:
|
||||
response = client.answer(query="refund window", requested_space_ids=[fixture.space_id])
|
||||
|
||||
assert response.answer
|
||||
assert response.citations
|
||||
assert all(claim.citation_ids for claim in response.claims)
|
||||
|
||||
|
||||
def test_answer_stream_emits_metadata_then_done_in_order() -> None:
|
||||
fixture = build_answer_app()
|
||||
with _client(fixture.app) as client:
|
||||
events = list(
|
||||
client.answer_stream(query="refund window", requested_space_ids=[fixture.space_id])
|
||||
)
|
||||
|
||||
assert [event.type for event in events] == [
|
||||
AnswerStreamEventType.RETRIEVAL_METADATA,
|
||||
AnswerStreamEventType.DONE,
|
||||
]
|
||||
done_answer = events[1].as_done_answer()
|
||||
assert done_answer is not None
|
||||
assert done_answer.citations
|
||||
assert "refund" in done_answer.answer.lower()
|
||||
assert events[0].as_done_answer() is None
|
||||
|
||||
|
||||
def test_answer_stream_reports_terminal_error_event_on_invalid_generation() -> None:
|
||||
fixture = build_answer_app(generator_responses=["not json", "still not json"])
|
||||
with _client(fixture.app) as client:
|
||||
events = list(
|
||||
client.answer_stream(query="refund window", requested_space_ids=[fixture.space_id])
|
||||
)
|
||||
|
||||
assert [event.type for event in events] == [
|
||||
AnswerStreamEventType.RETRIEVAL_METADATA,
|
||||
AnswerStreamEventType.ERROR,
|
||||
]
|
||||
assert events[1].data["code"] == "validation_retries_exhausted"
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from ragcore_sdk import AnswerStreamEventType, AsyncRAGCoreClient
|
||||
|
||||
from conftest import build_answer_app, build_search_app
|
||||
|
||||
|
||||
def _async_client(app: object) -> AsyncRAGCoreClient:
|
||||
return AsyncRAGCoreClient(
|
||||
base_url="http://testserver",
|
||||
token="sdk-test-token",
|
||||
transport=httpx.ASGITransport(app=app), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
async def test_async_search_returns_typed_results() -> None:
|
||||
fixture = build_search_app()
|
||||
async with _async_client(fixture.app) as client:
|
||||
response = await client.search(query="refund", requested_space_ids=[fixture.space_id])
|
||||
|
||||
assert response.results[0].citation.excerpt_sha256
|
||||
|
||||
|
||||
async def test_async_answer_stream_emits_metadata_then_done() -> None:
|
||||
fixture = build_answer_app()
|
||||
async with _async_client(fixture.app) as client:
|
||||
events = [
|
||||
event
|
||||
async for event in client.answer_stream(
|
||||
query="refund window", requested_space_ids=[fixture.space_id]
|
||||
)
|
||||
]
|
||||
|
||||
assert [event.type for event in events] == [
|
||||
AnswerStreamEventType.RETRIEVAL_METADATA,
|
||||
AnswerStreamEventType.DONE,
|
||||
]
|
||||
assert events[1].as_done_answer() is not None
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
from ragcore_sdk import RAGCoreAPIError, RAGCoreClient
|
||||
|
||||
from conftest import build_context_app, run_app
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _client(app: object) -> Iterator[RAGCoreClient]:
|
||||
with run_app(app) as base_url, RAGCoreClient(base_url=base_url, token="sdk-test-token") as client: # type: ignore[arg-type] # noqa: E501
|
||||
yield client
|
||||
|
||||
|
||||
def test_context_returns_typed_blocks_and_citations() -> None:
|
||||
fixture = build_context_app()
|
||||
with _client(fixture.app) as client:
|
||||
response = client.context(query="refund", requested_space_ids=[fixture.space_id])
|
||||
|
||||
assert response.answerability.value in {
|
||||
"answerable",
|
||||
"partially_answerable",
|
||||
"not_answerable",
|
||||
"conflicting_evidence",
|
||||
}
|
||||
assert response.token_count >= 0
|
||||
if response.blocks:
|
||||
assert response.blocks[0].citation_ids
|
||||
assert response.citations
|
||||
|
||||
|
||||
def test_context_token_budget_below_minimum_raises_typed_problem() -> None:
|
||||
fixture = build_context_app()
|
||||
with _client(fixture.app) as client, pytest.raises(RAGCoreAPIError) as excinfo:
|
||||
client.context(
|
||||
query="refund",
|
||||
requested_space_ids=[fixture.space_id],
|
||||
max_context_tokens=1,
|
||||
)
|
||||
|
||||
problem = excinfo.value.problem
|
||||
assert problem.status == 422
|
||||
assert problem.code == "VALIDATION_ERROR"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Retry behavior: only ever on connection failures or a `503`, never on a
|
||||
`4xx`, and never inside a started SSE stream. See `ragcore_sdk.client`'s
|
||||
module docstring for the reasoning.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from ragcore_sdk import RAGCoreAPIError, RAGCoreClient, RAGCoreConnectionError
|
||||
|
||||
from conftest import build_search_app, run_app
|
||||
|
||||
|
||||
class FlakyTransport(httpx.BaseTransport):
|
||||
"""Returns a synthetic 503 for the first `fail_times` requests, then
|
||||
delegates to a real HTTP transport pointed at the real running server.
|
||||
Counts every request it sees.
|
||||
"""
|
||||
|
||||
def __init__(self, *, fail_times: int) -> None:
|
||||
self._inner = httpx.HTTPTransport()
|
||||
self._fail_times = fail_times
|
||||
self.call_count = 0
|
||||
|
||||
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||
self.call_count += 1
|
||||
if self.call_count <= self._fail_times:
|
||||
return httpx.Response(
|
||||
503,
|
||||
json={
|
||||
"type": "https://ragcore.example.test/problems/search_unavailable",
|
||||
"title": "Search unavailable",
|
||||
"status": 503,
|
||||
"code": "SEARCH_UNAVAILABLE",
|
||||
"detail": "synthetic failure for retry test",
|
||||
"trace_id": "test-trace",
|
||||
},
|
||||
)
|
||||
return self._inner.handle_request(request)
|
||||
|
||||
|
||||
class AlwaysDownTransport(httpx.BaseTransport):
|
||||
def __init__(self) -> None:
|
||||
self.call_count = 0
|
||||
|
||||
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||
self.call_count += 1
|
||||
raise httpx.ConnectError("synthetic connection failure", request=request)
|
||||
|
||||
|
||||
def test_retries_on_503_then_succeeds() -> None:
|
||||
fixture = build_search_app()
|
||||
with run_app(fixture.app) as base_url:
|
||||
transport = FlakyTransport(fail_times=2)
|
||||
client = RAGCoreClient(
|
||||
base_url=base_url,
|
||||
token="sdk-test-token",
|
||||
transport=transport,
|
||||
max_retries=3,
|
||||
)
|
||||
try:
|
||||
response = client.search(query="refund", requested_space_ids=[fixture.space_id])
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert transport.call_count == 3
|
||||
assert response.results
|
||||
|
||||
|
||||
def test_does_not_retry_a_client_error() -> None:
|
||||
fixture = build_search_app()
|
||||
with run_app(fixture.app) as base_url:
|
||||
# fail_times=0: every request goes straight through to the real app,
|
||||
# so this only counts calls -- the 422 below comes from the app's
|
||||
# own request validation, not from the transport.
|
||||
counting = FlakyTransport(fail_times=0)
|
||||
client = RAGCoreClient(
|
||||
base_url=base_url,
|
||||
token="sdk-test-token",
|
||||
transport=counting,
|
||||
max_retries=3,
|
||||
)
|
||||
try:
|
||||
with pytest.raises(RAGCoreAPIError) as excinfo:
|
||||
client.search(query=" ")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert excinfo.value.problem.status == 422
|
||||
assert counting.call_count == 1
|
||||
|
||||
|
||||
def test_connection_failure_exhausts_retries_then_raises() -> None:
|
||||
transport = AlwaysDownTransport()
|
||||
client = RAGCoreClient(
|
||||
base_url="http://127.0.0.1:1",
|
||||
token="sdk-test-token",
|
||||
transport=transport,
|
||||
max_retries=2,
|
||||
)
|
||||
try:
|
||||
with pytest.raises(RAGCoreConnectionError):
|
||||
client.search(query="refund")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert transport.call_count == 3
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from ragcore_sdk import RAGCoreAPIError, RAGCoreClient
|
||||
|
||||
from conftest import build_search_app, run_app
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _client(app: object, *, token: str = "sdk-test-token") -> Iterator[RAGCoreClient]: # noqa: S107
|
||||
with run_app(app) as base_url, RAGCoreClient(base_url=base_url, token=token) as client: # type: ignore[arg-type]
|
||||
yield client
|
||||
|
||||
|
||||
def test_search_returns_typed_results() -> None:
|
||||
fixture = build_search_app()
|
||||
with _client(fixture.app) as client:
|
||||
response = client.search(query="refund", requested_space_ids=[fixture.space_id])
|
||||
|
||||
assert response.effective_space_ids == [fixture.space_id]
|
||||
assert response.results[0].citation.excerpt_sha256
|
||||
assert response.results[0].rank == 1
|
||||
assert response.degraded is False
|
||||
|
||||
|
||||
def test_search_out_of_scope_space_narrows_silently_to_empty() -> None:
|
||||
fixture = build_search_app()
|
||||
forbidden_space = uuid4()
|
||||
with _client(fixture.app) as client:
|
||||
response = client.search(query="refund", requested_space_ids=[forbidden_space])
|
||||
|
||||
assert response.effective_space_ids == []
|
||||
assert response.results == []
|
||||
|
||||
|
||||
def test_search_validation_error_raises_typed_problem() -> None:
|
||||
fixture = build_search_app()
|
||||
with _client(fixture.app) as client, pytest.raises(RAGCoreAPIError) as excinfo:
|
||||
client.search(query=" ")
|
||||
|
||||
problem = excinfo.value.problem
|
||||
assert problem.status == 422
|
||||
assert problem.code == "VALIDATION_ERROR"
|
||||
assert problem.trace_id
|
||||
assert problem.errors
|
||||
|
||||
|
||||
def test_bearer_token_never_appears_in_client_repr_or_error_message() -> None:
|
||||
fixture = build_search_app()
|
||||
secret_token = "super-secret-bearer-value-should-never-leak"
|
||||
with _client(fixture.app, token=secret_token) as client:
|
||||
assert secret_token not in repr(client)
|
||||
with pytest.raises(RAGCoreAPIError) as excinfo:
|
||||
client.search(query=" ")
|
||||
assert secret_token not in str(excinfo.value)
|
||||
problem = excinfo.value.problem
|
||||
for field in problem.__slots__:
|
||||
assert secret_token not in repr(getattr(problem, field))
|
||||
@@ -0,0 +1,54 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { asDoneAnswer, RAGCoreClient } from "../../../../sdk/typescript/src/index.js";
|
||||
import { FIXTURE_TRIGGER_ANSWER_ERROR, startFixtureServer, type FixtureServer } from "./fixtureServer.js";
|
||||
|
||||
describe("answers", () => {
|
||||
let server: FixtureServer;
|
||||
let client: RAGCoreClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = await startFixtureServer();
|
||||
client = new RAGCoreClient(server.url, "sdk-test-token");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("returns a typed response for a non-streaming answer", async () => {
|
||||
const spaceId = randomUUID();
|
||||
const response = await client.answer("refund window", { requestedSpaceIds: [spaceId] });
|
||||
|
||||
expect(response.answer).toBeTruthy();
|
||||
expect(response.citations.length).toBeGreaterThan(0);
|
||||
expect(response.claims.every((claim) => claim.citation_ids.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("streams retrieval_metadata then done in order", async () => {
|
||||
const spaceId = randomUUID();
|
||||
const events = [];
|
||||
for await (const event of client.answerStream("refund window", { requestedSpaceIds: [spaceId] })) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["retrieval_metadata", "done"]);
|
||||
const doneAnswer = asDoneAnswer(events[1]!);
|
||||
expect(doneAnswer).not.toBeNull();
|
||||
expect(doneAnswer?.citations.length).toBeGreaterThan(0);
|
||||
expect(asDoneAnswer(events[0]!)).toBeNull();
|
||||
});
|
||||
|
||||
it("streams a terminal error event on a generation failure", async () => {
|
||||
const spaceId = randomUUID();
|
||||
const events = [];
|
||||
for await (const event of client.answerStream(`refund window ${FIXTURE_TRIGGER_ANSWER_ERROR}`, {
|
||||
requestedSpaceIds: [spaceId],
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["retrieval_metadata", "error"]);
|
||||
expect(events[1]?.data["code"]).toBe("validation_retries_exhausted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { RAGCoreAPIError, RAGCoreClient } from "../../../../sdk/typescript/src/index.js";
|
||||
import { startFixtureServer, type FixtureServer } from "./fixtureServer.js";
|
||||
|
||||
describe("context", () => {
|
||||
let server: FixtureServer;
|
||||
let client: RAGCoreClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = await startFixtureServer();
|
||||
client = new RAGCoreClient(server.url, "sdk-test-token");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("returns typed blocks and citations", async () => {
|
||||
const spaceId = randomUUID();
|
||||
const response = await client.context("refund", { requestedSpaceIds: [spaceId] });
|
||||
|
||||
expect(["answerable", "partially_answerable", "not_answerable", "conflicting_evidence"]).toContain(
|
||||
response.answerability
|
||||
);
|
||||
expect(response.token_count).toBeGreaterThanOrEqual(0);
|
||||
if (response.blocks.length > 0) {
|
||||
expect(response.blocks[0]?.citation_ids.length).toBeGreaterThan(0);
|
||||
expect(response.citations.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises a typed problem on a validation failure", async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.context(" ");
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(RAGCoreAPIError);
|
||||
expect((caught as RAGCoreAPIError).problem.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* A minimal, real local HTTP server standing in for RAGCore's own
|
||||
* `/v1/{search,context,answers}` routes, used only to test the TypeScript
|
||||
* SDK's own HTTP layer (request shape, response typing, SSE parsing,
|
||||
* Problem Details mapping, retries) -- not to re-prove RAGCore's
|
||||
* authorization/retrieval business logic, which the real Python contract
|
||||
* tests (`tests/contract/api/**`) and the Python SDK's own real-backend
|
||||
* tests (`tests/contract/sdk/python/**`, M7-01) already cover against the
|
||||
* actual FastAPI app.
|
||||
*
|
||||
* Response shapes here are copied verbatim from the real Pydantic schemas
|
||||
* (`ragcore.api.v1.{search,context,answers}.schemas`) and the real SSE wire
|
||||
* format (`ragcore.api.v1.answers.routes._sse_body`:
|
||||
* `event: <type>\ndata: <json>\n\n`), including the real (fixed) RFC7807
|
||||
* Problem Details shape with a populated `errors` array on a 422.
|
||||
*/
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export interface FixtureServerOptions {
|
||||
/** Return a synthetic 503 for this many requests before serving normally. */
|
||||
failFirstNRequests?: number;
|
||||
}
|
||||
|
||||
export interface FixtureServer {
|
||||
url: string;
|
||||
requestCount: () => number;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
const FORBIDDEN_SPACE_ID = "00000000-0000-0000-0000-000000000fff";
|
||||
const TRIGGER_ANSWER_ERROR = "TRIGGER_ERROR";
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
if (raw.length === 0) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown, contentType = "application/json"): void {
|
||||
const payload = JSON.stringify(body);
|
||||
res.writeHead(status, { "content-type": contentType });
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
function sendValidationProblem(res: ServerResponse, field: string, message: string): void {
|
||||
sendJson(
|
||||
res,
|
||||
422,
|
||||
{
|
||||
type: "https://ragcore.example.test/problems/validation_error",
|
||||
title: "Request validation failed",
|
||||
status: 422,
|
||||
code: "VALIDATION_ERROR",
|
||||
detail: "The request did not satisfy the API contract.",
|
||||
trace_id: randomUUID(),
|
||||
errors: [{ field, message }],
|
||||
},
|
||||
"application/problem+json"
|
||||
);
|
||||
}
|
||||
|
||||
function fixtureCitation(id: string): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
document_id: randomUUID(),
|
||||
document_version_id: randomUUID(),
|
||||
title: "Refund policy",
|
||||
section: "Refunds",
|
||||
page_start: 4,
|
||||
page_end: 4,
|
||||
line_start: null,
|
||||
line_end: null,
|
||||
excerpt: "The refund window is thirty days from the original purchase date.",
|
||||
excerpt_sha256: "a".repeat(64),
|
||||
source_uri: "https://example.test/policy.md",
|
||||
};
|
||||
}
|
||||
|
||||
export function startFixtureServer(options: FixtureServerOptions = {}): Promise<FixtureServer> {
|
||||
let requestCount = 0;
|
||||
let failuresRemaining = options.failFirstNRequests ?? 0;
|
||||
|
||||
const server: Server = createServer((req, res) => {
|
||||
void (async () => {
|
||||
requestCount += 1;
|
||||
if (failuresRemaining > 0) {
|
||||
failuresRemaining -= 1;
|
||||
sendJson(
|
||||
res,
|
||||
503,
|
||||
{
|
||||
type: "https://ragcore.example.test/problems/search_unavailable",
|
||||
title: "Search unavailable",
|
||||
status: 503,
|
||||
code: "SEARCH_UNAVAILABLE",
|
||||
detail: "synthetic failure for retry test",
|
||||
trace_id: randomUUID(),
|
||||
errors: [],
|
||||
},
|
||||
"application/problem+json"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = req.headers["authorization"];
|
||||
if (typeof auth !== "string" || !auth.startsWith("Bearer ")) {
|
||||
sendJson(
|
||||
res,
|
||||
401,
|
||||
{
|
||||
type: "https://ragcore.example.test/problems/authentication_required",
|
||||
title: "Authentication required",
|
||||
status: 401,
|
||||
code: "AUTHENTICATION_REQUIRED",
|
||||
detail: "Authentication is required.",
|
||||
trace_id: randomUUID(),
|
||||
errors: [],
|
||||
},
|
||||
"application/problem+json"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = (await readBody(req)) as Record<string, unknown>;
|
||||
const query = typeof body["query"] === "string" ? body["query"] : "";
|
||||
if (query.trim() === "") {
|
||||
sendValidationProblem(res, "body.query", "String should match pattern '.*\\S.*'");
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedSpaceIds = Array.isArray(body["requested_space_ids"])
|
||||
? (body["requested_space_ids"] as string[])
|
||||
: [];
|
||||
const forbidden = requestedSpaceIds.includes(FORBIDDEN_SPACE_ID);
|
||||
const effectiveSpaceIds = forbidden ? [] : requestedSpaceIds.length > 0 ? requestedSpaceIds : [randomUUID()];
|
||||
const retrievalRunId = randomUUID();
|
||||
const citationId = randomUUID();
|
||||
|
||||
if (req.url === "/v1/search") {
|
||||
sendJson(res, 200, {
|
||||
retrieval_run_id: retrievalRunId,
|
||||
effective_space_ids: effectiveSpaceIds,
|
||||
results:
|
||||
effectiveSpaceIds.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
chunk_id: randomUUID(),
|
||||
parent_chunk_id: null,
|
||||
text: "The refund window is thirty days from the original purchase date.",
|
||||
rank: 1,
|
||||
scores: { dense: 0.87, sparse: 0.41, fused: 1 / 61, rerank: null },
|
||||
citation: fixtureCitation(citationId),
|
||||
},
|
||||
],
|
||||
degraded: false,
|
||||
degradation_codes: [],
|
||||
timings_ms: { dense: 12.5 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/v1/context") {
|
||||
sendJson(res, 200, {
|
||||
retrieval_run_id: retrievalRunId,
|
||||
answerability: effectiveSpaceIds.length === 0 ? "not_answerable" : "partially_answerable",
|
||||
answerability_reasons: [],
|
||||
blocks:
|
||||
effectiveSpaceIds.length === 0
|
||||
? []
|
||||
: [{ text: "Refund evidence.", citation_ids: [citationId], token_count: 4 }],
|
||||
citations: effectiveSpaceIds.length === 0 ? [] : [fixtureCitation(citationId)],
|
||||
token_count: effectiveSpaceIds.length === 0 ? 0 : 4,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/v1/answers") {
|
||||
const stream = body["stream"] === true;
|
||||
const answerable = effectiveSpaceIds.length > 0;
|
||||
const errored = query.includes(TRIGGER_ANSWER_ERROR);
|
||||
|
||||
if (!stream) {
|
||||
if (errored) {
|
||||
sendJson(
|
||||
res,
|
||||
503,
|
||||
{
|
||||
type: "https://ragcore.example.test/problems/generation_unavailable",
|
||||
title: "Answers unavailable",
|
||||
status: 503,
|
||||
code: "GENERATION_UNAVAILABLE",
|
||||
detail: "The answer service is temporarily unavailable.",
|
||||
trace_id: randomUUID(),
|
||||
errors: [],
|
||||
},
|
||||
"application/problem+json"
|
||||
);
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, {
|
||||
answer_id: randomUUID(),
|
||||
retrieval_run_id: retrievalRunId,
|
||||
answerability: answerable ? "partially_answerable" : "not_answerable",
|
||||
answer: answerable ? "The refund window is thirty days from purchase." : "",
|
||||
citations: answerable ? [fixtureCitation(citationId)] : [],
|
||||
claims: answerable ? [{ text: "Thirty days.", citation_ids: [citationId] }] : [],
|
||||
degraded: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
const metadata = {
|
||||
retrieval_run_id: retrievalRunId,
|
||||
effective_space_ids: effectiveSpaceIds,
|
||||
answerability: answerable ? "partially_answerable" : "not_answerable",
|
||||
answerability_reasons: [],
|
||||
};
|
||||
res.write(`event: retrieval_metadata\ndata: ${JSON.stringify(metadata)}\n\n`);
|
||||
if (errored) {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ code: "validation_retries_exhausted" })}\n\n`);
|
||||
} else {
|
||||
const done = {
|
||||
answer_id: randomUUID(),
|
||||
retrieval_run_id: retrievalRunId,
|
||||
answerability: answerable ? "partially_answerable" : "not_answerable",
|
||||
answer: answerable ? "The refund window is thirty days from purchase." : "",
|
||||
citations: answerable ? [fixtureCitation(citationId)] : [],
|
||||
claims: answerable ? [{ text: "Thirty days.", citation_ids: [citationId] }] : [],
|
||||
degraded: false,
|
||||
};
|
||||
res.write(`event: done\ndata: ${JSON.stringify(done)}\n\n`);
|
||||
}
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 404, {
|
||||
type: "about:blank",
|
||||
title: "Not Found",
|
||||
status: 404,
|
||||
code: "NOT_FOUND",
|
||||
detail: null,
|
||||
trace_id: randomUUID(),
|
||||
errors: [],
|
||||
});
|
||||
})();
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
reject(new Error("failed to bind fixture server"));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
requestCount: () => requestCount,
|
||||
close: () => new Promise((res) => server.close(() => res())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const FIXTURE_FORBIDDEN_SPACE_ID = FORBIDDEN_SPACE_ID;
|
||||
export const FIXTURE_TRIGGER_ANSWER_ERROR = TRIGGER_ANSWER_ERROR;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RAGCoreAPIError, RAGCoreClient, RAGCoreConnectionError } from "../../../../sdk/typescript/src/index.js";
|
||||
import { startFixtureServer } from "./fixtureServer.js";
|
||||
|
||||
describe("retries", () => {
|
||||
it("retries a 503 twice then succeeds", async () => {
|
||||
const server = await startFixtureServer({ failFirstNRequests: 2 });
|
||||
try {
|
||||
const client = new RAGCoreClient(server.url, "sdk-test-token", { maxRetries: 3 });
|
||||
const response = await client.search("refund", { requestedSpaceIds: [randomUUID()] });
|
||||
|
||||
expect(server.requestCount()).toBe(3);
|
||||
expect(response.results.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("never retries a real 4xx client error", async () => {
|
||||
const server = await startFixtureServer();
|
||||
try {
|
||||
const client = new RAGCoreClient(server.url, "sdk-test-token", { maxRetries: 3 });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.search(" ");
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(RAGCoreAPIError);
|
||||
expect((caught as RAGCoreAPIError).problem.status).toBe(422);
|
||||
expect(server.requestCount()).toBe(1);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("exhausts retries on a connection failure and raises RAGCoreConnectionError", async () => {
|
||||
// Port 1 is a privileged port nothing is listening on in this sandbox --
|
||||
// fetch fails at the connection stage every time, deterministically.
|
||||
const client = new RAGCoreClient("http://127.0.0.1:1", "sdk-test-token", { maxRetries: 2, timeoutMs: 2000 });
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.search("refund");
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(RAGCoreConnectionError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { RAGCoreAPIError, RAGCoreClient } from "../../../../sdk/typescript/src/index.js";
|
||||
import { FIXTURE_FORBIDDEN_SPACE_ID, startFixtureServer, type FixtureServer } from "./fixtureServer.js";
|
||||
|
||||
describe("search", () => {
|
||||
let server: FixtureServer;
|
||||
let client: RAGCoreClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = await startFixtureServer();
|
||||
client = new RAGCoreClient(server.url, "sdk-test-token");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("returns typed results", async () => {
|
||||
const spaceId = randomUUID();
|
||||
const response = await client.search("refund", { requestedSpaceIds: [spaceId] });
|
||||
|
||||
expect(response.effective_space_ids).toEqual([spaceId]);
|
||||
expect(response.results[0]?.citation.excerpt_sha256).toBeTruthy();
|
||||
expect(response.results[0]?.rank).toBe(1);
|
||||
expect(response.degraded).toBe(false);
|
||||
});
|
||||
|
||||
it("narrows a forbidden space to an empty result silently", async () => {
|
||||
const response = await client.search("refund", { requestedSpaceIds: [FIXTURE_FORBIDDEN_SPACE_ID] });
|
||||
|
||||
expect(response.effective_space_ids).toEqual([]);
|
||||
expect(response.results).toEqual([]);
|
||||
});
|
||||
|
||||
it("raises a typed problem with populated errors on a validation failure", async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.search(" ");
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(RAGCoreAPIError);
|
||||
const problem = (caught as RAGCoreAPIError).problem;
|
||||
expect(problem.status).toBe(422);
|
||||
expect(problem.code).toBe("VALIDATION_ERROR");
|
||||
expect(problem.trace_id).toBeTruthy();
|
||||
expect(problem.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("never leaks the bearer token through the client or a thrown error", async () => {
|
||||
const secretToken = "super-secret-bearer-value-should-never-leak";
|
||||
const secretClient = new RAGCoreClient(server.url, secretToken);
|
||||
|
||||
expect(String(secretClient)).not.toContain(secretToken);
|
||||
expect(JSON.stringify(secretClient)).not.toContain(secretToken);
|
||||
|
||||
try {
|
||||
await secretClient.search(" ");
|
||||
throw new Error("expected search to reject");
|
||||
} catch (error) {
|
||||
expect(String(error)).not.toContain(secretToken);
|
||||
expect(JSON.stringify((error as RAGCoreAPIError).problem)).not.toContain(secretToken);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user