mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 23:06:40 +08:00
497 lines
19 KiB
Python
497 lines
19 KiB
Python
import json
|
|
from collections.abc import Callable
|
|
from datetime import datetime
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from controllers.console.datasets.datasets import (
|
|
DatasetDetailResponse,
|
|
DatasetListResponse,
|
|
DatasetQueryListResponse,
|
|
RelatedAppListResponse,
|
|
)
|
|
from enums import DeploymentEdition
|
|
from machinery.context import RequestContext
|
|
from models import Account, App, Dataset, Document
|
|
from models.account import Tenant, TenantAccountJoin, TenantAccountRole
|
|
from models.dataset import (
|
|
AppDatasetJoin,
|
|
DatasetPermission,
|
|
DatasetPermissionEnum,
|
|
DatasetQuery,
|
|
DocumentSegment,
|
|
)
|
|
from models.enums import (
|
|
CreatorUserRole,
|
|
DatasetQuerySource,
|
|
DataSourceType,
|
|
DocumentCreatedFrom,
|
|
IndexingStatus,
|
|
SegmentStatus,
|
|
)
|
|
from models.model import AppMode
|
|
from repositories.app.console_repository import ConsoleAppRepository
|
|
from services.enterprise import rbac_service
|
|
from services.knowledge.dataset_access import DatasetNotFoundError
|
|
from services.knowledge.datasets.adapters import SQLAlchemyDatasetOperations
|
|
from services.knowledge.datasets.application import DatasetListFilter
|
|
from services.knowledge.documents.adapters import SQLAlchemyDocumentOperations
|
|
from services.knowledge.entities.document_creation import DocumentIndexingJobs
|
|
from services.knowledge.entities.knowledge_entities import KnowledgeConfig
|
|
from services.knowledge.resource_scope import DatasetRef
|
|
from services.rbac import contracts as rbac_contracts
|
|
from services.tag_application_service import CreateTagInput, TagApplicationService, TagBindingInput
|
|
from tests.helpers.unit.config_override import apply_config_overrides
|
|
from tests.helpers.unit.rbac_fakes import RBACDomain
|
|
|
|
CONTEXT = RequestContext("request", None, "actor", "tenant")
|
|
REF = DatasetRef("tenant", "dataset")
|
|
|
|
|
|
def dataset(**values: object) -> Dataset:
|
|
row = Dataset(
|
|
id="dataset",
|
|
tenant_id="tenant",
|
|
name="Dataset",
|
|
created_by="actor",
|
|
maintainer="actor",
|
|
indexing_technique="economy",
|
|
permission="all_team_members",
|
|
)
|
|
for key, value in values.items():
|
|
setattr(row, key, value)
|
|
return row
|
|
|
|
|
|
def document(**values: object) -> Document:
|
|
row = Document(
|
|
tenant_id="tenant",
|
|
dataset_id="dataset",
|
|
position=1,
|
|
data_source_type=DataSourceType("local_file"),
|
|
batch="batch",
|
|
name="Document",
|
|
created_from=DocumentCreatedFrom("web"),
|
|
created_by="actor",
|
|
)
|
|
row.id = "document"
|
|
row.indexing_status = IndexingStatus.ERROR
|
|
for key, value in values.items():
|
|
setattr(row, key, value)
|
|
return row
|
|
|
|
|
|
@pytest.fixture
|
|
def operations(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
application_tags: TagApplicationService,
|
|
rbac_domain: RBACDomain,
|
|
) -> SQLAlchemyDatasetOperations:
|
|
apply_config_overrides(monkeypatch, RBAC_ENABLED=False)
|
|
with sqlite_session_factory.begin() as session:
|
|
tenant = Tenant(name="Workspace")
|
|
tenant.id = "tenant"
|
|
account = Account(name="Actor", email="actor@example.com")
|
|
account.id = "actor"
|
|
session.add_all(
|
|
[
|
|
tenant,
|
|
account,
|
|
TenantAccountJoin(tenant_id="tenant", account_id="actor", role=TenantAccountRole.OWNER),
|
|
dataset(),
|
|
dataset(id="foreign", tenant_id="other", name="Foreign"),
|
|
]
|
|
)
|
|
return SQLAlchemyDatasetOperations(
|
|
session_factory=sqlite_session_factory,
|
|
tags=application_tags,
|
|
app_queries=ConsoleAppRepository(session_factory=sqlite_session_factory),
|
|
members=rbac_domain.rbac.members,
|
|
)
|
|
|
|
|
|
def test_listing_materializes_page_and_owner_scoped_partial_members(
|
|
operations: SQLAlchemyDatasetOperations, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
with sqlite_session_factory.begin() as session:
|
|
row = session.get(Dataset, "dataset")
|
|
assert row is not None
|
|
row.permission = DatasetPermissionEnum.PARTIAL_TEAM
|
|
session.add_all(
|
|
[
|
|
DatasetPermission(tenant_id="tenant", dataset_id="dataset", account_id="member"),
|
|
DatasetPermission(tenant_id="other", dataset_id="dataset", account_id="foreign"),
|
|
]
|
|
)
|
|
result = operations.list_datasets(CONTEXT, DatasetListFilter(page=0, limit=1000, ids=["dataset", "foreign"]), None)
|
|
response = DatasetListResponse.model_validate(result).model_dump(mode="json")
|
|
assert response["total"] == 1
|
|
assert response["page"] == 1
|
|
assert response["limit"] == 100
|
|
assert response["has_more"] is False
|
|
assert response["data"][0]["partial_member_list"] == ["member"]
|
|
assert response["data"][0]["retrieval_model_dict"]["top_k"] == 2
|
|
|
|
|
|
def test_listing_serializes_legacy_retrieval_settings_without_rewriting_rows(
|
|
operations: SQLAlchemyDatasetOperations, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
stored = {
|
|
"search_method": "",
|
|
"reranking_enable": "",
|
|
"reranking_model": None,
|
|
"score_threshold_enabled": "false",
|
|
"top_k": 4,
|
|
}
|
|
with sqlite_session_factory.begin() as session:
|
|
row = session.get(Dataset, "dataset")
|
|
assert row is not None
|
|
row.retrieval_model = stored
|
|
|
|
result = operations.list_datasets(CONTEXT, DatasetListFilter(ids=["dataset", "foreign"]), None)
|
|
response = DatasetListResponse.model_validate(result).model_dump(mode="json")
|
|
|
|
assert response["total"] == 1
|
|
assert response["data"][0]["id"] == "dataset"
|
|
retrieval = response["data"][0]["retrieval_model_dict"]
|
|
assert retrieval["search_method"] == "semantic_search"
|
|
assert retrieval["reranking_enable"] is False
|
|
assert retrieval["score_threshold_enabled"] is False
|
|
assert retrieval["top_k"] == 4
|
|
with sqlite_session_factory() as session:
|
|
row = session.get(Dataset, "dataset")
|
|
assert row is not None
|
|
assert row.retrieval_model == stored
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("edition", "community_dev_enabled", "can_upgrade"),
|
|
[
|
|
(DeploymentEdition.CLOUD, False, True),
|
|
(DeploymentEdition.COMMUNITY, False, False),
|
|
(DeploymentEdition.COMMUNITY, True, True),
|
|
],
|
|
)
|
|
def test_listing_upgrade_discovery_uses_server_availability(
|
|
operations: SQLAlchemyDatasetOperations,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
edition: DeploymentEdition,
|
|
community_dev_enabled: bool,
|
|
can_upgrade: bool,
|
|
) -> None:
|
|
apply_config_overrides(
|
|
monkeypatch,
|
|
DEPLOYMENT_EDITION=edition,
|
|
KNOWLEDGE_FS_ENABLED=True,
|
|
KNOWLEDGE_FS_COMMUNITY_DEV_ENABLED=community_dev_enabled,
|
|
)
|
|
|
|
result = operations.list_datasets(CONTEXT, DatasetListFilter(ids=["dataset"]), None)
|
|
discovery = result["data"][0]["knowledge_fs_upgrade"]
|
|
|
|
assert discovery["can_upgrade"] is can_upgrade
|
|
assert discovery["block_reason"] == (None if can_upgrade else "feature_disabled")
|
|
|
|
|
|
@pytest.mark.parametrize("foreign_tag", [False, True])
|
|
def test_listing_filters_tags_within_workspace(
|
|
operations: SQLAlchemyDatasetOperations,
|
|
application_tags: TagApplicationService,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
foreign_tag: bool,
|
|
) -> None:
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add(dataset(id="untagged", name="Untagged"))
|
|
context = RequestContext("request", None, "actor", "other") if foreign_tag else CONTEXT
|
|
tag = application_tags.create_tag(context, CreateTagInput(name="Selected", type="knowledge"))
|
|
application_tags.create_bindings(
|
|
context,
|
|
TagBindingInput(tag_ids=(tag.id,), target_id="foreign" if foreign_tag else "dataset", type="knowledge"),
|
|
)
|
|
|
|
result = operations.list_datasets(CONTEXT, DatasetListFilter(tag_ids=[tag.id]), None)
|
|
response = DatasetListResponse.model_validate(result)
|
|
|
|
assert [row.id for row in response.data] == ([] if foreign_tag else ["dataset"])
|
|
assert response.total == (0 if foreign_tag else 1)
|
|
|
|
|
|
@pytest.mark.parametrize("ids", [[], ["dataset"]])
|
|
@pytest.mark.parametrize("maintainer", ["actor", "other"])
|
|
def test_list_visibility_restricts_even_requested_ids(
|
|
operations: SQLAlchemyDatasetOperations,
|
|
ids: list[str],
|
|
maintainer: str,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
apply_config_overrides(monkeypatch, RBAC_ENABLED=True)
|
|
with sqlite_session_factory.begin() as session:
|
|
row = session.get(Dataset, "dataset")
|
|
assert row is not None
|
|
row.maintainer = maintainer
|
|
result = operations.list_datasets(CONTEXT, DatasetListFilter(ids=["dataset"]), ids)
|
|
assert result["total"] == (1 if maintainer == "actor" else len(ids))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"method",
|
|
[
|
|
"get_dataset",
|
|
"is_in_use",
|
|
"queries",
|
|
"related_apps",
|
|
"indexing_status",
|
|
"error_documents",
|
|
"partial_members",
|
|
"update_dataset",
|
|
"delete_dataset",
|
|
"set_api_enabled",
|
|
],
|
|
)
|
|
def test_wrong_tenant_ref_cannot_read_or_write(operations: SQLAlchemyDatasetOperations, method: str) -> None:
|
|
args: list[object] = (
|
|
[CONTEXT] if method in {"get_dataset", "update_dataset", "delete_dataset", "set_api_enabled"} else []
|
|
)
|
|
args.append(DatasetRef("other", "dataset"))
|
|
if method == "update_dataset":
|
|
args.append({"name": "changed"})
|
|
if method == "set_api_enabled":
|
|
args.append(True)
|
|
extra: dict[str, int] = {"page": 1, "limit": 20} if method == "queries" else {}
|
|
methods: dict[str, Callable[..., object]] = {
|
|
"get_dataset": operations.get_dataset,
|
|
"is_in_use": operations.is_in_use,
|
|
"queries": operations.queries,
|
|
"related_apps": operations.related_apps,
|
|
"indexing_status": operations.indexing_status,
|
|
"error_documents": operations.error_documents,
|
|
"partial_members": operations.partial_members,
|
|
"update_dataset": operations.update_dataset,
|
|
"delete_dataset": operations.delete_dataset,
|
|
"set_api_enabled": operations.set_api_enabled,
|
|
}
|
|
with pytest.raises(DatasetNotFoundError):
|
|
methods[method](*args, **extra)
|
|
|
|
|
|
def test_create_update_and_api_status_commit_owned_changes(
|
|
operations: SQLAlchemyDatasetOperations, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
created = operations.create_dataset(
|
|
CONTEXT, {"name": "Created", "description": "desc", "indexing_technique": "economy", "permission": "only_me"}
|
|
)
|
|
assert DatasetDetailResponse.model_validate(created).name == "Created"
|
|
result = operations.update_dataset(CONTEXT, REF, {"name": "Renamed", "permission": "all_team_members"})
|
|
assert result["name"] == "Renamed"
|
|
operations.set_api_enabled(CONTEXT, REF, True)
|
|
with sqlite_session_factory() as session:
|
|
row = session.get(Dataset, "dataset")
|
|
assert row is not None
|
|
assert row.name == "Renamed"
|
|
assert row.enable_api is True
|
|
assert session.get(Dataset, created["id"]) is not None
|
|
|
|
|
|
@pytest.mark.parametrize("entry_point", ["empty", "documents"])
|
|
@pytest.mark.parametrize("rbac_enabled", [False, True])
|
|
def test_created_dataset_initializes_rbac_access(
|
|
operations: SQLAlchemyDatasetOperations,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
entry_point: str,
|
|
rbac_enabled: bool,
|
|
) -> None:
|
|
apply_config_overrides(monkeypatch, RBAC_ENABLED=rbac_enabled)
|
|
|
|
def save_documents(
|
|
created_dataset: Dataset, _config: KnowledgeConfig, _account: Account, *, session: Session
|
|
) -> tuple[list[Document], str, DocumentIndexingJobs]:
|
|
row = document(id="created-document", dataset_id=created_dataset.id, data_source_type="upload_file")
|
|
session.add(row)
|
|
session.flush()
|
|
return [row], "batch", DocumentIndexingJobs(DatasetRef(created_dataset.tenant_id, created_dataset.id))
|
|
|
|
with (
|
|
patch.object(rbac_service.RBACService.DatasetAccess, "replace_whitelist") as replace_whitelist,
|
|
patch.object(rbac_service, "try_sync_creator_access_policy_member_bindings") as sync_creator,
|
|
patch(
|
|
"tasks.initialize_created_app_rbac_access_task.initialize_created_app_rbac_access_task.delay"
|
|
) as initialize,
|
|
patch(
|
|
"services.knowledge.documents.adapters.DocumentService.save_prepared_documents",
|
|
side_effect=save_documents,
|
|
),
|
|
):
|
|
if entry_point == "empty":
|
|
result = operations.create_dataset(
|
|
CONTEXT,
|
|
{"name": "Created", "description": "desc", "indexing_technique": "economy", "permission": "only_me"},
|
|
)
|
|
dataset_id = result["id"]
|
|
else:
|
|
result = SQLAlchemyDocumentOperations(session_factory=sqlite_session_factory).initialize_dataset(
|
|
CONTEXT,
|
|
{
|
|
"indexing_technique": "economy",
|
|
"data_source": {
|
|
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": ["file"]}}
|
|
},
|
|
"process_rule": {"mode": "automatic"},
|
|
},
|
|
)
|
|
dataset_id = result["dataset"]["id"]
|
|
if rbac_enabled:
|
|
replace_whitelist.assert_called_once_with(
|
|
CONTEXT.active_workspace_id,
|
|
CONTEXT.account_id,
|
|
dataset_id,
|
|
rbac_contracts.ReplaceMemberBindings(automatic_include_workspace_members=False),
|
|
)
|
|
else:
|
|
replace_whitelist.assert_not_called()
|
|
if entry_point == "documents":
|
|
if rbac_enabled:
|
|
sync_creator.assert_called_once_with(
|
|
CONTEXT.active_workspace_id,
|
|
CONTEXT.account_id,
|
|
rbac_contracts.RBACResourceType.DATASET,
|
|
dataset_id,
|
|
)
|
|
else:
|
|
sync_creator.assert_not_called()
|
|
initialize.assert_not_called()
|
|
|
|
with sqlite_session_factory() as session:
|
|
assert session.get(Dataset, dataset_id) is not None
|
|
|
|
|
|
def test_partial_members_update_and_clear_are_committed(
|
|
operations: SQLAlchemyDatasetOperations, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
result = operations.update_dataset(
|
|
CONTEXT, REF, {"permission": "partial_members", "partial_member_list": [{"user_id": "actor"}]}
|
|
)
|
|
assert result["partial_member_list"] == ["actor"]
|
|
result = operations.update_dataset(CONTEXT, REF, {"permission": "all_team_members"})
|
|
assert result["partial_member_list"] == []
|
|
with sqlite_session_factory() as session:
|
|
assert session.scalar(select(func.count()).select_from(DatasetPermission)) == 0
|
|
|
|
|
|
def test_status_counts_and_errors_exclude_foreign_documents(
|
|
operations: SQLAlchemyDatasetOperations, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add_all(
|
|
[
|
|
document(),
|
|
document(id="foreign-doc", tenant_id="other"),
|
|
DocumentSegment(
|
|
tenant_id="tenant",
|
|
dataset_id="dataset",
|
|
document_id="document",
|
|
position=1,
|
|
content="content",
|
|
word_count=1,
|
|
tokens=1,
|
|
created_by="actor",
|
|
status=SegmentStatus.WAITING,
|
|
),
|
|
DocumentSegment(
|
|
tenant_id="other",
|
|
dataset_id="dataset",
|
|
document_id="document",
|
|
position=1,
|
|
content="content",
|
|
word_count=1,
|
|
tokens=1,
|
|
created_by="actor",
|
|
),
|
|
]
|
|
)
|
|
result = operations.indexing_status(REF)
|
|
assert len(result["data"]) == 1
|
|
assert result["data"][0]["total_segments"] == 1
|
|
assert result["data"][0]["completed_segments"] == 0
|
|
assert operations.error_documents(REF)["total"] == 1
|
|
|
|
|
|
def test_queries_and_related_apps_materialize_after_session_close(
|
|
operations: SQLAlchemyDatasetOperations, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add(
|
|
DatasetQuery(
|
|
dataset_id="dataset",
|
|
content="query",
|
|
source=DatasetQuerySource.HIT_TESTING,
|
|
source_app_id=None,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by="actor",
|
|
)
|
|
)
|
|
session.add_all(
|
|
[
|
|
App(id="app", tenant_id="tenant", name="App", mode=AppMode.CHAT, enable_api=False, enable_site=False),
|
|
App(
|
|
id="foreign-app",
|
|
tenant_id="other",
|
|
name="Other",
|
|
mode=AppMode.CHAT,
|
|
enable_api=False,
|
|
enable_site=False,
|
|
),
|
|
AppDatasetJoin(app_id="app", dataset_id="dataset"),
|
|
AppDatasetJoin(app_id="foreign-app", dataset_id="dataset"),
|
|
]
|
|
)
|
|
response = DatasetQueryListResponse.model_validate(operations.queries(REF, page=0, limit=0)).model_dump(mode="json")
|
|
assert response["limit"] == 1
|
|
assert response["has_more"] is False
|
|
assert response["data"][0]["queries"][0]["content"] == "query"
|
|
related = RelatedAppListResponse.model_validate(operations.related_apps(REF)).model_dump(mode="json")
|
|
assert related["total"] == 1
|
|
assert related["data"][0]["id"] == "app"
|
|
assert related["data"][0]["mode"] == "chat"
|
|
|
|
|
|
def test_query_history_pagination_preserves_invalid_image_records(
|
|
operations: SQLAlchemyDatasetOperations, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
contents = [
|
|
"legacy text",
|
|
json.dumps(
|
|
[{"content_type": "text_query", "content": "example"}, {"content_type": "image_query", "content": ""}]
|
|
),
|
|
]
|
|
ids = []
|
|
with sqlite_session_factory.begin() as session:
|
|
for day, content in enumerate(contents, 1):
|
|
row = DatasetQuery(
|
|
dataset_id="dataset",
|
|
content=content,
|
|
source=DatasetQuerySource.HIT_TESTING,
|
|
source_app_id=None,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by="actor",
|
|
)
|
|
row.created_at = datetime(2026, 1, day)
|
|
session.add(row)
|
|
session.flush()
|
|
ids.append(row.id)
|
|
first = DatasetQueryListResponse.model_validate(operations.queries(REF, page=1, limit=1))
|
|
second = DatasetQueryListResponse.model_validate(operations.queries(REF, page=2, limit=1))
|
|
assert (first.total, first.page, first.limit, first.has_more) == (2, 1, 1, True)
|
|
assert (second.total, second.page, second.limit, second.has_more) == (2, 2, 1, False)
|
|
assert [first.data[0].id, second.data[0].id] == list(reversed(ids))
|
|
assert [item.content for item in first.data[0].queries] == ["example", ""]
|
|
assert first.data[0].queries[1].file_info is None
|
|
assert second.data[0].queries[0].content == "legacy text"
|
|
with sqlite_session_factory() as session:
|
|
assert list(session.scalars(select(DatasetQuery.content).order_by(DatasetQuery.created_at))) == contents
|