test(api): replace mock doubles in knowledge-fs service tests with typed fakes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Stephen Zhou
2026-10-15 19:47:34 +08:00
co-authored by Claude Opus 5.5
parent f8c571a05b
commit a9fe279ef6
21 changed files with 617 additions and 213 deletions
@@ -0,0 +1,102 @@
"""Small typed test fakes shared by the knowledge-fs core/service unit tests.
They replace generic mock doubles with plain objects that record calls in lists.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from services.knowledge_fs.observability import (
KnowledgeFSBatchStatusMetric,
KnowledgeFSCapabilityIssuanceMetric,
KnowledgeFSControlSpaceStateMetric,
KnowledgeFSLifecycleTaskMetric,
)
@dataclass(frozen=True)
class RecordedCall:
args: tuple[object, ...]
kwargs: dict[str, object]
@dataclass
class RecordingCallable:
"""A callable that records each invocation and returns or raises a configured result."""
return_value: object = None
side_effect: BaseException | Callable[..., object] | None = None
name: str = ""
calls: list[RecordedCall] = field(default_factory=list)
def __call__(self, *args: object, **kwargs: object) -> object:
self.calls.append(RecordedCall(args=args, kwargs=kwargs))
if isinstance(self.side_effect, BaseException):
raise self.side_effect
if self.side_effect is not None:
return self.side_effect(*args, **kwargs)
return self.return_value
@property
def call_count(self) -> int:
return len(self.calls)
@property
def last_call(self) -> RecordedCall:
assert self.calls, f"{self.name or 'callable'} was not called"
return self.calls[-1]
def assert_called_once_with(self, *args: object, **kwargs: object) -> None:
assert self.calls == [RecordedCall(args=args, kwargs=kwargs)], self.calls
def assert_not_called(self) -> None:
assert self.calls == [], self.calls
@dataclass(frozen=True)
class Marker:
"""An opaque, identity-comparable stand-in for a collaborator nobody inspects."""
label: str
@dataclass(frozen=True)
class FakeCapabilityClaims:
"""The subset of signed Capability v2 claims that brokers read back after issuance."""
exp: int
iat: int = 0
@dataclass(frozen=True)
class FakeIssuedCapability:
token: str
claims: FakeCapabilityClaims
@dataclass
class RecordingOperationalMetrics:
"""KnowledgeFS operational metrics port that keeps every emitted event in order."""
batch_statuses: list[KnowledgeFSBatchStatusMetric] = field(default_factory=list)
capability_issuances: list[KnowledgeFSCapabilityIssuanceMetric] = field(default_factory=list)
control_space_states: list[KnowledgeFSControlSpaceStateMetric] = field(default_factory=list)
lifecycle_tasks: list[KnowledgeFSLifecycleTaskMetric] = field(default_factory=list)
gauge_readers: list[Callable[[], Mapping[str, int]]] = field(default_factory=list)
def record_batch_status(self, event: KnowledgeFSBatchStatusMetric) -> None:
self.batch_statuses.append(event)
def record_capability_issuance(self, event: KnowledgeFSCapabilityIssuanceMetric) -> None:
self.capability_issuances.append(event)
def record_control_space_state(self, event: KnowledgeFSControlSpaceStateMetric) -> None:
self.control_space_states.append(event)
def record_lifecycle_task(self, event: KnowledgeFSLifecycleTaskMetric) -> None:
self.lifecycle_tasks.append(event)
def register_control_space_state_gauge(self, read_counts: Callable[[], Mapping[str, int]]) -> None:
self.gauge_readers.append(read_counts)
@@ -907,7 +907,7 @@ class TestKnowledgeFSRoleMutations:
@pytest.fixture
def events(self, monkeypatch: pytest.MonkeyPatch, transport: RBACTransport) -> list[str]:
events: list[str] = []
send = transport.send
forward_to_transport = transport.send
def record_remote(
method: str,
@@ -920,7 +920,7 @@ class TestKnowledgeFSRoleMutations:
timeout: float,
) -> JsonValue:
events.append("remote")
return send(
return forward_to_transport(
method,
endpoint,
tenant_id=tenant_id,
@@ -1,7 +1,5 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from sqlalchemy.orm import Session, sessionmaker
@@ -16,15 +14,16 @@ from services.knowledge_fs.batch_capability import (
)
from services.knowledge_fs.product_remote import KnowledgeFSOperationUnavailableError
from services.knowledge_fs_capability import CapabilityIssueRequest
from tests.unit_tests.knowledge_fs_core_fakes import FakeCapabilityClaims, FakeIssuedCapability
class FakeIssuer:
def __init__(self) -> None:
self.requests: list[CapabilityIssueRequest] = []
def issue(self, request: CapabilityIssueRequest) -> SimpleNamespace:
def issue(self, request: CapabilityIssueRequest) -> FakeIssuedCapability:
self.requests.append(request)
return SimpleNamespace(token="batch-token", claims=SimpleNamespace(exp=2_000_000_000))
return FakeIssuedCapability(token="batch-token", claims=FakeCapabilityClaims(exp=2_000_000_000))
class FakeCutoverGate:
@@ -2,8 +2,8 @@ from __future__ import annotations
import uuid
from collections.abc import Callable
from types import SimpleNamespace
from typing import Literal, cast, override
from dataclasses import dataclass
from typing import Literal, override
import pytest
import sqlalchemy as sa
@@ -76,28 +76,38 @@ class FakeProduct:
)
@dataclass(frozen=True)
class _IssuedClaims:
iat: int
exp: int
@dataclass(frozen=True)
class _IssuedCapability:
token: str
claims: _IssuedClaims
def _signed_capability() -> _IssuedCapability:
return _IssuedCapability(token="signed-capability", claims=_IssuedClaims(iat=1_999_999_940, exp=2_000_000_000))
class FakeIssuer:
def __init__(self) -> None:
self.requests: list[CapabilityIssueRequest] = []
def issue(self, request: CapabilityIssueRequest) -> SimpleNamespace:
def issue(self, request: CapabilityIssueRequest) -> _IssuedCapability:
self.requests.append(request)
return SimpleNamespace(
token="signed-capability",
claims=SimpleNamespace(iat=1_999_999_940, exp=2_000_000_000),
)
return _signed_capability()
class AuditFailingOnceIssuer(FakeIssuer):
@override
def issue(self, request: CapabilityIssueRequest) -> SimpleNamespace:
def issue(self, request: CapabilityIssueRequest) -> _IssuedCapability:
self.requests.append(request)
if len(self.requests) == 1:
raise RuntimeError("issuance audit persistence failed")
return SimpleNamespace(
token="signed-capability",
claims=SimpleNamespace(iat=1_999_999_940, exp=2_000_000_000),
)
return _signed_capability()
class NarrowingIssuer(FakeIssuer):
@@ -113,7 +123,7 @@ class NarrowingIssuer(FakeIssuer):
self.saw_committed_reservation = False
@override
def issue(self, request: CapabilityIssueRequest) -> SimpleNamespace:
def issue(self, request: CapabilityIssueRequest) -> _IssuedCapability:
with self._maker() as session:
reservation = session.scalar(
sa.select(KnowledgeFSCapabilityIssuanceReservation).where(
@@ -900,13 +910,37 @@ def test_uncutover_workspace_rejects_service_and_app_capabilities_before_issuanc
if principal_kind == "service":
with pytest.raises(KnowledgeFSOperationUnavailableError, match="not cut over"):
broker.issue_service(
profile=cast(KnowledgeFSServiceApiProfile, SimpleNamespace(tenant_id="tenant-1")),
profile=KnowledgeFSServiceApiProfile(
tenant_id="tenant-1",
control_space_id="control-space-1",
api_token_id="api-token-1",
principal_id="api-token-1",
knowledge_space_id="space-1",
knowledge_space_revision=0,
membership_epoch=1,
space_acl_epoch=2,
external_access_epoch=3,
content_policy_revision=4,
),
operation_id="createQuery",
)
else:
with pytest.raises(KnowledgeFSOperationUnavailableError, match="not cut over"):
broker.issue_app(
profile=cast(KnowledgeFSAppPrincipalProfile, SimpleNamespace(tenant_id="tenant-1")),
profile=KnowledgeFSAppPrincipalProfile(
tenant_id="tenant-1",
control_space_id="control-space-1",
app_id="app-1",
join_id="join-1",
caller_kind=KnowledgeFSAppSpaceJoinType.AGENT,
action="queries.create",
knowledge_space_id="space-1",
knowledge_space_revision=0,
membership_epoch=1,
space_acl_epoch=2,
external_access_epoch=3,
content_policy_revision=4,
),
operation_id="createQuery",
)
@@ -1,37 +1,79 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock
from models.knowledge_fs import KnowledgeFSControlSpaceState
from models.knowledge_fs import KnowledgeFSControlSpace, KnowledgeFSControlSpaceState
from repositories.knowledge_fs_control_space_repository import KnowledgeFSControlSpaceCASUpdate
from services.knowledge_fs.control_space_lifecycle import KnowledgeFSControlSpaceLifecycleService
from services.knowledge_fs.observability import KnowledgeFSControlSpaceStateMetric
@dataclass
class _SequencedRepository:
reads: list[KnowledgeFSControlSpace]
cas_updates: list[KnowledgeFSControlSpaceCASUpdate] = field(default_factory=list)
get_calls: list[tuple[str, str]] = field(default_factory=list)
def get(self, *, tenant_id: str, control_space_id: str) -> KnowledgeFSControlSpace | None:
self.get_calls.append((tenant_id, control_space_id))
return self.reads.pop(0)
def compare_and_set_lifecycle(self, update: KnowledgeFSControlSpaceCASUpdate) -> bool:
self.cas_updates.append(update)
return True
@dataclass
class _RecordingMetrics:
control_space_states: list[KnowledgeFSControlSpaceStateMetric] = field(default_factory=list)
def record_control_space_state(self, event: KnowledgeFSControlSpaceStateMetric) -> None:
self.control_space_states.append(event)
def _control_space(
*,
knowledge_space_id: str | None,
resource_version: int,
state: KnowledgeFSControlSpaceState,
updated_at: datetime,
) -> KnowledgeFSControlSpace:
space = KnowledgeFSControlSpace(
tenant_id="tenant-secret",
owner_account_id="account-1",
provisioning_key="provision-1",
)
space.id = "control-secret"
space.deletion_irreversible_at = None
space.knowledge_space_id = knowledge_space_id
space.knowledge_space_revision = 0
space.resource_version = resource_version
space.state = state
space.updated_at = updated_at
return space
def test_successful_transition_records_previous_state_dwell_without_resource_labels() -> None:
entered_at = datetime(2026, 7, 21, 10, 0, 0)
before = SimpleNamespace(
deletion_irreversible_at=None,
before = _control_space(
knowledge_space_id=None,
knowledge_space_revision=0,
resource_version=3,
state=KnowledgeFSControlSpaceState.PROVISIONING,
updated_at=entered_at,
)
after = SimpleNamespace(
**{
**vars(before),
"knowledge_space_id": "space-1",
"resource_version": 4,
"state": KnowledgeFSControlSpaceState.ACTIVE,
"updated_at": entered_at + timedelta(seconds=12),
}
after = _control_space(
knowledge_space_id="space-1",
resource_version=4,
state=KnowledgeFSControlSpaceState.ACTIVE,
updated_at=entered_at + timedelta(seconds=12),
)
repository = _SequencedRepository(reads=[before, after])
metrics = _RecordingMetrics()
service = KnowledgeFSControlSpaceLifecycleService(
repository, # type: ignore[arg-type]
metrics=metrics, # type: ignore[arg-type]
)
repository = MagicMock()
repository.get.side_effect = (before, after)
repository.compare_and_set_lifecycle.return_value = True
metrics = MagicMock()
service = KnowledgeFSControlSpaceLifecycleService(repository, metrics=metrics)
transitioned = service.transition(
tenant_id="tenant-secret",
@@ -44,12 +86,13 @@ def test_successful_transition_records_previous_state_dwell_without_resource_lab
)
assert transitioned is after
assert metrics.record_control_space_state.call_args.args[0] == (
assert len(repository.cas_updates) == 1
assert metrics.control_space_states[-1] == (
12.0,
"provisioning",
"active",
)
serialized = str(metrics.record_control_space_state.call_args_list)
serialized = str(metrics.control_space_states)
assert "tenant-secret" not in serialized
assert "control-secret" not in serialized
assert "space-1" not in serialized
@@ -1,7 +1,6 @@
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import MagicMock
import pytest
from sqlalchemy.orm import Session, sessionmaker
@@ -13,6 +12,7 @@ from models.knowledge_fs_cutover import (
)
from services.knowledge_fs.cutover_runtime_gate import SQLKnowledgeFSWorkspaceRuntimeGate
from services.knowledge_fs.product_remote import KnowledgeFSOperationUnavailableError
from tests.unit_tests.knowledge_fs_core_fakes import RecordingCallable
_WATERMARK: KnowledgeFSCutoverRevisionWatermark = {
"membership_epoch": 1,
@@ -33,10 +33,14 @@ def test_runtime_gate_fails_closed_without_a_cutover_ledger(sqlite_session: Sess
gate.require_capability_v2(tenant_id="tenant-1")
class _Initializer:
def __init__(self, ensure_initialized: RecordingCallable) -> None:
self.ensure_initialized = ensure_initialized
@pytest.mark.parametrize("sqlite_session", [(KnowledgeFSWorkspaceCutoverLedger,)], indirect=True)
def test_runtime_gate_initializes_a_missing_greenfield_workspace_once(sqlite_session: Session) -> None:
session_maker = sessionmaker(bind=sqlite_session.get_bind(), expire_on_commit=False)
initializer = MagicMock()
def initialize(*, tenant_id: str) -> None:
with session_maker.begin() as session:
@@ -55,13 +59,14 @@ def test_runtime_gate_initializes_a_missing_greenfield_workspace_once(sqlite_ses
)
)
initializer.ensure_initialized.side_effect = initialize
ensure_initialized = RecordingCallable(side_effect=initialize)
initializer = _Initializer(ensure_initialized)
gate = SQLKnowledgeFSWorkspaceRuntimeGate(session_maker, initializer=initializer)
gate.require_product_routes(tenant_id="tenant-1")
gate.require_capability_v2(tenant_id="tenant-1")
initializer.ensure_initialized.assert_called_once_with(tenant_id="tenant-1")
ensure_initialized.assert_called_once_with(tenant_id="tenant-1")
@pytest.mark.parametrize("sqlite_session", [(KnowledgeFSWorkspaceCutoverLedger,)], indirect=True)
@@ -3,7 +3,6 @@ from __future__ import annotations
from collections.abc import Generator
from pathlib import Path
from typing import override
from unittest.mock import MagicMock
from zipfile import ZipFile
import pytest
@@ -45,6 +44,16 @@ class FakeObjectStorage(KnowledgeFSObjectStorageService):
return None if body is None else (chunk for chunk in (body[:2], body[2:]))
class FailingObjectStorage(FakeObjectStorage):
def __init__(self, error: Exception) -> None:
super().__init__({})
self.error = error
@override
def head_object(self, *, key: str) -> KnowledgeFSObjectMetadata | None:
raise self.error
def descriptor(
*, document_id: str, filename: str, object_key: str, size_bytes: int
) -> KnowledgeFSDocumentDownloadDescriptor:
@@ -92,9 +101,7 @@ def test_load_stream_translates_object_storage_errors(
storage_error: Exception,
expected_error: type[Exception],
) -> None:
object_storage = MagicMock(spec=KnowledgeFSObjectStorageService)
object_storage.head_object.side_effect = storage_error
service = KnowledgeFSDownloadService(object_storage=object_storage)
service = KnowledgeFSDownloadService(object_storage=FailingObjectStorage(storage_error))
with pytest.raises(expected_error):
service.load_stream(descriptor(document_id="document-1", filename="a.txt", object_key="object-1", size_bytes=4))
@@ -1,6 +1,6 @@
from __future__ import annotations
from unittest.mock import MagicMock
from dataclasses import dataclass, field
from uuid import uuid4
import pytest
@@ -13,6 +13,14 @@ from services.knowledge_fs.cutover import KnowledgeFSCutoverGateBlockedError
from services.knowledge_fs.greenfield_initializer import KnowledgeFSWorkspaceGreenfieldInitializer
@dataclass
class _RecordingCutover:
greenfield_initializations: list[tuple[str, str]] = field(default_factory=list)
def initialize_greenfield(self, *, tenant_id: str, owner_account_id: str) -> None:
self.greenfield_initializations.append((tenant_id, owner_account_id))
@pytest.fixture
def greenfield_session_maker(sqlite_engine: Engine) -> sessionmaker[Session]:
tables = [
@@ -39,15 +47,15 @@ def test_greenfield_initializer_uses_the_unique_workspace_owner(
role=TenantAccountRole.OWNER,
)
)
cutover = MagicMock()
initializer = KnowledgeFSWorkspaceGreenfieldInitializer(greenfield_session_maker, cutover=cutover)
cutover = _RecordingCutover()
initializer = KnowledgeFSWorkspaceGreenfieldInitializer(
greenfield_session_maker,
cutover=cutover, # type: ignore[arg-type]
)
initializer.ensure_initialized(tenant_id=tenant_id)
cutover.initialize_greenfield.assert_called_once_with(
tenant_id=tenant_id,
owner_account_id=owner_account_id,
)
assert cutover.greenfield_initializations == [(tenant_id, owner_account_id)]
def test_greenfield_initializer_rejects_a_workspace_without_one_owner(
@@ -58,7 +66,10 @@ def test_greenfield_initializer_rejects_a_workspace_without_one_owner(
session.add(tenant)
session.flush()
tenant_id = tenant.id
initializer = KnowledgeFSWorkspaceGreenfieldInitializer(greenfield_session_maker, cutover=MagicMock())
initializer = KnowledgeFSWorkspaceGreenfieldInitializer(
greenfield_session_maker,
cutover=_RecordingCutover(), # type: ignore[arg-type]
)
with pytest.raises(KnowledgeFSCutoverGateBlockedError, match="exactly one Workspace owner"):
initializer.ensure_initialized(tenant_id=tenant_id)
@@ -72,10 +83,13 @@ def test_greenfield_initializer_rejects_an_archived_workspace(
session.add(tenant)
session.flush()
tenant_id = tenant.id
cutover = MagicMock()
initializer = KnowledgeFSWorkspaceGreenfieldInitializer(greenfield_session_maker, cutover=cutover)
cutover = _RecordingCutover()
initializer = KnowledgeFSWorkspaceGreenfieldInitializer(
greenfield_session_maker,
cutover=cutover, # type: ignore[arg-type]
)
with pytest.raises(KnowledgeFSCutoverGateBlockedError, match="not active"):
initializer.ensure_initialized(tenant_id=tenant_id)
cutover.initialize_greenfield.assert_not_called()
assert cutover.greenfield_initializations == []
@@ -1,9 +1,8 @@
import json
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from sqlalchemy.orm import sessionmaker
from models.account import Account
from services.knowledge_fs.initial_source_preview_job import (
@@ -31,9 +30,15 @@ def _payload() -> KnowledgeFSInitialWebsiteSourcePreviewPayload:
)
def _account() -> Account:
account = Account(name="Account One", email="account-1@example.com")
account.id = "account-1"
return account
def test_start_validates_binding_and_enqueues_preview_without_running_it_inline() -> None:
service = KnowledgeFSInitialSourcePreviewJobService(MagicMock())
account = cast(Account, SimpleNamespace(id="account-1"))
service = KnowledgeFSInitialSourcePreviewJobService(sessionmaker())
account = _account()
with (
patch(
@@ -76,8 +81,8 @@ def test_start_validates_binding_and_enqueues_preview_without_running_it_inline(
def test_start_rejects_a_second_active_preview_for_the_same_account() -> None:
service = KnowledgeFSInitialSourcePreviewJobService(MagicMock())
account = cast(Account, SimpleNamespace(id="account-1"))
service = KnowledgeFSInitialSourcePreviewJobService(sessionmaker())
account = _account()
with (
patch(
@@ -93,8 +98,8 @@ def test_start_rejects_a_second_active_preview_for_the_same_account() -> None:
def test_start_releases_the_active_preview_slot_when_enqueue_fails() -> None:
service = KnowledgeFSInitialSourcePreviewJobService(MagicMock())
account = cast(Account, SimpleNamespace(id="account-1"))
service = KnowledgeFSInitialSourcePreviewJobService(sessionmaker())
account = _account()
with (
patch(
@@ -3,7 +3,6 @@ from __future__ import annotations
from collections import deque
from datetime import datetime, timedelta
from typing import override
from unittest.mock import MagicMock, call
import pytest
from sqlalchemy.orm import Session, sessionmaker
@@ -31,6 +30,7 @@ from services.knowledge_fs.lifecycle_port import (
)
from services.knowledge_fs.lifecycle_saga import KnowledgeFSLifecycleSagaRunner
from services.knowledge_fs.observability import KnowledgeFSLifecycleTaskMetric
from tests.unit_tests.knowledge_fs_core_fakes import RecordingOperationalMetrics
from tests.unit_tests.services.knowledge_fs_fakes import UnexpectedLifecycleRemote
@@ -129,7 +129,7 @@ def test_integrated_provision_saga_registers_remote_space_and_acks(sqlite_sessio
command = session.get(KnowledgeFSLifecycleOutbox, intent.outbox.id)
assert command is not None
command.created_at = now - timedelta(seconds=12)
metrics = MagicMock()
metrics = RecordingOperationalMetrics()
runner = KnowledgeFSLifecycleSagaRunner(session_maker, remote, metrics=metrics)
result = runner.dispatch_one(
@@ -153,12 +153,12 @@ def test_integrated_provision_saga_registers_remote_space_and_acks(sqlite_sessio
assert remote.provision_requests[0].model_intent["pluginId"] == "langgenius/openai"
assert remote.provision_requests[0].profile_intent is not None
assert remote.provision_requests[0].profile_intent["defaultMode"] == "fast"
assert metrics.record_lifecycle_task.call_args_list == [
call(KnowledgeFSLifecycleTaskMetric(None, "provision", "running")),
call(KnowledgeFSLifecycleTaskMetric(12.0, "provision", "succeeded")),
assert metrics.lifecycle_tasks == [
KnowledgeFSLifecycleTaskMetric(None, "provision", "running"),
KnowledgeFSLifecycleTaskMetric(12.0, "provision", "succeeded"),
]
assert "tenant-1" not in str(metrics.record_lifecycle_task.call_args_list)
assert intent.control_space.id not in str(metrics.record_lifecycle_task.call_args_list)
assert "tenant-1" not in str(metrics.lifecycle_tasks)
assert intent.control_space.id not in str(metrics.lifecycle_tasks)
@pytest.mark.parametrize(
@@ -232,7 +232,7 @@ def test_deletion_saga_crosses_irreversible_point_and_remains_retryable_when_pro
KnowledgeFSDeletionProgress(KnowledgeFSDeletionPhase.COMPLETED, 4, irreversible_at),
)
)
metrics = MagicMock()
metrics = RecordingOperationalMetrics()
runner = KnowledgeFSLifecycleSagaRunner(session_maker, remote, metrics=metrics)
first = runner.dispatch_one(
@@ -260,7 +260,7 @@ def test_deletion_saga_crosses_irreversible_point_and_remains_retryable_when_pro
assert command is not None
assert command.status is KnowledgeFSLifecycleOutboxStatus.SUCCEEDED
assert len(remote.deletion_requests) == 2
assert [metric.status for ((metric,), _) in metrics.record_lifecycle_task.call_args_list] == [
assert [metric.status for metric in metrics.lifecycle_tasks] == [
"running",
"retry",
"running",
@@ -1,6 +1,9 @@
from __future__ import annotations
from unittest.mock import MagicMock
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from opentelemetry.metrics import Observation
from services.knowledge_fs.observability import (
KnowledgeFSBatchStatusMetric,
@@ -9,14 +12,59 @@ from services.knowledge_fs.observability import (
KnowledgeFSLifecycleTaskMetric,
OpenTelemetryKnowledgeFSOperationalMetrics,
)
from tests.unit_tests.knowledge_fs_core_fakes import RecordedCall
@dataclass
class _FakeInstrument:
name: str
log: list[RecordedCall]
calls: list[RecordedCall] = field(default_factory=list)
def add(self, amount: int, *, attributes: dict[str, str]) -> None:
self._record(amount, attributes=attributes)
def record(self, amount: float, *, attributes: dict[str, str]) -> None:
self._record(amount, attributes=attributes)
def _record(self, *args: object, **kwargs: object) -> None:
call = RecordedCall(args=args, kwargs=kwargs)
self.calls.append(call)
self.log.append(call)
@dataclass
class _FakeMeter:
log: list[RecordedCall] = field(default_factory=list)
counters: dict[str, _FakeInstrument] = field(default_factory=dict)
histograms: dict[str, _FakeInstrument] = field(default_factory=dict)
gauge_calls: list[RecordedCall] = field(default_factory=list)
def create_counter(self, name: str, *, description: str, unit: str) -> _FakeInstrument:
self.log.append(RecordedCall(args=(name,), kwargs={"description": description, "unit": unit}))
return self.counters.setdefault(name, _FakeInstrument(name=name, log=self.log))
def create_histogram(self, name: str, *, description: str, unit: str) -> _FakeInstrument:
self.log.append(RecordedCall(args=(name,), kwargs={"description": description, "unit": unit}))
return self.histograms.setdefault(name, _FakeInstrument(name=name, log=self.log))
def create_observable_gauge(
self,
name: str,
*,
callbacks: tuple[Callable[[object], Iterable[Observation]], ...],
description: str,
unit: str,
) -> object:
call = RecordedCall(args=(name,), kwargs={"callbacks": callbacks, "description": description, "unit": unit})
self.gauge_calls.append(call)
self.log.append(call)
return object()
def test_operational_metrics_use_only_bounded_labels_and_numeric_measurements() -> None:
meter = MagicMock()
counters: dict[str, MagicMock] = {}
histograms: dict[str, MagicMock] = {}
meter.create_counter.side_effect = lambda name, **_: counters.setdefault(name, MagicMock())
meter.create_histogram.side_effect = lambda name, **_: histograms.setdefault(name, MagicMock())
meter = _FakeMeter()
counters = meter.counters
metrics = OpenTelemetryKnowledgeFSOperationalMetrics(meter=meter)
metrics.record_capability_issuance(
@@ -51,8 +99,8 @@ def test_operational_metrics_use_only_bounded_labels_and_numeric_measurements()
)
)
capability_counter = counters["dify.knowledge_fs.capability_issuance"]
assert capability_counter.add.call_args.args == (1,)
assert capability_counter.add.call_args.kwargs == {
assert capability_counter.calls[-1].args == (1,)
assert capability_counter.calls[-1].kwargs == {
"attributes": {
"caller_kind": "interactive",
"operation_id": "getKnowledgeSpace",
@@ -60,7 +108,7 @@ def test_operational_metrics_use_only_bounded_labels_and_numeric_measurements()
"reason": "success",
}
}
all_calls = str(meter.mock_calls)
all_calls = str(meter.log)
assert "tenant_id" not in all_calls
assert "control_space_id" not in all_calls
assert "jti" not in all_calls
@@ -68,17 +116,23 @@ def test_operational_metrics_use_only_bounded_labels_and_numeric_measurements()
def test_control_space_current_state_gauge_uses_bounded_states_and_is_best_effort() -> None:
meter = MagicMock()
meter = _FakeMeter()
metrics = OpenTelemetryKnowledgeFSOperationalMetrics(meter=meter)
read_counts = MagicMock(return_value={"deleting": 2, "error": 1, "provisioning": 3})
counts_error: list[Exception] = []
def read_counts() -> Mapping[str, int]:
if counts_error:
raise counts_error[0]
return {"deleting": 2, "error": 1, "provisioning": 3}
metrics.register_control_space_state_gauge(read_counts)
gauge_call = meter.create_observable_gauge.call_args
assert len(meter.gauge_calls) == 1
gauge_call = meter.gauge_calls[0]
assert gauge_call.args == ("dify.knowledge_fs.control_spaces",)
assert gauge_call.kwargs["unit"] == "{space}"
callback = gauge_call.kwargs["callbacks"][0]
observations = tuple(callback(MagicMock()))
observations = tuple(callback(object()))
assert [(observation.value, observation.attributes) for observation in observations] == [
(3, {"aggregation_scope": "global_database_snapshot", "state": "provisioning"}),
(2, {"aggregation_scope": "global_database_snapshot", "state": "deleting"}),
@@ -87,7 +141,7 @@ def test_control_space_current_state_gauge_uses_bounded_states_and_is_best_effor
assert "aggregate replica series with max, never sum" in gauge_call.kwargs["description"]
read_counts.side_effect = RuntimeError("database unavailable")
assert tuple(callback(MagicMock())) == ()
counts_error.append(RuntimeError("database unavailable"))
assert tuple(callback(object())) == ()
metrics.register_control_space_state_gauge(read_counts)
assert meter.create_observable_gauge.call_count == 1
assert len(meter.gauge_calls) == 1
@@ -1,6 +1,8 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from sqlalchemy.orm import Session
from core.rbac.entities import RBACPermission
from services.enterprise import rbac_service
@@ -29,7 +31,7 @@ def test_enterprise_knowledge_fs_permissions_use_one_batch_request() -> None:
"tenant-1",
"account-1",
["control-1", "control-2"],
session=MagicMock(),
session=Session(),
)
inner_call.assert_called_once_with(
@@ -67,7 +69,7 @@ def test_knowledge_fs_capabilities_reuse_the_legacy_dataset_permission_points()
def test_rbac_disabled_fallback_grants_every_dataset_point_knowledge_fs_consults() -> None:
with config_overrides_context(RBAC_ENABLED=False):
permissions = rbac_service.RBACService.KnowledgeFSPermissions.batch_get(
"tenant-1", "account-1", ["control-1"], session=MagicMock()
"tenant-1", "account-1", ["control-1"], session=Session()
)
assert permissions["control-1"] == list(KNOWLEDGE_FS_RBAC_PERMISSION_KEYS)
@@ -98,7 +100,7 @@ def test_rbac_transport_failure_filters_every_control_space() -> None:
port = DifyKnowledgeFSProductRBACPort()
with patch.object(rbac_service.RBACService.KnowledgeFSPermissions, "batch_get", side_effect=RuntimeError("down")):
allowed = port.filter_authorized_control_space_ids(
session=MagicMock(),
session=Session(),
tenant_id="tenant-1",
account_id="account-1",
control_space_ids=["control-1"],
@@ -119,7 +121,7 @@ def test_rbac_permission_map_ignores_unknown_keys_and_preserves_one_resource_bat
},
) as batch_get:
permissions = port.permission_keys_by_control_space(
session=MagicMock(),
session=Session(),
tenant_id="tenant-1",
account_id="account-1",
control_space_ids=["control-1", "control-2"],
@@ -3,7 +3,6 @@ from __future__ import annotations
from collections.abc import Sequence
from datetime import UTC, datetime
from typing import Never, override
from unittest.mock import MagicMock
import pytest
from sqlalchemy import event
@@ -36,6 +35,7 @@ from services.knowledge_fs.product_remote import (
UnavailableKnowledgeFSProductRemote,
)
from services.knowledge_fs.product_service import KnowledgeFSProductService
from tests.unit_tests.knowledge_fs_core_fakes import RecordingOperationalMetrics
class FakeRBAC:
@@ -268,7 +268,7 @@ def test_list_filters_locally_then_fetches_one_explicit_authorized_batch(sqlite_
remote = FakeRemote({"space-owner": summary, "space-hidden": summary})
batch_capabilities = FakeBatchCapabilities()
rbac = FakeRBAC()
metrics = MagicMock()
metrics = RecordingOperationalMetrics()
clock = iter((10.0, 10.25))
service = KnowledgeFSProductService(
sessionmaker(bind=sqlite_session.get_bind(), expire_on_commit=False),
@@ -326,8 +326,8 @@ def test_list_filters_locally_then_fetches_one_explicit_authorized_batch(sqlite_
KnowledgeFSProductPermission.READ,
KnowledgeFSProductPermission.QUERY,
}
assert metrics.record_batch_status.call_args.args[0] == (0.25, 2, "degraded", 3, 1)
assert "tenant-1" not in str(metrics.record_batch_status.call_args_list)
assert metrics.batch_statuses[-1] == (0.25, 2, "degraded", 3, 1)
assert "tenant-1" not in str(metrics.batch_statuses)
@pytest.mark.parametrize(
@@ -768,7 +768,7 @@ def test_list_fails_closed_without_remote_io_when_batch_capability_cannot_be_iss
)
remote = FakeRemote({"space-1": summary})
batch_capabilities = FakeBatchCapabilities(fail=True)
metrics = MagicMock()
metrics = RecordingOperationalMetrics()
clock = iter((20.0, 20.5))
service = KnowledgeFSProductService(
sessionmaker(bind=sqlite_session.get_bind(), expire_on_commit=False),
@@ -785,7 +785,7 @@ def test_list_fails_closed_without_remote_io_when_batch_capability_cannot_be_iss
assert response.data[0].technical_status == "unavailable"
assert len(batch_capabilities.calls) == 1
assert remote.batch_calls == []
assert metrics.record_batch_status.call_args.args[0] == (0.5, 1, "failed", 1, 0)
assert metrics.batch_statuses[-1] == (0.5, 1, "failed", 1, 0)
@pytest.mark.parametrize(
@@ -2,12 +2,15 @@ from __future__ import annotations
from collections.abc import Callable, Iterator
from contextlib import nullcontext
from types import SimpleNamespace
from datetime import datetime
import pytest
from extensions.storage.storage_type import StorageType
from graphon.file import File, FileTransferMethod, FileType
from models.enums import CreatorUserRole
from models.model import UploadFile
from models.tools import ToolFile
from services.knowledge_fs import query_images
@@ -25,25 +28,28 @@ def _upload(
mime_type: str = "image/png",
size: int = 12,
tenant_id: str = "tenant-1",
) -> SimpleNamespace:
return SimpleNamespace(
created_by=account_id,
created_by_role=CreatorUserRole.ACCOUNT,
id=upload_file_id,
key=f"uploads/{upload_file_id}",
mime_type=mime_type,
size=size,
) -> UploadFile:
upload = UploadFile(
tenant_id=tenant_id,
storage_type=StorageType.LOCAL,
key=f"uploads/{upload_file_id}",
name="image.png",
size=size,
extension="png",
mime_type=mime_type,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=account_id,
created_at=datetime(2026, 7, 21, 12, 0),
used=False,
used_at=None,
used_by=None,
)
upload.id = upload_file_id
return upload
def _install_files(monkeypatch: pytest.MonkeyPatch, files: list[SimpleNamespace]) -> _Session:
def _install_files(monkeypatch: pytest.MonkeyPatch, files: list[UploadFile]) -> _Session:
session = _Session()
def get_upload_files_by_ids(_tenant_id: str, _ids: list[str], *, session: _Session) -> dict[str, SimpleNamespace]:
def get_upload_files_by_ids(_tenant_id: str, _ids: list[str], *, session: _Session) -> dict[str, UploadFile]:
assert session is not None
return {item.id: item for item in files}
@@ -59,7 +65,8 @@ def _install_files(monkeypatch: pytest.MonkeyPatch, files: list[SimpleNamespace]
def test_validate_query_images_checks_actor_bounds_and_marks_usage(monkeypatch: pytest.MonkeyPatch) -> None:
upload = _upload()
session = _install_files(monkeypatch, [upload])
monkeypatch.setattr(query_images, "naive_utc_now", lambda: "now")
used_at = datetime(2026, 7, 21, 12, 30)
monkeypatch.setattr(query_images, "naive_utc_now", lambda: used_at)
result = query_images.validate_query_image_references(
tenant_id="tenant-1",
@@ -77,7 +84,7 @@ def test_validate_query_images_checks_actor_bounds_and_marks_usage(monkeypatch:
]
assert upload.used is True
assert upload.used_by == "account-1"
assert upload.used_at == "now"
assert upload.used_at == used_at
assert session.committed is True
@@ -92,7 +99,7 @@ def test_validate_query_images_checks_actor_bounds_and_marks_usage(monkeypatch:
)
def test_validate_query_images_rejects_hidden_or_invalid_files(
monkeypatch: pytest.MonkeyPatch,
upload: SimpleNamespace,
upload: UploadFile,
code: str,
) -> None:
_install_files(monkeypatch, [upload])
@@ -221,7 +228,7 @@ def test_workflow_grant_is_file_tenant_subject_scoped_and_loads_without_actor_ow
_install_files(monkeypatch, [upload])
class _Controller:
def get_upload_file(self, **_kwargs: object) -> SimpleNamespace:
def get_upload_file(self, **_kwargs: object) -> UploadFile:
return upload
monkeypatch.setattr(query_images, "DatabaseFileAccessController", _Controller)
@@ -263,21 +270,23 @@ def test_workflow_grant_loads_tool_files(
config_overrides: Callable[..., None],
) -> None:
body = b"\x89PNG\r\n\x1a\nrest"
tool_file = SimpleNamespace(
tool_file = ToolFile(
user_id="account-1",
tenant_id="tenant-1",
conversation_id=None,
file_key="tool-files/image.png",
id="00000000-0000-4000-8000-000000000002",
mimetype="image/png",
size=len(body),
tenant_id="tenant-1",
)
tool_file.id = "00000000-0000-4000-8000-000000000002"
class _ToolFileSession(_Session):
def get(self, model: object, file_id: str) -> SimpleNamespace | None:
def get(self, model: object, file_id: str) -> ToolFile | None:
assert model is query_images.ToolFile
return tool_file if file_id == tool_file.id else None
class _Controller:
def get_tool_file(self, **_kwargs: object) -> SimpleNamespace:
def get_tool_file(self, **_kwargs: object) -> ToolFile:
return tool_file
monkeypatch.setattr(query_images.session_factory, "create_session", lambda: nullcontext(_ToolFileSession()))
@@ -348,7 +357,7 @@ def test_workflow_grant_rejects_cross_tenant_files_and_expiry(monkeypatch: pytes
upload = _upload(tenant_id="other-tenant")
class _Controller:
def get_upload_file(self, **_kwargs: object) -> SimpleNamespace:
def get_upload_file(self, **_kwargs: object) -> UploadFile:
return upload
monkeypatch.setattr(query_images, "DatabaseFileAccessController", _Controller)
@@ -1,23 +1,23 @@
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from sqlalchemy.orm import sessionmaker
from core.db.session_factory import session_factory
from repositories import sqlalchemy_knowledge_fs_capability_issuance_auditor
from services import knowledge_fs_capability
from services.knowledge_fs import lifecycle_remote_http, remote_registry
from tests.unit_tests.config_override import apply_config_overrides
from tests.unit_tests.knowledge_fs_core_fakes import Marker
def test_configured_lifecycle_remote_assembles_capability_v2_http_client(
monkeypatch: pytest.MonkeyPatch,
) -> None:
maker = MagicMock(name="session-maker")
auditor = MagicMock(name="capability-auditor")
issuer = MagicMock(name="capability-issuer")
client = MagicMock(name="lifecycle-http-client")
maker = sessionmaker()
auditor = Marker("capability-auditor")
issuer = Marker("capability-issuer")
client = Marker("lifecycle-http-client")
captured: dict[str, object] = {}
apply_config_overrides(
@@ -71,7 +71,7 @@ def test_lifecycle_remote_assembly_fails_closed_without_endpoint_or_capability_i
def test_explicit_lifecycle_remote_factory_remains_available_for_isolated_process_tests(
monkeypatch: pytest.MonkeyPatch,
) -> None:
remote = MagicMock(name="remote")
remote = Marker("remote")
monkeypatch.setattr(remote_registry, "_remote_factory", None)
remote_registry.configure_knowledge_fs_lifecycle_remote_factory(lambda: remote)
@@ -1,28 +1,27 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from sqlalchemy.orm import sessionmaker
from services.knowledge_fs import runtime
from services.knowledge_fs.product_remote import KnowledgeFSOperationUnavailableError
from tests.unit_tests.config_override import apply_config_overrides
from tests.unit_tests.knowledge_fs_core_fakes import Marker, RecordingCallable
def test_runtime_fails_closed_when_the_remote_endpoint_is_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
apply_config_overrides(monkeypatch, KNOWLEDGE_FS_BASE_URL=None)
with pytest.raises(KnowledgeFSOperationUnavailableError, match="not configured"):
runtime.create_knowledge_fs_runtime(MagicMock())
runtime.create_knowledge_fs_runtime(sessionmaker())
def test_process_runtime_is_built_once_for_the_application_session_factory(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_maker = MagicMock(name="session_maker")
built_runtime = SimpleNamespace(component="runtime")
factory = MagicMock(return_value=built_runtime)
session_maker = sessionmaker()
built_runtime = Marker("runtime")
factory = RecordingCallable(return_value=built_runtime)
monkeypatch.setattr(runtime, "create_knowledge_fs_runtime", factory)
first = runtime.get_knowledge_fs_runtime(session_maker)
@@ -66,14 +65,13 @@ def test_runtime_wires_one_shared_authorization_and_remote_graph(monkeypatch: py
"create_configured_knowledge_fs_capability_issuer",
"get_knowledge_fs_lifecycle_remote",
)
factories: dict[str, MagicMock] = {}
factories: dict[str, RecordingCallable] = {}
for name in factory_names:
factory = MagicMock(name=name)
factory.return_value = SimpleNamespace(component=name)
factory = RecordingCallable(return_value=Marker(name), name=name)
factories[name] = factory
monkeypatch.setattr(runtime, name, factory)
session_maker = MagicMock(name="session_maker")
session_maker = sessionmaker()
result = runtime.create_knowledge_fs_runtime(session_maker)
assert result.application is factories["KnowledgeFSProductApplicationService"].return_value
@@ -95,7 +93,7 @@ def test_runtime_wires_one_shared_authorization_and_remote_graph(monkeypatch: py
audit=factories["SQLAlchemyKnowledgeFSCapabilityIssuanceAuditor"].return_value
)
assert (
factories["KnowledgeFSProductService"].call_args.kwargs["cutover_gate"]
factories["KnowledgeFSProductService"].last_call.kwargs["cutover_gate"]
is factories["SQLKnowledgeFSWorkspaceRuntimeGate"].return_value
)
factories["KnowledgeFSWorkspaceCutoverService"].assert_called_once_with(
@@ -111,7 +109,7 @@ def test_runtime_wires_one_shared_authorization_and_remote_graph(monkeypatch: py
initializer=factories["KnowledgeFSWorkspaceGreenfieldInitializer"].return_value,
)
assert (
factories["KnowledgeFSProductApplicationService"].call_args.kwargs["rbac"]
factories["KnowledgeFSProductApplicationService"].last_call.kwargs["rbac"]
is factories["DifyKnowledgeFSProductRBACPort"].return_value
)
assert factories["KnowledgeFSAppExecutionCapabilityService"].call_args.kwargs["admission"] is result.app_admission
assert factories["KnowledgeFSAppExecutionCapabilityService"].last_call.kwargs["admission"] is result.app_admission
@@ -1,16 +1,19 @@
from __future__ import annotations
from contextlib import nullcontext
from datetime import datetime
from io import BytesIO
from types import SimpleNamespace
import pytest
from werkzeug.datastructures import FileStorage
from core.knowledge_fs.errors import KnowledgeFSProductRequestRejectedError, KnowledgeFSQueryImageError
from extensions.storage.storage_type import StorageType
from models.enums import ApiTokenType, CreatorUserRole
from models.model import UploadFile
from services.knowledge_fs import query_images, service_query_image_upload
from services.knowledge_fs.service_api_authorization import KnowledgeFSServiceApiProfile
from tests.unit_tests.config_override import apply_config_overrides
TOKEN_ID = "00000000-0000-4000-8000-000000000001"
OTHER_TOKEN_ID = "00000000-0000-4000-8000-000000000002"
@@ -52,7 +55,7 @@ def _install(monkeypatch: pytest.MonkeyPatch) -> tuple[_Session, dict[str, bytes
monkeypatch.setattr(service_query_image_upload.storage, "save", lambda key, body: objects.__setitem__(key, body))
monkeypatch.setattr(service_query_image_upload.storage, "delete", lambda key: objects.pop(key, None))
monkeypatch.setattr(query_images.storage, "load", lambda key, **_kwargs: iter([objects[key]]))
monkeypatch.setattr(service_query_image_upload, "dify_config", SimpleNamespace(STORAGE_TYPE="local"))
apply_config_overrides(monkeypatch, STORAGE_TYPE="local")
return session, objects
@@ -60,19 +63,22 @@ def _file(body: bytes = PNG, *, mime_type: str = "image/png", filename: str = "i
return FileStorage(stream=BytesIO(body), filename=filename, content_type=mime_type)
def _record(*, role: CreatorUserRole = CreatorUserRole.DATASET_API_KEY, owner: str = TOKEN_ID):
return SimpleNamespace(
id=FILE_ID,
def _record(*, role: CreatorUserRole = CreatorUserRole.DATASET_API_KEY, owner: str = TOKEN_ID) -> UploadFile:
record = UploadFile(
tenant_id=TENANT_ID,
storage_type=StorageType.LOCAL,
key="image-key",
name="image.png",
size=len(PNG),
extension="png",
mime_type="image/png",
created_by_role=role,
created_by=owner,
mime_type="image/png",
size=len(PNG),
key="image-key",
created_at=datetime(2026, 7, 21, 12, 0),
used=False,
used_by=None,
used_at=None,
)
record.id = FILE_ID
return record
def test_upload_admission_and_inner_load_use_same_active_key_owner(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -1,4 +1,4 @@
from unittest.mock import MagicMock
from dataclasses import dataclass, field
import pytest
import sqlalchemy as sa
@@ -38,8 +38,29 @@ def _tag(tag_id: str, *, tenant_id: str = "tenant-1", tag_type: TagType = TagTyp
return tag
def _service(factory: sessionmaker[Session]) -> tuple[KnowledgeFSSpaceTagService, MagicMock]:
product = MagicMock()
@dataclass
class _RecordingAuthorization:
permissions: list[KnowledgeFSProductPermission] = field(default_factory=list)
def authorize_control_space_in_session(
self,
*,
session: Session,
tenant_id: str,
account_id: str,
control_space_id: str,
permission: KnowledgeFSProductPermission,
require_active: bool = False,
) -> object:
assert session.is_active
assert (tenant_id, account_id, control_space_id) == ("tenant-1", "account-1", "control-1")
assert require_active is False
self.permissions.append(permission)
return object()
def _service(factory: sessionmaker[Session]) -> tuple[KnowledgeFSSpaceTagService, _RecordingAuthorization]:
product = _RecordingAuthorization()
return KnowledgeFSSpaceTagService(factory, product=product), product
@@ -68,8 +89,7 @@ def test_replace_tags_is_idempotent_and_lists_final_set(sqlite_session_factory:
] == ["tag-1"]
with sqlite_session_factory() as session:
assert session.scalar(sa.select(sa.func.count(KnowledgeFSSpaceTagBinding.id))) == 1
first_authorization = product.authorize_control_space_in_session.call_args_list[0]
assert first_authorization.kwargs["permission"] is KnowledgeFSProductPermission.EDIT
assert product.permissions[0] is KnowledgeFSProductPermission.EDIT
@pytest.mark.parametrize(
@@ -1,7 +1,7 @@
import hashlib
from contextlib import nullcontext
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from dataclasses import dataclass
from unittest.mock import patch
import pytest
from pydantic import ValidationError
@@ -24,11 +24,20 @@ from services.knowledge_fs.text_store_native import (
from services.knowledge_fs.vector_store import VectorStoreUnavailableError
from services.knowledge_fs.vector_store_elasticsearch import ElasticsearchVectorStore
from services.knowledge_fs.vector_store_weaviate import WeaviateVectorStore
from tests.unit_tests.knowledge_fs_core_fakes import Marker
A, B, C, GENERATION = (f"00000000-0000-4000-8000-{n:012d}" for n in (1, 2, 3, 4))
SCOPE = {"tenant_id": A, "knowledge_space_id": B, "index_version": "fts-v1"}
@dataclass(frozen=True)
class _StaticTextClient:
result: dict
def execute(self, _payload: TextPayload) -> dict:
return self.result
def point(identifier=A, text="refund policy 退 款"):
return {
"id": identifier,
@@ -139,7 +148,7 @@ def test_checksum_and_extra_configuration_rejected():
)
def test_invalid_backend_readback_rejected(result):
with pytest.raises(VectorStoreUnavailableError):
execute_text_request(SimpleNamespace(execute=lambda _: result), request("get", ids=[A]))
execute_text_request(_StaticTextClient(result), request("get", ids=[A]))
@pytest.mark.parametrize(
@@ -148,12 +157,12 @@ def test_invalid_backend_readback_rejected(result):
def test_unauthorized_duplicate_and_nonfinite_matches_rejected(matches):
with pytest.raises(VectorStoreUnavailableError):
execute_text_request(
SimpleNamespace(execute=lambda _: {"points": [], "matches": matches}),
_StaticTextClient({"points": [], "matches": matches}),
request("search", ids=[A], query="refund"),
)
with pytest.raises(VectorStoreUnavailableError):
execute_text_request(
SimpleNamespace(execute=lambda _: {"points": [], "matches": matches * 2}),
_StaticTextClient({"points": [], "matches": matches * 2}),
request("search", ids=[A], query="refund", limit=1),
)
@@ -167,7 +176,7 @@ def test_unauthorized_duplicate_and_nonfinite_matches_rejected(matches):
)
def test_selection_reuses_vector_configuration(config_overrides, backend, wrapper, expected):
config_overrides(VECTOR_STORE=backend)
native = wrapper(MagicMock())
native = wrapper(Marker("native-client"))
with patch(
"services.knowledge_fs.vector_store.configured_vector_client", return_value=nullcontext(native)
) as factory:
@@ -1,87 +1,140 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from unittest.mock import patch
import pymysql
import pytest
from models.dataset import TidbAuthBinding
from models.enums import TidbAuthBindingStatus
from services.knowledge_fs.text_store import TextIndexPendingError, configured_text_client, execute_text_request
from services.knowledge_fs.text_store_tidb import TidbTextStore, _sql_endpoint
from services.knowledge_fs.vector_store import VectorStoreUnavailableError
from tests.unit_tests.services.test_knowledge_fs_text_store import GENERATION, A, point, request
@dataclass
class _FakeCursor:
"""pymysql cursor double: queued fetchone rows win over the steady fetchone value."""
fetchone_queue: list[object] = field(default_factory=list)
fetchone_value: object = (1,)
fetchall_value: list[tuple[object, ...]] = field(default_factory=list)
execute_error: BaseException | Callable[..., None] | None = None
executed: list[tuple[object, ...]] = field(default_factory=list)
executemany_calls: list[tuple[str, Sequence[object]]] = field(default_factory=list)
def __enter__(self) -> "_FakeCursor":
return self
def __exit__(self, *_exc_info: object) -> None:
return None
def execute(self, sql: str, *args: object) -> None:
self.executed.append((sql, *args))
if isinstance(self.execute_error, BaseException):
raise self.execute_error
if self.execute_error is not None:
self.execute_error(sql, *args)
def executemany(self, sql: str, rows: Sequence[object]) -> None:
self.executemany_calls.append((sql, rows))
def fetchone(self) -> object:
if self.fetchone_queue:
return self.fetchone_queue.pop(0)
return self.fetchone_value
def fetchall(self) -> list[tuple[object, ...]]:
return self.fetchall_value
@dataclass
class _FakeConnection:
cursor_double: _FakeCursor = field(default_factory=_FakeCursor)
close_count: int = 0
def cursor(self) -> _FakeCursor:
return self.cursor_double
def close(self) -> None:
self.close_count += 1
@pytest.fixture
def store():
connection = MagicMock()
cursor = connection.cursor.return_value.__enter__.return_value
cursor.fetchone.return_value = (1,)
cursor.fetchall.return_value = []
return TidbTextStore(connection)
def connection() -> _FakeConnection:
return _FakeConnection()
def test_native_tidb_ddl_write_readback_query_and_delete(store):
cursor = store.connection.cursor.return_value.__enter__.return_value
cursor.fetchone.side_effect = [None, None, (1,)]
@pytest.fixture
def cursor(connection: _FakeConnection) -> _FakeCursor:
return connection.cursor_double
@pytest.fixture
def store(connection: _FakeConnection):
return TidbTextStore(connection) # type: ignore[arg-type]
def test_native_tidb_ddl_write_readback_query_and_delete(store, connection, cursor):
cursor.fetchone_queue = [None, None, (1,)]
execute_text_request(store, request("upsert", points=[point()]))
sqls = [call.args[0] for call in cursor.execute.call_args_list]
sqls = [call[0] for call in cursor.executed]
assert any("FULLTEXT INDEX" in sql and "MULTILINGUAL" in sql for sql in sqls)
assert "ON DUPLICATE KEY UPDATE id=id" in cursor.executemany.call_args.args[0]
cursor.fetchone.side_effect = None
cursor.fetchone.return_value = (1,)
cursor.fetchall.return_value = [(A, GENERATION, point()["content_hash"], point()["text"])]
assert "ON DUPLICATE KEY UPDATE id=id" in cursor.executemany_calls[-1][0]
cursor.fetchone_queue = []
cursor.fetchone_value = (1,)
cursor.fetchall_value = [(A, GENERATION, point()["content_hash"], point()["text"])]
assert execute_text_request(store, request("get", ids=[A]))["points"] == [point()]
cursor.fetchall.return_value = [(A, 1.25)]
cursor.fetchall_value = [(A, 1.25)]
assert execute_text_request(store, request("search", ids=[A], query="退 款"))["matches"] == [
{"id": A, "score": 1.25}
]
sql, params = cursor.execute.call_args.args
sql, params = cursor.executed[-1]
assert "IGNORE INDEX (PRIMARY)" in sql
assert "READ_FROM_STORAGE(TIFLASH[" in sql
assert "CONCAT(' ',text,' ') LIKE %s ESCAPE '!'" in sql
assert "id IN (%s)" in sql
assert params == ("退 款", "退 款", A, "% 款 %", "% 退 %", 10)
cursor.fetchall.return_value = []
cursor.fetchall_value = []
execute_text_request(store, request("delete", ids=[A]))
cursor.fetchall.return_value = [(A, GENERATION, point()["content_hash"], point()["text"])]
cursor.fetchall_value = [(A, GENERATION, point()["content_hash"], point()["text"])]
with pytest.raises(VectorStoreUnavailableError, match="not visible"):
execute_text_request(store, request("delete", ids=[A]))
store.close()
store.connection.close.assert_called_once()
assert connection.close_count == 1
@pytest.mark.parametrize("operation", ["get", "delete", "search"])
def test_missing_index_distinct_from_provider_failure(store, operation):
cursor = store.connection.cursor.return_value.__enter__.return_value
cursor.fetchone.return_value = None
def test_missing_index_distinct_from_provider_failure(store, cursor, operation):
cursor.fetchone_value = None
payload = request(operation, ids=[A], **({"query": "refund"} if operation == "search" else {}))
if operation == "search":
with pytest.raises(VectorStoreUnavailableError, match="missing"):
execute_text_request(store, payload)
else:
assert execute_text_request(store, payload) == {"points": [], "matches": []}
cursor.execute.side_effect = OSError("unavailable")
cursor.execute_error = OSError("unavailable")
with pytest.raises(OSError):
execute_text_request(store, payload)
@pytest.mark.parametrize("replica", [None, (0,)])
def test_unready_columnar_index_requires_retry(store, replica):
cursor = store.connection.cursor.return_value.__enter__.return_value
cursor.fetchone.side_effect = [(1,), replica]
def test_unready_columnar_index_requires_retry(store, cursor, replica):
cursor.fetchone_queue = [(1,), replica]
with pytest.raises(TextIndexPendingError):
execute_text_request(store, request("search", ids=[A], query="refund"))
@pytest.mark.parametrize("code", [1061, 8200])
def test_concurrent_ddl_only_accepts_duplicate_index(store, code):
cursor = store.connection.cursor.return_value.__enter__.return_value
cursor.fetchone.side_effect = [(1,), None, (1,)]
def test_concurrent_ddl_only_accepts_duplicate_index(store, cursor, code):
cursor.fetchone_queue = [(1,), None, (1,)]
def execute(sql, *_args):
if "ALTER TABLE" in sql:
raise pymysql.err.OperationalError(code, "duplicate or unsupported")
cursor.execute.side_effect = execute
cursor.execute_error = execute
if code == 1061:
execute_text_request(store, request("upsert", points=[point()]))
else:
@@ -94,7 +147,15 @@ def test_uses_existing_dify_tenant_binding_and_tls_metadata_endpoint(config_over
config_overrides(TIDB_API_URL="https://cloud.example.test")
config_overrides(TIDB_PUBLIC_KEY="test-public")
config_overrides(TIDB_PRIVATE_KEY="test-private")
binding = SimpleNamespace(cluster_id="cluster-test", account="test-account", password="test-password")
binding = TidbAuthBinding(
tenant_id=A,
cluster_id="cluster-test",
cluster_name="cluster",
active=True,
status=TidbAuthBindingStatus.ACTIVE,
account="test-account",
password="test-password",
)
_sql_endpoint.cache_clear()
with (
patch("services.tidb_binding_service.resolve_tidb_auth_binding", return_value=binding) as resolver,
@@ -2,7 +2,7 @@ import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from types import SimpleNamespace
from dataclasses import dataclass
from unittest.mock import MagicMock, patch
import httpx
@@ -10,17 +10,53 @@ import pytest
from dify_vdb_tidb_on_qdrant.tidb_service import TidbService
from redis.exceptions import LockError
from sqlalchemy import select
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from core.rag.embedding.embedding_base import Embeddings
from models.account import Tenant
from models.dataset import TidbAuthBinding
from models.dataset import Dataset, TidbAuthBinding
from models.enums import TidbAuthBindingStatus
from services import tidb_binding_service as service
from tests.unit_tests.knowledge_fs_core_fakes import RecordingCallable
@dataclass(frozen=True)
class _Db:
engine: Engine
@dataclass(frozen=True)
class _Redis:
lock: RecordingCallable
@dataclass(frozen=True)
class _Environment:
engine: Engine
tenants: list[Tenant]
redis: _Redis
create: MagicMock
get: MagicMock
class _UnusedEmbeddings(Embeddings):
def embed_documents(self, texts: list[str]) -> list[list[float]]:
raise NotImplementedError
def embed_multimodal_documents(self, multimodel_documents: list[dict[str, object]]) -> list[list[float]]:
raise NotImplementedError
def embed_query(self, text: str) -> list[float]:
raise NotImplementedError
def embed_multimodal_query(self, multimodel_document: dict[str, object]) -> list[float]:
raise NotImplementedError
@pytest.fixture
def environment(sqlite_engine, monkeypatch, config_overrides):
monkeypatch.setattr(service, "db", SimpleNamespace(engine=sqlite_engine))
monkeypatch.setattr(service, "db", _Db(engine=sqlite_engine))
lock = threading.Lock()
@contextmanager
@@ -28,7 +64,7 @@ def environment(sqlite_engine, monkeypatch, config_overrides):
with lock:
yield
redis = SimpleNamespace(lock=MagicMock(side_effect=acquire))
redis = _Redis(lock=RecordingCallable(side_effect=acquire))
monkeypatch.setattr(service, "redis_client", redis)
config_overrides(
TIDB_PROJECT_ID="project",
@@ -54,7 +90,7 @@ def environment(sqlite_engine, monkeypatch, config_overrides):
},
) as get,
):
yield SimpleNamespace(engine=sqlite_engine, tenants=tenants, redis=redis, create=create, get=get)
yield _Environment(engine=sqlite_engine, tenants=tenants, redis=redis, create=create, get=get)
def add_binding(env, *, tenant=None, active=False, status=TidbAuthBindingStatus.ACTIVE, **changes):
@@ -353,7 +389,7 @@ def test_legacy_dataset_factory_uses_shared_binding_and_resumes_pending(environm
from dify_vdb_tidb_on_qdrant import tidb_on_qdrant_vector as legacy
binding = add_binding(environment, tenant=environment.tenants[0].id, active=True)
dataset = SimpleNamespace(id=str(uuid.uuid4()), tenant_id=binding.tenant_id, index_struct_dict=None)
dataset = Dataset(id=str(uuid.uuid4()), tenant_id=binding.tenant_id, index_struct=None)
with (
patch.object(
legacy, "resolve_tidb_auth_binding", side_effect=[service.TidbBindingPendingError(), binding]
@@ -361,7 +397,7 @@ def test_legacy_dataset_factory_uses_shared_binding_and_resumes_pending(environm
patch.object(legacy.time, "sleep") as sleep,
patch.object(legacy, "TidbOnQdrantVector") as vector,
):
legacy.TidbOnQdrantVectorFactory().init_vector(dataset, [], MagicMock(), session=None)
legacy.TidbOnQdrantVectorFactory().init_vector(dataset, [], _UnusedEmbeddings(), session=None)
assert resolve.call_count == 2
resolve.assert_called_with(binding.tenant_id, allow_create=True)
sleep.assert_called_once_with(5)
@@ -372,12 +408,12 @@ def test_legacy_dataset_factory_uses_shared_binding_and_resumes_pending(environm
def test_legacy_dataset_factory_bounds_readiness_wait(environment):
from dify_vdb_tidb_on_qdrant import tidb_on_qdrant_vector as legacy
dataset = SimpleNamespace(id=str(uuid.uuid4()), tenant_id=environment.tenants[0].id)
dataset = Dataset(id=str(uuid.uuid4()), tenant_id=environment.tenants[0].id)
with (
patch.object(legacy, "resolve_tidb_auth_binding", side_effect=service.TidbBindingPendingError()),
patch.object(legacy.time, "monotonic", side_effect=[0, 901]),
patch.object(legacy.time, "sleep") as sleep,
):
with pytest.raises(service.TidbBindingPendingError):
legacy.TidbOnQdrantVectorFactory().init_vector(dataset, [], MagicMock(), session=None)
legacy.TidbOnQdrantVectorFactory().init_vector(dataset, [], _UnusedEmbeddings(), session=None)
sleep.assert_not_called()