Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1c32e1bc8 |
@@ -144,7 +144,6 @@ from .workspace import (
|
||||
models,
|
||||
plugin,
|
||||
rbac,
|
||||
skills,
|
||||
snippets,
|
||||
tool_providers,
|
||||
trigger_providers,
|
||||
@@ -226,7 +225,6 @@ __all__ = [
|
||||
"saved_message",
|
||||
"setup",
|
||||
"site",
|
||||
"skills",
|
||||
"snippet_workflow",
|
||||
"snippet_workflow_draft_variable",
|
||||
"snippets",
|
||||
|
||||
@@ -59,7 +59,7 @@ class TagBindingRemovePayload(BaseModel):
|
||||
|
||||
|
||||
class TagListQueryParam(BaseModel):
|
||||
type: TagType | Literal[""] = Field("", description="Tag type filter")
|
||||
type: Literal["knowledge", "app", "snippet", ""] = Field("", description="Tag type filter")
|
||||
keyword: str | None = Field(None, description="Search keyword")
|
||||
|
||||
|
||||
|
||||
@@ -1,834 +0,0 @@
|
||||
"""Console API for workspace-level Skill Management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from flask import request, send_file
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from controllers.common.fields import BinaryFileResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from fields.base import ResponseModel
|
||||
from libs import helper
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models.account import Account
|
||||
from services.skill_management_service import (
|
||||
SkillAssistMessagePayload,
|
||||
SkillCreatePayload,
|
||||
SkillDraftFileOperationPayload,
|
||||
SkillDraftTreePayload,
|
||||
SkillImportPayload,
|
||||
SkillManagementService,
|
||||
SkillManagementServiceError,
|
||||
SkillMetadataPayload,
|
||||
SkillPublishPayload,
|
||||
SkillRestorePayload,
|
||||
SkillVersionUpdatePayload,
|
||||
)
|
||||
|
||||
_FILE_UPLOAD_PARAMS = {
|
||||
"file": {
|
||||
"description": "Skill draft file payload",
|
||||
"in": "formData",
|
||||
"type": "file",
|
||||
"required": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class WorkspaceSkillsQuery(BaseModel):
|
||||
keyword: str | None = Field(default=None, description="Search keyword matching skill name or description.")
|
||||
page: int = Field(default=1, ge=1, le=99999, description="Page number.")
|
||||
limit: int = Field(default=20, ge=1, le=100, description="Number of items per page.")
|
||||
tag: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Skill tag filters. Repeat the parameter for multiple tags.",
|
||||
)
|
||||
|
||||
|
||||
class SkillDeletePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
confirmation_name: str | None = Field(
|
||||
default=None,
|
||||
description="Required when deleting a referenced Skill. Must match the Skill name.",
|
||||
)
|
||||
|
||||
|
||||
class AgentSkillBindingsPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
skill_ids: list[str] = Field(default_factory=list, description="Ordered Skill IDs bound to the Agent.")
|
||||
|
||||
|
||||
class SkillFileQuery(BaseModel):
|
||||
path: str = Field(description="Skill file path relative to the Skill root.")
|
||||
version_id: str | None = Field(default=None, description="Optional published version ID. Omit for current draft.")
|
||||
|
||||
|
||||
class SkillResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
display_name: str
|
||||
icon: str
|
||||
description: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
name_manually_edited: bool = False
|
||||
visibility: str
|
||||
latest_published_version_id: str | None = None
|
||||
reference_count: int = 0
|
||||
created_by: str | None = None
|
||||
created_by_name: str | None = None
|
||||
updated_by: str | None = None
|
||||
updated_by_name: str | None = None
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class SkillFileResponse(ResponseModel):
|
||||
id: str | None = None
|
||||
path: str
|
||||
kind: str
|
||||
storage: str | None = None
|
||||
mime_type: str | None = None
|
||||
content: str | None = None
|
||||
tool_file_id: str | None = None
|
||||
size: int | None = None
|
||||
hash: str | None = None
|
||||
|
||||
|
||||
class SkillFilePreviewResponse(ResponseModel):
|
||||
path: str
|
||||
mime_type: str
|
||||
content: str
|
||||
size: int
|
||||
hash: str
|
||||
|
||||
|
||||
class SkillFileUploadResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
mime_type: str
|
||||
size: int
|
||||
hash: str
|
||||
|
||||
|
||||
class SkillDetailResponse(SkillResponse):
|
||||
files: list[SkillFileResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillListResponse(ResponseModel):
|
||||
data: list[SkillResponse] = Field(default_factory=list)
|
||||
has_more: bool = False
|
||||
limit: int = 20
|
||||
page: int = 1
|
||||
total: int = 0
|
||||
|
||||
|
||||
class SkillTagResponse(ResponseModel):
|
||||
tag: str
|
||||
count: int
|
||||
|
||||
|
||||
class SkillTagListResponse(ResponseModel):
|
||||
data: list[SkillTagResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillVersionResponse(ResponseModel):
|
||||
id: str
|
||||
skill_id: str
|
||||
version_number: int
|
||||
version_name: str
|
||||
publish_note: str
|
||||
hash_code: str
|
||||
archive_size: int
|
||||
published_by: str | None = None
|
||||
published_by_name: str | None = None
|
||||
is_latest: bool = False
|
||||
created_at: int
|
||||
|
||||
|
||||
class SkillVersionListResponse(ResponseModel):
|
||||
data: list[SkillVersionResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillVersionDetailResponse(SkillVersionResponse):
|
||||
files: list[SkillFileResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillVersionDeleteResponse(ResponseModel):
|
||||
id: str
|
||||
deleted: bool
|
||||
latest_published_version_id: str | None = None
|
||||
|
||||
|
||||
class SkillReferenceResponse(ResponseModel):
|
||||
type: str
|
||||
agent_id: str
|
||||
agent_icon: str | None = None
|
||||
agent_icon_background: str | None = None
|
||||
agent_icon_type: str | None = None
|
||||
app_id: str | None = None
|
||||
name: str
|
||||
display_name: str
|
||||
workflow_id: str | None = None
|
||||
workflow_name: str | None = None
|
||||
workflow_icon: str | None = None
|
||||
workflow_icon_background: str | None = None
|
||||
workflow_icon_type: str | None = None
|
||||
workflow_version: str | None = None
|
||||
node_id: str | None = None
|
||||
node_name: str | None = None
|
||||
|
||||
|
||||
class SkillReferenceListResponse(ResponseModel):
|
||||
data: list[SkillReferenceResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillDeleteResponse(ResponseModel):
|
||||
id: str
|
||||
deleted: bool
|
||||
|
||||
|
||||
class AgentSkillBindingItemResponse(ResponseModel):
|
||||
id: str
|
||||
priority: int
|
||||
name: str
|
||||
display_name: str
|
||||
icon: str
|
||||
description: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
status: str
|
||||
file_count: int
|
||||
latest_published_version_id: str | None = None
|
||||
latest_published_at: int | None = None
|
||||
updated_at: int
|
||||
|
||||
|
||||
class AgentSkillBindingsResponse(ResponseModel):
|
||||
agent_id: str
|
||||
skill_ids: list[str] = Field(default_factory=list)
|
||||
data: list[AgentSkillBindingItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
WorkspaceSkillsQuery,
|
||||
SkillCreatePayload,
|
||||
SkillAssistMessagePayload,
|
||||
SkillMetadataPayload,
|
||||
SkillDraftFileOperationPayload,
|
||||
SkillDraftTreePayload,
|
||||
SkillPublishPayload,
|
||||
SkillRestorePayload,
|
||||
SkillVersionUpdatePayload,
|
||||
SkillDeletePayload,
|
||||
SkillFileQuery,
|
||||
AgentSkillBindingsPayload,
|
||||
)
|
||||
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
SkillResponse,
|
||||
SkillFileResponse,
|
||||
SkillFilePreviewResponse,
|
||||
SkillFileUploadResponse,
|
||||
SkillDetailResponse,
|
||||
SkillListResponse,
|
||||
SkillTagResponse,
|
||||
SkillTagListResponse,
|
||||
SkillVersionResponse,
|
||||
SkillVersionListResponse,
|
||||
SkillVersionDetailResponse,
|
||||
SkillVersionDeleteResponse,
|
||||
SkillReferenceResponse,
|
||||
SkillReferenceListResponse,
|
||||
SkillDeleteResponse,
|
||||
AgentSkillBindingItemResponse,
|
||||
AgentSkillBindingsResponse,
|
||||
BinaryFileResponse,
|
||||
)
|
||||
|
||||
|
||||
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, object], int]:
|
||||
body: dict[str, object] = {"code": exc.code, "message": exc.message}
|
||||
if exc.details:
|
||||
body["details"] = exc.details
|
||||
return body, exc.status_code
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills")
|
||||
class WorkspaceSkillsApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(WorkspaceSkillsQuery))
|
||||
@console_ns.response(200, "Workspace skills", console_ns.models[SkillListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str):
|
||||
query_input: dict[str, object] = {
|
||||
"keyword": request.args.get("keyword"),
|
||||
"tag": request.args.getlist("tag"),
|
||||
}
|
||||
if "limit" in request.args:
|
||||
query_input["limit"] = request.args.get("limit")
|
||||
if "page" in request.args:
|
||||
query_input["page"] = request.args.get("page")
|
||||
query = WorkspaceSkillsQuery.model_validate(query_input)
|
||||
result = SkillManagementService().list_skills(
|
||||
tenant_id=current_tenant_id,
|
||||
keyword=query.keyword,
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
tags=[tag for tag in query.tag if tag],
|
||||
)
|
||||
return dump_response(SkillListResponse, result)
|
||||
|
||||
@console_ns.expect(console_ns.models[SkillCreatePayload.__name__])
|
||||
@console_ns.response(201, "Skill created", console_ns.models[SkillDetailResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account):
|
||||
try:
|
||||
payload = SkillCreatePayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService().create_skill(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillDetailResponse, result), 201
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except ValueError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/files/upload")
|
||||
class WorkspaceSkillFileUploadApi(Resource):
|
||||
@console_ns.doc(consumes=["multipart/form-data"], params=_FILE_UPLOAD_PARAMS)
|
||||
@console_ns.response(201, "Skill draft file uploaded", console_ns.models[SkillFileUploadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account):
|
||||
if "file" not in request.files:
|
||||
return {"code": "no_file_uploaded", "message": "no file uploaded"}, 400
|
||||
|
||||
file = request.files["file"]
|
||||
if not file.filename:
|
||||
return {"code": "filename_missing", "message": "filename is required"}, 400
|
||||
|
||||
result = SkillManagementService().upload_file(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
filename=file.filename,
|
||||
content=file.stream.read(),
|
||||
mime_type=file.mimetype,
|
||||
)
|
||||
return dump_response(SkillFileUploadResponse, result), 201
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/tags")
|
||||
class WorkspaceSkillTagsApi(Resource):
|
||||
@console_ns.response(200, "Workspace Skill tags", console_ns.models[SkillTagListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str):
|
||||
result = SkillManagementService().list_tags(tenant_id=current_tenant_id)
|
||||
return dump_response(SkillTagListResponse, result)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/import")
|
||||
class WorkspaceSkillImportApi(Resource):
|
||||
@console_ns.doc(description="Import a Skill zip package from multipart form field `file`.")
|
||||
@console_ns.response(201, "Skill imported", console_ns.models[SkillDetailResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account):
|
||||
upload = request.files.get("file")
|
||||
if upload is None:
|
||||
return {"code": "invalid_request", "message": "file is required"}, 400
|
||||
try:
|
||||
payload = SkillImportPayload(content=upload.read(), filename=upload.filename or "skill.zip")
|
||||
result = SkillManagementService().import_skill(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillDetailResponse, result), 201
|
||||
except (ValidationError, ValueError) as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>")
|
||||
class WorkspaceSkillApi(Resource):
|
||||
@console_ns.response(200, "Skill detail", console_ns.models[SkillDetailResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, skill_id: str):
|
||||
try:
|
||||
result = SkillManagementService().get_skill(tenant_id=current_tenant_id, skill_id=skill_id)
|
||||
return dump_response(SkillDetailResponse, result)
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
@console_ns.expect(console_ns.models[SkillMetadataPayload.__name__])
|
||||
@console_ns.response(200, "Skill updated", console_ns.models[SkillResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def patch(self, current_tenant_id: str, current_user: Account, skill_id: str):
|
||||
try:
|
||||
payload = SkillMetadataPayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService().update_metadata(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
skill_id=skill_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except ValueError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
@console_ns.expect(console_ns.models[SkillDeletePayload.__name__])
|
||||
@console_ns.response(200, "Skill deleted", console_ns.models[SkillDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_tenant_id
|
||||
def delete(self, current_tenant_id: str, skill_id: str):
|
||||
try:
|
||||
payload = SkillDeletePayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService().delete_skill(
|
||||
tenant_id=current_tenant_id,
|
||||
skill_id=skill_id,
|
||||
confirmation_name=payload.confirmation_name,
|
||||
)
|
||||
return dump_response(SkillDeleteResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/duplicate")
|
||||
class WorkspaceSkillDuplicateApi(Resource):
|
||||
@console_ns.response(201, "Skill duplicated", console_ns.models[SkillDetailResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
|
||||
try:
|
||||
result = SkillManagementService().duplicate_skill(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
skill_id=skill_id,
|
||||
)
|
||||
return dump_response(SkillDetailResponse, result), 201
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/export")
|
||||
class WorkspaceSkillExportApi(Resource):
|
||||
@console_ns.response(200, "Published Skill zip archive")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, skill_id: str):
|
||||
try:
|
||||
result = SkillManagementService().pull_published_archive(tenant_id=current_tenant_id, skill_id=skill_id)
|
||||
return send_file(
|
||||
io.BytesIO(result.payload),
|
||||
mimetype=result.mime_type,
|
||||
as_attachment=True,
|
||||
download_name=result.filename,
|
||||
)
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/assist/messages")
|
||||
class WorkspaceSkillAssistMessageApi(Resource):
|
||||
"""Stream read-only Skill Authoring suggestions from the default workspace model."""
|
||||
|
||||
@console_ns.expect(console_ns.models[SkillAssistMessagePayload.__name__])
|
||||
@console_ns.response(200, "Skill Authoring assistant event stream")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
|
||||
try:
|
||||
payload = SkillAssistMessagePayload.model_validate(console_ns.payload or {})
|
||||
response = SkillManagementService().create_assistant_action_stream(
|
||||
tenant_id=current_tenant_id,
|
||||
skill_id=skill_id,
|
||||
user_id=current_user.id,
|
||||
message=payload.message,
|
||||
attachments=payload.attachments,
|
||||
model_payload=payload.model,
|
||||
target_path=payload.target_path,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
return helper.compact_generate_response(response)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files")
|
||||
class WorkspaceSkillFilesApi(Resource):
|
||||
@console_ns.expect(console_ns.models[SkillDraftFileOperationPayload.__name__])
|
||||
@console_ns.response(200, "Draft file operation applied", console_ns.models[SkillDetailResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def patch(self, current_tenant_id: str, current_user: Account, skill_id: str):
|
||||
try:
|
||||
payload = SkillDraftFileOperationPayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService().apply_draft_file_operation(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
skill_id=skill_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillDetailResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except ValueError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
@console_ns.expect(console_ns.models[SkillDraftTreePayload.__name__])
|
||||
@console_ns.response(200, "Draft files replaced", console_ns.models[SkillDetailResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def put(self, current_tenant_id: str, current_user: Account, skill_id: str):
|
||||
try:
|
||||
payload = SkillDraftTreePayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService().replace_draft_tree(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
skill_id=skill_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillDetailResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except ValueError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/preview")
|
||||
class WorkspaceSkillFilePreviewApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(SkillFileQuery))
|
||||
@console_ns.response(200, "Skill file text preview", console_ns.models[SkillFilePreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, skill_id: str):
|
||||
try:
|
||||
query = SkillFileQuery.model_validate(
|
||||
{
|
||||
"path": request.args.get("path"),
|
||||
"version_id": request.args.get("version_id"),
|
||||
}
|
||||
)
|
||||
result = SkillManagementService().preview_file(
|
||||
tenant_id=current_tenant_id,
|
||||
skill_id=skill_id,
|
||||
path=query.path,
|
||||
version_id=query.version_id,
|
||||
)
|
||||
return dump_response(SkillFilePreviewResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except ValueError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/content")
|
||||
class WorkspaceSkillFileContentApi(Resource):
|
||||
@console_ns.doc(params={**query_params_from_model(SkillFileQuery), "download": "Return as an attachment when 1."})
|
||||
@console_ns.response(200, "Skill file content", console_ns.models[BinaryFileResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, skill_id: str):
|
||||
try:
|
||||
query = SkillFileQuery.model_validate(
|
||||
{
|
||||
"path": request.args.get("path"),
|
||||
"version_id": request.args.get("version_id"),
|
||||
}
|
||||
)
|
||||
result = SkillManagementService().pull_file(
|
||||
tenant_id=current_tenant_id,
|
||||
skill_id=skill_id,
|
||||
path=query.path,
|
||||
version_id=query.version_id,
|
||||
)
|
||||
return send_file(
|
||||
io.BytesIO(result.payload),
|
||||
mimetype=result.mime_type,
|
||||
as_attachment=request.args.get("download") == "1",
|
||||
download_name=result.filename,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except ValueError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/publish")
|
||||
class WorkspaceSkillPublishApi(Resource):
|
||||
@console_ns.expect(console_ns.models[SkillPublishPayload.__name__])
|
||||
@console_ns.response(200, "Skill published", console_ns.models[SkillVersionResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
|
||||
try:
|
||||
payload = SkillPublishPayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService().publish_skill(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
skill_id=skill_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillVersionResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/restore")
|
||||
class WorkspaceSkillRestoreApi(Resource):
|
||||
@console_ns.expect(console_ns.models[SkillRestorePayload.__name__])
|
||||
@console_ns.response(200, "Skill version restored", console_ns.models[SkillVersionResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
|
||||
try:
|
||||
payload = SkillRestorePayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService().restore_version(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
skill_id=skill_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillVersionResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/references")
|
||||
class WorkspaceSkillReferencesApi(Resource):
|
||||
@console_ns.response(200, "Skill references", console_ns.models[SkillReferenceListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, skill_id: str):
|
||||
try:
|
||||
result = SkillManagementService().list_skill_references(tenant_id=current_tenant_id, skill_id=skill_id)
|
||||
return dump_response(SkillReferenceListResponse, result)
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/versions")
|
||||
class WorkspaceSkillVersionsApi(Resource):
|
||||
@console_ns.response(200, "Skill versions", console_ns.models[SkillVersionListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, skill_id: str):
|
||||
try:
|
||||
result = SkillManagementService().list_versions(tenant_id=current_tenant_id, skill_id=skill_id)
|
||||
return dump_response(SkillVersionListResponse, result)
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/skills/<string:skill_id>/versions/<string:version_id>")
|
||||
class WorkspaceSkillVersionApi(Resource):
|
||||
@console_ns.response(200, "Skill version detail", console_ns.models[SkillVersionDetailResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, skill_id: str, version_id: str):
|
||||
try:
|
||||
result = SkillManagementService().get_version(
|
||||
tenant_id=current_tenant_id,
|
||||
skill_id=skill_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
return dump_response(SkillVersionDetailResponse, result)
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
@console_ns.expect(console_ns.models[SkillVersionUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Skill version updated", console_ns.models[SkillVersionResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_tenant_id
|
||||
def patch(self, current_tenant_id: str, skill_id: str, version_id: str):
|
||||
try:
|
||||
payload = SkillVersionUpdatePayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService().update_version(
|
||||
tenant_id=current_tenant_id,
|
||||
skill_id=skill_id,
|
||||
version_id=version_id,
|
||||
payload=payload,
|
||||
)
|
||||
return dump_response(SkillVersionResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
@console_ns.response(200, "Skill version deleted", console_ns.models[SkillVersionDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, current_tenant_id: str, current_user: Account, skill_id: str, version_id: str):
|
||||
try:
|
||||
result = SkillManagementService().delete_version(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
skill_id=skill_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
return dump_response(SkillVersionDeleteResponse, result)
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/agents/<string:agent_id>/skills")
|
||||
class WorkspaceAgentSkillBindingsApi(Resource):
|
||||
@console_ns.response(200, "Agent Skill bindings", console_ns.models[AgentSkillBindingsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, agent_id: str):
|
||||
result = SkillManagementService().list_agent_bindings(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return dump_response(AgentSkillBindingsResponse, result)
|
||||
|
||||
@console_ns.expect(console_ns.models[AgentSkillBindingsPayload.__name__])
|
||||
@console_ns.response(200, "Agent Skill bindings replaced", console_ns.models[AgentSkillBindingsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def put(self, current_tenant_id: str, current_user: Account, agent_id: str):
|
||||
try:
|
||||
payload = AgentSkillBindingsPayload.model_validate(console_ns.payload or {})
|
||||
result = SkillManagementService().replace_agent_bindings(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
skill_ids=payload.skill_ids,
|
||||
)
|
||||
return dump_response(AgentSkillBindingsResponse, result)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WorkspaceAgentSkillBindingsApi",
|
||||
"WorkspaceSkillApi",
|
||||
"WorkspaceSkillDuplicateApi",
|
||||
"WorkspaceSkillExportApi",
|
||||
"WorkspaceSkillFilesApi",
|
||||
"WorkspaceSkillImportApi",
|
||||
"WorkspaceSkillPublishApi",
|
||||
"WorkspaceSkillReferencesApi",
|
||||
"WorkspaceSkillRestoreApi",
|
||||
"WorkspaceSkillTagsApi",
|
||||
"WorkspaceSkillVersionApi",
|
||||
"WorkspaceSkillVersionsApi",
|
||||
"WorkspaceSkillsApi",
|
||||
]
|
||||
@@ -23,7 +23,6 @@ from .knowledge import retrieval as _knowledge_retrieval
|
||||
from .plugin import agent_config as _agent_config
|
||||
from .plugin import agent_drive as _agent_drive
|
||||
from .plugin import plugin as _plugin
|
||||
from .plugin import skills as _skills
|
||||
from .workspace import workspace as _workspace
|
||||
|
||||
api.add_namespace(inner_api_ns)
|
||||
@@ -37,7 +36,6 @@ __all__ = [
|
||||
"_mail",
|
||||
"_plugin",
|
||||
"_runtime_credentials",
|
||||
"_skills",
|
||||
"_workspace",
|
||||
"api",
|
||||
"bp",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Inner API for published workspace Skills.
|
||||
|
||||
These endpoints are called by trusted runtime services. They expose only
|
||||
published Skill artifacts, never draft files or editable metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from flask import request, send_file
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from services.skill_management_service import SkillManagementService, SkillManagementServiceError
|
||||
|
||||
|
||||
class _SkillTargetQuery(BaseModel):
|
||||
tenant_id: str
|
||||
|
||||
|
||||
def _target_query_from_request() -> _SkillTargetQuery:
|
||||
return _SkillTargetQuery.model_validate({"tenant_id": request.args.get("tenant_id")})
|
||||
|
||||
|
||||
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, str], int]:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
@inner_api_ns.route("/skills/<string:skill_id>/pull")
|
||||
class PublishedSkillPullApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("published_skill_pull")
|
||||
def get(self, skill_id: str):
|
||||
try:
|
||||
query = _target_query_from_request()
|
||||
result = SkillManagementService().pull_published_archive(tenant_id=query.tenant_id, skill_id=skill_id)
|
||||
return send_file(
|
||||
io.BytesIO(result.payload),
|
||||
mimetype=result.mime_type,
|
||||
as_attachment=True,
|
||||
download_name=result.filename,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
__all__ = ["PublishedSkillPullApi"]
|
||||
@@ -43,7 +43,6 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
||||
build_config_layer_config,
|
||||
build_knowledge_layer_config,
|
||||
build_shell_layer_config,
|
||||
load_runtime_agent_skill_configs,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig
|
||||
from models.provider_ids import ModelProviderID
|
||||
@@ -126,22 +125,14 @@ class AgentAppRuntimeRequestBuilder:
|
||||
"cli_tool_count": len(agent_soul.tools.cli_tools),
|
||||
}
|
||||
|
||||
runtime_config_skills = load_runtime_agent_skill_configs(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
agent_id=context.agent_id,
|
||||
)
|
||||
config_layer_config, config_warnings = build_config_layer_config(
|
||||
agent_soul,
|
||||
agent_id=context.agent_id,
|
||||
config_version_id=context.agent_config_snapshot_id,
|
||||
config_version_kind=context.agent_config_version_kind,
|
||||
runtime_config_skills=runtime_config_skills,
|
||||
)
|
||||
append_runtime_warnings(metadata, config_warnings)
|
||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(
|
||||
agent_soul,
|
||||
runtime_config_skills=runtime_config_skills,
|
||||
)
|
||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
|
||||
request = self._request_builder.build_for_agent_app(
|
||||
|
||||
@@ -66,7 +66,7 @@ from services.enterprise.plugin_manager_service import (
|
||||
PreUninstallPluginRequest,
|
||||
)
|
||||
from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import FeatureService, PluginInstallationPermissionModel, PluginInstallationScope
|
||||
from services.feature_service import FeatureService, PluginInstallationScope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_provider_entities_adapter: TypeAdapter[list[ProviderEntity]] = TypeAdapter(list[ProviderEntity])
|
||||
@@ -604,30 +604,22 @@ class PluginService:
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _check_marketplace_only_permission() -> None:
|
||||
def _check_marketplace_only_permission():
|
||||
"""
|
||||
Check if the marketplace only permission is enabled
|
||||
"""
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
if permission.restrict_to_marketplace_only:
|
||||
features = FeatureService.get_system_features()
|
||||
if features.plugin_installation_permission.restrict_to_marketplace_only:
|
||||
raise PluginInstallationForbiddenError("Plugin installation is restricted to marketplace only")
|
||||
|
||||
@staticmethod
|
||||
def _get_plugin_installation_permission() -> PluginInstallationPermissionModel:
|
||||
"""Resolve the validated policy and reject deny-all before any installation side effect."""
|
||||
permission = FeatureService.get_plugin_installation_permission()
|
||||
if permission.plugin_installation_scope == PluginInstallationScope.NONE:
|
||||
raise PluginInstallationForbiddenError("Installing plugins is not allowed")
|
||||
return permission
|
||||
|
||||
@staticmethod
|
||||
def _check_plugin_installation_scope(plugin_verification: PluginVerification | None) -> None:
|
||||
def _check_plugin_installation_scope(plugin_verification: PluginVerification | None):
|
||||
"""
|
||||
Check the plugin installation scope
|
||||
"""
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
features = FeatureService.get_system_features()
|
||||
|
||||
match permission.plugin_installation_scope:
|
||||
match features.plugin_installation_permission.plugin_installation_scope:
|
||||
case PluginInstallationScope.OFFICIAL_ONLY:
|
||||
if (
|
||||
plugin_verification is None
|
||||
@@ -642,10 +634,10 @@ class PluginService:
|
||||
raise PluginInstallationForbiddenError(
|
||||
"Plugin installation is restricted to official and specific partners"
|
||||
)
|
||||
case PluginInstallationScope.NONE:
|
||||
raise PluginInstallationForbiddenError("Installing plugins is not allowed")
|
||||
case PluginInstallationScope.ALL:
|
||||
pass
|
||||
case _:
|
||||
raise PluginInstallationForbiddenError("Plugin installation policy is invalid")
|
||||
|
||||
@staticmethod
|
||||
def get_debugging_key(tenant_id: str) -> str:
|
||||
@@ -915,7 +907,7 @@ class PluginService:
|
||||
# check if plugin pkg is already downloaded
|
||||
manager = PluginInstaller()
|
||||
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
features = FeatureService.get_system_features()
|
||||
|
||||
try:
|
||||
manager.fetch_plugin_manifest(tenant_id, new_plugin_unique_identifier)
|
||||
@@ -927,7 +919,7 @@ class PluginService:
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
)
|
||||
|
||||
# check if the plugin is available to install
|
||||
@@ -982,11 +974,11 @@ class PluginService:
|
||||
"""
|
||||
PluginService._check_marketplace_only_permission()
|
||||
manager = PluginInstaller()
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
features = FeatureService.get_system_features()
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
)
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
@@ -1004,13 +996,13 @@ class PluginService:
|
||||
pkg = download_with_size_limit(
|
||||
f"https://github.com/{repo}/releases/download/{version}/{package}", dify_config.PLUGIN_MAX_PACKAGE_SIZE
|
||||
)
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
features = FeatureService.get_system_features()
|
||||
|
||||
manager = PluginInstaller()
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
)
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
@@ -1084,7 +1076,7 @@ class PluginService:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
raise ValueError("marketplace is not enabled")
|
||||
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
features = FeatureService.get_system_features()
|
||||
|
||||
manager = PluginInstaller()
|
||||
try:
|
||||
@@ -1094,7 +1086,7 @@ class PluginService:
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
)
|
||||
# check if the plugin is available to install
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
@@ -1116,7 +1108,7 @@ class PluginService:
|
||||
# collect actual plugin_unique_identifiers
|
||||
actual_plugin_unique_identifiers = []
|
||||
metas = []
|
||||
permission = PluginService._get_plugin_installation_permission()
|
||||
features = FeatureService.get_system_features()
|
||||
|
||||
# check if already downloaded
|
||||
for plugin_unique_identifier in plugin_unique_identifiers:
|
||||
@@ -1134,7 +1126,7 @@ class PluginService:
|
||||
response = manager.upload_pkg(
|
||||
tenant_id,
|
||||
pkg,
|
||||
verify_signature=permission.restrict_to_marketplace_only,
|
||||
verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
|
||||
)
|
||||
# check if the plugin is available to install
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
@@ -107,8 +107,6 @@ class MCPTool(Tool):
|
||||
if self.entity.output_schema and result.structuredContent:
|
||||
for k, v in result.structuredContent.items():
|
||||
yield self.create_variable_message(k, v)
|
||||
elif result.structuredContent:
|
||||
yield self.create_json_message(result.structuredContent)
|
||||
|
||||
def _process_text_content(self, content: TextContent) -> Generator[ToolInvokeMessage, None, None]:
|
||||
"""Process text content and yield appropriate messages."""
|
||||
|
||||
@@ -38,7 +38,6 @@ from dify_agent.layers.shell import (
|
||||
)
|
||||
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendModelConfig,
|
||||
@@ -207,22 +206,14 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
"cli_tool_count": len(agent_soul.tools.cli_tools),
|
||||
}
|
||||
|
||||
runtime_config_skills = load_runtime_agent_skill_configs(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
agent_id=context.agent.id,
|
||||
)
|
||||
config_layer_config, config_warnings = build_config_layer_config(
|
||||
agent_soul,
|
||||
agent_id=context.agent.id,
|
||||
config_version_id=context.snapshot.id,
|
||||
config_version_kind="snapshot",
|
||||
runtime_config_skills=runtime_config_skills,
|
||||
)
|
||||
append_runtime_warnings(metadata, config_warnings)
|
||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(
|
||||
agent_soul,
|
||||
runtime_config_skills=runtime_config_skills,
|
||||
)
|
||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
|
||||
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
|
||||
@@ -892,16 +883,11 @@ def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, s
|
||||
existing.extend(warnings)
|
||||
|
||||
|
||||
def build_config_aware_soul_mention_resolver(
|
||||
agent_soul: AgentSoulConfig,
|
||||
*,
|
||||
runtime_config_skills: Sequence[DifyConfigSkillConfig] = (),
|
||||
):
|
||||
def build_config_aware_soul_mention_resolver(agent_soul: AgentSoulConfig):
|
||||
"""Resolve config skill/file mentions and delegate the rest to Agent Soul."""
|
||||
|
||||
base_resolver = build_soul_mention_resolver(agent_soul)
|
||||
skill_names = {item.name for item in agent_soul.config_skills if not item.is_missing}
|
||||
skill_names.update(item.name for item in runtime_config_skills)
|
||||
file_names = {item.name for item in agent_soul.config_files if not item.is_missing}
|
||||
|
||||
def _resolve(mention: object) -> str | None:
|
||||
@@ -919,34 +905,12 @@ def build_config_aware_soul_mention_resolver(
|
||||
return _resolve
|
||||
|
||||
|
||||
def load_runtime_agent_skill_configs(*, tenant_id: str, agent_id: str) -> list[DifyConfigSkillConfig]:
|
||||
"""Return workspace-bound Skills as prompt-safe runtime config skills."""
|
||||
from services.skill_management_service import SkillManagementService
|
||||
|
||||
try:
|
||||
runtime_skills = SkillManagementService().list_runtime_agent_skills(tenant_id=tenant_id, agent_id=agent_id)
|
||||
except OperationalError as exc:
|
||||
if "no such table: agent_skill_bindings" not in str(exc.orig):
|
||||
raise
|
||||
runtime_skills = []
|
||||
return [
|
||||
DifyConfigSkillConfig(
|
||||
name=str(item["name"]),
|
||||
description=str(item.get("description") or ""),
|
||||
size=cast(int | None, item.get("size")),
|
||||
mime_type=cast(str | None, item.get("mime_type")),
|
||||
)
|
||||
for item in runtime_skills
|
||||
]
|
||||
|
||||
|
||||
def build_config_layer_config(
|
||||
agent_soul: AgentSoulConfig,
|
||||
*,
|
||||
agent_id: str | None = None,
|
||||
config_version_id: str | None = None,
|
||||
config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot",
|
||||
runtime_config_skills: Sequence[DifyConfigSkillConfig] = (),
|
||||
) -> tuple[DifyConfigLayerConfig, list[dict[str, str]]]:
|
||||
"""Build the always-present Agent config layer from Agent Soul state.
|
||||
|
||||
@@ -963,23 +927,8 @@ def build_config_layer_config(
|
||||
)
|
||||
)
|
||||
available_skills = [skill for skill in agent_soul.config_skills if not skill.is_missing]
|
||||
skill_configs = [
|
||||
DifyConfigSkillConfig(
|
||||
name=skill.name,
|
||||
description=skill.description,
|
||||
size=skill.size,
|
||||
mime_type=skill.mime_type,
|
||||
)
|
||||
for skill in available_skills
|
||||
]
|
||||
seen_skill_names = {skill.name for skill in skill_configs}
|
||||
for skill in runtime_config_skills:
|
||||
if skill.name in seen_skill_names:
|
||||
continue
|
||||
seen_skill_names.add(skill.name)
|
||||
skill_configs.append(skill)
|
||||
available_files = [file_ref for file_ref in agent_soul.config_files if not file_ref.is_missing]
|
||||
skill_names = {skill.name for skill in skill_configs}
|
||||
skill_names = {skill.name for skill in available_skills}
|
||||
file_names = {file_ref.name for file_ref in available_files}
|
||||
warnings: list[dict[str, str]] = [
|
||||
{
|
||||
@@ -1016,7 +965,15 @@ def build_config_layer_config(
|
||||
kind=config_version_kind,
|
||||
writable=config_version_kind == "build_draft",
|
||||
),
|
||||
skills=skill_configs,
|
||||
skills=[
|
||||
DifyConfigSkillConfig(
|
||||
name=skill.name,
|
||||
description=skill.description,
|
||||
size=skill.size,
|
||||
mime_type=skill.mime_type,
|
||||
)
|
||||
for skill in available_skills
|
||||
],
|
||||
files=[
|
||||
DifyConfigFileConfig(
|
||||
name=file_ref.name,
|
||||
|
||||
+1
-5
@@ -289,11 +289,7 @@ UUIDStr = Annotated[str, AfterValidator(_strict_uuid)]
|
||||
|
||||
def alphanumeric(value: str):
|
||||
# check if the value is alphanumeric and underlined
|
||||
# Use re.fullmatch instead of re.match to reject trailing newlines.
|
||||
# In Python, '$' matches at end-of-string OR just before a trailing newline,
|
||||
# so re.match accepts "tool_name\n". re.fullmatch requires the entire
|
||||
# string to match. Regression for #39666 (sibling of #39234 / #39548).
|
||||
if re.fullmatch(r"^[a-zA-Z0-9_]+$", value):
|
||||
if re.match(r"^[a-zA-Z0-9_]+$", value):
|
||||
return value
|
||||
|
||||
raise ValueError(f"{value} is not a valid alphanumeric value")
|
||||
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
"""add workspace skill management
|
||||
|
||||
Revision ID: a4f8d2c9e1b0
|
||||
Revises: 6f5a9c2d8e1b
|
||||
Create Date: 2026-07-09 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
from models.types import StringUUID
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a4f8d2c9e1b0"
|
||||
down_revision = "6f5a9c2d8e1b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _uuid_column(name: str, *, nullable: bool = False) -> sa.Column:
|
||||
return sa.Column(name, StringUUID(), nullable=nullable)
|
||||
|
||||
|
||||
def _long_text() -> sa.types.TypeEngine:
|
||||
return sa.Text().with_variant(mysql.LONGTEXT(), "mysql")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"skills",
|
||||
_uuid_column("id"),
|
||||
_uuid_column("tenant_id"),
|
||||
sa.Column("name", sa.String(length=64), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=128), nullable=False),
|
||||
sa.Column("icon", sa.String(length=16), nullable=False, server_default="📄"),
|
||||
sa.Column("description", sa.String(length=1024), nullable=False, server_default=""),
|
||||
sa.Column("name_manually_edited", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("visibility", sa.String(length=32), nullable=False, server_default="workspace"),
|
||||
_uuid_column("latest_published_version_id", nullable=True),
|
||||
_uuid_column("created_by", nullable=True),
|
||||
_uuid_column("updated_by", nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.PrimaryKeyConstraint("id", name="skill_pkey"),
|
||||
sa.UniqueConstraint("tenant_id", "name", name="skill_tenant_name_unique"),
|
||||
)
|
||||
op.create_index("skills_tenant_updated_at_idx", "skills", ["tenant_id", "updated_at"])
|
||||
|
||||
op.create_table(
|
||||
"skill_draft_files",
|
||||
_uuid_column("id"),
|
||||
_uuid_column("skill_id"),
|
||||
sa.Column("path", sa.String(length=512), nullable=False),
|
||||
sa.Column("kind", sa.String(length=32), nullable=False),
|
||||
sa.Column("storage", sa.String(length=32), nullable=True),
|
||||
sa.Column("mime_type", sa.String(length=255), nullable=True),
|
||||
sa.Column("content_text", _long_text(), nullable=True),
|
||||
_uuid_column("tool_file_id", nullable=True),
|
||||
sa.Column("size", sa.BigInteger(), nullable=True),
|
||||
sa.Column("hash", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.PrimaryKeyConstraint("id", name="skill_draft_file_pkey"),
|
||||
sa.UniqueConstraint("skill_id", "path", name="skill_draft_file_skill_path_unique"),
|
||||
)
|
||||
op.create_index("skill_draft_files_skill_path_idx", "skill_draft_files", ["skill_id", "path"])
|
||||
|
||||
op.create_table(
|
||||
"skill_versions",
|
||||
_uuid_column("id"),
|
||||
_uuid_column("skill_id"),
|
||||
sa.Column("version_number", sa.Integer(), nullable=False),
|
||||
sa.Column("version_name", sa.String(length=128), nullable=False, server_default=""),
|
||||
sa.Column("publish_note", sa.String(length=1024), nullable=False, server_default=""),
|
||||
sa.Column("manifest", _long_text(), nullable=False),
|
||||
_uuid_column("archive_tool_file_id"),
|
||||
sa.Column("hash_code", sa.String(length=255), nullable=False),
|
||||
sa.Column("archive_size", sa.BigInteger(), nullable=False),
|
||||
_uuid_column("published_by", nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.PrimaryKeyConstraint("id", name="skill_version_pkey"),
|
||||
sa.UniqueConstraint("skill_id", "version_number", name="skill_version_skill_number_unique"),
|
||||
)
|
||||
op.create_index("skill_versions_skill_created_at_idx", "skill_versions", ["skill_id", "created_at"])
|
||||
|
||||
op.create_table(
|
||||
"agent_skill_bindings",
|
||||
_uuid_column("id"),
|
||||
_uuid_column("tenant_id"),
|
||||
_uuid_column("agent_id"),
|
||||
_uuid_column("skill_id"),
|
||||
sa.Column("priority", sa.Integer(), nullable=False),
|
||||
_uuid_column("created_by", nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.PrimaryKeyConstraint("id", name="agent_skill_binding_pkey"),
|
||||
sa.UniqueConstraint("tenant_id", "agent_id", "skill_id", name="agent_skill_binding_unique"),
|
||||
sa.UniqueConstraint("tenant_id", "agent_id", "priority", name="agent_skill_binding_priority_unique"),
|
||||
)
|
||||
op.create_index("agent_skill_bindings_skill_idx", "agent_skill_bindings", ["tenant_id", "skill_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("agent_skill_bindings_skill_idx", table_name="agent_skill_bindings")
|
||||
op.drop_table("agent_skill_bindings")
|
||||
op.drop_index("skill_versions_skill_created_at_idx", table_name="skill_versions")
|
||||
op.drop_table("skill_versions")
|
||||
op.drop_index("skill_draft_files_skill_path_idx", table_name="skill_draft_files")
|
||||
op.drop_table("skill_draft_files")
|
||||
op.drop_index("skills_tenant_updated_at_idx", table_name="skills")
|
||||
op.drop_table("skills")
|
||||
@@ -113,7 +113,6 @@ from .provider import (
|
||||
TenantDefaultModel,
|
||||
TenantPreferredModelProvider,
|
||||
)
|
||||
from .skill import AgentSkillBinding, Skill, SkillDraftFile, SkillFileKind, SkillFileStorage, SkillVersion
|
||||
from .snippet import CustomizedSnippet, SnippetType
|
||||
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
||||
from .task import CeleryTask, CeleryTaskSet
|
||||
@@ -174,7 +173,6 @@ __all__ = [
|
||||
"AgentRuntimeSessionOwnerType",
|
||||
"AgentRuntimeSessionStatus",
|
||||
"AgentScope",
|
||||
"AgentSkillBinding",
|
||||
"AgentSource",
|
||||
"AgentStatus",
|
||||
"ApiRequest",
|
||||
@@ -248,11 +246,6 @@ __all__ = [
|
||||
"RecommendedApp",
|
||||
"SavedMessage",
|
||||
"Site",
|
||||
"Skill",
|
||||
"SkillDraftFile",
|
||||
"SkillFileKind",
|
||||
"SkillFileStorage",
|
||||
"SkillVersion",
|
||||
"SnippetType",
|
||||
"Tag",
|
||||
"TagBinding",
|
||||
|
||||
@@ -249,7 +249,6 @@ class TagType(StrEnum):
|
||||
KNOWLEDGE = "knowledge"
|
||||
APP = "app"
|
||||
SNIPPET = "snippet"
|
||||
SKILL = "skill"
|
||||
|
||||
|
||||
class DatasetMetadataType(StrEnum):
|
||||
|
||||
+1
-1
@@ -2667,7 +2667,7 @@ class Tag(TypeBase):
|
||||
sa.Index("tag_name_idx", "name"),
|
||||
)
|
||||
|
||||
TAG_TYPE_LIST = ["knowledge", "app", "snippet", "skill"]
|
||||
TAG_TYPE_LIST = ["knowledge", "app", "snippet"]
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Workspace-level Skill Management models.
|
||||
|
||||
These tables are the source of truth for reusable workspace Skills. Agent Soul
|
||||
``config_skills`` and Agent Drive skill rows remain per-agent runtime/config
|
||||
assets; they may consume a published Skill snapshot but do not own the Skill's
|
||||
draft, metadata, version history, or Agent binding priority.
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import Index, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base, DefaultFieldsMixin
|
||||
from models.types import EnumText, JSONModelColumn, LongText, StringUUID
|
||||
|
||||
|
||||
class SkillFileKind(StrEnum):
|
||||
"""Draft file entry kind."""
|
||||
|
||||
FILE = "file"
|
||||
DIRECTORY = "directory"
|
||||
|
||||
|
||||
class SkillFileStorage(StrEnum):
|
||||
"""How a draft file's content is stored."""
|
||||
|
||||
TEXT = "text"
|
||||
TOOL_FILE = "tool_file"
|
||||
|
||||
|
||||
class SkillVersionManifestFile(BaseModel):
|
||||
"""One file entry captured in a published Skill snapshot manifest."""
|
||||
|
||||
path: str
|
||||
mime_type: str | None = None
|
||||
size: int
|
||||
hash: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SkillVersionManifest(BaseModel):
|
||||
"""Published Skill snapshot file index."""
|
||||
|
||||
files: list[SkillVersionManifestFile]
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class Skill(DefaultFieldsMixin, Base):
|
||||
"""Workspace-level reusable Skill metadata and draft status."""
|
||||
|
||||
__tablename__ = "skills"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="skill_pkey"),
|
||||
UniqueConstraint("tenant_id", "name", name="skill_tenant_name_unique"),
|
||||
Index("skills_tenant_updated_at_idx", "tenant_id", "updated_at"),
|
||||
)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(64), nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(sa.String(128), nullable=False)
|
||||
icon: Mapped[str] = mapped_column(sa.String(16), nullable=False, default="📄", server_default="📄")
|
||||
description: Mapped[str] = mapped_column(sa.String(1024), nullable=False, default="", server_default="")
|
||||
name_manually_edited: Mapped[bool] = mapped_column(
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
sa.String(32),
|
||||
nullable=False,
|
||||
default="workspace",
|
||||
server_default="workspace",
|
||||
)
|
||||
latest_published_version_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
|
||||
|
||||
class SkillDraftFile(DefaultFieldsMixin, Base):
|
||||
"""One draft file or directory in a workspace Skill."""
|
||||
|
||||
__tablename__ = "skill_draft_files"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="skill_draft_file_pkey"),
|
||||
UniqueConstraint("skill_id", "path", name="skill_draft_file_skill_path_unique"),
|
||||
Index("skill_draft_files_skill_path_idx", "skill_id", "path"),
|
||||
)
|
||||
|
||||
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
path: Mapped[str] = mapped_column(sa.String(512), nullable=False)
|
||||
kind: Mapped[SkillFileKind] = mapped_column(EnumText(SkillFileKind, length=32), nullable=False)
|
||||
storage: Mapped[SkillFileStorage | None] = mapped_column(EnumText(SkillFileStorage, length=32), nullable=True)
|
||||
mime_type: Mapped[str | None] = mapped_column(sa.String(255), nullable=True)
|
||||
content_text: Mapped[str | None] = mapped_column(LongText, nullable=True)
|
||||
tool_file_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
size: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
|
||||
hash: Mapped[str | None] = mapped_column(sa.String(255), nullable=True)
|
||||
|
||||
|
||||
class SkillVersion(DefaultFieldsMixin, Base):
|
||||
"""Immutable published Skill snapshot.
|
||||
|
||||
``hash_code`` uniquely identifies a published version for downstream
|
||||
execution audit. It includes Skill identity, version number, and archive
|
||||
content digest instead of being only the archive content hash.
|
||||
"""
|
||||
|
||||
__tablename__ = "skill_versions"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="skill_version_pkey"),
|
||||
UniqueConstraint("skill_id", "version_number", name="skill_version_skill_number_unique"),
|
||||
Index("skill_versions_skill_created_at_idx", "skill_id", "created_at"),
|
||||
)
|
||||
|
||||
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
version_number: Mapped[int] = mapped_column(sa.Integer, nullable=False)
|
||||
version_name: Mapped[str] = mapped_column(sa.String(128), nullable=False, default="", server_default="")
|
||||
publish_note: Mapped[str] = mapped_column(sa.String(1024), nullable=False, default="", server_default="")
|
||||
manifest: Mapped[SkillVersionManifest] = mapped_column(JSONModelColumn(SkillVersionManifest), nullable=False)
|
||||
archive_tool_file_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
hash_code: Mapped[str] = mapped_column(sa.String(255), nullable=False)
|
||||
archive_size: Mapped[int] = mapped_column(sa.BigInteger, nullable=False)
|
||||
published_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
|
||||
|
||||
class AgentSkillBinding(DefaultFieldsMixin, Base):
|
||||
"""Direct Agent-to-workspace-Skill binding.
|
||||
|
||||
``priority`` is retained as an internal ordering column for the current
|
||||
schema constraints. Runtime Skill selection is Agent-driven and must not
|
||||
treat it as a matching priority.
|
||||
"""
|
||||
|
||||
__tablename__ = "agent_skill_bindings"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="agent_skill_binding_pkey"),
|
||||
UniqueConstraint("tenant_id", "agent_id", "skill_id", name="agent_skill_binding_unique"),
|
||||
UniqueConstraint("tenant_id", "agent_id", "priority", name="agent_skill_binding_priority_unique"),
|
||||
Index("agent_skill_bindings_skill_idx", "tenant_id", "skill_id"),
|
||||
)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
priority: Mapped[int] = mapped_column(sa.Integer, nullable=False)
|
||||
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentSkillBinding",
|
||||
"Skill",
|
||||
"SkillDraftFile",
|
||||
"SkillFileKind",
|
||||
"SkillFileStorage",
|
||||
"SkillVersion",
|
||||
"SkillVersionManifest",
|
||||
"SkillVersionManifestFile",
|
||||
]
|
||||
@@ -9551,7 +9551,7 @@ Remove one or more tag bindings from a target.
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| keyword | query | Search keyword | No | string |
|
||||
| type | query | Tag type filter | No | string |
|
||||
| type | query | Tag type filter | No | string, <br>**Available values:** "", "app", "knowledge", "snippet" |
|
||||
|
||||
#### Responses
|
||||
|
||||
@@ -10087,38 +10087,6 @@ Get list of available agent providers
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [AgentProviderListResponse](#agentproviderlistresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/agents/{agent_id}/skills
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent Skill bindings | **application/json**: [AgentSkillBindingsResponse](#agentskillbindingsresponse)<br> |
|
||||
|
||||
### [PUT] /workspaces/current/agents/{agent_id}/skills
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [AgentSkillBindingsPayload](#agentskillbindingspayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent Skill bindings replaced | **application/json**: [AgentSkillBindingsResponse](#agentskillbindingsresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/customized-snippets
|
||||
**List customized snippets with pagination and search**
|
||||
|
||||
@@ -12020,341 +11988,6 @@ Returns permission flags that control workspace features like member invitations
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [WorkspaceAccessMatrix](#workspaceaccessmatrix)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| keyword | query | Search keyword matching skill name or description. | No | string |
|
||||
| limit | query | Number of items per page. | No | integer, <br>**Default:** 20 |
|
||||
| page | query | Page number. | No | integer, <br>**Default:** 1 |
|
||||
| tag | query | Skill tag filters. Repeat the parameter for multiple tags. | No | [ string ] |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workspace skills | **application/json**: [SkillListResponse](#skilllistresponse)<br> |
|
||||
|
||||
### [POST] /workspaces/current/skills
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillCreatePayload](#skillcreatepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Skill created | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||
|
||||
### [POST] /workspaces/current/skills/files/upload
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Skill draft file uploaded | **application/json**: [SkillFileUploadResponse](#skillfileuploadresponse)<br> |
|
||||
|
||||
### [POST] /workspaces/current/skills/import
|
||||
Import a Skill zip package from multipart form field `file`.
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Skill imported | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/tags
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workspace Skill tags | **application/json**: [SkillTagListResponse](#skilltaglistresponse)<br> |
|
||||
|
||||
### [DELETE] /workspaces/current/skills/{skill_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillDeletePayload](#skilldeletepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill deleted | **application/json**: [SkillDeleteResponse](#skilldeleteresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/{skill_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill detail | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||
|
||||
### [PATCH] /workspaces/current/skills/{skill_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillMetadataPayload](#skillmetadatapayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill updated | **application/json**: [SkillResponse](#skillresponse)<br> |
|
||||
|
||||
### [POST] /workspaces/current/skills/{skill_id}/assist/messages
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillAssistMessagePayload](#skillassistmessagepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Skill Authoring assistant event stream |
|
||||
|
||||
### [POST] /workspaces/current/skills/{skill_id}/duplicate
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Skill duplicated | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/{skill_id}/export
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Published Skill zip archive |
|
||||
|
||||
### [PATCH] /workspaces/current/skills/{skill_id}/files
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillDraftFileOperationPayload](#skilldraftfileoperationpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Draft file operation applied | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||
|
||||
### [PUT] /workspaces/current/skills/{skill_id}/files
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillDraftTreePayload](#skilldrafttreepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Draft files replaced | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/{skill_id}/files/content
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| download | query | Return as an attachment when 1. | No | string |
|
||||
| path | query | Skill file path relative to the Skill root. | Yes | string |
|
||||
| version_id | query | Optional published version ID. Omit for current draft. | No | string |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill file content | **application/json**: [BinaryFileResponse](#binaryfileresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/{skill_id}/files/preview
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| path | query | Skill file path relative to the Skill root. | Yes | string |
|
||||
| version_id | query | Optional published version ID. Omit for current draft. | No | string |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill file text preview | **application/json**: [SkillFilePreviewResponse](#skillfilepreviewresponse)<br> |
|
||||
|
||||
### [POST] /workspaces/current/skills/{skill_id}/publish
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillPublishPayload](#skillpublishpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill published | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/{skill_id}/references
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill references | **application/json**: [SkillReferenceListResponse](#skillreferencelistresponse)<br> |
|
||||
|
||||
### [POST] /workspaces/current/skills/{skill_id}/restore
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillRestorePayload](#skillrestorepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill version restored | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/{skill_id}/versions
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill versions | **application/json**: [SkillVersionListResponse](#skillversionlistresponse)<br> |
|
||||
|
||||
### [DELETE] /workspaces/current/skills/{skill_id}/versions/{version_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
| version_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill version deleted | **application/json**: [SkillVersionDeleteResponse](#skillversiondeleteresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/{skill_id}/versions/{version_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
| version_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill version detail | **application/json**: [SkillVersionDetailResponse](#skillversiondetailresponse)<br> |
|
||||
|
||||
### [PATCH] /workspaces/current/skills/{skill_id}/versions/{version_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
| version_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillVersionUpdatePayload](#skillversionupdatepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill version updated | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/tool-labels
|
||||
#### Responses
|
||||
|
||||
@@ -15076,37 +14709,6 @@ Visibility and lifecycle scope of an Agent record.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### AgentSkillBindingItemResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | Yes |
|
||||
| display_name | string | | Yes |
|
||||
| file_count | integer | | Yes |
|
||||
| icon | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| latest_published_at | integer | | No |
|
||||
| latest_published_version_id | string | | No |
|
||||
| name | string | | Yes |
|
||||
| priority | integer | | Yes |
|
||||
| status | string | | Yes |
|
||||
| tags | [ string ] | | No |
|
||||
| updated_at | integer | | Yes |
|
||||
|
||||
#### AgentSkillBindingsPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| skill_ids | [ string ] | Ordered Skill IDs bound to the Agent. | No |
|
||||
|
||||
#### AgentSkillBindingsResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| agent_id | string | | Yes |
|
||||
| data | [ [AgentSkillBindingItemResponse](#agentskillbindingitemresponse) ] | | No |
|
||||
| skill_ids | [ string ] | | No |
|
||||
|
||||
#### AgentSkillRefConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -22063,186 +21665,6 @@ Simple provider entity response.
|
||||
| title | string | | Yes |
|
||||
| use_icon_as_answer_icon | boolean | | Yes |
|
||||
|
||||
#### SkillAssistAttachmentPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| mime_type | string | | No |
|
||||
| name | string | | Yes |
|
||||
| size | integer | | No |
|
||||
| tool_file_id | string | | Yes |
|
||||
|
||||
#### SkillAssistMessagePayload
|
||||
|
||||
One user message and optional uploaded context for the read-only Skill Authoring assistant.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| attachments | [ [SkillAssistAttachmentPayload](#skillassistattachmentpayload) ] | | No |
|
||||
| message | string | | Yes |
|
||||
| model | [SkillAssistModelPayload](#skillassistmodelpayload) | | No |
|
||||
|
||||
#### SkillAssistModelPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| model | string | | Yes |
|
||||
| model_settings | object | | No |
|
||||
| plugin_id | string | | No |
|
||||
| provider | string | | Yes |
|
||||
|
||||
#### SkillCreatePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| display_name | string | | No |
|
||||
| icon | string, <br>**Default:** 📄 | | No |
|
||||
| name | string | | No |
|
||||
| tags | [ string ] | | No |
|
||||
|
||||
#### SkillDeletePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| confirmation_name | string | Required when deleting a referenced Skill. Must match the Skill name. | No |
|
||||
|
||||
#### SkillDeleteResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| deleted | boolean | | Yes |
|
||||
| id | string | | Yes |
|
||||
|
||||
#### SkillDetailResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | Yes |
|
||||
| created_by | string | | No |
|
||||
| created_by_name | string | | No |
|
||||
| description | string | | Yes |
|
||||
| display_name | string | | Yes |
|
||||
| files | [ [SkillFileResponse](#skillfileresponse) ] | | No |
|
||||
| icon | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| latest_published_version_id | string | | No |
|
||||
| name | string | | Yes |
|
||||
| name_manually_edited | boolean | | No |
|
||||
| reference_count | integer | | No |
|
||||
| tags | [ string ] | | No |
|
||||
| updated_at | integer | | Yes |
|
||||
| updated_by | string | | No |
|
||||
| updated_by_name | string | | No |
|
||||
| visibility | string | | Yes |
|
||||
|
||||
#### SkillDraftFileOperation
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| SkillDraftFileOperation | string | | |
|
||||
|
||||
#### SkillDraftFileOperationPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | | No |
|
||||
| expected_updated_at | integer | | No |
|
||||
| hash | string | | No |
|
||||
| mime_type | string | | No |
|
||||
| operation | [SkillDraftFileOperation](#skilldraftfileoperation) | | Yes |
|
||||
| path | string | | Yes |
|
||||
| size | integer | | No |
|
||||
| target_path | string | | No |
|
||||
| tool_file_id | string | | No |
|
||||
|
||||
#### SkillDraftTreeItemPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | | No |
|
||||
| hash | string | | No |
|
||||
| kind | [SkillFileKind](#skillfilekind) | | No |
|
||||
| mime_type | string | | No |
|
||||
| path | string | | Yes |
|
||||
| size | integer | | No |
|
||||
| storage | [SkillFileStorage](#skillfilestorage) | | No |
|
||||
| tool_file_id | string | | No |
|
||||
|
||||
#### SkillDraftTreePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| expected_updated_at | integer | | No |
|
||||
| files | [ [SkillDraftTreeItemPayload](#skilldrafttreeitempayload) ] | | No |
|
||||
|
||||
#### SkillFileKind
|
||||
|
||||
Draft file entry kind.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| SkillFileKind | string | Draft file entry kind. | |
|
||||
|
||||
#### SkillFilePreviewResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | | Yes |
|
||||
| hash | string | | Yes |
|
||||
| mime_type | string | | Yes |
|
||||
| path | string | | Yes |
|
||||
| size | integer | | Yes |
|
||||
|
||||
#### SkillFileQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| path | string | Skill file path relative to the Skill root. | Yes |
|
||||
| version_id | string | Optional published version ID. Omit for current draft. | No |
|
||||
|
||||
#### SkillFileResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | | No |
|
||||
| hash | string | | No |
|
||||
| id | string | | No |
|
||||
| kind | string | | Yes |
|
||||
| mime_type | string | | No |
|
||||
| path | string | | Yes |
|
||||
| size | integer | | No |
|
||||
| storage | string | | No |
|
||||
| tool_file_id | string | | No |
|
||||
|
||||
#### SkillFileStorage
|
||||
|
||||
How a draft file's content is stored.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| SkillFileStorage | string | How a draft file's content is stored. | |
|
||||
|
||||
#### SkillFileUploadResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| hash | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| mime_type | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| size | integer | | Yes |
|
||||
|
||||
#### SkillListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [SkillResponse](#skillresponse) ] | | No |
|
||||
| has_more | boolean | | No |
|
||||
| limit | integer, <br>**Default:** 20 | | No |
|
||||
| page | integer, <br>**Default:** 1 | | No |
|
||||
| total | integer | | No |
|
||||
|
||||
#### SkillManifest
|
||||
|
||||
Validated metadata extracted from a Skill package.
|
||||
@@ -22256,91 +21678,6 @@ Validated metadata extracted from a Skill package.
|
||||
| name | string | | Yes |
|
||||
| size | integer | | Yes |
|
||||
|
||||
#### SkillMetadataPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| display_name | string | | No |
|
||||
| expected_updated_at | integer | | No |
|
||||
| icon | string | | No |
|
||||
| tags | [ string ] | | No |
|
||||
|
||||
#### SkillPublishPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| publish_note | string | | No |
|
||||
| version_name | string | | No |
|
||||
|
||||
#### SkillReferenceListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [SkillReferenceResponse](#skillreferenceresponse) ] | | No |
|
||||
|
||||
#### SkillReferenceResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| agent_icon | string | | No |
|
||||
| agent_icon_background | string | | No |
|
||||
| agent_icon_type | string | | No |
|
||||
| agent_id | string | | Yes |
|
||||
| app_id | string | | No |
|
||||
| display_name | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| node_id | string | | No |
|
||||
| node_name | string | | No |
|
||||
| type | string | | Yes |
|
||||
| workflow_icon | string | | No |
|
||||
| workflow_icon_background | string | | No |
|
||||
| workflow_icon_type | string | | No |
|
||||
| workflow_id | string | | No |
|
||||
| workflow_name | string | | No |
|
||||
| workflow_version | string | | No |
|
||||
|
||||
#### SkillResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | Yes |
|
||||
| created_by | string | | No |
|
||||
| created_by_name | string | | No |
|
||||
| description | string | | Yes |
|
||||
| display_name | string | | Yes |
|
||||
| icon | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| latest_published_version_id | string | | No |
|
||||
| name | string | | Yes |
|
||||
| name_manually_edited | boolean | | No |
|
||||
| reference_count | integer | | No |
|
||||
| tags | [ string ] | | No |
|
||||
| updated_at | integer | | Yes |
|
||||
| updated_by | string | | No |
|
||||
| updated_by_name | string | | No |
|
||||
| visibility | string | | Yes |
|
||||
|
||||
#### SkillRestorePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| publish_note | string | | No |
|
||||
| version_id | string | | Yes |
|
||||
| version_name | string | | No |
|
||||
|
||||
#### SkillTagListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [SkillTagResponse](#skilltagresponse) ] | | No |
|
||||
|
||||
#### SkillTagResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| count | integer | | Yes |
|
||||
| tag | string | | Yes |
|
||||
|
||||
#### SkillToolInferenceResult
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -22349,60 +21686,6 @@ Validated metadata extracted from a Skill package.
|
||||
| inferable | boolean | | Yes |
|
||||
| reason | string | | No |
|
||||
|
||||
#### SkillVersionDeleteResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| deleted | boolean | | Yes |
|
||||
| id | string | | Yes |
|
||||
| latest_published_version_id | string | | No |
|
||||
|
||||
#### SkillVersionDetailResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| archive_size | integer | | Yes |
|
||||
| created_at | integer | | Yes |
|
||||
| files | [ [SkillFileResponse](#skillfileresponse) ] | | No |
|
||||
| hash_code | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| is_latest | boolean | | No |
|
||||
| publish_note | string | | Yes |
|
||||
| published_by | string | | No |
|
||||
| published_by_name | string | | No |
|
||||
| skill_id | string | | Yes |
|
||||
| version_name | string | | Yes |
|
||||
| version_number | integer | | Yes |
|
||||
|
||||
#### SkillVersionListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [SkillVersionResponse](#skillversionresponse) ] | | No |
|
||||
|
||||
#### SkillVersionResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| archive_size | integer | | Yes |
|
||||
| created_at | integer | | Yes |
|
||||
| hash_code | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| is_latest | boolean | | No |
|
||||
| publish_note | string | | Yes |
|
||||
| published_by | string | | No |
|
||||
| published_by_name | string | | No |
|
||||
| skill_id | string | | Yes |
|
||||
| version_name | string | | Yes |
|
||||
| version_number | integer | | Yes |
|
||||
|
||||
#### SkillVersionUpdatePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| publish_note | string | | No |
|
||||
| version_name | string | | No |
|
||||
|
||||
#### SnippetDependencyCheckResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -22870,7 +22153,7 @@ Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword | string | Search keyword | No |
|
||||
| type | [TagType](#tagtype)<br>string | Tag type filter | No |
|
||||
| type | string, <br>**Available values:** "", "app", "knowledge", "snippet" | Tag type filter<br>*Enum:* `""`, `"app"`, `"knowledge"`, `"snippet"` | No |
|
||||
|
||||
#### TagListResponse
|
||||
|
||||
@@ -25056,15 +24339,6 @@ Workflow tool configuration
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| permission_keys | [ string ] | | No |
|
||||
|
||||
#### WorkspaceSkillsQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword | string | Search keyword matching skill name or description. | No |
|
||||
| limit | integer, <br>**Default:** 20 | Number of items per page. | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number. | No |
|
||||
| tag | [ string ] | Skill tag filters. Repeat the parameter for multiple tags. | No |
|
||||
|
||||
#### WorkspaceTenantResultResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
+12
-21
@@ -1,5 +1,3 @@
|
||||
"""Unit tests for Aliyun trace utility transformations and database lookups."""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
@@ -27,13 +25,11 @@ from dify_trace_aliyun.utils import (
|
||||
serialize_json_data,
|
||||
)
|
||||
from opentelemetry.trace import Link, StatusCode
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.rag.models.document import Document
|
||||
from graphon.entities import WorkflowNodeExecution
|
||||
from graphon.enums import WorkflowNodeExecutionStatus
|
||||
from models import EndUser
|
||||
from models.enums import EndUserType
|
||||
|
||||
|
||||
def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
@@ -44,40 +40,35 @@ def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPat
|
||||
assert get_user_id_from_message_data(message_data) == "account_id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
|
||||
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
|
||||
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
message_data = MagicMock()
|
||||
message_data.from_account_id = "account_id"
|
||||
message_data.from_end_user_id = "end_user_id"
|
||||
|
||||
end_user_data = EndUser(
|
||||
id="end_user_id",
|
||||
tenant_id="tenant_id",
|
||||
app_id="app_id",
|
||||
type=EndUserType.BROWSER,
|
||||
session_id="session_id",
|
||||
)
|
||||
sqlite3_session.add(end_user_data)
|
||||
sqlite3_session.commit()
|
||||
end_user_data = MagicMock(spec=EndUser)
|
||||
end_user_data.session_id = "session_id"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get.return_value = end_user_data
|
||||
|
||||
from dify_trace_aliyun.utils import db
|
||||
|
||||
monkeypatch.setattr(db, "session", sqlite3_session)
|
||||
monkeypatch.setattr(db, "session", mock_session)
|
||||
|
||||
assert get_user_id_from_message_data(message_data) == "session_id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
|
||||
def test_get_user_id_from_message_data_end_user_not_found(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
def test_get_user_id_from_message_data_end_user_not_found(monkeypatch: pytest.MonkeyPatch):
|
||||
message_data = MagicMock()
|
||||
message_data.from_account_id = "account_id"
|
||||
message_data.from_end_user_id = "end_user_id"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get.return_value = None
|
||||
|
||||
from dify_trace_aliyun.utils import db
|
||||
|
||||
monkeypatch.setattr(db, "session", sqlite3_session)
|
||||
monkeypatch.setattr(db, "session", mock_session)
|
||||
|
||||
assert get_user_id_from_message_data(message_data) == "account_id"
|
||||
|
||||
|
||||
+29
-67
@@ -1,8 +1,5 @@
|
||||
"""Unit tests for LangSmith trace translation with SQLite-backed lookups."""
|
||||
|
||||
import collections
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import override
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -14,7 +11,6 @@ from dify_trace_langsmith.entities.langsmith_trace_entity import (
|
||||
LangSmithRunUpdateModel,
|
||||
)
|
||||
from dify_trace_langsmith.langsmith_trace import LangSmithDataTrace
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.ops.entities.trace_entity import (
|
||||
DatasetRetrievalTraceInfo,
|
||||
@@ -28,7 +24,6 @@ from core.ops.entities.trace_entity import (
|
||||
)
|
||||
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey
|
||||
from models import EndUser
|
||||
from models.enums import EndUserType
|
||||
|
||||
|
||||
def _dt() -> datetime:
|
||||
@@ -113,8 +108,7 @@ def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
mocks["generate_name_trace"].assert_called_once_with(info)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
|
||||
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
# Setup trace info
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
@@ -143,10 +137,10 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3
|
||||
workflow_data=workflow_data,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
# Mock dependencies
|
||||
mock_session = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
|
||||
# Mock node executions
|
||||
node_llm = MagicMock()
|
||||
@@ -234,10 +228,7 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3
|
||||
assert call_args[4].run_type == LangSmithRunType.retriever
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_no_start_time(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
workflow_data.finished_at = _dt() + timedelta(seconds=1)
|
||||
@@ -265,10 +256,9 @@ def test_workflow_trace_no_start_time(
|
||||
workflow_data=workflow_data,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
mock_session = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
repo = MagicMock()
|
||||
repo.get_by_workflow_execution.return_value = []
|
||||
mock_factory = MagicMock()
|
||||
@@ -281,10 +271,7 @@ def test_workflow_trace_no_start_time(
|
||||
assert trace_instance.add_run.called
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_missing_app_id(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
trace_info.trace_id = "trace-1"
|
||||
trace_info.message_id = None
|
||||
@@ -300,17 +287,15 @@ def test_workflow_trace_missing_app_id(
|
||||
trace_info.workflow_run_outputs = {}
|
||||
trace_info.error = ""
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
mock_session = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
|
||||
with pytest.raises(ValueError, match="No app_id found in trace_info metadata"):
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
|
||||
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
|
||||
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
message_data = MagicMock()
|
||||
message_data.id = "msg-1"
|
||||
message_data.from_account_id = "acc-1"
|
||||
@@ -336,19 +321,10 @@ def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_
|
||||
message_file_data=MagicMock(url="file-url"),
|
||||
)
|
||||
|
||||
end_user = EndUser(
|
||||
id="end-user-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
type=EndUserType.BROWSER,
|
||||
session_id="session-id-123",
|
||||
)
|
||||
sqlite3_session.add(end_user)
|
||||
sqlite3_session.commit()
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
# Mock EndUser lookup
|
||||
mock_end_user = MagicMock(spec=EndUser)
|
||||
mock_end_user.session_id = "session-id-123"
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db.session.get", lambda model, pk: mock_end_user)
|
||||
|
||||
trace_instance.add_run = MagicMock()
|
||||
|
||||
@@ -545,13 +521,9 @@ def test_update_run_error(trace_instance):
|
||||
trace_instance.update_run(update_data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_usage_extraction_error(
|
||||
trace_instance,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
sqlite3_session: Session,
|
||||
) -> None:
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
workflow_data.finished_at = _dt() + timedelta(seconds=1)
|
||||
@@ -604,10 +576,8 @@ def test_workflow_trace_usage_extraction_error(
|
||||
mock_factory = MagicMock()
|
||||
mock_factory.create_workflow_node_execution_repository.return_value = repo
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", mock_factory)
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock())
|
||||
|
||||
trace_instance.add_run = MagicMock()
|
||||
@@ -674,11 +644,9 @@ def _make_workflow_trace_info(
|
||||
)
|
||||
|
||||
|
||||
def _patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session: Session) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
def _patch_workflow_trace_deps(monkeypatch, trace_instance):
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
repo = MagicMock()
|
||||
repo.get_by_workflow_execution.return_value = []
|
||||
factory = MagicMock()
|
||||
@@ -688,17 +656,14 @@ def _patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session: Ses
|
||||
trace_instance.add_run = MagicMock()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_id_uses_message_id_not_external(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Chatflow with external trace_id: LangSmith trace_id must be message_id, not external."""
|
||||
trace_info = _make_workflow_trace_info(
|
||||
message_id="msg-abc",
|
||||
workflow_run_id="run-xyz",
|
||||
trace_id="external-999",
|
||||
)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance)
|
||||
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
@@ -712,17 +677,14 @@ def test_workflow_trace_id_uses_message_id_not_external(
|
||||
assert trace_info.metadata.get("external_trace_id") == "external-999"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_id_pure_workflow_uses_run_id(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Pure workflow (no message_id) with external trace_id: trace_id must be workflow_run_id."""
|
||||
trace_info = _make_workflow_trace_info(
|
||||
message_id=None,
|
||||
workflow_run_id="run-xyz",
|
||||
trace_id="external-999",
|
||||
)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance)
|
||||
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.16.1"
|
||||
version = "1.16.0"
|
||||
requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
|
||||
@@ -19,11 +19,12 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import io
|
||||
import posixpath
|
||||
import re
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Bounds — generous but finite so a hostile upload can't exhaust memory/disk.
|
||||
_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024
|
||||
@@ -32,8 +33,7 @@ _MAX_SKILL_MD_BYTES = 1 * 1024 * 1024
|
||||
_MAX_ENTRIES = 5000
|
||||
_ALLOWED_EXTENSIONS = (".zip", ".skill")
|
||||
_SKILL_MD_NAME = "SKILL.md"
|
||||
_SKILL_NAME_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
||||
_MAX_SKILL_DESCRIPTION_LENGTH = 1024
|
||||
_HEADING_RE = re.compile(r"^\s*#\s+(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
class SkillPackageError(Exception):
|
||||
@@ -53,18 +53,13 @@ class SkillPackageError(Exception):
|
||||
class SkillManifest(BaseModel):
|
||||
"""Validated metadata extracted from a Skill package."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=64, pattern=_SKILL_NAME_PATTERN)
|
||||
description: str = Field(min_length=1, max_length=_MAX_SKILL_DESCRIPTION_LENGTH)
|
||||
name: str
|
||||
description: str
|
||||
entry_path: str # path of SKILL.md inside the archive
|
||||
files: list[str] # all (safe) file paths inside the archive
|
||||
size: int # total uncompressed bytes
|
||||
hash: str # sha256 of the archive bytes
|
||||
|
||||
@field_validator("name", "description", mode="before")
|
||||
@classmethod
|
||||
def _strip_required_string(cls, value: object) -> object:
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
|
||||
class NormalizedSkillPackage(BaseModel):
|
||||
"""Canonical skill package bytes and metadata ready to store in agent drive."""
|
||||
@@ -113,17 +108,14 @@ class SkillPackageService:
|
||||
normalized_size = sum(max(info.file_size, 0) for info in normalized_members.values())
|
||||
|
||||
name, description = self._parse_skill_md(skill_md)
|
||||
try:
|
||||
manifest = SkillManifest(
|
||||
name=name,
|
||||
description=description,
|
||||
entry_path=_SKILL_MD_NAME,
|
||||
files=sorted(normalized_members),
|
||||
size=normalized_size,
|
||||
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise self._manifest_validation_error(exc) from exc
|
||||
manifest = SkillManifest(
|
||||
name=name,
|
||||
description=description,
|
||||
entry_path=_SKILL_MD_NAME,
|
||||
files=sorted(normalized_members),
|
||||
size=normalized_size,
|
||||
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
|
||||
)
|
||||
return NormalizedSkillPackage(
|
||||
manifest=manifest,
|
||||
archive_bytes=normalized_archive_bytes,
|
||||
@@ -131,31 +123,6 @@ class SkillPackageService:
|
||||
strip_prefix=strip_prefix,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _manifest_validation_error(exc: ValidationError) -> SkillPackageError:
|
||||
first_error = exc.errors()[0]
|
||||
loc = first_error["loc"]
|
||||
field = loc[0] if loc else "manifest"
|
||||
error_type = first_error["type"]
|
||||
if field == "name":
|
||||
code = "missing_skill_name" if error_type == "string_too_short" else "invalid_skill_name"
|
||||
message = (
|
||||
"SKILL.md frontmatter name is required"
|
||||
if code == "missing_skill_name"
|
||||
else "SKILL.md frontmatter name must be lowercase letters, numbers, and hyphens only, "
|
||||
"must not start or end with a hyphen, and must be at most 64 characters"
|
||||
)
|
||||
return SkillPackageError(code, message, status_code=400)
|
||||
if field == "description":
|
||||
code = "missing_skill_description" if error_type == "string_too_short" else "invalid_skill_description"
|
||||
message = (
|
||||
"SKILL.md frontmatter description is required"
|
||||
if code == "missing_skill_description"
|
||||
else f"SKILL.md frontmatter description must be at most {_MAX_SKILL_DESCRIPTION_LENGTH} characters"
|
||||
)
|
||||
return SkillPackageError(code, message, status_code=400)
|
||||
return SkillPackageError("invalid_skill_manifest", "SKILL.md frontmatter is invalid", status_code=400)
|
||||
|
||||
def _open_archive(self, *, content: bytes, filename: str) -> zipfile.ZipFile:
|
||||
self._check_extension(filename)
|
||||
if not content:
|
||||
@@ -313,6 +280,13 @@ class SkillPackageService:
|
||||
frontmatter = cls._parse_frontmatter(content)
|
||||
name = str(frontmatter.get("name") or "").strip()
|
||||
description = str(frontmatter.get("description") or "").strip()
|
||||
if not name:
|
||||
heading = _HEADING_RE.search(content)
|
||||
name = heading.group(1).strip() if heading else ""
|
||||
if not name:
|
||||
raise SkillPackageError(
|
||||
"missing_skill_name", "SKILL.md must declare a name (frontmatter or top heading)", status_code=400
|
||||
)
|
||||
return name, description
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -46,7 +46,6 @@ from models.tools import ToolFile
|
||||
from services.agent.config_skill_normalize_service import ConfigSkillNormalizeService
|
||||
from services.agent.skill_package_service import SkillPackageError
|
||||
from services.agent_drive_service import DriveFileRef
|
||||
from services.skill_management_service import SkillManagementService, SkillManagementServiceError
|
||||
|
||||
|
||||
class AgentConfigVersionKind(StrEnum):
|
||||
@@ -99,7 +98,6 @@ class ConfigPushPayload(BaseModel):
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AgentConfigTarget:
|
||||
tenant_id: str
|
||||
agent_id: str
|
||||
version_id: str
|
||||
kind: AgentConfigVersionKind
|
||||
@@ -148,7 +146,6 @@ class AgentConfigService:
|
||||
user_id=user_id,
|
||||
)
|
||||
return AgentConfigTarget(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=target.agent_id,
|
||||
version_id=target.version_id,
|
||||
kind=target.kind,
|
||||
@@ -194,7 +191,7 @@ class AgentConfigService:
|
||||
return {
|
||||
"agent_id": target.agent_id,
|
||||
"config_version": self._config_version_payload(target),
|
||||
"items": self._skill_items_for_target(target),
|
||||
"items": [self._serialize_skill_item(skill) for skill in target.agent_soul.config_skills],
|
||||
}
|
||||
|
||||
def list_files(
|
||||
@@ -236,27 +233,10 @@ class AgentConfigService:
|
||||
config_version_kind=config_version_kind,
|
||||
user_id=user_id,
|
||||
)
|
||||
try:
|
||||
skill = self._require_skill(target.agent_soul, name=name)
|
||||
file_id = self._available_skill_file_id(skill)
|
||||
payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
|
||||
return ConfigDownload(
|
||||
filename=f"{skill.name}.zip",
|
||||
mime_type=mime_type or "application/zip",
|
||||
payload=payload,
|
||||
)
|
||||
except AgentConfigServiceError as exc:
|
||||
if exc.code != "config_skill_not_found":
|
||||
raise
|
||||
try:
|
||||
result = SkillManagementService().pull_runtime_agent_skill(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
name=name,
|
||||
)
|
||||
return ConfigDownload(filename=result.filename, mime_type=result.mime_type, payload=result.payload)
|
||||
except SkillManagementServiceError as exc:
|
||||
raise AgentConfigServiceError("config_skill_not_found", "config skill not found", status_code=404) from exc
|
||||
skill = self._require_skill(target.agent_soul, name=name)
|
||||
file_id = self._available_skill_file_id(skill)
|
||||
payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
|
||||
return ConfigDownload(filename=f"{skill.name}.zip", mime_type=mime_type or "application/zip", payload=payload)
|
||||
|
||||
def download_skill_url(
|
||||
self,
|
||||
@@ -299,45 +279,9 @@ class AgentConfigService:
|
||||
config_version_kind=config_version_kind,
|
||||
user_id=user_id,
|
||||
)
|
||||
try:
|
||||
skill = self._require_skill(target.agent_soul, name=name)
|
||||
file_id = self._available_skill_file_id(skill)
|
||||
archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
|
||||
skill_item = self._serialize_skill_item(skill)
|
||||
except AgentConfigServiceError as exc:
|
||||
if exc.code != "config_skill_not_found":
|
||||
raise
|
||||
try:
|
||||
workspace_archive = SkillManagementService().pull_runtime_agent_skill(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
name=name,
|
||||
)
|
||||
except SkillManagementServiceError as skill_exc:
|
||||
raise AgentConfigServiceError(
|
||||
"config_skill_not_found",
|
||||
"config skill not found",
|
||||
status_code=404,
|
||||
) from skill_exc
|
||||
archive_bytes = workspace_archive.payload
|
||||
skill_item = next(
|
||||
(
|
||||
item
|
||||
for item in SkillManagementService().list_runtime_agent_skills(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
if item["name"] == name
|
||||
),
|
||||
{
|
||||
"id": name,
|
||||
"name": name,
|
||||
"description": "",
|
||||
"size": None,
|
||||
"hash": None,
|
||||
"mime_type": "application/zip",
|
||||
},
|
||||
)
|
||||
skill = self._require_skill(target.agent_soul, name=name)
|
||||
file_id = self._available_skill_file_id(skill)
|
||||
archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
|
||||
try:
|
||||
archive_items, skill_md = self._inspect_skill_archive(archive_bytes)
|
||||
except (OSError, ValueError, zipfile.BadZipFile) as exc:
|
||||
@@ -347,7 +291,7 @@ class AgentConfigService:
|
||||
status_code=500,
|
||||
) from exc
|
||||
return {
|
||||
**skill_item,
|
||||
**self._serialize_skill_item(skill),
|
||||
"source": "config_skill_zip",
|
||||
"files": archive_items,
|
||||
"skill_md": skill_md,
|
||||
@@ -895,7 +839,6 @@ class AgentConfigService:
|
||||
status_code=404,
|
||||
)
|
||||
return AgentConfigTarget(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
version_id=version.id,
|
||||
kind=config_version_kind,
|
||||
@@ -1190,7 +1133,9 @@ class AgentConfigService:
|
||||
return {
|
||||
"agent_id": target.agent_id,
|
||||
"config_version": AgentConfigService._config_version_payload(target),
|
||||
"skills": {"items": AgentConfigService._skill_items_for_target(target)},
|
||||
"skills": {
|
||||
"items": [AgentConfigService._serialize_skill_item(skill) for skill in target.agent_soul.config_skills]
|
||||
},
|
||||
"files": {
|
||||
"items": [
|
||||
AgentConfigService._serialize_file_item(file_ref) for file_ref in target.agent_soul.config_files
|
||||
@@ -1200,20 +1145,6 @@ class AgentConfigService:
|
||||
"note": target.agent_soul.config_note,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _skill_items_for_target(target: AgentConfigTarget) -> list[dict[str, object]]:
|
||||
items = [AgentConfigService._serialize_skill_item(skill) for skill in target.agent_soul.config_skills]
|
||||
seen_names = {str(item["name"]) for item in items}
|
||||
for item in SkillManagementService().list_runtime_agent_skills(
|
||||
tenant_id=target.tenant_id,
|
||||
agent_id=target.agent_id,
|
||||
):
|
||||
if item["name"] in seen_names:
|
||||
continue
|
||||
seen_names.add(str(item["name"]))
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _config_version_payload(target: AgentConfigTarget) -> dict[str, object]:
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from configs import dify_config
|
||||
from constants.dsl_version import CURRENT_APP_DSL_VERSION
|
||||
@@ -12,8 +10,6 @@ from enums.hosted_provider import HostedTrialProvider
|
||||
from services.billing_service import BillingInfo, BillingService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FeatureResponseModel(BaseModel):
|
||||
model_config = ConfigDict(json_schema_serialization_defaults_required=True, protected_namespaces=())
|
||||
@@ -135,13 +131,6 @@ class PluginInstallationPermissionModel(FeatureResponseModel):
|
||||
restrict_to_marketplace_only: bool = False
|
||||
|
||||
|
||||
class _EnterprisePluginInstallationPermission(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
plugin_installation_scope: PluginInstallationScope = Field(alias="pluginInstallationScope")
|
||||
restrict_to_marketplace_only: bool = Field(alias="restrictToMarketplaceOnly", strict=True)
|
||||
|
||||
|
||||
class FeatureModel(FeatureResponseModel):
|
||||
billing: BillingModel = BillingModel()
|
||||
education: EducationModel = EducationModel()
|
||||
@@ -296,14 +285,6 @@ class FeatureService:
|
||||
"""Return whether Enterprise plugin credential policies must be enforced."""
|
||||
return dify_config.ENTERPRISE_ENABLED
|
||||
|
||||
@classmethod
|
||||
def get_plugin_installation_permission(cls) -> PluginInstallationPermissionModel:
|
||||
"""Resolve the validated deployment-wide plugin installation policy."""
|
||||
if not dify_config.ENTERPRISE_ENABLED:
|
||||
return PluginInstallationPermissionModel()
|
||||
|
||||
return cls._resolve_plugin_installation_permission(EnterpriseService.get_info())
|
||||
|
||||
@classmethod
|
||||
def get_license(cls) -> LicenseModel:
|
||||
"""Return full license detail. Enterprise-only; requires an authenticated caller.
|
||||
@@ -471,33 +452,6 @@ class FeatureService:
|
||||
)
|
||||
return license_model
|
||||
|
||||
@classmethod
|
||||
def _resolve_plugin_installation_permission(
|
||||
cls, enterprise_info: Mapping[str, object]
|
||||
) -> PluginInstallationPermissionModel:
|
||||
if "PluginInstallationPermission" not in enterprise_info:
|
||||
return PluginInstallationPermissionModel()
|
||||
|
||||
try:
|
||||
permission = _EnterprisePluginInstallationPermission.model_validate(
|
||||
enterprise_info["PluginInstallationPermission"]
|
||||
)
|
||||
except ValidationError as exc:
|
||||
# Do not attach the exception because it may contain raw Enterprise configuration values.
|
||||
logger.error( # noqa: TRY400
|
||||
"Invalid Enterprise plugin installation permission; denying all plugin installations: %s",
|
||||
exc.errors(include_input=False),
|
||||
)
|
||||
return PluginInstallationPermissionModel(
|
||||
plugin_installation_scope=PluginInstallationScope.NONE,
|
||||
restrict_to_marketplace_only=True,
|
||||
)
|
||||
|
||||
return PluginInstallationPermissionModel(
|
||||
plugin_installation_scope=permission.plugin_installation_scope,
|
||||
restrict_to_marketplace_only=permission.restrict_to_marketplace_only,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel):
|
||||
enterprise_info = EnterpriseService.get_info()
|
||||
@@ -545,4 +499,11 @@ class FeatureService:
|
||||
status=LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
|
||||
)
|
||||
|
||||
features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info)
|
||||
if "PluginInstallationPermission" in enterprise_info:
|
||||
plugin_installation_info = enterprise_info["PluginInstallationPermission"]
|
||||
features.plugin_installation_permission.plugin_installation_scope = plugin_installation_info[
|
||||
"pluginInstallationScope"
|
||||
]
|
||||
features.plugin_installation_permission.restrict_to_marketplace_only = plugin_installation_info[
|
||||
"restrictToMarketplaceOnly"
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,6 @@ from werkzeug.exceptions import NotFound
|
||||
from models.dataset import Dataset
|
||||
from models.enums import TagType
|
||||
from models.model import App, Tag, TagBinding
|
||||
from models.skill import Skill
|
||||
from models.snippet import CustomizedSnippet
|
||||
|
||||
type _TagTypeLike = TagType | str
|
||||
@@ -283,11 +282,5 @@ class TagService:
|
||||
)
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
elif type == "skill":
|
||||
skill = session.scalar(
|
||||
select(Skill).where(Skill.tenant_id == current_user.current_tenant_id, Skill.id == target_id).limit(1)
|
||||
)
|
||||
if not skill:
|
||||
raise NotFound("Skill not found")
|
||||
else:
|
||||
raise NotFound("Invalid binding type")
|
||||
|
||||
+480
-71
@@ -1,85 +1,494 @@
|
||||
"""Integration coverage for Notion page bindings backed by persisted documents."""
|
||||
"""Testcontainers integration tests for controllers.console.datasets.data_source endpoints."""
|
||||
|
||||
from inspect import unwrap
|
||||
from unittest.mock import MagicMock, patch
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console.datasets.data_source import DataSourceNotionListApi
|
||||
from models import Account
|
||||
from controllers.console.datasets import data_source
|
||||
from controllers.console.datasets.data_source import (
|
||||
DataSourceApi,
|
||||
DataSourceNotionDatasetSyncApi,
|
||||
DataSourceNotionDocumentSyncApi,
|
||||
DataSourceNotionIndexingEstimateApi,
|
||||
DataSourceNotionListApi,
|
||||
DataSourceNotionPreviewApi,
|
||||
)
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType
|
||||
from models import Account, DataSourceOauthBinding
|
||||
from models.dataset import Document
|
||||
from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus
|
||||
|
||||
|
||||
def test_notion_page_is_marked_bound_from_persisted_document(
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
dataset_id = str(uuid4())
|
||||
account = Account(name="Test User", email="user@example.com")
|
||||
account.id = str(uuid4())
|
||||
document = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
position=1,
|
||||
data_source_type=DataSourceType.NOTION_IMPORT,
|
||||
data_source_info='{"notion_page_id": "page-1"}',
|
||||
batch=f"batch-{uuid4()}",
|
||||
name="Notion Page",
|
||||
created_from=DocumentCreatedFrom.WEB,
|
||||
created_by=str(uuid4()),
|
||||
indexing_status=IndexingStatus.COMPLETED,
|
||||
enabled=True,
|
||||
)
|
||||
db_session_with_containers.add(document)
|
||||
db_session_with_containers.commit()
|
||||
runtime = MagicMock(
|
||||
get_online_document_pages=lambda **_kwargs: iter(
|
||||
[
|
||||
MagicMock(
|
||||
result=[
|
||||
MagicMock(
|
||||
workspace_id="workspace-1",
|
||||
workspace_name="Workspace",
|
||||
workspace_icon=None,
|
||||
pages=[
|
||||
MagicMock(
|
||||
page_id="page-1",
|
||||
page_name="Page",
|
||||
type="page",
|
||||
parent_id="parent",
|
||||
page_icon=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
datasource_provider_type=lambda: None,
|
||||
)
|
||||
@pytest.fixture
|
||||
def current_user() -> Account:
|
||||
account = Account(name="Test User", email="u1@example.com")
|
||||
account.id = "u1"
|
||||
return account
|
||||
|
||||
with (
|
||||
flask_app_with_containers.test_request_context(f"/?credential_id=c1&dataset_id={dataset_id}"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"token": "token"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(data_source_type="notion_import"),
|
||||
),
|
||||
patch(
|
||||
"core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime",
|
||||
return_value=runtime,
|
||||
),
|
||||
|
||||
@pytest.fixture
|
||||
def mock_engine() -> Iterator[None]:
|
||||
with patch.object(
|
||||
type(data_source.db),
|
||||
"engine",
|
||||
new_callable=PropertyMock,
|
||||
return_value=MagicMock(),
|
||||
):
|
||||
response, status = unwrap(DataSourceNotionListApi().get)(
|
||||
DataSourceNotionListApi(), db_session_with_containers, tenant_id, account
|
||||
yield
|
||||
|
||||
|
||||
class TestDataSourceApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_success(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
binding = DataSourceOauthBinding(
|
||||
tenant_id="tenant-1",
|
||||
access_token="token",
|
||||
provider="notion",
|
||||
source_info={
|
||||
"workspace_name": "Workspace",
|
||||
"workspace_id": "workspace-1",
|
||||
"workspace_icon": None,
|
||||
"total": 1,
|
||||
"pages": [
|
||||
{
|
||||
"page_id": "page-1",
|
||||
"page_name": "Page",
|
||||
"page_icon": {"type": "emoji", "emoji": "P", "url": None},
|
||||
"parent_id": "parent-1",
|
||||
"type": "page",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
binding.id = "b1"
|
||||
binding.created_at = datetime(2026, 5, 25, 1, 2, 3, tzinfo=UTC)
|
||||
binding.disabled = False
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.db.session.scalars",
|
||||
return_value=MagicMock(all=lambda: [binding]),
|
||||
),
|
||||
):
|
||||
response, status = method(api, "tenant-1")
|
||||
|
||||
assert status == 200
|
||||
assert response["data"][0] == {
|
||||
"id": "b1",
|
||||
"provider": "notion",
|
||||
"created_at": 1779670923,
|
||||
"is_bound": True,
|
||||
"disabled": False,
|
||||
"source_info": {
|
||||
"workspace_name": "Workspace",
|
||||
"workspace_id": "workspace-1",
|
||||
"workspace_icon": None,
|
||||
"pages": [
|
||||
{
|
||||
"page_name": "Page",
|
||||
"page_id": "page-1",
|
||||
"page_icon": {"type": "emoji", "url": None, "emoji": "P"},
|
||||
"parent_id": "parent-1",
|
||||
"type": "page",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
"link": "http://localhost/console/api/oauth/data-source/notion",
|
||||
}
|
||||
|
||||
def test_get_no_bindings(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.db.session.scalars",
|
||||
return_value=MagicMock(all=lambda: []),
|
||||
),
|
||||
):
|
||||
response, status = method(api, "tenant-1")
|
||||
|
||||
assert status == 200
|
||||
assert response["data"] == []
|
||||
|
||||
def test_patch_enable_binding(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
|
||||
binding = MagicMock(id="b1", disabled=True)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
|
||||
with app.test_request_context("/"):
|
||||
response, status = method(api, session, "tenant-1", "b1", "enable")
|
||||
|
||||
assert status == 200
|
||||
assert binding.disabled is False
|
||||
|
||||
def test_patch_disable_binding(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
|
||||
binding = MagicMock(id="b1", disabled=False)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
|
||||
with app.test_request_context("/"):
|
||||
response, status = method(api, session, "tenant-1", "b1", "disable")
|
||||
|
||||
assert status == 200
|
||||
assert binding.disabled is True
|
||||
|
||||
def test_patch_binding_not_found(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = None
|
||||
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, session, "tenant-1", "b1", "enable")
|
||||
|
||||
def test_patch_enable_already_enabled(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
|
||||
binding = MagicMock(id="b1", disabled=False)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(ValueError):
|
||||
method(api, session, "tenant-1", "b1", "enable")
|
||||
|
||||
def test_patch_disable_already_disabled(self, app: Flask) -> None:
|
||||
api = DataSourceApi()
|
||||
method = inspect.unwrap(api.patch)
|
||||
|
||||
binding = MagicMock(id="b1", disabled=True)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(ValueError):
|
||||
method(api, session, "tenant-1", "b1", "disable")
|
||||
|
||||
|
||||
class TestDataSourceNotionListApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_credential_not_found(self, app: Flask, current_user: Account) -> None:
|
||||
api = DataSourceNotionListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, MagicMock(), "tenant-1", current_user)
|
||||
|
||||
def test_get_success_no_dataset_id(self, app: Flask, current_user: Account, mock_engine: None) -> None:
|
||||
api = DataSourceNotionListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
page = MagicMock(
|
||||
page_id="p1",
|
||||
page_name="Page 1",
|
||||
type="page",
|
||||
parent_id="parent",
|
||||
page_icon=None,
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
assert response["notion_info"][0]["pages"][0]["is_bound"] is True
|
||||
online_document_message = MagicMock(
|
||||
result=[
|
||||
MagicMock(
|
||||
workspace_id="w1",
|
||||
workspace_name="My Workspace",
|
||||
workspace_icon="icon",
|
||||
pages=[page],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"token": "t"},
|
||||
),
|
||||
patch(
|
||||
"core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime",
|
||||
return_value=MagicMock(
|
||||
get_online_document_pages=lambda **kw: iter([online_document_message]),
|
||||
datasource_provider_type=lambda: None,
|
||||
),
|
||||
),
|
||||
):
|
||||
response, status = method(api, MagicMock(), "tenant-1", current_user)
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_get_success_with_dataset_id(
|
||||
self, app: Flask, current_user: Account, mock_engine: None, db_session_with_containers: Session
|
||||
) -> None:
|
||||
api = DataSourceNotionListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
tenant_id = str(uuid4())
|
||||
dataset_id = str(uuid4())
|
||||
|
||||
page = MagicMock(
|
||||
page_id="p1",
|
||||
page_name="Page 1",
|
||||
type="page",
|
||||
parent_id="parent",
|
||||
page_icon=None,
|
||||
)
|
||||
|
||||
online_document_message = MagicMock(
|
||||
result=[
|
||||
MagicMock(
|
||||
workspace_id="w1",
|
||||
workspace_name="My Workspace",
|
||||
workspace_icon="icon",
|
||||
pages=[page],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
dataset = MagicMock(data_source_type="notion_import")
|
||||
document = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
position=1,
|
||||
data_source_type=DataSourceType.NOTION_IMPORT,
|
||||
data_source_info='{"notion_page_id": "p1"}',
|
||||
batch=f"batch-{uuid4()}",
|
||||
name="Notion Page",
|
||||
created_from=DocumentCreatedFrom.WEB,
|
||||
created_by=str(uuid4()),
|
||||
indexing_status=IndexingStatus.COMPLETED,
|
||||
enabled=True,
|
||||
)
|
||||
db_session_with_containers.add(document)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
with (
|
||||
app.test_request_context(f"/?credential_id=c1&dataset_id={dataset_id}"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"token": "t"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=dataset,
|
||||
),
|
||||
patch(
|
||||
"core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime",
|
||||
return_value=MagicMock(
|
||||
get_online_document_pages=lambda **kw: iter([online_document_message]),
|
||||
datasource_provider_type=lambda: None,
|
||||
),
|
||||
),
|
||||
):
|
||||
response, status = method(api, db_session_with_containers, tenant_id, current_user)
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_get_invalid_dataset_type(self, app: Flask, current_user: Account, mock_engine: None) -> None:
|
||||
api = DataSourceNotionListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
dataset = MagicMock(data_source_type="other_type")
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1&dataset_id=ds1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"token": "t"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=dataset,
|
||||
),
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
method(api, MagicMock(), "tenant-1", current_user)
|
||||
|
||||
|
||||
class TestDataSourceNotionPreviewApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_preview_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionPreviewApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
extractor = MagicMock(extract=lambda: [MagicMock(page_content="hello")])
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"integration_secret": "t"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.NotionExtractor",
|
||||
return_value=extractor,
|
||||
),
|
||||
):
|
||||
response, status = method(api, "tenant-1", "p1", "page")
|
||||
|
||||
assert status == 200
|
||||
|
||||
|
||||
class TestDataSourceNotionIndexingEstimateApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_post_indexing_estimate_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionIndexingEstimateApi()
|
||||
method = inspect.unwrap(api.post)
|
||||
|
||||
empty_rules: dict[str, object] = {}
|
||||
payload: dict[str, object] = {
|
||||
"notion_info_list": [
|
||||
{
|
||||
"workspace_id": "w1",
|
||||
"credential_id": "c1",
|
||||
"pages": [{"page_id": "p1", "type": "page"}],
|
||||
}
|
||||
],
|
||||
"process_rule": {"rules": empty_rules},
|
||||
"doc_form": IndexStructureType.PARAGRAPH_INDEX,
|
||||
"doc_language": "English",
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="POST", json=payload, headers={"Content-Type": "application/json"}),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.estimate_args_validate",
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.IndexingRunner.indexing_estimate",
|
||||
return_value=MagicMock(model_dump=lambda: {"total_pages": 1}),
|
||||
),
|
||||
):
|
||||
response, status = method(api, MagicMock(), "tenant-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
|
||||
class TestDataSourceNotionDatasetSyncApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionDatasetSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document_by_dataset_id",
|
||||
return_value=[MagicMock(id="d1")],
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response, status = method(api, MagicMock(), "ds-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_get_dataset_not_found(self, app: Flask) -> None:
|
||||
api = DataSourceNotionDatasetSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, MagicMock(), "ds-1")
|
||||
|
||||
|
||||
class TestDataSourceNotionDocumentSyncApi:
|
||||
@pytest.fixture
|
||||
def app(self, flask_app_with_containers: Flask) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
def test_get_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionDocumentSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response, status = method(api, MagicMock(), "ds-1", "doc-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_get_document_not_found(self, app: Flask) -> None:
|
||||
api = DataSourceNotionDocumentSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, MagicMock(), "ds-1", "doc-1")
|
||||
|
||||
+58
-1
@@ -4,7 +4,7 @@ import datetime
|
||||
import json
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
@@ -1172,8 +1172,65 @@ class TestMessagesCleanServiceIntegration:
|
||||
# Verify all messages were deleted
|
||||
assert db_session_with_containers.query(Message).where(Message.id.in_(msg_ids)).count() == 0
|
||||
|
||||
def test_from_time_range_validation(self):
|
||||
"""Test that from_time_range raises ValueError for invalid inputs."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
now = datetime.datetime.now()
|
||||
|
||||
with pytest.raises(ValueError, match="start_from .* must be less than end_before"):
|
||||
MessagesCleanService.from_time_range(policy, now, now)
|
||||
|
||||
with pytest.raises(ValueError, match="batch_size .* must be greater than 0"):
|
||||
MessagesCleanService.from_time_range(policy, now - datetime.timedelta(days=1), now, batch_size=0)
|
||||
|
||||
def test_from_time_range_success(self):
|
||||
"""Test that from_time_range creates a service with correct parameters."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
start = datetime.datetime(2024, 1, 1)
|
||||
end = datetime.datetime(2024, 2, 1)
|
||||
|
||||
service = MessagesCleanService.from_time_range(policy, start, end)
|
||||
assert service._start_from == start
|
||||
assert service._end_before == end
|
||||
|
||||
def test_from_days_validation(self):
|
||||
"""Test that from_days raises ValueError for invalid inputs."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
|
||||
with pytest.raises(ValueError, match="days .* must be greater than or equal to 0"):
|
||||
MessagesCleanService.from_days(policy, days=-1)
|
||||
|
||||
with pytest.raises(ValueError, match="batch_size .* must be greater than 0"):
|
||||
MessagesCleanService.from_days(policy, days=30, batch_size=0)
|
||||
|
||||
def test_from_days_success(self):
|
||||
"""Test that from_days creates a service with correct parameters."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
|
||||
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
|
||||
fixed_now = datetime.datetime(2024, 6, 1)
|
||||
mock_now.return_value = fixed_now
|
||||
|
||||
service = MessagesCleanService.from_days(policy, days=10)
|
||||
assert service._start_from is None
|
||||
assert service._end_before == fixed_now - datetime.timedelta(days=10)
|
||||
|
||||
def test_batch_delete_message_relations_empty(self, db_session_with_containers: Session):
|
||||
"""Test that batch_delete_message_relations with empty list does nothing."""
|
||||
# Get execute call count before
|
||||
MessagesCleanService._batch_delete_message_relations(db_session_with_containers, [])
|
||||
# No exception means success — empty list is a no-op
|
||||
|
||||
def test_run_calls_clean_messages(self):
|
||||
"""Test that run() delegates to _clean_messages_by_time_range."""
|
||||
policy = MagicMock(spec=BillingDisabledPolicy)
|
||||
service = MessagesCleanService(
|
||||
policy=policy,
|
||||
end_before=datetime.datetime.now(),
|
||||
batch_size=10,
|
||||
)
|
||||
with patch.object(service, "_clean_messages_by_time_range") as mock_clean:
|
||||
mock_clean.return_value = {"total_deleted": 5}
|
||||
result = service.run()
|
||||
assert result == {"total_deleted": 5}
|
||||
mock_clean.assert_called_once()
|
||||
|
||||
+31
-41
@@ -1,20 +1,16 @@
|
||||
"""Unit tests for OAuthServerService with SQLite-backed database access."""
|
||||
"""Testcontainers integration tests for OAuthServerService."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from models.engine import db
|
||||
from models.model import OAuthProviderApp
|
||||
from services.oauth_server import (
|
||||
OAUTH_ACCESS_TOKEN_EXPIRES_IN,
|
||||
@@ -27,24 +23,10 @@ from services.oauth_server import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_db() -> Iterator[Session]:
|
||||
"""Provide the production database extension with an isolated SQLite provider table."""
|
||||
app = Flask(__name__)
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
db.init_app(app)
|
||||
|
||||
with app.app_context():
|
||||
OAuthProviderApp.__table__.create(db.engine)
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
|
||||
class TestOAuthServerServiceGetProviderApp:
|
||||
"""Verify provider lookup against a real SQLAlchemy database."""
|
||||
"""DB-backed tests for get_oauth_provider_app."""
|
||||
|
||||
def test_get_oauth_provider_app_returns_app_when_exists(self, oauth_db: Session) -> None:
|
||||
client_id = f"client-{uuid4()}"
|
||||
def _create_oauth_provider_app(self, db_session_with_containers: Session, *, client_id: str) -> OAuthProviderApp:
|
||||
app = OAuthProviderApp(
|
||||
app_icon="icon.png",
|
||||
client_id=client_id,
|
||||
@@ -53,30 +35,35 @@ class TestOAuthServerServiceGetProviderApp:
|
||||
redirect_uris=["https://example.com/callback"],
|
||||
scope="read",
|
||||
)
|
||||
oauth_db.add(app)
|
||||
oauth_db.commit()
|
||||
db_session_with_containers.add(app)
|
||||
db_session_with_containers.commit()
|
||||
return app
|
||||
|
||||
def test_get_oauth_provider_app_returns_app_when_exists(self, db_session_with_containers: Session):
|
||||
client_id = f"client-{uuid4()}"
|
||||
created = self._create_oauth_provider_app(db_session_with_containers, client_id=client_id)
|
||||
|
||||
result = OAuthServerService.get_oauth_provider_app(client_id)
|
||||
|
||||
assert result is not None
|
||||
assert result.client_id == client_id
|
||||
assert result.id == app.id
|
||||
assert result.id == created.id
|
||||
|
||||
def test_get_oauth_provider_app_returns_none_when_not_exists(self, oauth_db: Session) -> None:
|
||||
def test_get_oauth_provider_app_returns_none_when_not_exists(self, db_session_with_containers: Session):
|
||||
result = OAuthServerService.get_oauth_provider_app(f"nonexistent-{uuid4()}")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestOAuthServerServiceTokenOperations:
|
||||
"""Verify Redis-backed token signing and validation branches."""
|
||||
"""Redis-backed tests for token sign/validate operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis(self):
|
||||
with patch("services.oauth_server.redis_client") as mock:
|
||||
yield mock
|
||||
|
||||
def test_sign_authorization_code_stores_and_returns_code(self, mock_redis) -> None:
|
||||
def test_sign_authorization_code_stores_and_returns_code(self, mock_redis):
|
||||
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000111")
|
||||
with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid):
|
||||
code = OAuthServerService.sign_oauth_authorization_code("client-1", "user-1")
|
||||
@@ -88,7 +75,7 @@ class TestOAuthServerServiceTokenOperations:
|
||||
ex=600,
|
||||
)
|
||||
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_code(self, mock_redis) -> None:
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_code(self, mock_redis):
|
||||
mock_redis.get.return_value = None
|
||||
|
||||
with pytest.raises(BadRequest, match="invalid code"):
|
||||
@@ -98,13 +85,14 @@ class TestOAuthServerServiceTokenOperations:
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
def test_sign_access_token_issues_tokens_for_valid_code(self, mock_redis) -> None:
|
||||
def test_sign_access_token_issues_tokens_for_valid_code(self, mock_redis):
|
||||
token_uuids = [
|
||||
uuid.UUID("00000000-0000-0000-0000-000000000201"),
|
||||
uuid.UUID("00000000-0000-0000-0000-000000000202"),
|
||||
]
|
||||
with patch("services.oauth_server.uuid.uuid4", side_effect=token_uuids):
|
||||
mock_redis.get.return_value = b"user-1"
|
||||
|
||||
access_token, refresh_token = OAuthServerService.sign_oauth_access_token(
|
||||
grant_type=OAuthGrantType.AUTHORIZATION_CODE,
|
||||
code="code-1",
|
||||
@@ -126,7 +114,7 @@ class TestOAuthServerServiceTokenOperations:
|
||||
ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_refresh_token(self, mock_redis) -> None:
|
||||
def test_sign_access_token_raises_bad_request_for_invalid_refresh_token(self, mock_redis):
|
||||
mock_redis.get.return_value = None
|
||||
|
||||
with pytest.raises(BadRequest, match="invalid refresh token"):
|
||||
@@ -136,10 +124,11 @@ class TestOAuthServerServiceTokenOperations:
|
||||
client_id="client-1",
|
||||
)
|
||||
|
||||
def test_sign_access_token_issues_new_token_for_valid_refresh(self, mock_redis) -> None:
|
||||
def test_sign_access_token_issues_new_token_for_valid_refresh(self, mock_redis):
|
||||
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000301")
|
||||
with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid):
|
||||
mock_redis.get.return_value = b"user-1"
|
||||
|
||||
access_token, returned_refresh = OAuthServerService.sign_oauth_access_token(
|
||||
grant_type=OAuthGrantType.REFRESH_TOKEN,
|
||||
refresh_token="refresh-1",
|
||||
@@ -149,14 +138,14 @@ class TestOAuthServerServiceTokenOperations:
|
||||
assert access_token == str(deterministic_uuid)
|
||||
assert returned_refresh == "refresh-1"
|
||||
|
||||
def test_sign_access_token_returns_none_for_unknown_grant_type(self, mock_redis) -> None:
|
||||
def test_sign_access_token_returns_none_for_unknown_grant_type(self, mock_redis):
|
||||
grant_type = cast(OAuthGrantType, "invalid-grant-type")
|
||||
|
||||
result = OAuthServerService.sign_oauth_access_token(grant_type=grant_type, client_id="client-1")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_sign_refresh_token_stores_with_expected_expiry(self, mock_redis) -> None:
|
||||
def test_sign_refresh_token_stores_with_expected_expiry(self, mock_redis):
|
||||
deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000401")
|
||||
with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid):
|
||||
refresh_token = OAuthServerService._sign_oauth_refresh_token("client-2", "user-2")
|
||||
@@ -168,21 +157,22 @@ class TestOAuthServerServiceTokenOperations:
|
||||
ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN,
|
||||
)
|
||||
|
||||
def test_validate_access_token_returns_none_when_not_found(self, mock_redis, sqlite_engine: Engine) -> None:
|
||||
def test_validate_access_token_returns_none_when_not_found(self, mock_redis, db_session_with_containers: Session):
|
||||
mock_redis.get.return_value = None
|
||||
session = MagicMock()
|
||||
|
||||
with Session(sqlite_engine) as session:
|
||||
result = OAuthServerService.validate_oauth_access_token("client-1", "missing-token", session)
|
||||
result = OAuthServerService.validate_oauth_access_token("client-1", "missing-token", db_session_with_containers)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_validate_access_token_loads_user_when_exists(self, mock_redis, sqlite_engine: Engine) -> None:
|
||||
def test_validate_access_token_loads_user_when_exists(self, mock_redis, db_session_with_containers: Session):
|
||||
mock_redis.get.return_value = b"user-88"
|
||||
expected_user = MagicMock()
|
||||
|
||||
with Session(sqlite_engine) as session:
|
||||
with patch("services.oauth_server.AccountService.load_user", return_value=expected_user) as mock_load:
|
||||
result = OAuthServerService.validate_oauth_access_token("client-1", "access-token", session)
|
||||
mock_load.assert_called_once_with("user-88", session)
|
||||
with patch("services.oauth_server.AccountService.load_user", return_value=expected_user) as mock_load:
|
||||
result = OAuthServerService.validate_oauth_access_token(
|
||||
"client-1", "access-token", db_session_with_containers
|
||||
)
|
||||
|
||||
assert result is expected_user
|
||||
mock_load.assert_called_once_with("user-88", db_session_with_containers)
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -11,13 +12,13 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models import EndUser, Workflow, WorkflowAppLog, WorkflowArchiveLog, WorkflowRun
|
||||
from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom
|
||||
from models.enums import AppTriggerType, CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom
|
||||
from models.workflow import WorkflowAppLogCreatedFrom
|
||||
from services.account_service import AccountService, TenantService
|
||||
|
||||
# Delay import of AppService to avoid circular dependency
|
||||
# from services.app_service import AppService, CreateAppParams
|
||||
from services.workflow_app_service import WorkflowAppService
|
||||
from services.workflow_app_service import LogView, WorkflowAppService
|
||||
from tests.test_containers_integration_tests.helpers import generate_valid_password
|
||||
|
||||
|
||||
@@ -1626,3 +1627,73 @@ class TestWorkflowAppService:
|
||||
end_user_item = next(d for d in result["data"] if d["created_by_end_user"] is not None)
|
||||
assert account_item["created_by_account"].id == account.id
|
||||
assert end_user_item["created_by_end_user"].id == end_user.id
|
||||
|
||||
|
||||
class TestLogView:
|
||||
def test_details_and_proxy_attributes(self):
|
||||
log = SimpleNamespace(id="log-1", status="succeeded")
|
||||
view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}})
|
||||
|
||||
assert view.details == {"trigger_metadata": {"type": "plugin"}}
|
||||
assert view.status == "succeeded"
|
||||
|
||||
|
||||
class TestHandleTriggerMetadata:
|
||||
def test_returns_empty_dict_when_metadata_missing(self):
|
||||
service = WorkflowAppService()
|
||||
assert service.handle_trigger_metadata("tenant-1", None) == {}
|
||||
|
||||
def test_enriches_plugin_icons(self):
|
||||
service = WorkflowAppService()
|
||||
meta = {
|
||||
"type": AppTriggerType.TRIGGER_PLUGIN.value,
|
||||
"icon_filename": "light.png",
|
||||
"icon_dark_filename": "dark.png",
|
||||
}
|
||||
with patch(
|
||||
"services.workflow_app_service.PluginService.get_plugin_icon_url",
|
||||
side_effect=["https://cdn/light.png", "https://cdn/dark.png"],
|
||||
) as mock_icon:
|
||||
result = service.handle_trigger_metadata("tenant-1", json.dumps(meta))
|
||||
|
||||
assert result["icon"] == "https://cdn/light.png"
|
||||
assert result["icon_dark"] == "https://cdn/dark.png"
|
||||
assert mock_icon.call_count == 2
|
||||
|
||||
def test_non_plugin_metadata_without_icon_lookup(self):
|
||||
service = WorkflowAppService()
|
||||
meta = {"type": AppTriggerType.TRIGGER_WEBHOOK.value}
|
||||
with patch("services.workflow_app_service.PluginService.get_plugin_icon_url") as mock_icon:
|
||||
result = service.handle_trigger_metadata("tenant-1", json.dumps(meta))
|
||||
|
||||
assert result["type"] == AppTriggerType.TRIGGER_WEBHOOK.value
|
||||
mock_icon.assert_not_called()
|
||||
|
||||
|
||||
class TestSafeJsonLoads:
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
('{"k":"v"}', {"k": "v"}),
|
||||
("not-json", None),
|
||||
({"raw": True}, {"raw": True}),
|
||||
],
|
||||
)
|
||||
def test_handles_various_inputs(self, value, expected):
|
||||
assert WorkflowAppService._safe_json_loads(value) == expected
|
||||
|
||||
|
||||
class TestSafeParseUuid:
|
||||
def test_returns_none_for_short_or_invalid_values(self):
|
||||
service = WorkflowAppService()
|
||||
assert service._safe_parse_uuid("short") is None
|
||||
assert service._safe_parse_uuid("x" * 40) is None
|
||||
|
||||
def test_returns_uuid_for_valid_string(self):
|
||||
service = WorkflowAppService()
|
||||
raw = str(uuid.uuid4())
|
||||
result = service._safe_parse_uuid(raw)
|
||||
assert result is not None
|
||||
assert str(result) == raw
|
||||
|
||||
@@ -6,7 +6,6 @@ import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -16,12 +15,9 @@ import pytest
|
||||
import sqlalchemy as sa
|
||||
from click.testing import CliRunner
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
|
||||
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models import Dataset, DatasetPermission, DatasetPermissionEnum
|
||||
from models.account import Tenant
|
||||
from models.base import TypeBase
|
||||
from models.enums import CredentialSourceType
|
||||
from models.provider import ProviderModel
|
||||
from tests.helpers.legacy_model_type_migration import (
|
||||
@@ -63,40 +59,6 @@ def command_module():
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rbac_session(sqlite_engine: sa.Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]:
|
||||
"""Bind RBAC command reads to persisted SQLite dataset rows."""
|
||||
|
||||
TypeBase.metadata.create_all(
|
||||
sqlite_engine,
|
||||
tables=[Dataset.__table__, DatasetPermission.__table__],
|
||||
)
|
||||
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr("commands.rbac.session_factory.create_session", factory)
|
||||
with factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def _persist_dataset(
|
||||
session: Session,
|
||||
*,
|
||||
dataset_id: str = "dataset-1",
|
||||
tenant_id: str = "tenant-1",
|
||||
permission: DatasetPermissionEnum = DatasetPermissionEnum.ONLY_ME,
|
||||
created_by: str = "creator-account-1",
|
||||
) -> Dataset:
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
tenant_id=tenant_id,
|
||||
name=f"Dataset {dataset_id}",
|
||||
permission=permission,
|
||||
created_by=created_by,
|
||||
)
|
||||
session.add(dataset)
|
||||
session.commit()
|
||||
return dataset
|
||||
|
||||
|
||||
def _parse_json_lines(output: io.StringIO) -> list[dict[str, object]]:
|
||||
return [json.loads(line) for line in output.getvalue().splitlines() if line.strip()]
|
||||
|
||||
@@ -401,35 +363,56 @@ def test_dataset_permission_rbac_migration_maps_legacy_permissions_to_enum_scope
|
||||
|
||||
def test_dataset_permission_rbac_migration_uses_dataset_creator_as_operator(
|
||||
command_module,
|
||||
rbac_session: Session,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
rbac_module = importlib.import_module("commands.rbac")
|
||||
_persist_dataset(rbac_session)
|
||||
dataset_row = SimpleNamespace(
|
||||
id="dataset-1",
|
||||
tenant_id="tenant-1",
|
||||
permission="only_me",
|
||||
created_by="creator-account-1",
|
||||
)
|
||||
execute_results = [[dataset_row], [], []]
|
||||
calls: list[dict[str, object]] = []
|
||||
read_transaction_ended = False
|
||||
session_closed = False
|
||||
|
||||
class FakeExecuteResult:
|
||||
def __init__(self, rows: list[object]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> list[object]:
|
||||
return self._rows
|
||||
|
||||
class FakeSession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
nonlocal session_closed
|
||||
session_closed = True
|
||||
pass
|
||||
|
||||
def execute(self, stmt):
|
||||
return FakeExecuteResult(execute_results.pop(0))
|
||||
|
||||
class FakeSessionFactory:
|
||||
@staticmethod
|
||||
def create_session() -> FakeSession:
|
||||
return FakeSession()
|
||||
|
||||
def fake_replace_whitelist(**kwargs):
|
||||
assert read_transaction_ended is True
|
||||
assert session_closed is True
|
||||
calls.append(kwargs)
|
||||
|
||||
def _record_transaction_end(session: Session, transaction: object) -> None:
|
||||
nonlocal read_transaction_ended
|
||||
del transaction
|
||||
if session.get_bind() is rbac_session.get_bind():
|
||||
read_transaction_ended = True
|
||||
|
||||
sa.event.listen(Session, "after_transaction_end", _record_transaction_end)
|
||||
monkeypatch.setattr(rbac_module, "session_factory", FakeSessionFactory)
|
||||
monkeypatch.setattr(rbac_module.RBACService.DatasetAccess, "replace_whitelist", fake_replace_whitelist)
|
||||
try:
|
||||
command_module.migrate_dataset_permissions_to_rbac.callback(
|
||||
tenant_id=None,
|
||||
dataset_id=None,
|
||||
batch_size=500,
|
||||
dry_run=False,
|
||||
)
|
||||
finally:
|
||||
sa.event.remove(Session, "after_transaction_end", _record_transaction_end)
|
||||
|
||||
command_module.migrate_dataset_permissions_to_rbac.callback(
|
||||
tenant_id=None,
|
||||
dataset_id=None,
|
||||
batch_size=500,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
assert calls[0]["tenant_id"] == "tenant-1"
|
||||
assert calls[0]["account_id"] == "creator-account-1"
|
||||
@@ -439,19 +422,41 @@ def test_dataset_permission_rbac_migration_uses_dataset_creator_as_operator(
|
||||
|
||||
def test_dataset_permission_rbac_migration_dry_run_outputs_structured_proposed_changes(
|
||||
command_module,
|
||||
rbac_session: Session,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
rbac_module = importlib.import_module("commands.rbac")
|
||||
dataset = _persist_dataset(rbac_session, permission=DatasetPermissionEnum.PARTIAL_TEAM)
|
||||
rbac_session.add(
|
||||
DatasetPermission(
|
||||
dataset_id=dataset.id,
|
||||
account_id="member-account-1",
|
||||
tenant_id=dataset.tenant_id,
|
||||
)
|
||||
dataset_row = SimpleNamespace(
|
||||
id="dataset-1",
|
||||
tenant_id="tenant-1",
|
||||
permission="partial_members",
|
||||
created_by="creator-account-1",
|
||||
)
|
||||
rbac_session.commit()
|
||||
permission_row = SimpleNamespace(dataset_id="dataset-1", account_id="member-account-1")
|
||||
execute_results = [[dataset_row], [permission_row], []]
|
||||
|
||||
class FakeExecuteResult:
|
||||
def __init__(self, rows: list[object]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> list[object]:
|
||||
return self._rows
|
||||
|
||||
class FakeSession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
pass
|
||||
|
||||
def execute(self, stmt):
|
||||
return FakeExecuteResult(execute_results.pop(0))
|
||||
|
||||
class FakeSessionFactory:
|
||||
@staticmethod
|
||||
def create_session() -> FakeSession:
|
||||
return FakeSession()
|
||||
|
||||
monkeypatch.setattr(rbac_module, "session_factory", FakeSessionFactory)
|
||||
monkeypatch.setattr(
|
||||
rbac_module.RBACService.DatasetAccess,
|
||||
"replace_whitelist",
|
||||
@@ -1301,36 +1306,50 @@ def test_provider_models_processing_uses_same_plan_locking_and_transaction_entry
|
||||
begin_calls: list[str] = []
|
||||
configure_calls: list[str] = []
|
||||
|
||||
def _record_begin(session: Session, transaction: SessionTransaction) -> None:
|
||||
if session.get_bind() is sqlite_engine and transaction.parent is None:
|
||||
begin_calls.append(current_phase["name"])
|
||||
class _FakeBeginContext:
|
||||
def __init__(self, phase: str) -> None:
|
||||
self._phase = phase
|
||||
|
||||
def _fake_build_plan(self, session: Session, candidate, *, lock_rows: bool):
|
||||
assert session.get_bind() is sqlite_engine
|
||||
def __enter__(self) -> None:
|
||||
begin_calls.append(self._phase)
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, phase: str) -> None:
|
||||
self._phase = phase
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
def begin(self) -> _FakeBeginContext:
|
||||
return _FakeBeginContext(self._phase)
|
||||
|
||||
def _fake_session_factory(engine: sa.Engine) -> _FakeSession:
|
||||
return _FakeSession(current_phase["name"])
|
||||
|
||||
def _fake_build_plan(self, session, candidate, *, lock_rows: bool):
|
||||
lock_rows_seen.append((current_phase["name"], lock_rows))
|
||||
return migration_module._ProviderModelGroupPlan(
|
||||
group_row_ids=[str(candidate.row.id)],
|
||||
winner=None,
|
||||
loser_rows=[],
|
||||
)
|
||||
return SimpleNamespace(group_row_ids=[str(candidate.row.id)], winner=None, loser_rows=[])
|
||||
|
||||
def _fake_emit_plan(self, plan, *, session, tx_id: str, business_key: dict[str, object]) -> None:
|
||||
return None
|
||||
|
||||
def _fake_configure(self, session: Session) -> None:
|
||||
assert session.get_bind() is sqlite_engine
|
||||
def _fake_configure(self, session) -> None:
|
||||
configure_calls.append(current_phase["name"])
|
||||
|
||||
monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory)
|
||||
monkeypatch.setattr(migration_module.Migration, "_build_provider_model_group_plan", _fake_build_plan)
|
||||
monkeypatch.setattr(migration_module.Migration, "_emit_provider_model_group_plan", _fake_emit_plan)
|
||||
monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", _fake_configure)
|
||||
sa.event.listen(Session, "after_transaction_create", _record_begin)
|
||||
try:
|
||||
dry_migration._process_provider_model_group(candidate, business_key)
|
||||
current_phase["name"] = "apply"
|
||||
apply_migration._process_provider_model_group(candidate, business_key)
|
||||
finally:
|
||||
sa.event.remove(Session, "after_transaction_create", _record_begin)
|
||||
|
||||
dry_migration._process_provider_model_group(candidate, business_key)
|
||||
current_phase["name"] = "apply"
|
||||
apply_migration._process_provider_model_group(candidate, business_key)
|
||||
|
||||
assert [phase for phase, _ in lock_rows_seen] == ["dry", "apply"]
|
||||
assert lock_rows_seen[0][1] == lock_rows_seen[1][1]
|
||||
@@ -1373,22 +1392,6 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou
|
||||
sqlite_engine: sa.Engine,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
create_minimal_legacy_model_type_schema(sqlite_engine)
|
||||
created_at = datetime(2025, 1, 1, 12, 0, 0)
|
||||
_insert_load_balancing_model_config(
|
||||
sqlite_engine,
|
||||
row_id="40000000-0000-0000-0000-000000000001",
|
||||
tenant_id="tenant-1",
|
||||
provider_name="openai",
|
||||
model_name="gpt-4o-mini",
|
||||
model_type="text-generation",
|
||||
name="credential",
|
||||
encrypted_config="{}",
|
||||
credential_id="50000000-0000-0000-0000-000000000001",
|
||||
enabled=True,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
)
|
||||
output = io.StringIO()
|
||||
migration = migration_module.Migration(
|
||||
tenant_id="tenant-1",
|
||||
@@ -1398,18 +1401,37 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou
|
||||
model_types=(ModelType.LLM,),
|
||||
orm_models=(migration_module.LoadBalancingModelConfig,),
|
||||
)
|
||||
candidate = migration._load_load_balancing_model_config_candidates(None)[0]
|
||||
candidate = migration_module._RowWithRawModelType(
|
||||
row=SimpleNamespace(id="lb-row-1"),
|
||||
raw_model_type="text-generation",
|
||||
canonical_model_type=ModelType.LLM,
|
||||
)
|
||||
lock_timeout_exc = OperationalError("SELECT 1", {}, SimpleNamespace(pgcode="55P03"))
|
||||
transaction_begins = 0
|
||||
|
||||
def _record_begin(session: Session, transaction: SessionTransaction) -> None:
|
||||
nonlocal transaction_begins
|
||||
if session.get_bind() is sqlite_engine and transaction.parent is None:
|
||||
transaction_begins += 1
|
||||
class _FakeBeginContext:
|
||||
def __enter__(self) -> None:
|
||||
return None
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
class _FakeSession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
def begin(self) -> _FakeBeginContext:
|
||||
return _FakeBeginContext()
|
||||
|
||||
def _fake_session_factory(engine: sa.Engine) -> _FakeSession:
|
||||
return _FakeSession()
|
||||
|
||||
def _fake_reload(self, session, original_candidate, *, lock_rows: bool):
|
||||
raise lock_timeout_exc
|
||||
|
||||
monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory)
|
||||
monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", lambda self, session: None)
|
||||
monkeypatch.setattr(
|
||||
migration_module.Migration,
|
||||
@@ -1417,22 +1439,17 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou
|
||||
_fake_reload,
|
||||
)
|
||||
|
||||
sa.event.listen(Session, "after_transaction_create", _record_begin)
|
||||
try:
|
||||
migration._process_load_balancing_model_config_row(candidate)
|
||||
finally:
|
||||
sa.event.remove(Session, "after_transaction_create", _record_begin)
|
||||
migration._process_load_balancing_model_config_row(candidate)
|
||||
|
||||
lines = _parse_json_lines(output)
|
||||
assert len(lines) == 1
|
||||
assert lines[0]["event"] == "lock_timeout_skipped"
|
||||
attrs = cast(dict[str, object], lines[0]["attrs"])
|
||||
assert attrs["table_name"] == "load_balancing_model_configs"
|
||||
assert attrs["id"] == str(candidate.row.id)
|
||||
assert attrs["id"] == "lb-row-1"
|
||||
assert attrs["error"] == str(lock_timeout_exc)
|
||||
assert isinstance(attrs["stacktrace"], str)
|
||||
assert "OperationalError" in attrs["stacktrace"]
|
||||
assert transaction_begins == 1
|
||||
|
||||
|
||||
def test_process_load_balancing_model_config_row_logs_update_after_sql_execution(
|
||||
@@ -1440,23 +1457,6 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
sqlite_engine: sa.Engine,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
create_minimal_legacy_model_type_schema(sqlite_engine)
|
||||
created_at = datetime(2025, 1, 1, 12, 0, 0)
|
||||
row_id = "40000000-0000-0000-0000-000000000002"
|
||||
_insert_load_balancing_model_config(
|
||||
sqlite_engine,
|
||||
row_id=row_id,
|
||||
tenant_id="tenant-1",
|
||||
provider_name="openai",
|
||||
model_name="gpt-4o-mini",
|
||||
model_type="text-generation",
|
||||
name="credential",
|
||||
encrypted_config="{}",
|
||||
credential_id="50000000-0000-0000-0000-000000000002",
|
||||
enabled=True,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
)
|
||||
migration = migration_module.Migration(
|
||||
tenant_id="tenant-1",
|
||||
engine=sqlite_engine,
|
||||
@@ -1465,33 +1465,42 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
model_types=(ModelType.LLM,),
|
||||
orm_models=(migration_module.LoadBalancingModelConfig,),
|
||||
)
|
||||
candidate = migration._load_load_balancing_model_config_candidates(None)[0]
|
||||
candidate = migration_module._RowWithRawModelType(
|
||||
row=SimpleNamespace(id="lb-row-1"),
|
||||
raw_model_type="text-generation",
|
||||
canonical_model_type=ModelType.LLM,
|
||||
)
|
||||
action_log: list[str] = []
|
||||
|
||||
def _record_begin(session: Session, transaction: SessionTransaction) -> None:
|
||||
if session.get_bind() is sqlite_engine and transaction.parent is None:
|
||||
class _FakeBeginContext:
|
||||
def __enter__(self) -> None:
|
||||
action_log.append("begin")
|
||||
|
||||
def _record_sql(
|
||||
connection: sa.Connection,
|
||||
cursor: object,
|
||||
statement: str,
|
||||
parameters: object,
|
||||
context: object,
|
||||
executemany: bool,
|
||||
) -> None:
|
||||
del connection, cursor, parameters, context, executemany
|
||||
if statement.lstrip().upper().startswith("UPDATE"):
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
class _FakeSession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
def begin(self) -> _FakeBeginContext:
|
||||
return _FakeBeginContext()
|
||||
|
||||
def execute(self, stmt) -> None:
|
||||
action_log.append("sql_execute")
|
||||
|
||||
def _fake_session_factory(engine: sa.Engine) -> _FakeSession:
|
||||
return _FakeSession()
|
||||
|
||||
def _fake_configure(self, session) -> None:
|
||||
action_log.append("configure_lock_timeout")
|
||||
|
||||
original_reload = migration_module.Migration._reload_load_balancing_model_config_candidate
|
||||
|
||||
def _record_reload(self, session: Session, original_candidate, *, lock_rows: bool):
|
||||
def _fake_reload(self, session, original_candidate, *, lock_rows: bool):
|
||||
action_log.append(f"reload_candidate:{lock_rows}")
|
||||
return original_reload(self, session, original_candidate, lock_rows=lock_rows)
|
||||
return candidate
|
||||
|
||||
def _fake_log_row_updated(self, *args, **kwargs) -> None:
|
||||
action_log.append("log_row_updated")
|
||||
@@ -1499,11 +1508,12 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
def _fake_cache_cleanup(self, *, row_id: str, tx_id: str) -> None:
|
||||
action_log.append("cache_cleanup")
|
||||
|
||||
monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory)
|
||||
monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", _fake_configure)
|
||||
monkeypatch.setattr(
|
||||
migration_module.Migration,
|
||||
"_reload_load_balancing_model_config_candidate",
|
||||
_record_reload,
|
||||
_fake_reload,
|
||||
)
|
||||
monkeypatch.setattr(migration_module.Migration, "_log_row_updated", _fake_log_row_updated)
|
||||
monkeypatch.setattr(
|
||||
@@ -1512,13 +1522,7 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
_fake_cache_cleanup,
|
||||
)
|
||||
|
||||
sa.event.listen(Session, "after_transaction_create", _record_begin)
|
||||
sa.event.listen(sqlite_engine, "before_cursor_execute", _record_sql)
|
||||
try:
|
||||
migration._process_load_balancing_model_config_row(candidate)
|
||||
finally:
|
||||
sa.event.remove(sqlite_engine, "before_cursor_execute", _record_sql)
|
||||
sa.event.remove(Session, "after_transaction_create", _record_begin)
|
||||
migration._process_load_balancing_model_config_row(candidate)
|
||||
|
||||
assert action_log == [
|
||||
"begin",
|
||||
@@ -1528,10 +1532,6 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution
|
||||
"log_row_updated",
|
||||
"cache_cleanup",
|
||||
]
|
||||
with Session(sqlite_engine) as session:
|
||||
persisted = session.get(migration_module.LoadBalancingModelConfig, row_id)
|
||||
assert persisted is not None
|
||||
assert persisted.model_type == ModelType.LLM
|
||||
|
||||
|
||||
def test_load_balancing_model_config_cache_delete_failure_logs_stacktrace(
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Callable, Iterator
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal, cast
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console.datasets import data_source as module
|
||||
from controllers.console.datasets.data_source import DataSourceApi, DataSourceNotionListApi
|
||||
from models import Account, DataSourceOauthBinding
|
||||
from models.engine import db
|
||||
|
||||
ControllerMethod = Callable[..., tuple[dict[str, object], int]]
|
||||
|
||||
@@ -26,15 +22,10 @@ def unwrap(func: object) -> ControllerMethod:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flask_app() -> Iterator[Flask]:
|
||||
def flask_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
db.init_app(app)
|
||||
|
||||
with app.app_context():
|
||||
DataSourceOauthBinding.__table__.create(db.engine)
|
||||
yield app
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -44,13 +35,9 @@ def current_user() -> Account:
|
||||
return account
|
||||
|
||||
|
||||
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
BINDING_ID = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
|
||||
def _add_binding(session: Session, *, disabled: bool) -> DataSourceOauthBinding:
|
||||
def test_get_data_source_integrates_serializes_orm_binding(flask_app: Flask) -> None:
|
||||
binding = DataSourceOauthBinding(
|
||||
tenant_id=TENANT_ID,
|
||||
tenant_id="tenant-1",
|
||||
access_token="token",
|
||||
provider="notion",
|
||||
source_info={
|
||||
@@ -68,31 +55,24 @@ def _add_binding(session: Session, *, disabled: bool) -> DataSourceOauthBinding:
|
||||
}
|
||||
],
|
||||
},
|
||||
disabled=disabled,
|
||||
)
|
||||
binding.id = BINDING_ID
|
||||
binding.id = "binding-1"
|
||||
binding.created_at = datetime(2026, 5, 25, 1, 2, 3, tzinfo=UTC)
|
||||
session.add(binding)
|
||||
session.commit()
|
||||
return binding
|
||||
binding.disabled = False
|
||||
|
||||
|
||||
def test_get_data_source_integrates_serializes_orm_binding(
|
||||
flask_app: Flask,
|
||||
) -> None:
|
||||
binding = _add_binding(db.session, disabled=False)
|
||||
expected_created_at = int(binding.created_at.timestamp())
|
||||
|
||||
with flask_app.test_request_context("/"):
|
||||
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), TENANT_ID)
|
||||
with (
|
||||
flask_app.test_request_context("/"),
|
||||
patch.object(module.db.session, "scalars", return_value=MagicMock(all=lambda: [binding])),
|
||||
):
|
||||
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), "tenant-1")
|
||||
|
||||
assert status == 200
|
||||
assert response == {
|
||||
"data": [
|
||||
{
|
||||
"id": BINDING_ID,
|
||||
"id": "binding-1",
|
||||
"provider": "notion",
|
||||
"created_at": expected_created_at,
|
||||
"created_at": 1779670923,
|
||||
"is_bound": True,
|
||||
"disabled": False,
|
||||
"source_info": {
|
||||
@@ -116,75 +96,34 @@ def test_get_data_source_integrates_serializes_orm_binding(
|
||||
}
|
||||
|
||||
|
||||
def test_get_data_source_integrates_preserves_empty_list_when_no_binding(
|
||||
flask_app: Flask,
|
||||
) -> None:
|
||||
with flask_app.test_request_context("/"):
|
||||
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), TENANT_ID)
|
||||
def test_get_data_source_integrates_preserves_empty_list_when_no_binding(flask_app: Flask) -> None:
|
||||
with (
|
||||
flask_app.test_request_context("/"),
|
||||
patch.object(module.db.session, "scalars", return_value=MagicMock(all=lambda: [])),
|
||||
):
|
||||
response, status = unwrap(DataSourceApi().get)(DataSourceApi(), "tenant-1")
|
||||
|
||||
assert status == 200
|
||||
assert response == {"data": []}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("disabled", "action", "expected_disabled"),
|
||||
[(True, "enable", False), (False, "disable", True)],
|
||||
)
|
||||
@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True)
|
||||
def test_patch_data_source_binding_updates_state(
|
||||
flask_app: Flask,
|
||||
sqlite_session: Session,
|
||||
disabled: bool,
|
||||
action: Literal["enable", "disable"],
|
||||
expected_disabled: bool,
|
||||
) -> None:
|
||||
_add_binding(sqlite_session, disabled=disabled)
|
||||
sqlite_session.expunge_all()
|
||||
def test_patch_data_source_binding_uses_injected_session(flask_app: Flask) -> None:
|
||||
binding = MagicMock(disabled=True)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = binding
|
||||
|
||||
with flask_app.test_request_context("/"):
|
||||
response, status = unwrap(DataSourceApi().patch)(
|
||||
DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), action
|
||||
)
|
||||
response, status = unwrap(DataSourceApi().patch)(DataSourceApi(), session, "tenant-1", uuid4(), "enable")
|
||||
|
||||
sqlite_session.flush()
|
||||
sqlite_session.expire_all()
|
||||
binding = sqlite_session.scalar(select(DataSourceOauthBinding).where(DataSourceOauthBinding.id == BINDING_ID))
|
||||
assert status == 200
|
||||
assert response == {"result": "success"}
|
||||
assert binding is not None
|
||||
assert binding.disabled is expected_disabled
|
||||
assert binding.disabled is False
|
||||
session.scalar.assert_called_once()
|
||||
session.add.assert_not_called()
|
||||
session.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True)
|
||||
def test_patch_data_source_binding_rejects_unknown_binding(
|
||||
flask_app: Flask,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
with flask_app.test_request_context("/"), pytest.raises(NotFound, match="Data source binding not found"):
|
||||
unwrap(DataSourceApi().patch)(DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), "enable")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("disabled", "action"), [(False, "enable"), (True, "disable")])
|
||||
@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True)
|
||||
def test_patch_data_source_binding_rejects_current_state(
|
||||
flask_app: Flask,
|
||||
sqlite_session: Session,
|
||||
disabled: bool,
|
||||
action: Literal["enable", "disable"],
|
||||
) -> None:
|
||||
_add_binding(sqlite_session, disabled=disabled)
|
||||
sqlite_session.expunge_all()
|
||||
|
||||
with flask_app.test_request_context("/"), pytest.raises(ValueError):
|
||||
unwrap(DataSourceApi().patch)(DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), action)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_notion_pre_import_pages_serializes_frontend_list_shape(
|
||||
flask_app: Flask,
|
||||
current_user: Account,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask, current_user: Account) -> None:
|
||||
page = MagicMock(
|
||||
page_id="page-1",
|
||||
page_name="Page",
|
||||
@@ -206,6 +145,8 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(
|
||||
get_online_document_pages=MagicMock(return_value=iter([online_document_message])),
|
||||
datasource_provider_type=MagicMock(return_value="online_document"),
|
||||
)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
flask_app.test_request_context("/?credential_id=credential-1"),
|
||||
patch.object(
|
||||
@@ -217,7 +158,7 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(
|
||||
patch("core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime", return_value=runtime),
|
||||
):
|
||||
response, status = unwrap(DataSourceNotionListApi().get)(
|
||||
DataSourceNotionListApi(), sqlite_session, "tenant-1", current_user
|
||||
DataSourceNotionListApi(), session, "tenant-1", current_user
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
@@ -242,38 +183,3 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(
|
||||
}
|
||||
runtime.get_online_document_pages.assert_called_once()
|
||||
assert runtime.get_online_document_pages.call_args.kwargs["datasource_parameters"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_notion_pre_import_pages_rejects_missing_credential(
|
||||
flask_app: Flask,
|
||||
current_user: Account,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
with (
|
||||
flask_app.test_request_context("/?credential_id=credential-1"),
|
||||
patch.object(module.DatasourceProviderService, "get_datasource_credentials", return_value=None),
|
||||
pytest.raises(NotFound, match="Credential not found"),
|
||||
):
|
||||
unwrap(DataSourceNotionListApi().get)(DataSourceNotionListApi(), sqlite_session, TENANT_ID, current_user)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_notion_pre_import_pages_rejects_non_notion_dataset(
|
||||
flask_app: Flask,
|
||||
current_user: Account,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
dataset = MagicMock(data_source_type="other_type")
|
||||
|
||||
with (
|
||||
flask_app.test_request_context("/?credential_id=credential-1&dataset_id=dataset-1"),
|
||||
patch.object(
|
||||
module.DatasourceProviderService,
|
||||
"get_datasource_credentials",
|
||||
return_value={"token": "token"},
|
||||
),
|
||||
patch.object(module.DatasetService, "get_dataset", return_value=dataset),
|
||||
pytest.raises(ValueError, match="Dataset is not notion type"),
|
||||
):
|
||||
unwrap(DataSourceNotionListApi().get)(DataSourceNotionListApi(), sqlite_session, TENANT_ID, current_user)
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
"""Unit tests for controllers.console.datasets.data_source Notion endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console.datasets.data_source import (
|
||||
DataSourceNotionDatasetSyncApi,
|
||||
DataSourceNotionDocumentSyncApi,
|
||||
DataSourceNotionIndexingEstimateApi,
|
||||
DataSourceNotionPreviewApi,
|
||||
)
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType
|
||||
from models import Account
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def current_user() -> Account:
|
||||
account = Account(name="Test User", email="u1@example.com")
|
||||
account.id = "u1"
|
||||
return account
|
||||
|
||||
|
||||
class TestDataSourceNotionPreviewApi:
|
||||
def test_get_preview_success(self, app: Flask) -> None:
|
||||
api = DataSourceNotionPreviewApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
extractor = MagicMock(extract=lambda: [MagicMock(page_content="hello")])
|
||||
|
||||
with (
|
||||
app.test_request_context("/?credential_id=c1"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials",
|
||||
return_value={"integration_secret": "t"},
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.NotionExtractor",
|
||||
return_value=extractor,
|
||||
),
|
||||
):
|
||||
response, status = method(api, "tenant-1", "p1", "page")
|
||||
|
||||
assert status == 200
|
||||
|
||||
|
||||
class TestDataSourceNotionIndexingEstimateApi:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_post_indexing_estimate_success(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionIndexingEstimateApi()
|
||||
method = inspect.unwrap(api.post)
|
||||
|
||||
empty_rules: dict[str, object] = {}
|
||||
payload: dict[str, object] = {
|
||||
"notion_info_list": [
|
||||
{
|
||||
"workspace_id": "w1",
|
||||
"credential_id": "c1",
|
||||
"pages": [{"page_id": "p1", "type": "page"}],
|
||||
}
|
||||
],
|
||||
"process_rule": {"rules": empty_rules},
|
||||
"doc_form": IndexStructureType.PARAGRAPH_INDEX,
|
||||
"doc_language": "English",
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="POST", json=payload, headers={"Content-Type": "application/json"}),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.estimate_args_validate",
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.IndexingRunner.indexing_estimate",
|
||||
return_value=MagicMock(model_dump=lambda: {"total_pages": 1}),
|
||||
),
|
||||
):
|
||||
response, status = method(api, sqlite_session, "tenant-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
|
||||
class TestDataSourceNotionDatasetSyncApi:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_success(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionDatasetSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document_by_dataset_id",
|
||||
return_value=[MagicMock(id="d1")],
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response, status = method(api, sqlite_session, "ds-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_dataset_not_found(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionDatasetSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, sqlite_session, "ds-1")
|
||||
|
||||
|
||||
class TestDataSourceNotionDocumentSyncApi:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_success(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionDocumentSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.document_indexing_sync_task.delay",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response, status = method(api, sqlite_session, "ds-1", "doc-1")
|
||||
|
||||
assert status == 200
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_document_not_found(self, app: Flask, sqlite_session: Session) -> None:
|
||||
api = DataSourceNotionDocumentSyncApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DatasetService.get_dataset",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.data_source.DocumentService.get_document",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, sqlite_session, "ds-1", "doc-1")
|
||||
@@ -1,394 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from inspect import unwrap
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.workspace.skills import (
|
||||
WorkspaceAgentSkillBindingsApi,
|
||||
WorkspaceSkillAssistMessageApi,
|
||||
WorkspaceSkillFilesApi,
|
||||
WorkspaceSkillsApi,
|
||||
WorkspaceSkillTagsApi,
|
||||
WorkspaceSkillVersionApi,
|
||||
)
|
||||
from models.account import Account
|
||||
from services.skill_management_service import SkillAssistAttachmentPayload, SkillManagementServiceError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> Flask:
|
||||
flask_app = Flask("test_workspace_skills")
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def current_user() -> Account:
|
||||
user = Account(name="Test User", email="test@example.com")
|
||||
user.id = "user-1"
|
||||
return user
|
||||
|
||||
|
||||
def _skill_detail() -> dict:
|
||||
return {
|
||||
"id": "skill-1",
|
||||
"name": "finance-sop",
|
||||
"display_name": "Finance SOP",
|
||||
"icon": "📄",
|
||||
"description": "",
|
||||
"tags": [],
|
||||
"name_manually_edited": False,
|
||||
"visibility": "workspace",
|
||||
"latest_published_version_id": None,
|
||||
"reference_count": 0,
|
||||
"created_by": "user-1",
|
||||
"created_by_name": "Test User",
|
||||
"updated_by": "user-1",
|
||||
"updated_by_name": "Test User",
|
||||
"created_at": 1,
|
||||
"updated_at": 1,
|
||||
"files": [
|
||||
{
|
||||
"id": "file-1",
|
||||
"path": "SKILL.md",
|
||||
"kind": "file",
|
||||
"storage": "text",
|
||||
"mime_type": "text/markdown",
|
||||
"content": "---\nname: finance-sop\n---\n# Body",
|
||||
"tool_file_id": None,
|
||||
"size": 32,
|
||||
"hash": "hash",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_create_skill_validates_payload_and_returns_detail(app: Flask, current_user: Account) -> None:
|
||||
api = WorkspaceSkillsApi()
|
||||
method = unwrap(api.post)
|
||||
service = MagicMock()
|
||||
service.create_skill.return_value = _skill_detail()
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="POST"),
|
||||
patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value={}),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload, status = method(api, "tenant-1", current_user)
|
||||
|
||||
assert status == 201
|
||||
assert payload["id"] == "skill-1"
|
||||
assert payload["files"][0]["path"] == "SKILL.md"
|
||||
service.create_skill.assert_called_once()
|
||||
assert service.create_skill.call_args.kwargs["tenant_id"] == "tenant-1"
|
||||
assert service.create_skill.call_args.kwargs["user_id"] == "user-1"
|
||||
|
||||
|
||||
def test_list_skills_uses_default_pagination_when_query_omits_page_and_limit(app: Flask) -> None:
|
||||
api = WorkspaceSkillsApi()
|
||||
method = unwrap(api.get)
|
||||
service = MagicMock()
|
||||
service.list_skills.return_value = {
|
||||
"data": [],
|
||||
"has_more": False,
|
||||
"limit": 20,
|
||||
"page": 1,
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/?keyword=finance&tag=ops&tag=", method="GET"),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload = method(api, "tenant-1")
|
||||
|
||||
assert payload == {
|
||||
"data": [],
|
||||
"has_more": False,
|
||||
"limit": 20,
|
||||
"page": 1,
|
||||
"total": 0,
|
||||
}
|
||||
service.list_skills.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
keyword="finance",
|
||||
page=1,
|
||||
limit=20,
|
||||
tags=["ops"],
|
||||
)
|
||||
|
||||
|
||||
def test_get_agent_skill_bindings_returns_card_data(app: Flask) -> None:
|
||||
api = WorkspaceAgentSkillBindingsApi()
|
||||
method = unwrap(api.get)
|
||||
service = MagicMock()
|
||||
service.list_agent_bindings.return_value = {
|
||||
"agent_id": "agent-1",
|
||||
"skill_ids": ["skill-1"],
|
||||
"data": [
|
||||
{
|
||||
"id": "skill-1",
|
||||
"priority": 0,
|
||||
"name": "finance-sop",
|
||||
"display_name": "Finance SOP",
|
||||
"icon": "📄",
|
||||
"description": "Handle finance.",
|
||||
"tags": ["Finance"],
|
||||
"status": "published",
|
||||
"file_count": 2,
|
||||
"latest_published_version_id": "version-1",
|
||||
"latest_published_at": 123,
|
||||
"updated_at": 124,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload = method(api, "tenant-1", "agent-1")
|
||||
|
||||
assert payload["skill_ids"] == ["skill-1"]
|
||||
assert payload["data"][0]["display_name"] == "Finance SOP"
|
||||
assert payload["data"][0]["file_count"] == 2
|
||||
service.list_agent_bindings.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
|
||||
|
||||
|
||||
def test_patch_skill_file_operation_validates_payload_and_returns_detail(app: Flask, current_user: Account) -> None:
|
||||
api = WorkspaceSkillFilesApi()
|
||||
method = unwrap(api.patch)
|
||||
service = MagicMock()
|
||||
service.apply_draft_file_operation.return_value = _skill_detail()
|
||||
request_payload = {
|
||||
"operation": "upsert_text",
|
||||
"path": "references/policy.md",
|
||||
"content": "Policy",
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="PATCH"),
|
||||
patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=request_payload),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload = method(api, "tenant-1", current_user, "skill-1")
|
||||
|
||||
assert payload["id"] == "skill-1"
|
||||
service.apply_draft_file_operation.assert_called_once()
|
||||
call = service.apply_draft_file_operation.call_args.kwargs
|
||||
assert call["tenant_id"] == "tenant-1"
|
||||
assert call["user_id"] == "user-1"
|
||||
assert call["skill_id"] == "skill-1"
|
||||
assert call["payload"].operation == "upsert_text"
|
||||
|
||||
|
||||
def test_patch_skill_file_operation_returns_error_details(app: Flask, current_user: Account) -> None:
|
||||
api = WorkspaceSkillFilesApi()
|
||||
method = unwrap(api.patch)
|
||||
service = MagicMock()
|
||||
service.apply_draft_file_operation.side_effect = SkillManagementServiceError(
|
||||
"missing_skill_name",
|
||||
"SKILL.md frontmatter name is required",
|
||||
details={"path": "SKILL.md", "field": "name", "line": 2},
|
||||
)
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="PATCH"),
|
||||
patch.object(
|
||||
type(console_ns),
|
||||
"payload",
|
||||
new_callable=PropertyMock,
|
||||
return_value={"operation": "delete", "path": "SKILL.md"},
|
||||
),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload, status = method(api, "tenant-1", current_user, "skill-1")
|
||||
|
||||
assert status == 400
|
||||
assert payload == {
|
||||
"code": "missing_skill_name",
|
||||
"message": "SKILL.md frontmatter name is required",
|
||||
"details": {"path": "SKILL.md", "field": "name", "line": 2},
|
||||
}
|
||||
|
||||
|
||||
def test_list_skill_tags_returns_filter_options(app: Flask) -> None:
|
||||
api = WorkspaceSkillTagsApi()
|
||||
method = unwrap(api.get)
|
||||
service = MagicMock()
|
||||
service.list_tags.return_value = {"data": [{"tag": "finance", "count": 2}]}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload = method(api, "tenant-1")
|
||||
|
||||
assert payload == {"data": [{"tag": "finance", "count": 2}]}
|
||||
service.list_tags.assert_called_once_with(tenant_id="tenant-1")
|
||||
|
||||
|
||||
def test_get_skill_version_returns_version_detail(app: Flask) -> None:
|
||||
api = WorkspaceSkillVersionApi()
|
||||
method = unwrap(api.get)
|
||||
service = MagicMock()
|
||||
service.get_version.return_value = {
|
||||
"id": "version-1",
|
||||
"skill_id": "skill-1",
|
||||
"version_number": 1,
|
||||
"version_name": "Initial finance policy",
|
||||
"publish_note": "Initial finance policy",
|
||||
"hash_code": "hash-code",
|
||||
"archive_size": 123,
|
||||
"published_by": "user-1",
|
||||
"published_by_name": "Li Wei",
|
||||
"is_latest": True,
|
||||
"created_at": 1,
|
||||
"files": [
|
||||
{
|
||||
"id": None,
|
||||
"path": "SKILL.md",
|
||||
"kind": "file",
|
||||
"storage": "text",
|
||||
"mime_type": "text/markdown",
|
||||
"content": "# Version",
|
||||
"tool_file_id": None,
|
||||
"size": 9,
|
||||
"hash": "file-hash",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload = method(api, "tenant-1", "skill-1", "version-1")
|
||||
|
||||
assert payload["files"][0]["content"] == "# Version"
|
||||
service.get_version.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
skill_id="skill-1",
|
||||
version_id="version-1",
|
||||
)
|
||||
|
||||
|
||||
def test_patch_skill_version_renames_version(app: Flask) -> None:
|
||||
api = WorkspaceSkillVersionApi()
|
||||
method = unwrap(api.patch)
|
||||
service = MagicMock()
|
||||
service.update_version.return_value = {
|
||||
"id": "version-1",
|
||||
"skill_id": "skill-1",
|
||||
"version_number": 1,
|
||||
"version_name": "Approval threshold",
|
||||
"publish_note": "",
|
||||
"hash_code": "hash-code",
|
||||
"archive_size": 123,
|
||||
"published_by": "user-1",
|
||||
"published_by_name": "Li Wei",
|
||||
"is_latest": True,
|
||||
"created_at": 1,
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="PATCH"),
|
||||
patch.object(
|
||||
type(console_ns),
|
||||
"payload",
|
||||
new_callable=PropertyMock,
|
||||
return_value={"version_name": "Approval threshold"},
|
||||
),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload = method(api, "tenant-1", "skill-1", "version-1")
|
||||
|
||||
assert payload["version_name"] == "Approval threshold"
|
||||
service.update_version.assert_called_once()
|
||||
assert service.update_version.call_args.kwargs["payload"].version_name == "Approval threshold"
|
||||
|
||||
|
||||
def test_delete_skill_version_returns_new_latest(app: Flask, current_user: Account) -> None:
|
||||
api = WorkspaceSkillVersionApi()
|
||||
method = unwrap(api.delete)
|
||||
service = MagicMock()
|
||||
service.delete_version.return_value = {
|
||||
"id": "version-2",
|
||||
"deleted": True,
|
||||
"latest_published_version_id": "version-1",
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="DELETE"),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload = method(api, "tenant-1", current_user, "skill-1", "version-2")
|
||||
|
||||
assert payload == {"id": "version-2", "deleted": True, "latest_published_version_id": "version-1"}
|
||||
service.delete_version.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
skill_id="skill-1",
|
||||
version_id="version-2",
|
||||
)
|
||||
|
||||
|
||||
def test_skill_assistant_runs_agent_app_stream(app: Flask, current_user: Account) -> None:
|
||||
api = WorkspaceSkillAssistMessageApi()
|
||||
method = unwrap(api.post)
|
||||
service = MagicMock()
|
||||
assistant_app = MagicMock()
|
||||
assistant_app.id = "assistant-app-1"
|
||||
service.get_or_create_assistant_app.return_value = (assistant_app, "<skill_draft>draft</skill_draft>")
|
||||
app_model = MagicMock()
|
||||
app_response = MagicMock()
|
||||
compact_response = MagicMock()
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="POST"),
|
||||
patch.object(
|
||||
type(console_ns),
|
||||
"payload",
|
||||
new_callable=PropertyMock,
|
||||
return_value={
|
||||
"attachments": [
|
||||
{
|
||||
"tool_file_id": "tool-file-1",
|
||||
"name": "requirements.md",
|
||||
"mime_type": "text/markdown",
|
||||
"size": 128,
|
||||
}
|
||||
],
|
||||
"message": "Create an approval checklist.",
|
||||
},
|
||||
),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
patch(
|
||||
"controllers.console.workspace.skills.db.session",
|
||||
return_value=MagicMock(get=MagicMock(return_value=app_model)),
|
||||
),
|
||||
patch("controllers.console.workspace.skills.AppGenerateService.generate", return_value=app_response),
|
||||
patch("controllers.console.workspace.skills.helper.compact_generate_response", return_value=compact_response),
|
||||
):
|
||||
response = method(api, "tenant-1", current_user, "skill-1")
|
||||
|
||||
assert response is compact_response
|
||||
service.get_or_create_assistant_app.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
skill_id="skill-1",
|
||||
user_id="user-1",
|
||||
attachments=[
|
||||
SkillAssistAttachmentPayload(
|
||||
tool_file_id="tool-file-1",
|
||||
name="requirements.md",
|
||||
mime_type="text/markdown",
|
||||
size=128,
|
||||
)
|
||||
],
|
||||
message="Create an approval checklist.",
|
||||
model_payload=None,
|
||||
)
|
||||
@@ -15,15 +15,12 @@ Focus on:
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||
|
||||
from controllers.service_api.app.error import NotChatAppError
|
||||
@@ -47,14 +44,6 @@ from services.errors.message import (
|
||||
from services.message_service import MessageService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orm_session(sqlite_engine: Engine) -> Iterator[Session]:
|
||||
"""Provide a real caller-owned session for MessageService interface tests."""
|
||||
|
||||
with Session(sqlite_engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
|
||||
class TestMessageListQuery:
|
||||
"""Test suite for MessageListQuery Pydantic model."""
|
||||
|
||||
@@ -264,7 +253,7 @@ class TestMessageService:
|
||||
assert callable(MessageService.get_suggested_questions_after_answer)
|
||||
|
||||
@patch.object(MessageService, "pagination_by_first_id")
|
||||
def test_pagination_by_first_id_returns_pagination_result(self, mock_pagination, orm_session: Session):
|
||||
def test_pagination_by_first_id_returns_pagination_result(self, mock_pagination):
|
||||
"""Test pagination_by_first_id returns expected format."""
|
||||
mock_result = Mock()
|
||||
mock_result.data = []
|
||||
@@ -278,7 +267,7 @@ class TestMessageService:
|
||||
conversation_id=str(uuid.uuid4()),
|
||||
first_id=None,
|
||||
limit=20,
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
assert hasattr(result, "data")
|
||||
@@ -286,7 +275,7 @@ class TestMessageService:
|
||||
assert hasattr(result, "has_more")
|
||||
|
||||
@patch.object(MessageService, "pagination_by_first_id")
|
||||
def test_pagination_raises_conversation_not_exists_error(self, mock_pagination, orm_session: Session):
|
||||
def test_pagination_raises_conversation_not_exists_error(self, mock_pagination):
|
||||
"""Test pagination raises ConversationNotExistsError."""
|
||||
import services.errors.conversation
|
||||
|
||||
@@ -299,11 +288,11 @@ class TestMessageService:
|
||||
conversation_id="invalid_id",
|
||||
first_id=None,
|
||||
limit=20,
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "pagination_by_first_id")
|
||||
def test_pagination_raises_first_message_not_exists_error(self, mock_pagination, orm_session: Session):
|
||||
def test_pagination_raises_first_message_not_exists_error(self, mock_pagination):
|
||||
"""Test pagination raises FirstMessageNotExistsError."""
|
||||
mock_pagination.side_effect = FirstMessageNotExistsError()
|
||||
|
||||
@@ -314,11 +303,11 @@ class TestMessageService:
|
||||
conversation_id=str(uuid.uuid4()),
|
||||
first_id="invalid_first_id",
|
||||
limit=20,
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "create_feedback")
|
||||
def test_create_feedback_with_rating_and_content(self, mock_create_feedback, orm_session: Session):
|
||||
def test_create_feedback_with_rating_and_content(self, mock_create_feedback):
|
||||
"""Test create_feedback with rating and content."""
|
||||
mock_create_feedback.return_value = None
|
||||
|
||||
@@ -328,13 +317,13 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
rating=FeedbackRating.LIKE,
|
||||
content="Great response!",
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
mock_create_feedback.assert_called_once()
|
||||
|
||||
@patch.object(MessageService, "create_feedback")
|
||||
def test_create_feedback_raises_message_not_exists_error(self, mock_create_feedback, orm_session: Session):
|
||||
def test_create_feedback_raises_message_not_exists_error(self, mock_create_feedback):
|
||||
"""Test create_feedback raises MessageNotExistsError."""
|
||||
mock_create_feedback.side_effect = MessageNotExistsError()
|
||||
|
||||
@@ -345,11 +334,11 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
rating=FeedbackRating.LIKE,
|
||||
content=None,
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "get_all_messages_feedbacks")
|
||||
def test_get_all_messages_feedbacks_returns_list(self, mock_get_feedbacks, orm_session: Session):
|
||||
def test_get_all_messages_feedbacks_returns_list(self, mock_get_feedbacks):
|
||||
"""Test get_all_messages_feedbacks returns list of feedbacks."""
|
||||
mock_feedbacks = [
|
||||
{"message_id": str(uuid.uuid4()), "rating": "like"},
|
||||
@@ -357,15 +346,13 @@ class TestMessageService:
|
||||
]
|
||||
mock_get_feedbacks.return_value = mock_feedbacks
|
||||
|
||||
result = MessageService.get_all_messages_feedbacks(
|
||||
app_model=Mock(spec=App), page=1, limit=20, session=orm_session
|
||||
)
|
||||
result = MessageService.get_all_messages_feedbacks(app_model=Mock(spec=App), page=1, limit=20, session=Mock())
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["rating"] == "like"
|
||||
|
||||
@patch.object(MessageService, "get_suggested_questions_after_answer")
|
||||
def test_get_suggested_questions_returns_questions_list(self, mock_get_questions, orm_session: Session):
|
||||
def test_get_suggested_questions_returns_questions_list(self, mock_get_questions):
|
||||
"""Test get_suggested_questions_after_answer returns list of questions."""
|
||||
mock_questions = ["What about this aspect?", "Can you elaborate on that?", "How does this relate to...?"]
|
||||
mock_get_questions.return_value = mock_questions
|
||||
@@ -375,14 +362,14 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
message_id=str(uuid.uuid4()),
|
||||
invoke_from=Mock(),
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
assert len(result) == 3
|
||||
assert isinstance(result[0], str)
|
||||
|
||||
@patch.object(MessageService, "get_suggested_questions_after_answer")
|
||||
def test_get_suggested_questions_raises_disabled_error(self, mock_get_questions, orm_session: Session):
|
||||
def test_get_suggested_questions_raises_disabled_error(self, mock_get_questions):
|
||||
"""Test get_suggested_questions_after_answer raises SuggestedQuestionsAfterAnswerDisabledError."""
|
||||
mock_get_questions.side_effect = SuggestedQuestionsAfterAnswerDisabledError()
|
||||
|
||||
@@ -392,11 +379,11 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
message_id=str(uuid.uuid4()),
|
||||
invoke_from=Mock(),
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
@patch.object(MessageService, "get_suggested_questions_after_answer")
|
||||
def test_get_suggested_questions_raises_message_not_exists_error(self, mock_get_questions, orm_session: Session):
|
||||
def test_get_suggested_questions_raises_message_not_exists_error(self, mock_get_questions):
|
||||
"""Test get_suggested_questions_after_answer raises MessageNotExistsError."""
|
||||
mock_get_questions.side_effect = MessageNotExistsError()
|
||||
|
||||
@@ -406,7 +393,7 @@ class TestMessageService:
|
||||
user=Mock(spec=EndUser),
|
||||
message_id="invalid_message_id",
|
||||
invoke_from=Mock(),
|
||||
session=orm_session,
|
||||
session=Mock(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+16
-34
@@ -24,7 +24,6 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
@@ -39,7 +38,6 @@ from controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow import (
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from models.account import Account
|
||||
from models.dataset import Dataset
|
||||
from services.errors.file import FileTooLargeError, UnsupportedFileTypeError
|
||||
from services.rag_pipeline.entity.pipeline_service_api_entities import (
|
||||
DatasourceNodeRunApiEntity,
|
||||
@@ -48,20 +46,6 @@ from services.rag_pipeline.entity.pipeline_service_api_entities import (
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
|
||||
|
||||
def _persist_dataset(session: Session, *, tenant_id: str, dataset_id: str) -> Dataset:
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
tenant_id=tenant_id,
|
||||
name="Pipeline dataset",
|
||||
created_by="account-1",
|
||||
data_source_type=None,
|
||||
indexing_technique=None,
|
||||
)
|
||||
session.add(dataset)
|
||||
session.commit()
|
||||
return dataset
|
||||
|
||||
|
||||
class TestDatasourceNodeRunPayload:
|
||||
"""Test suite for DatasourceNodeRunPayload Pydantic model."""
|
||||
|
||||
@@ -566,15 +550,13 @@ class TestPipelineRunApiPost:
|
||||
)
|
||||
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.RagPipelineService")
|
||||
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns")
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_post_success_streaming(
|
||||
self, mock_ns, mock_svc_cls, mock_current_user, mock_gen_svc, mock_helper, app, sqlite_session: Session
|
||||
):
|
||||
def test_post_success_streaming(self, mock_ns, mock_svc_cls, mock_current_user, mock_gen_svc, mock_helper, app):
|
||||
"""Test successful pipeline run with streaming response."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
dataset_id = str(uuid.uuid4())
|
||||
|
||||
_persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
session = Mock()
|
||||
session.scalar.return_value = Mock()
|
||||
|
||||
mock_ns.payload = {
|
||||
"inputs": {"key": "val"},
|
||||
@@ -595,33 +577,33 @@ class TestPipelineRunApiPost:
|
||||
|
||||
with app.test_request_context("/datasets/test/pipeline/run", method="POST"):
|
||||
api = PipelineRunApi()
|
||||
response = api.post.__wrapped__(api, sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
response = api.post.__wrapped__(api, session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
|
||||
assert response == {"result": "ok"}
|
||||
mock_svc_cls.assert_called_once_with(sqlite_session)
|
||||
mock_svc_cls.assert_called_once_with(session)
|
||||
mock_gen_svc.generate.assert_called_once()
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_post_not_found(self, app: Flask, sqlite_session: Session):
|
||||
def test_post_not_found(self, app: Flask):
|
||||
"""Test NotFound when dataset check fails."""
|
||||
session = Mock()
|
||||
session.scalar.return_value = None
|
||||
|
||||
with app.test_request_context("/datasets/test/pipeline/run", method="POST"):
|
||||
api = PipelineRunApi()
|
||||
with pytest.raises(NotFound):
|
||||
api.post.__wrapped__(
|
||||
api,
|
||||
sqlite_session,
|
||||
session,
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
dataset_id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.current_user", new="not_account")
|
||||
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns")
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_post_forbidden_non_account_user(self, mock_ns, app: Flask, sqlite_session: Session):
|
||||
def test_post_forbidden_non_account_user(self, mock_ns, app: Flask):
|
||||
"""Test Forbidden when current_user is not an Account."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
dataset_id = str(uuid.uuid4())
|
||||
_persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
session = Mock()
|
||||
session.scalar.return_value = Mock()
|
||||
mock_ns.payload = {
|
||||
"inputs": {},
|
||||
"datasource_type": "online_document",
|
||||
@@ -636,9 +618,9 @@ class TestPipelineRunApiPost:
|
||||
with pytest.raises(Forbidden):
|
||||
api.post.__wrapped__(
|
||||
api,
|
||||
sqlite_session,
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
session,
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
dataset_id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,13 +3,10 @@ Unit tests for Service API wraps (authentication decorators)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
|
||||
|
||||
from controllers.service_api.wraps import (
|
||||
@@ -24,11 +21,12 @@ from controllers.service_api.wraps import (
|
||||
validate_dataset_token,
|
||||
)
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from models import Account, Tenant, TenantAccountJoin
|
||||
from models.account import TenantAccountRole
|
||||
from models.dataset import Dataset, RateLimitLog
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App, AppMode, IconType
|
||||
from models.account import TenantStatus
|
||||
from models.model import ApiToken
|
||||
from tests.unit_tests.conftest import (
|
||||
setup_mock_dataset_owner_execute_result,
|
||||
setup_mock_tenant_owner_execute_result,
|
||||
)
|
||||
|
||||
|
||||
def _configure_current_app_mock(mock_current_app):
|
||||
@@ -36,51 +34,6 @@ def _configure_current_app_mock(mock_current_app):
|
||||
mock_current_app._get_current_object = Mock(return_value=Mock())
|
||||
|
||||
|
||||
def _session_proxy(session: Session) -> MagicMock:
|
||||
"""Emulate Flask-SQLAlchemy's callable scoped-session proxy around a test session."""
|
||||
proxy = MagicMock(wraps=session)
|
||||
proxy.return_value = session
|
||||
return proxy
|
||||
|
||||
|
||||
def _api_token(*, tenant_id: str, app_id: str | None = None, token_type: ApiTokenType) -> ApiToken:
|
||||
return ApiToken(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
type=token_type,
|
||||
token="test_token",
|
||||
)
|
||||
|
||||
|
||||
def _persist_workspace(session: Session) -> tuple[Tenant, Account, TenantAccountJoin]:
|
||||
tenant = Tenant(name="Workspace")
|
||||
account = Account(name="Owner", email=f"owner-{uuid.uuid4()}@example.com")
|
||||
membership = TenantAccountJoin(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
current=True,
|
||||
role=TenantAccountRole.OWNER,
|
||||
)
|
||||
session.add_all([tenant, account, membership])
|
||||
session.commit()
|
||||
return tenant, account, membership
|
||||
|
||||
|
||||
def _app_model(*, tenant_id: str, enable_api: bool = True) -> App:
|
||||
return App(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
name="Service API App",
|
||||
mode=AppMode.CHAT,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="chat",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=False,
|
||||
enable_api=enable_api,
|
||||
)
|
||||
|
||||
|
||||
class TestValidateAndGetApiToken:
|
||||
"""Test suite for validate_and_get_api_token function"""
|
||||
|
||||
@@ -117,24 +70,21 @@ class TestValidateAndGetApiToken:
|
||||
def test_valid_token_returns_api_token(self, mock_fetch_token, mock_cache_cls, mock_record_usage, app: Flask):
|
||||
"""Test that valid token returns the ApiToken object."""
|
||||
# Arrange
|
||||
api_token = _api_token(
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
app_id=str(uuid.uuid4()),
|
||||
token_type=ApiTokenType.APP,
|
||||
)
|
||||
api_token.token = "valid_token_123"
|
||||
mock_api_token = Mock(spec=ApiToken)
|
||||
mock_api_token.token = "valid_token_123"
|
||||
mock_api_token.type = "app"
|
||||
|
||||
mock_cache_instance = Mock()
|
||||
mock_cache_instance.get.return_value = None # Cache miss
|
||||
mock_cache_cls.get = mock_cache_instance.get
|
||||
mock_fetch_token.return_value = api_token
|
||||
mock_fetch_token.return_value = mock_api_token
|
||||
|
||||
# Act
|
||||
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer valid_token_123"}):
|
||||
result = validate_and_get_api_token("app")
|
||||
|
||||
# Assert
|
||||
assert result == api_token
|
||||
assert result == mock_api_token
|
||||
|
||||
@patch("controllers.service_api.wraps.record_token_usage")
|
||||
@patch("controllers.service_api.wraps.ApiTokenCache")
|
||||
@@ -167,124 +117,116 @@ class TestValidateAppToken:
|
||||
return app
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(App, ApiToken, Tenant, Account, TenantAccountJoin)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_valid_app_token_allows_access(
|
||||
self,
|
||||
mock_current_app,
|
||||
mock_validate_token,
|
||||
mock_user_logged_in,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app
|
||||
):
|
||||
"""Test that valid app token allows access to decorated view."""
|
||||
# Arrange
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
|
||||
tenant, account, _ = _persist_workspace(sqlite_session)
|
||||
app_model = _app_model(tenant_id=tenant.id)
|
||||
api_token = _api_token(tenant_id=tenant.id, app_id=app_model.id, token_type=ApiTokenType.APP)
|
||||
sqlite_session.add_all([app_model, api_token])
|
||||
sqlite_session.commit()
|
||||
mock_validate_token.return_value = api_token
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = str(uuid.uuid4())
|
||||
mock_api_token.tenant_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_app = Mock()
|
||||
mock_app.id = mock_api_token.app_id
|
||||
mock_app.status = "normal"
|
||||
mock_app.enable_api = True
|
||||
mock_app.tenant_id = mock_api_token.tenant_id
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.status = TenantStatus.NORMAL
|
||||
mock_tenant.id = mock_api_token.tenant_id
|
||||
|
||||
mock_account = Mock()
|
||||
mock_account.id = str(uuid.uuid4())
|
||||
|
||||
# Use side_effect to return app first, then tenant via session.get()
|
||||
mock_db.session.get.side_effect = [mock_app, mock_tenant]
|
||||
|
||||
# Mock the tenant owner execute result (execute(select(...)).one_or_none())
|
||||
setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account)
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(app_model):
|
||||
return {"success": True, "app_id": app_model.id}
|
||||
|
||||
# Act
|
||||
with (
|
||||
app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}),
|
||||
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
|
||||
):
|
||||
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}):
|
||||
result = protected_view()
|
||||
|
||||
# Assert
|
||||
assert result["success"] is True
|
||||
assert result["app_id"] == app_model.id
|
||||
assert account.current_tenant_id == tenant.id
|
||||
assert result["app_id"] == mock_app.id
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_app_not_found_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
def test_app_not_found_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
|
||||
"""Test that Forbidden is raised when app no longer exists."""
|
||||
# Arrange
|
||||
api_token = _api_token(
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
app_id=str(uuid.uuid4()),
|
||||
token_type=ApiTokenType.APP,
|
||||
)
|
||||
mock_validate_token.return_value = api_token
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_db.session.get.return_value = None
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(**kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
protected_view()
|
||||
assert "no longer exists" in str(exc_info.value)
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_app_status_abnormal_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
def test_app_status_abnormal_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
|
||||
"""Test that Forbidden is raised when app status is abnormal."""
|
||||
# Arrange
|
||||
app_model = _app_model(tenant_id=str(uuid.uuid4()))
|
||||
sqlite_session.add(app_model)
|
||||
sqlite_session.commit()
|
||||
app_model.status = "abnormal"
|
||||
mock_validate_token.return_value = _api_token(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
token_type=ApiTokenType.APP,
|
||||
)
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_app = Mock()
|
||||
mock_app.status = "abnormal"
|
||||
mock_db.session.get.return_value = mock_app
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(**kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
protected_view()
|
||||
assert "status is abnormal" in str(exc_info.value)
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_app_api_disabled_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
def test_app_api_disabled_raises_forbidden(self, mock_validate_token, mock_db, app: Flask):
|
||||
"""Test that Forbidden is raised when app API is disabled."""
|
||||
# Arrange
|
||||
app_model = _app_model(tenant_id=str(uuid.uuid4()), enable_api=False)
|
||||
sqlite_session.add(app_model)
|
||||
sqlite_session.commit()
|
||||
mock_validate_token.return_value = _api_token(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
token_type=ApiTokenType.APP,
|
||||
)
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_app = Mock()
|
||||
mock_app.status = "normal"
|
||||
mock_app.enable_api = False
|
||||
mock_db.session.get.return_value = mock_app
|
||||
|
||||
@validate_app_token
|
||||
def protected_view(**kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
protected_view()
|
||||
assert "API service has been disabled" in str(exc_info.value)
|
||||
@@ -526,35 +468,26 @@ class TestCloudEditionBillingRateLimitCheck:
|
||||
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.FeatureService.get_knowledge_rate_limit")
|
||||
@pytest.mark.parametrize("sqlite_session", [(RateLimitLog,)], indirect=True)
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.sessionmaker")
|
||||
def test_rejects_over_rate_limit(
|
||||
self,
|
||||
mock_get_rate_limit,
|
||||
mock_validate_token,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
self, mock_sessionmaker, mock_db, mock_get_rate_limit, mock_validate_token, app: Flask
|
||||
):
|
||||
"""Test that Forbidden is raised when over rate limit."""
|
||||
# Arrange
|
||||
tenant_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = _api_token(
|
||||
tenant_id=tenant_id,
|
||||
token_type=ApiTokenType.DATASET,
|
||||
)
|
||||
mock_validate_token.return_value = Mock(tenant_id="tenant123")
|
||||
|
||||
mock_rate_limit = Mock()
|
||||
mock_rate_limit.enabled = True
|
||||
mock_rate_limit.limit = 10
|
||||
mock_rate_limit.subscription_plan = "pro"
|
||||
mock_get_rate_limit.return_value = mock_rate_limit
|
||||
rate_limit_log_session = MagicMock()
|
||||
session_factory = MagicMock()
|
||||
session_factory.begin.return_value.__enter__.return_value = rate_limit_log_session
|
||||
mock_sessionmaker.return_value = session_factory
|
||||
|
||||
with (
|
||||
patch("controllers.service_api.wraps.redis_client") as mock_redis,
|
||||
patch(
|
||||
"controllers.service_api.wraps.db",
|
||||
SimpleNamespace(engine=sqlite_session.get_bind()),
|
||||
),
|
||||
):
|
||||
with patch("controllers.service_api.wraps.redis_client") as mock_redis:
|
||||
mock_redis.zcard.return_value = 15 # Over limit
|
||||
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
@@ -566,12 +499,9 @@ class TestCloudEditionBillingRateLimitCheck:
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
knowledge_request()
|
||||
assert "rate limit" in str(exc_info.value)
|
||||
|
||||
persisted_logs = sqlite_session.scalars(select(RateLimitLog)).all()
|
||||
assert len(persisted_logs) == 1
|
||||
assert persisted_logs[0].tenant_id == tenant_id
|
||||
assert persisted_logs[0].subscription_plan == "pro"
|
||||
assert persisted_logs[0].operation == "knowledge"
|
||||
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
|
||||
rate_limit_log_session.add.assert_called_once()
|
||||
mock_db.session.commit.assert_not_called()
|
||||
|
||||
|
||||
class TestValidateDatasetToken:
|
||||
@@ -585,62 +515,65 @@ class TestValidateDatasetToken:
|
||||
return app
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(Tenant, Account, TenantAccountJoin)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_valid_dataset_token(
|
||||
self,
|
||||
mock_current_app,
|
||||
mock_validate_token,
|
||||
mock_user_logged_in,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
def test_valid_dataset_token(self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app: Flask):
|
||||
"""Test that valid dataset token allows access."""
|
||||
# Arrange
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
|
||||
tenant, account, _ = _persist_workspace(sqlite_session)
|
||||
api_token = _api_token(tenant_id=tenant.id, token_type=ApiTokenType.DATASET)
|
||||
mock_validate_token.return_value = api_token
|
||||
tenant_id = str(uuid.uuid4())
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.tenant_id = tenant_id
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.id = tenant_id
|
||||
mock_tenant.status = TenantStatus.NORMAL
|
||||
|
||||
mock_ta = Mock()
|
||||
mock_ta.account_id = str(uuid.uuid4())
|
||||
|
||||
mock_account = Mock()
|
||||
mock_account.id = mock_ta.account_id
|
||||
mock_account.current_tenant = mock_tenant
|
||||
|
||||
# Mock the tenant account join query (execute(select(...)).one_or_none())
|
||||
setup_mock_dataset_owner_execute_result(mock_db, mock_tenant, mock_ta)
|
||||
|
||||
# Mock the account lookup via session.get()
|
||||
mock_db.session.get.return_value = mock_account
|
||||
|
||||
@validate_dataset_token
|
||||
def protected_view(tenant_id):
|
||||
return {"success": True, "tenant_id": tenant_id}
|
||||
|
||||
# Act
|
||||
with (
|
||||
app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}),
|
||||
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
|
||||
):
|
||||
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}):
|
||||
result = protected_view()
|
||||
|
||||
# Assert
|
||||
assert result["success"] is True
|
||||
assert result["tenant_id"] == tenant.id
|
||||
assert account.current_tenant_id == tenant.id
|
||||
assert result["tenant_id"] == tenant_id
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
def test_dataset_not_found_raises_not_found(self, mock_validate_token, app: Flask, sqlite_session: Session):
|
||||
def test_dataset_not_found_raises_not_found(self, mock_validate_token, mock_db, app: Flask):
|
||||
"""Test that NotFound is raised when dataset doesn't exist."""
|
||||
# Arrange
|
||||
api_token = _api_token(tenant_id=str(uuid.uuid4()), token_type=ApiTokenType.DATASET)
|
||||
mock_validate_token.return_value = api_token
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.tenant_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
@validate_dataset_token
|
||||
def protected_view(dataset_id=None, **kwargs):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.service_api.wraps.db.session", sqlite_session),
|
||||
):
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with pytest.raises(NotFound) as exc_info:
|
||||
protected_view(dataset_id=str(uuid.uuid4()))
|
||||
assert "Dataset not found" in str(exc_info.value)
|
||||
|
||||
@@ -7,7 +7,6 @@ from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from dify_agent.layers.config import DifyConfigSkillConfig
|
||||
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig
|
||||
from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
@@ -30,14 +29,6 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_runtime_agent_skills(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.load_runtime_agent_skill_configs",
|
||||
lambda *, tenant_id, agent_id: [],
|
||||
)
|
||||
|
||||
|
||||
def _exec_ctx() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
@@ -523,33 +514,6 @@ class TestAgentAppConfigLayer:
|
||||
"mentioned_file_names": [],
|
||||
}
|
||||
|
||||
def test_config_layer_includes_bound_workspace_skills(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.load_runtime_agent_skill_configs",
|
||||
lambda *, tenant_id, agent_id: [
|
||||
DifyConfigSkillConfig(
|
||||
name="workspace-skill",
|
||||
description="Bound workspace skill.",
|
||||
size=123,
|
||||
mime_type="application/zip",
|
||||
)
|
||||
],
|
||||
)
|
||||
soul = _soul_with_model()
|
||||
soul.prompt.system_prompt = "Use [§skill:workspace-skill:Workspace Skill§]."
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = builder.build(_ctx(soul))
|
||||
|
||||
config = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID)
|
||||
assert [skill.name for skill in config.config.skills] == ["workspace-skill"]
|
||||
assert config.config.mentioned_skill_names == ["workspace-skill"]
|
||||
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
|
||||
assert prompt_layer.config.prefix == "Use workspace-skill."
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("system_prompt", "expected_prefix"),
|
||||
[
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig
|
||||
from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline
|
||||
@@ -54,7 +54,6 @@ from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import AppMode, EndUser
|
||||
from models.workflow import WorkflowAppLog
|
||||
from tests.workflow_test_utils import build_test_variable_pool
|
||||
|
||||
|
||||
@@ -194,7 +193,7 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
|
||||
assert isinstance(responses[0], ValueError)
|
||||
|
||||
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine):
|
||||
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._graph_runtime_state = GraphRuntimeState(
|
||||
variable_pool=build_test_variable_pool(variables=build_system_variables(workflow_execution_id="run-id")),
|
||||
@@ -202,10 +201,11 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
)
|
||||
pipeline._workflow_response_converter.workflow_start_to_stream_response = lambda **kwargs: "started"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.generate_task_pipeline.db",
|
||||
SimpleNamespace(engine=sqlite_engine),
|
||||
)
|
||||
@contextmanager
|
||||
def _fake_session():
|
||||
yield SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(pipeline, "_database_session", _fake_session)
|
||||
monkeypatch.setattr(pipeline, "_save_workflow_app_log", lambda **kwargs: None)
|
||||
|
||||
responses = list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent()))
|
||||
@@ -339,18 +339,19 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
|
||||
assert responses == ["finish"]
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
|
||||
def test_save_workflow_app_log_created_from(self, sqlite_session: Session):
|
||||
def test_save_workflow_app_log_created_from(self):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API
|
||||
pipeline._user_id = "user"
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id")
|
||||
sqlite_session.flush()
|
||||
added: list[object] = []
|
||||
|
||||
saved_log = sqlite_session.scalar(select(WorkflowAppLog))
|
||||
assert saved_log is not None
|
||||
assert saved_log.workflow_run_id == "run-id"
|
||||
assert saved_log.created_from == "service-api"
|
||||
class _Session:
|
||||
def add(self, item):
|
||||
added.append(item)
|
||||
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
|
||||
assert added
|
||||
|
||||
def test_iteration_loop_and_human_input_handlers(self):
|
||||
pipeline = _make_pipeline()
|
||||
@@ -673,29 +674,35 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
assert "Fails to get audio trunk, task_id: task" in caplog.messages
|
||||
assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses)
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
|
||||
def test_database_session_rolls_back_on_error(
|
||||
self, monkeypatch: pytest.MonkeyPatch, sqlite_engine, sqlite_session: Session
|
||||
):
|
||||
def test_database_session_rolls_back_on_error(self, monkeypatch: pytest.MonkeyPatch):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API
|
||||
pipeline._user_id = "user"
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.generate_task_pipeline.db",
|
||||
SimpleNamespace(engine=sqlite_engine),
|
||||
)
|
||||
calls = {"enter": 0, "exit_exc": None}
|
||||
|
||||
def persist_then_fail() -> None:
|
||||
with pipeline._database_session() as session:
|
||||
pipeline._save_workflow_app_log(session=session, workflow_run_id="run-id")
|
||||
session.flush()
|
||||
raise RuntimeError("db error")
|
||||
class _BeginContext:
|
||||
def __enter__(self):
|
||||
calls["enter"] += 1
|
||||
return MagicMock()
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
calls["exit_exc"] = exc_type
|
||||
return False
|
||||
|
||||
class _Sessionmaker:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def begin(self):
|
||||
return _BeginContext()
|
||||
|
||||
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.sessionmaker", _Sessionmaker)
|
||||
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.db", SimpleNamespace(engine=object()))
|
||||
|
||||
with pytest.raises(RuntimeError, match="db error"):
|
||||
persist_then_fail()
|
||||
with pipeline._database_session():
|
||||
raise RuntimeError("db error")
|
||||
|
||||
sqlite_session.expire_all()
|
||||
assert sqlite_session.scalar(select(WorkflowAppLog)) is None
|
||||
assert calls["enter"] == 1
|
||||
assert calls["exit_exc"] is RuntimeError
|
||||
|
||||
def test_node_retry_and_started_handlers_cover_none_and_value(self):
|
||||
pipeline = _make_pipeline()
|
||||
@@ -855,30 +862,31 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
pipeline._handle_workflow_failed_and_stop_events = lambda event, **kwargs: iter(["stopped"])
|
||||
assert list(pipeline._process_stream_response()) == ["stopped"]
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
|
||||
def test_save_workflow_app_log_covers_invoke_from_variants(self, sqlite_session: Session):
|
||||
def test_save_workflow_app_log_covers_invoke_from_variants(self):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._user_id = "user-id"
|
||||
added: list[object] = []
|
||||
|
||||
class _Session:
|
||||
def add(self, item):
|
||||
added.append(item)
|
||||
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.EXPLORE
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id")
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert added[-1].created_from == "installed-app"
|
||||
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-2")
|
||||
sqlite_session.flush()
|
||||
saved_logs = sqlite_session.scalars(select(WorkflowAppLog).order_by(WorkflowAppLog.workflow_run_id)).all()
|
||||
assert [log.created_from for log in saved_logs] == ["installed-app", "web-app"]
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert added[-1].created_from == "web-app"
|
||||
|
||||
count_before = len(saved_logs)
|
||||
count_before = len(added)
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.DEBUGGER
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-3")
|
||||
sqlite_session.flush()
|
||||
assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert len(added) == count_before
|
||||
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id=None)
|
||||
sqlite_session.flush()
|
||||
assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id=None)
|
||||
assert len(added) == count_before
|
||||
|
||||
def test_save_output_for_event_writes_draft_variables(self):
|
||||
pipeline = _make_pipeline()
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.layers.trigger_post_layer import TriggerPostLayer
|
||||
from core.workflow.system_variables import build_system_variables
|
||||
@@ -17,63 +13,19 @@ from graphon.graph_events import (
|
||||
GraphRunSucceededEvent,
|
||||
)
|
||||
from graphon.runtime import VariablePool
|
||||
from models.enums import AppTriggerType, CreatorUserRole, WorkflowTriggerStatus
|
||||
from models.trigger import WorkflowTriggerLog
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TriggerDatabase:
|
||||
session: Session
|
||||
statements: list[str]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def trigger_database(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Iterator[TriggerDatabase]:
|
||||
"""Create the trigger-log table and bind layer-owned sessions to SQLite."""
|
||||
WorkflowTriggerLog.metadata.create_all(sqlite_engine, tables=[WorkflowTriggerLog.__table__])
|
||||
sqlite_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr("core.db.session_factory._session_maker", sqlite_session_maker)
|
||||
statements: list[str] = []
|
||||
|
||||
def record_statement(_connection, _cursor, statement, _parameters, _context, _executemany) -> None:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
with sqlite_session_maker() as session:
|
||||
try:
|
||||
yield TriggerDatabase(session=session, statements=statements)
|
||||
finally:
|
||||
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
|
||||
|
||||
def _persist_trigger_log(database: TriggerDatabase, *, trigger_log_id: str = "log-1") -> WorkflowTriggerLog:
|
||||
trigger_log = WorkflowTriggerLog(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id=None,
|
||||
root_node_id=None,
|
||||
trigger_metadata="{}",
|
||||
trigger_type=AppTriggerType.TRIGGER_WEBHOOK,
|
||||
trigger_data="{}",
|
||||
inputs="{}",
|
||||
outputs=None,
|
||||
status=WorkflowTriggerStatus.RUNNING,
|
||||
error=None,
|
||||
queue_name="workflow",
|
||||
celery_task_id=None,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-1",
|
||||
)
|
||||
trigger_log.id = trigger_log_id
|
||||
database.session.add(trigger_log)
|
||||
database.session.commit()
|
||||
return trigger_log
|
||||
from models.enums import WorkflowTriggerStatus
|
||||
|
||||
|
||||
class TestTriggerPostLayer:
|
||||
def test_on_event_updates_trigger_log(self, trigger_database: TriggerDatabase):
|
||||
trigger_log = _persist_trigger_log(trigger_database)
|
||||
def test_on_event_updates_trigger_log(self):
|
||||
trigger_log = SimpleNamespace(
|
||||
status=None,
|
||||
workflow_run_id=None,
|
||||
outputs=None,
|
||||
elapsed_time=None,
|
||||
total_tokens=None,
|
||||
finished_at=None,
|
||||
)
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={"answer": "ok"},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -83,10 +35,19 @@ class TestTriggerPostLayer:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
|
||||
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
|
||||
patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime,
|
||||
):
|
||||
mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC)
|
||||
|
||||
session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = session
|
||||
|
||||
repo = Mock()
|
||||
repo.get_by_id.return_value = trigger_log
|
||||
mock_repo_cls.return_value = repo
|
||||
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10),
|
||||
@@ -96,18 +57,25 @@ class TestTriggerPostLayer:
|
||||
|
||||
layer.on_event(GraphRunSucceededEvent())
|
||||
|
||||
trigger_database.session.expire_all()
|
||||
persisted_log = trigger_database.session.get(WorkflowTriggerLog, trigger_log.id)
|
||||
assert persisted_log is not None
|
||||
assert persisted_log.status == WorkflowTriggerStatus.SUCCEEDED
|
||||
assert persisted_log.workflow_run_id == "run-1"
|
||||
assert persisted_log.outputs == '{"answer":"ok"}'
|
||||
assert persisted_log.elapsed_time == 10
|
||||
assert persisted_log.total_tokens == 12
|
||||
assert persisted_log.finished_at is not None
|
||||
assert trigger_log.status == WorkflowTriggerStatus.SUCCEEDED
|
||||
assert trigger_log.workflow_run_id == "run-1"
|
||||
assert trigger_log.outputs is not None
|
||||
assert trigger_log.elapsed_time is not None
|
||||
assert trigger_log.total_tokens == 12
|
||||
assert trigger_log.finished_at is not None
|
||||
repo.update.assert_called_once_with(trigger_log)
|
||||
session.commit.assert_called_once()
|
||||
|
||||
def test_on_event_updates_trigger_log_for_aborted_event(self, trigger_database: TriggerDatabase):
|
||||
trigger_log = _persist_trigger_log(trigger_database)
|
||||
def test_on_event_updates_trigger_log_for_aborted_event(self):
|
||||
trigger_log = SimpleNamespace(
|
||||
status=None,
|
||||
workflow_run_id=None,
|
||||
outputs=None,
|
||||
error=None,
|
||||
elapsed_time=None,
|
||||
total_tokens=None,
|
||||
finished_at=None,
|
||||
)
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={"partial": "ok"},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -117,10 +85,19 @@ class TestTriggerPostLayer:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
|
||||
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
|
||||
patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime,
|
||||
):
|
||||
mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC)
|
||||
|
||||
session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = session
|
||||
|
||||
repo = Mock()
|
||||
repo.get_by_id.return_value = trigger_log
|
||||
mock_repo_cls.return_value = repo
|
||||
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10),
|
||||
@@ -130,22 +107,17 @@ class TestTriggerPostLayer:
|
||||
|
||||
layer.on_event(GraphRunAbortedEvent(reason="timeout"))
|
||||
|
||||
trigger_database.session.expire_all()
|
||||
persisted_log = trigger_database.session.get(WorkflowTriggerLog, trigger_log.id)
|
||||
assert persisted_log is not None
|
||||
assert persisted_log.status == WorkflowTriggerStatus.FAILED
|
||||
assert persisted_log.workflow_run_id == "run-1"
|
||||
assert persisted_log.outputs == '{"partial":"ok"}'
|
||||
assert persisted_log.error == "timeout"
|
||||
assert persisted_log.elapsed_time == 10
|
||||
assert persisted_log.total_tokens == 7
|
||||
assert persisted_log.finished_at is not None
|
||||
assert trigger_log.status == WorkflowTriggerStatus.FAILED
|
||||
assert trigger_log.workflow_run_id == "run-1"
|
||||
assert trigger_log.outputs is not None
|
||||
assert trigger_log.error == "timeout"
|
||||
assert trigger_log.elapsed_time is not None
|
||||
assert trigger_log.total_tokens == 7
|
||||
assert trigger_log.finished_at is not None
|
||||
repo.update.assert_called_once_with(trigger_log)
|
||||
session.commit.assert_called_once()
|
||||
|
||||
def test_on_event_handles_missing_trigger_log(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
trigger_database: TriggerDatabase,
|
||||
):
|
||||
def test_on_event_handles_missing_trigger_log(self, caplog: pytest.LogCaptureFixture):
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -154,20 +126,31 @@ class TestTriggerPostLayer:
|
||||
total_tokens=0,
|
||||
)
|
||||
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="missing",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
with (
|
||||
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
|
||||
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
|
||||
):
|
||||
session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = session
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"):
|
||||
layer.on_event(GraphRunFailedEvent(error="boom"))
|
||||
repo = Mock()
|
||||
repo.get_by_id.return_value = None
|
||||
mock_repo_cls.return_value = repo
|
||||
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="missing",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"):
|
||||
layer.on_event(GraphRunFailedEvent(error="boom"))
|
||||
|
||||
assert any(record.levelno == logging.ERROR for record in caplog.records)
|
||||
assert trigger_database.session.get(WorkflowTriggerLog, "missing") is None
|
||||
session.commit.assert_not_called()
|
||||
|
||||
def test_on_event_ignores_non_status_events(self, trigger_database: TriggerDatabase):
|
||||
def test_on_event_ignores_non_status_events(self):
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -176,14 +159,14 @@ class TestTriggerPostLayer:
|
||||
total_tokens=0,
|
||||
)
|
||||
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="log-1",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
with patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory:
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="log-1",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
|
||||
trigger_database.statements.clear()
|
||||
layer.on_event(Mock())
|
||||
layer.on_event(Mock())
|
||||
|
||||
assert trigger_database.statements == []
|
||||
mock_session_factory.create_session.assert_not_called()
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import types
|
||||
from collections.abc import Generator, Iterator
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from contexts.wrapper import RecyclableContextVar
|
||||
from core.datasource import datasource_manager as datasource_manager_module
|
||||
from core.datasource.datasource_manager import DatasourceManager
|
||||
from core.datasource.entities.datasource_entities import DatasourceMessage, DatasourceProviderType
|
||||
from core.datasource.errors import DatasourceProviderNotFoundError
|
||||
@@ -15,34 +12,6 @@ from core.workflow.file_reference import parse_file_reference
|
||||
from graphon.enums import WorkflowNodeExecutionStatus
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.node_events import StreamChunkEvent, StreamCompletedEvent
|
||||
from models.base import TypeBase
|
||||
from models.tools import ToolFile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool_file_session(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]:
|
||||
"""Bind datasource-owned lookups to a SQLite ToolFile table."""
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[ToolFile.__tablename__]])
|
||||
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(datasource_manager_module.session_factory, "create_session", session_maker)
|
||||
with session_maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def _persist_tool_file(session: Session, *, file_id: str, tenant_id: str) -> ToolFile:
|
||||
tool_file = ToolFile(
|
||||
user_id="user-1",
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=None,
|
||||
file_key="files/image.png",
|
||||
mimetype="image/png",
|
||||
name="image.png",
|
||||
size=10,
|
||||
)
|
||||
tool_file.id = file_id
|
||||
session.add(tool_file)
|
||||
session.commit()
|
||||
return tool_file
|
||||
|
||||
|
||||
def _gen_messages_text_only(text: str) -> Generator[DatasourceMessage, None, None]:
|
||||
@@ -404,8 +373,7 @@ def test_stream_node_events_emits_events_online_document(mocker: MockerFixture):
|
||||
assert events[-1].node_run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
|
||||
|
||||
def test_stream_node_events_builds_file_and_variables_from_messages(mocker: MockerFixture, tool_file_session: Session):
|
||||
_persist_tool_file(tool_file_session, file_id="tool_file_1", tenant_id="t1")
|
||||
def test_stream_node_events_builds_file_and_variables_from_messages(mocker: MockerFixture):
|
||||
mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored"))
|
||||
|
||||
def _transformed(**_kwargs):
|
||||
@@ -450,6 +418,19 @@ def test_stream_node_events_builds_file_and_variables_from_messages(mocker: Mock
|
||||
side_effect=_transformed,
|
||||
)
|
||||
|
||||
fake_tool_file = types.SimpleNamespace(mimetype="image/png")
|
||||
|
||||
class _Session:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def scalar(self, _stmt):
|
||||
return fake_tool_file
|
||||
|
||||
mocker.patch("core.datasource.datasource_manager.session_factory.create_session", return_value=_Session())
|
||||
mocker.patch("core.datasource.datasource_manager.get_file_type_by_mime_type", return_value=FileType.IMAGE)
|
||||
built = File(
|
||||
file_type=FileType.IMAGE,
|
||||
@@ -500,8 +481,7 @@ def test_stream_node_events_builds_file_and_variables_from_messages(mocker: Mock
|
||||
assert events[-1].node_run_result.outputs["x"] == 1
|
||||
|
||||
|
||||
def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture, tool_file_session: Session):
|
||||
_persist_tool_file(tool_file_session, file_id="missing", tenant_id="other-tenant")
|
||||
def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture):
|
||||
mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored"))
|
||||
|
||||
def _transformed(**_kwargs):
|
||||
@@ -516,6 +496,18 @@ def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture,
|
||||
side_effect=_transformed,
|
||||
)
|
||||
|
||||
class _Session:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def scalar(self, _stmt):
|
||||
return None
|
||||
|
||||
mocker.patch("core.datasource.datasource_manager.session_factory.create_session", return_value=_Session())
|
||||
|
||||
with pytest.raises(ValueError, match="ToolFile not found for file_id=missing, tenant_id=t1"):
|
||||
list(
|
||||
DatasourceManager.stream_node_events(
|
||||
|
||||
@@ -14,64 +14,18 @@ Tests follow the Arrange-Act-Assert pattern for clarity.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.datasource.entities.datasource_entities import DatasourceProviderType
|
||||
from core.datasource.online_document.online_document_provider import (
|
||||
OnlineDocumentDatasourcePluginProviderController,
|
||||
)
|
||||
from core.rag.extractor import notion_extractor as notion_extractor_module
|
||||
from core.rag.extractor.notion_extractor import NotionExtractor
|
||||
from core.rag.models.document import Document
|
||||
from models.base import TypeBase
|
||||
from models.dataset import Document as DocumentModel
|
||||
from models.enums import DataSourceType, DocumentCreatedFrom
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Database:
|
||||
"""Expose the real SQLite session used by the extractor update."""
|
||||
|
||||
session: Session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[_Database]:
|
||||
"""Bind a real session for Notion document metadata persistence."""
|
||||
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[DocumentModel.__table__])
|
||||
with Session(sqlite_engine, expire_on_commit=False) as session:
|
||||
database = _Database(session)
|
||||
monkeypatch.setattr(notion_extractor_module, "db", database)
|
||||
yield database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def persisted_document(database: _Database) -> DocumentModel:
|
||||
document = DocumentModel(
|
||||
id=str(uuid4()),
|
||||
tenant_id=str(uuid4()),
|
||||
dataset_id=str(uuid4()),
|
||||
position=1,
|
||||
data_source_type=DataSourceType.NOTION_IMPORT,
|
||||
data_source_info=json.dumps({"last_edited_time": "2024-01-01T00:00:00.000Z"}),
|
||||
batch="batch",
|
||||
name="Notion page",
|
||||
created_from=DocumentCreatedFrom.WEB,
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
database.session.add(document)
|
||||
database.session.commit()
|
||||
return document
|
||||
|
||||
|
||||
class TestNotionExtractorAuthentication:
|
||||
@@ -809,14 +763,9 @@ class TestNotionExtractorLastEditedTime:
|
||||
call_args = mock_request.call_args
|
||||
assert "databases/database-789" in call_args[0][1]
|
||||
|
||||
@patch("core.rag.extractor.notion_extractor.db")
|
||||
@patch("httpx.request")
|
||||
def test_update_last_edited_time(
|
||||
self,
|
||||
mock_request: Mock,
|
||||
extractor_page: NotionExtractor,
|
||||
database: _Database,
|
||||
persisted_document: DocumentModel,
|
||||
):
|
||||
def test_update_last_edited_time(self, mock_request, mock_db, extractor_page, mock_document_model):
|
||||
"""Test updating document model with last edited time."""
|
||||
# Arrange
|
||||
mock_response = Mock()
|
||||
@@ -828,11 +777,11 @@ class TestNotionExtractorLastEditedTime:
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
# Act
|
||||
extractor_page.update_last_edited_time(persisted_document)
|
||||
extractor_page.update_last_edited_time(mock_document_model)
|
||||
|
||||
# Assert
|
||||
database.session.expire(persisted_document)
|
||||
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z"
|
||||
assert mock_document_model.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z"
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
def test_update_last_edited_time_no_document(self, extractor_page):
|
||||
"""Test update_last_edited_time with None document model."""
|
||||
@@ -858,10 +807,9 @@ class TestNotionExtractorIntegration:
|
||||
mock_doc.data_source_info_dict = {"last_edited_time": "2024-01-01T00:00:00.000Z"}
|
||||
return mock_doc
|
||||
|
||||
@patch("core.rag.extractor.notion_extractor.db")
|
||||
@patch("httpx.request")
|
||||
def test_extract_page_complete_workflow(
|
||||
self, mock_request: Mock, database: _Database, persisted_document: DocumentModel
|
||||
):
|
||||
def test_extract_page_complete_workflow(self, mock_request, mock_db, mock_document_model):
|
||||
"""Test complete page extraction workflow."""
|
||||
# Arrange
|
||||
extractor = NotionExtractor(
|
||||
@@ -870,7 +818,7 @@ class TestNotionExtractorIntegration:
|
||||
notion_page_type="page",
|
||||
tenant_id="tenant-789",
|
||||
notion_access_token="test-token",
|
||||
document_model=persisted_document,
|
||||
document_model=mock_document_model,
|
||||
)
|
||||
|
||||
# Mock last edited time request
|
||||
@@ -921,18 +869,11 @@ class TestNotionExtractorIntegration:
|
||||
assert isinstance(documents[0], Document)
|
||||
assert "# Test Page" in documents[0].page_content
|
||||
assert "Test content" in documents[0].page_content
|
||||
database.session.expire(persisted_document)
|
||||
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z"
|
||||
|
||||
@patch("core.rag.extractor.notion_extractor.db")
|
||||
@patch("httpx.post")
|
||||
@patch("httpx.request")
|
||||
def test_extract_database_complete_workflow(
|
||||
self,
|
||||
mock_request: Mock,
|
||||
mock_post: Mock,
|
||||
database: _Database,
|
||||
persisted_document: DocumentModel,
|
||||
):
|
||||
def test_extract_database_complete_workflow(self, mock_request, mock_post, mock_db, mock_document_model):
|
||||
"""Test complete database extraction workflow."""
|
||||
# Arrange
|
||||
extractor = NotionExtractor(
|
||||
@@ -941,7 +882,7 @@ class TestNotionExtractorIntegration:
|
||||
notion_page_type="database",
|
||||
tenant_id="tenant-789",
|
||||
notion_access_token="test-token",
|
||||
document_model=persisted_document,
|
||||
document_model=mock_document_model,
|
||||
)
|
||||
|
||||
# Mock last edited time request
|
||||
@@ -980,8 +921,6 @@ class TestNotionExtractorIntegration:
|
||||
assert isinstance(documents[0], Document)
|
||||
assert "Name:Item 1" in documents[0].page_content
|
||||
assert "Status:Active" in documents[0].page_content
|
||||
database.session.expire(persisted_document)
|
||||
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z"
|
||||
|
||||
def test_extract_invalid_page_type(self):
|
||||
"""Test extract with invalid page type."""
|
||||
|
||||
@@ -2,21 +2,9 @@ from types import SimpleNamespace
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from sqlalchemy import Engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
import core.rag.extractor.excel_extractor as excel_module
|
||||
from core.rag.extractor.excel_extractor import ExcelExtractor
|
||||
from models.base import TypeBase
|
||||
from models.model import UploadFile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database_session_maker(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> sessionmaker[Session]:
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[UploadFile.__table__])
|
||||
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(excel_module.session_factory, "create_session", session_maker)
|
||||
return session_maker
|
||||
|
||||
|
||||
class _FakeCell:
|
||||
@@ -70,22 +58,82 @@ class _FakeImage:
|
||||
return self._raw_data
|
||||
|
||||
|
||||
class _FieldExpression:
|
||||
def __eq__(self, other):
|
||||
return ("eq", other)
|
||||
|
||||
def in_(self, values):
|
||||
return ("in", tuple(values))
|
||||
|
||||
|
||||
class _SelectStub:
|
||||
def where(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
|
||||
class _FakeUploadFile:
|
||||
tenant_id = _FieldExpression()
|
||||
key = _FieldExpression()
|
||||
_i = 0
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
type(self)._i += 1
|
||||
self.id = f"u{self._i}"
|
||||
self.key = kwargs["key"]
|
||||
|
||||
|
||||
class _PersistentSession:
|
||||
def __init__(self, persisted):
|
||||
self._persisted = persisted
|
||||
self.added = []
|
||||
self.commit_count = 0
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def scalars(self, _stmt):
|
||||
return SimpleNamespace(all=lambda: list(self._persisted.values()))
|
||||
|
||||
def add_all(self, objects) -> None:
|
||||
self.added.extend(objects)
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commit_count += 1
|
||||
for upload_file in self.added:
|
||||
self._persisted[upload_file.key] = upload_file
|
||||
self.added.clear()
|
||||
|
||||
|
||||
class _PersistentSessionFactory:
|
||||
def __init__(self):
|
||||
self.persisted = {}
|
||||
self.sessions = []
|
||||
|
||||
def create_session(self):
|
||||
session = _PersistentSession(self.persisted)
|
||||
self.sessions.append(session)
|
||||
return session
|
||||
|
||||
|
||||
def _patch_image_persistence(monkeypatch: pytest.MonkeyPatch):
|
||||
saves: list[tuple[str, bytes]] = []
|
||||
session_factory = _PersistentSessionFactory()
|
||||
|
||||
def save(key: str, data: bytes) -> None:
|
||||
saves.append((key, data))
|
||||
|
||||
monkeypatch.setattr(excel_module.storage, "save", save)
|
||||
_FakeUploadFile._i = 0
|
||||
monkeypatch.setattr(excel_module, "storage", SimpleNamespace(save=save))
|
||||
monkeypatch.setattr(excel_module, "session_factory", session_factory)
|
||||
monkeypatch.setattr(excel_module, "select", lambda *args, **kwargs: _SelectStub())
|
||||
monkeypatch.setattr(excel_module, "UploadFile", _FakeUploadFile)
|
||||
monkeypatch.setattr(excel_module.dify_config, "FILES_URL", "http://files.local", raising=False)
|
||||
monkeypatch.setattr(excel_module.dify_config, "STORAGE_TYPE", "local", raising=False)
|
||||
|
||||
return saves
|
||||
|
||||
|
||||
def _get_upload_files(session_maker: sessionmaker[Session]) -> list[UploadFile]:
|
||||
with session_maker() as session:
|
||||
return list(session.scalars(select(UploadFile)).all())
|
||||
return saves, session_factory
|
||||
|
||||
|
||||
class TestExcelExtractor:
|
||||
@@ -112,11 +160,7 @@ class TestExcelExtractor:
|
||||
assert docs[1].page_content == '"Name":"";"Link":"123"'
|
||||
assert all(doc.metadata["source"] == "/tmp/sample.xlsx" for doc in docs)
|
||||
|
||||
def test_extract_xlsx_turns_embedded_images_into_markdown_links(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
def test_extract_xlsx_turns_embedded_images_into_markdown_links(self, monkeypatch: pytest.MonkeyPatch):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nexcel-image"
|
||||
sheet = _FakeSheet(
|
||||
header_rows=[("Question", "Answer", "Image")],
|
||||
@@ -131,7 +175,7 @@ class TestExcelExtractor:
|
||||
)
|
||||
workbook = _FakeWorkbook({"Data": sheet})
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -140,30 +184,23 @@ class TestExcelExtractor:
|
||||
source_file_id="source-file-1",
|
||||
)
|
||||
docs = extractor.extract()
|
||||
upload_files = _get_upload_files(database_session_maker)
|
||||
|
||||
assert workbook.closed is True
|
||||
assert len(docs) == 2
|
||||
assert len(upload_files) == 1
|
||||
assert docs[0].page_content == (
|
||||
'"Question":"Q1";"Answer":"A1";'
|
||||
f'"Image":" '
|
||||
f'"'
|
||||
'"Image":" '
|
||||
'"'
|
||||
)
|
||||
assert docs[1].page_content == '"Question":"Q2";"Answer":"A2";"Image":""'
|
||||
assert len(saves) == 1
|
||||
assert saves[0][0].startswith("image_files/tenant-1/source-file-1/")
|
||||
assert saves[0][0].endswith(".png")
|
||||
assert saves[0][1] == image_bytes
|
||||
assert upload_files[0].tenant_id == "tenant-1"
|
||||
assert upload_files[0].key == saves[0][0]
|
||||
assert upload_files[0].used is True
|
||||
assert len(session_factory.persisted) == 1
|
||||
assert [session.commit_count for session in session_factory.sessions] == [1]
|
||||
|
||||
def test_extract_xlsx_keeps_rows_with_only_embedded_images(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
def test_extract_xlsx_keeps_rows_with_only_embedded_images(self, monkeypatch: pytest.MonkeyPatch):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nimage-only-row"
|
||||
sheet = _FakeSheet(
|
||||
header_rows=[("Question", "Answer", "Image")],
|
||||
@@ -175,7 +212,7 @@ class TestExcelExtractor:
|
||||
)
|
||||
workbook = _FakeWorkbook({"Data": sheet})
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -184,21 +221,17 @@ class TestExcelExtractor:
|
||||
source_file_id="source-file-1",
|
||||
)
|
||||
docs = extractor.extract()
|
||||
upload_files = _get_upload_files(database_session_maker)
|
||||
|
||||
assert workbook.closed is True
|
||||
assert len(docs) == 1
|
||||
assert len(upload_files) == 1
|
||||
assert docs[0].page_content == (
|
||||
f'"Question":"";"Answer":"";"Image":""'
|
||||
'"Question":"";"Answer":"";"Image":""'
|
||||
)
|
||||
assert len(saves) == 1
|
||||
assert len(session_factory.persisted) == 1
|
||||
assert [session.commit_count for session in session_factory.sessions] == [1]
|
||||
|
||||
def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(self, monkeypatch: pytest.MonkeyPatch):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nretry-safe-image"
|
||||
workbooks = [
|
||||
_FakeWorkbook(
|
||||
@@ -221,7 +254,7 @@ class TestExcelExtractor:
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbooks.pop(0))
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -231,17 +264,16 @@ class TestExcelExtractor:
|
||||
)
|
||||
first_docs = extractor.extract()
|
||||
second_docs = extractor.extract()
|
||||
upload_files = _get_upload_files(database_session_maker)
|
||||
assert len(upload_files) == 1
|
||||
|
||||
expected_page_content = (
|
||||
'"Question":"Q1";"Answer":"A1";'
|
||||
f'"Image":""'
|
||||
'"Question":"Q1";"Answer":"A1";"Image":""'
|
||||
)
|
||||
|
||||
assert first_docs[0].page_content == expected_page_content
|
||||
assert second_docs[0].page_content == expected_page_content
|
||||
assert len(saves) == 1
|
||||
assert len(session_factory.persisted) == 1
|
||||
assert [session.commit_count for session in session_factory.sessions] == [1, 0]
|
||||
|
||||
def test_extract_xls_path(self, monkeypatch: pytest.MonkeyPatch):
|
||||
class FakeExcelFile:
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import cast
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.layers.config import DifyConfigSkillConfig
|
||||
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig
|
||||
from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig
|
||||
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID
|
||||
@@ -41,14 +40,6 @@ from models.agent_config_entities import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_runtime_agent_skills(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.load_runtime_agent_skill_configs",
|
||||
lambda *, tenant_id, agent_id: [],
|
||||
)
|
||||
|
||||
|
||||
class FakeCredentialsProvider:
|
||||
def fetch(self, provider_name: str, model_name: str) -> dict[str, object]:
|
||||
assert provider_name == "openai"
|
||||
@@ -1429,30 +1420,6 @@ def test_build_config_layer_config_includes_soul_context_and_mentions():
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_build_config_layer_config_includes_runtime_agent_skills():
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config
|
||||
|
||||
soul = AgentSoulConfig(
|
||||
prompt={"system_prompt": "Use [§skill:workspace-skill:Workspace Skill§]."},
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
)
|
||||
config, warnings = build_config_layer_config(
|
||||
soul,
|
||||
runtime_config_skills=[
|
||||
DifyConfigSkillConfig(
|
||||
name="workspace-skill",
|
||||
description="Bound workspace skill.",
|
||||
size=123,
|
||||
mime_type="application/zip",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert [skill.name for skill in config.skills] == ["workspace-skill"]
|
||||
assert config.mentioned_skill_names == ["workspace-skill"]
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_build_config_layer_config_returns_empty_config_for_empty_agent_soul():
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config
|
||||
|
||||
@@ -1538,33 +1505,6 @@ def test_workflow_run_request_contains_config_layer():
|
||||
assert any(spec.name == DIFY_CONFIG_LAYER_ID and spec.type == "dify.config" for spec in specs)
|
||||
|
||||
|
||||
def test_workflow_run_request_includes_bound_workspace_skills(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.load_runtime_agent_skill_configs",
|
||||
lambda *, tenant_id, agent_id: [
|
||||
DifyConfigSkillConfig(
|
||||
name="workspace-skill",
|
||||
description="Bound workspace skill.",
|
||||
size=123,
|
||||
mime_type="application/zip",
|
||||
)
|
||||
],
|
||||
)
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = AgentSoulConfig(
|
||||
prompt={"system_prompt": "Use [§skill:workspace-skill:Workspace Skill§]."},
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
)
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
config = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID)
|
||||
assert [skill.name for skill in config.config.skills] == ["workspace-skill"]
|
||||
assert config.config.mentioned_skill_names == ["workspace-skill"]
|
||||
soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
|
||||
assert soul_prompt.config.prefix == "Use workspace-skill."
|
||||
|
||||
|
||||
def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt():
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = _soul_with_config_assets()
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from libs.helper import OptionalTimestampField, alphanumeric, email, escape_like_pattern, extract_tenant_id
|
||||
from libs.helper import OptionalTimestampField, email, escape_like_pattern, extract_tenant_id
|
||||
from models.account import Account
|
||||
from models.model import EndUser
|
||||
|
||||
@@ -153,47 +153,3 @@ class TestEmailValidator:
|
||||
def test_invalid_email_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid email"):
|
||||
email("not-an-email")
|
||||
|
||||
|
||||
class TestAlphanumericValidator:
|
||||
"""Tests for the alphanumeric() validator — regression for #39666."""
|
||||
|
||||
def test_valid_alphanumeric_accepted(self):
|
||||
assert alphanumeric("tool_name") == "tool_name"
|
||||
assert alphanumeric("Tool123") == "Tool123"
|
||||
assert alphanumeric("_underscore_start") == "_underscore_start"
|
||||
assert alphanumeric("a") == "a"
|
||||
|
||||
def test_trailing_newline_rejected(self):
|
||||
# re.match with $ accepts a trailing \n in Python; re.fullmatch does not.
|
||||
# This was the pre-fix behaviour: alphanumeric("tool\n") returned "tool\n".
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool_name\n")
|
||||
|
||||
def test_trailing_carriage_return_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool_name\r")
|
||||
|
||||
def test_trailing_crlf_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool_name\r\n")
|
||||
|
||||
def test_leading_newline_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("\ntool_name")
|
||||
|
||||
def test_embedded_whitespace_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool name")
|
||||
|
||||
def test_empty_string_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("")
|
||||
|
||||
def test_special_characters_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool-name")
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool.name")
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool/name")
|
||||
|
||||
@@ -13,7 +13,7 @@ from services.agent import skill_package_service as skill_package_service_module
|
||||
from services.agent.skill_package_service import NormalizedSkillPackage, SkillPackageError, SkillPackageService
|
||||
|
||||
_SKILL_MD = """---
|
||||
name: pdf-toolkit
|
||||
name: PDF Toolkit
|
||||
description: Tools for working with PDF files.
|
||||
---
|
||||
|
||||
@@ -43,7 +43,7 @@ def _archive_members(content: bytes) -> list[str]:
|
||||
def test_valid_skill_normalizes_manifest():
|
||||
manifest = _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"}).manifest
|
||||
|
||||
assert manifest.name == "pdf-toolkit"
|
||||
assert manifest.name == "PDF Toolkit"
|
||||
assert manifest.description == "Tools for working with PDF files."
|
||||
assert manifest.entry_path == "SKILL.md"
|
||||
assert set(manifest.files) == {"SKILL.md", "scripts/run.py"}
|
||||
@@ -51,10 +51,10 @@ def test_valid_skill_normalizes_manifest():
|
||||
assert len(manifest.hash) == 64
|
||||
|
||||
|
||||
def test_name_and_description_are_required_in_frontmatter():
|
||||
with pytest.raises(SkillPackageError) as exc_info:
|
||||
_normalize({"SKILL.md": b"# heading-name\n\nbody"})
|
||||
assert exc_info.value.code == "missing_skill_name"
|
||||
def test_name_falls_back_to_heading_without_frontmatter():
|
||||
manifest = _normalize({"SKILL.md": b"# Heading Name\n\nbody"}).manifest
|
||||
assert manifest.name == "Heading Name"
|
||||
assert manifest.description == ""
|
||||
|
||||
|
||||
def test_shallowest_skill_md_preferred_during_normalization():
|
||||
@@ -155,18 +155,7 @@ def test_validate_and_normalize_strips_deeper_selected_skill_root():
|
||||
({"README.md": b"x"}, "skill.zip", "missing_skill_md"),
|
||||
({"SKILL.md": _SKILL_MD.encode()}, "skill.tar", "unsupported_extension"),
|
||||
({"SKILL.md": b""}, "skill.zip", "empty_skill_md"),
|
||||
({"SKILL.md": b"---\ndescription: valid\n---\n# no name here"}, "skill.zip", "missing_skill_name"),
|
||||
({"SKILL.md": b"---\nname: pdf-toolkit\n---\n# no description"}, "skill.zip", "missing_skill_description"),
|
||||
(
|
||||
{"SKILL.md": b"---\nname: PDF Toolkit\ndescription: valid\n---\n# invalid name"},
|
||||
"skill.zip",
|
||||
"invalid_skill_name",
|
||||
),
|
||||
(
|
||||
{"SKILL.md": f"---\nname: pdf-toolkit\ndescription: {'x' * 1025}\n---\n# long".encode()},
|
||||
"skill.zip",
|
||||
"invalid_skill_description",
|
||||
),
|
||||
({"SKILL.md": b"no name here"}, "skill.zip", "missing_skill_name"),
|
||||
({"SKILL.md": b"\xff\xfenot utf8"}, "skill.zip", "skill_md_not_utf8"),
|
||||
],
|
||||
)
|
||||
@@ -235,10 +224,10 @@ def test_bad_frontmatter_yaml_rejected():
|
||||
assert exc_info.value.code == "invalid_frontmatter"
|
||||
|
||||
|
||||
def test_unterminated_frontmatter_rejected():
|
||||
with pytest.raises(SkillPackageError) as exc_info:
|
||||
_normalize({"SKILL.md": b"---\n# heading-wins\nbody"})
|
||||
assert exc_info.value.code == "missing_skill_name"
|
||||
def test_unterminated_frontmatter_falls_back_to_heading():
|
||||
# leading '---' with no closing fence -> no frontmatter, use the heading
|
||||
manifest = _normalize({"SKILL.md": b"---\n# Heading Wins\nbody"}).manifest
|
||||
assert manifest.name == "Heading Wins"
|
||||
|
||||
|
||||
def test_validate_and_normalize_rejects_files_outside_selected_skill_root():
|
||||
|
||||
@@ -20,7 +20,7 @@ _AGENT_ID = "22222222-2222-2222-2222-222222222222"
|
||||
_USER_ID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
_SKILL_MD = b"""---
|
||||
name: pdf-toolkit
|
||||
name: PDF Toolkit
|
||||
description: Work with PDFs.
|
||||
---
|
||||
|
||||
@@ -121,7 +121,7 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_manifest(
|
||||
assert skill_row.is_skill is True
|
||||
assert skill_row.skill_metadata is not None
|
||||
skill_metadata = DriveSkillMetadata.model_validate_json(skill_row.skill_metadata)
|
||||
assert skill_metadata.name == "pdf-toolkit"
|
||||
assert skill_metadata.name == "PDF Toolkit"
|
||||
assert skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"]
|
||||
assert archive_row.file_kind == AgentDriveFileKind.TOOL_FILE
|
||||
assert archive_row.file_id == archive_tool_file.id
|
||||
@@ -132,7 +132,7 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_manifest(
|
||||
# The returned upload response carries only the drive-derived fields the UI needs.
|
||||
skill = result["skill"]
|
||||
assert skill["path"] == "pdf-toolkit"
|
||||
assert skill["name"] == "pdf-toolkit"
|
||||
assert skill["name"] == "PDF Toolkit"
|
||||
assert skill["archive_key"] == "pdf-toolkit/.DIFY-SKILL-FULL.zip"
|
||||
assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md"
|
||||
assert result["manifest"]["entry_path"] == "SKILL.md"
|
||||
|
||||
@@ -1,105 +1,89 @@
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.account import (
|
||||
TenantPluginAutoUpgradeCategory,
|
||||
TenantPluginAutoUpgradeMode,
|
||||
TenantPluginAutoUpgradeStrategy,
|
||||
TenantPluginAutoUpgradeStrategySetting,
|
||||
)
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
MODULE = "services.plugin.plugin_auto_upgrade_service"
|
||||
PLUGIN_CATEGORY = TenantPluginAutoUpgradeCategory.TOOL
|
||||
STRATEGY_MODELS = (TenantPluginAutoUpgradeStrategy,)
|
||||
|
||||
|
||||
def _strategy(
|
||||
tenant_id: str,
|
||||
*,
|
||||
category: TenantPluginAutoUpgradeCategory = PLUGIN_CATEGORY,
|
||||
setting: TenantPluginAutoUpgradeStrategySetting = TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
mode: TenantPluginAutoUpgradeMode = TenantPluginAutoUpgradeMode.EXCLUDE,
|
||||
exclude: list[str] | None = None,
|
||||
include: list[str] | None = None,
|
||||
upgrade_time: int = 0,
|
||||
) -> TenantPluginAutoUpgradeStrategy:
|
||||
return TenantPluginAutoUpgradeStrategy(
|
||||
tenant_id=tenant_id,
|
||||
category=category,
|
||||
strategy_setting=setting,
|
||||
upgrade_time_of_day=upgrade_time,
|
||||
upgrade_mode=mode,
|
||||
exclude_plugins=exclude or [],
|
||||
include_plugins=include or [],
|
||||
)
|
||||
def _patched_session():
|
||||
"""Return a mock SQLAlchemy session for service calls."""
|
||||
session = MagicMock()
|
||||
return session
|
||||
|
||||
|
||||
class TestGetStrategy:
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_returns_strategy_when_found(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
strategy = _strategy(tenant_id)
|
||||
sqlite_session.add(strategy)
|
||||
sqlite_session.commit()
|
||||
def test_returns_strategy_when_found(self):
|
||||
session = _patched_session()
|
||||
strategy = MagicMock()
|
||||
session.scalar.return_value = strategy
|
||||
|
||||
result = PluginAutoUpgradeService.get_strategy(tenant_id, PLUGIN_CATEGORY, session=sqlite_session)
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is strategy
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_returns_none_when_not_found(self, sqlite_session: Session) -> None:
|
||||
assert PluginAutoUpgradeService.get_strategy(str(uuid4()), PLUGIN_CATEGORY, session=sqlite_session) is None
|
||||
def test_returns_none_when_not_found(self):
|
||||
session = _patched_session()
|
||||
session.scalar.return_value = None
|
||||
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestChangeStrategy:
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_creates_new_strategy(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
def test_creates_new_strategy(self):
|
||||
session = _patched_session()
|
||||
session.scalar.return_value = None
|
||||
|
||||
result = PluginAutoUpgradeService.change_strategy(
|
||||
tenant_id,
|
||||
TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
3,
|
||||
TenantPluginAutoUpgradeMode.ALL,
|
||||
[],
|
||||
[],
|
||||
category=PLUGIN_CATEGORY,
|
||||
session=sqlite_session,
|
||||
)
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.return_value = MagicMock()
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.change_strategy(
|
||||
"t1",
|
||||
TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
3,
|
||||
TenantPluginAutoUpgradeMode.ALL,
|
||||
[],
|
||||
[],
|
||||
category=PLUGIN_CATEGORY,
|
||||
session=session,
|
||||
)
|
||||
|
||||
strategy = sqlite_session.scalar(select(TenantPluginAutoUpgradeStrategy))
|
||||
assert result is True
|
||||
assert strategy is not None
|
||||
assert strategy.tenant_id == tenant_id
|
||||
assert strategy.upgrade_time_of_day == 3
|
||||
assert strategy.upgrade_mode == TenantPluginAutoUpgradeMode.ALL
|
||||
session.add.assert_called_once()
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_updates_existing_strategy(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id)
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
def test_updates_existing_strategy(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
session.scalar.return_value = existing
|
||||
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.change_strategy(
|
||||
tenant_id,
|
||||
"t1",
|
||||
TenantPluginAutoUpgradeStrategySetting.LATEST,
|
||||
5,
|
||||
TenantPluginAutoUpgradeMode.PARTIAL,
|
||||
["p1"],
|
||||
["p2"],
|
||||
category=PLUGIN_CATEGORY,
|
||||
session=sqlite_session,
|
||||
session=session,
|
||||
)
|
||||
|
||||
sqlite_session.refresh(existing)
|
||||
assert result is True
|
||||
assert existing.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST
|
||||
assert existing.upgrade_time_of_day == 5
|
||||
@@ -109,115 +93,157 @@ class TestChangeStrategy:
|
||||
|
||||
|
||||
class TestExcludePlugin:
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_creates_default_strategy_when_none_exists(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
def test_creates_default_strategy_when_none_exists(self):
|
||||
session = _patched_session()
|
||||
session.scalar.return_value = None
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin(tenant_id, "plugin-1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
with (
|
||||
patch(f"{MODULE}.select"),
|
||||
patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy"),
|
||||
):
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin(
|
||||
"t1",
|
||||
"plugin-1",
|
||||
PLUGIN_CATEGORY,
|
||||
session=session,
|
||||
)
|
||||
|
||||
strategy = sqlite_session.scalar(select(TenantPluginAutoUpgradeStrategy))
|
||||
assert result is True
|
||||
assert strategy is not None
|
||||
assert strategy.exclude_plugins == ["plugin-1"]
|
||||
session.add.assert_called_once()
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_appends_to_exclude_list_in_exclude_mode(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id, exclude=["p-existing"])
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
def test_appends_to_exclude_list_in_exclude_mode(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = TenantPluginAutoUpgradeMode.EXCLUDE
|
||||
existing.exclude_plugins = ["p-existing"]
|
||||
session.scalar.return_value = existing
|
||||
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p-new", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
sqlite_session.refresh(existing)
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is True
|
||||
assert existing.exclude_plugins == ["p-existing", "p-new"]
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_removes_from_include_list_in_partial_mode(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id, mode=TenantPluginAutoUpgradeMode.PARTIAL, include=["p1", "p2"])
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
def test_removes_from_include_list_in_partial_mode(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = TenantPluginAutoUpgradeMode.PARTIAL
|
||||
existing.include_plugins = ["p1", "p2"]
|
||||
session.scalar.return_value = existing
|
||||
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
sqlite_session.refresh(existing)
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is True
|
||||
assert existing.include_plugins == ["p2"]
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_switches_to_exclude_mode_from_all(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id, mode=TenantPluginAutoUpgradeMode.ALL)
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
def test_switches_to_exclude_mode_from_all(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = TenantPluginAutoUpgradeMode.ALL
|
||||
session.scalar.return_value = existing
|
||||
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
sqlite_session.refresh(existing)
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
assert result is True
|
||||
assert existing.upgrade_mode == TenantPluginAutoUpgradeMode.EXCLUDE
|
||||
assert existing.exclude_plugins == ["p1"]
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_no_duplicate_in_exclude_list(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
existing = _strategy(tenant_id, exclude=["p1"])
|
||||
sqlite_session.add(existing)
|
||||
sqlite_session.commit()
|
||||
def test_no_duplicate_in_exclude_list(self):
|
||||
session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = TenantPluginAutoUpgradeMode.EXCLUDE
|
||||
existing.exclude_plugins = ["p1"]
|
||||
session.scalar.return_value = existing
|
||||
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session)
|
||||
with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session)
|
||||
|
||||
sqlite_session.refresh(existing)
|
||||
assert existing.exclude_plugins == ["p1"]
|
||||
|
||||
|
||||
class TestBackfillStrategyCategories:
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_creates_default_missing_categories_without_fetching_daemon(self, sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
tool_strategy = _strategy(tenant_id)
|
||||
sqlite_session.add(tool_strategy)
|
||||
sqlite_session.commit()
|
||||
def test_creates_default_missing_categories_without_fetching_daemon(self):
|
||||
session = _patched_session()
|
||||
tool_strategy = SimpleNamespace(
|
||||
category=TenantPluginAutoUpgradeCategory.TOOL,
|
||||
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
upgrade_time_of_day=0,
|
||||
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
|
||||
exclude_plugins=[],
|
||||
include_plugins=[],
|
||||
)
|
||||
session.scalars.return_value.all.return_value = [tool_strategy]
|
||||
installer = MagicMock()
|
||||
|
||||
with patch(f"{MODULE}.PluginInstaller", return_value=installer):
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories(tenant_id, session=sqlite_session)
|
||||
expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id)
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories("t1", session=session)
|
||||
expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1")
|
||||
|
||||
strategies = list(sqlite_session.scalars(select(TenantPluginAutoUpgradeStrategy)).all())
|
||||
assert result.created_count == len(TenantPluginAutoUpgradeCategory) - 1
|
||||
assert result.normalized is False
|
||||
installer.list_plugins.assert_not_called()
|
||||
assert len(strategies) == len(TenantPluginAutoUpgradeCategory)
|
||||
assert tool_strategy.upgrade_time_of_day == expected_time
|
||||
created_strategies = [call.args[0] for call in session.add.call_args_list]
|
||||
model_strategy = next(
|
||||
strategy for strategy in strategies if strategy.category == TenantPluginAutoUpgradeCategory.MODEL
|
||||
strategy for strategy in created_strategies if strategy.category == TenantPluginAutoUpgradeCategory.MODEL
|
||||
)
|
||||
assert model_strategy.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST
|
||||
assert model_strategy.upgrade_time_of_day == expected_time
|
||||
|
||||
def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self) -> None:
|
||||
default_time = PluginAutoUpgradeService.default_upgrade_time_of_day(str(uuid4()))
|
||||
def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self):
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
default_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1")
|
||||
|
||||
assert default_time % (15 * 60) == 0
|
||||
assert 0 <= default_time < 24 * 60 * 60
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True)
|
||||
def test_creates_missing_categories_and_splits_known_plugins(
|
||||
self, sqlite_session: Session, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
tool_strategy = _strategy(
|
||||
tenant_id,
|
||||
exclude=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include=["model-plugin", "tool-plugin"],
|
||||
def test_creates_missing_categories_and_splits_known_plugins(self, caplog: pytest.LogCaptureFixture):
|
||||
session = _patched_session()
|
||||
tool_strategy = SimpleNamespace(
|
||||
category=TenantPluginAutoUpgradeCategory.TOOL,
|
||||
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
upgrade_time_of_day=0,
|
||||
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
|
||||
exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include_plugins=["model-plugin", "tool-plugin"],
|
||||
)
|
||||
model_strategy = _strategy(
|
||||
tenant_id,
|
||||
model_strategy = SimpleNamespace(
|
||||
category=TenantPluginAutoUpgradeCategory.MODEL,
|
||||
exclude=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include=["model-plugin", "tool-plugin"],
|
||||
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
|
||||
upgrade_time_of_day=0,
|
||||
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
|
||||
exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include_plugins=["model-plugin", "tool-plugin"],
|
||||
)
|
||||
sqlite_session.add_all([tool_strategy, model_strategy])
|
||||
sqlite_session.commit()
|
||||
session.scalars.return_value.all.return_value = [tool_strategy, model_strategy]
|
||||
|
||||
installed_plugins = [
|
||||
SimpleNamespace(
|
||||
plugin_id="tool-plugin",
|
||||
@@ -235,17 +261,18 @@ class TestBackfillStrategyCategories:
|
||||
patch(f"{MODULE}.PluginInstaller", return_value=installer),
|
||||
caplog.at_level(logging.WARNING, logger=MODULE),
|
||||
):
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories(tenant_id, session=sqlite_session)
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories("t1", session=session)
|
||||
|
||||
strategies = list(sqlite_session.scalars(select(TenantPluginAutoUpgradeStrategy)).all())
|
||||
assert result.created_count == len(TenantPluginAutoUpgradeCategory) - 2
|
||||
assert result.normalized is True
|
||||
assert len(strategies) == len(TenantPluginAutoUpgradeCategory)
|
||||
assert session.add.call_count == len(TenantPluginAutoUpgradeCategory) - 2
|
||||
assert tool_strategy.exclude_plugins == ["tool-plugin"]
|
||||
assert tool_strategy.include_plugins == ["tool-plugin"]
|
||||
assert model_strategy.exclude_plugins == ["model-plugin"]
|
||||
assert model_strategy.include_plugins == ["model-plugin"]
|
||||
assert (
|
||||
"Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: "
|
||||
f"tenant_id={tenant_id}, field=exclude_plugins, plugin_ids=['unknown-plugin']" in caplog.messages
|
||||
"tenant_id=t1, field=exclude_plugins, plugin_ids=['unknown-plugin']" in caplog.messages
|
||||
)
|
||||
|
||||
@@ -7,19 +7,28 @@ import pytest
|
||||
import zstandard
|
||||
from pydantic import TypeAdapter
|
||||
from redis import RedisError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.helper.model_provider_cache import ProviderCredentialsCacheType
|
||||
from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource
|
||||
from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginInstallTaskStatus, PluginModelProviderEntity
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
|
||||
from models.provider import Provider, ProviderCredential, ProviderType, TenantPreferredModelProvider
|
||||
|
||||
MODULE = "core.plugin.plugin_service"
|
||||
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222"
|
||||
USER_ID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.execute = Mock()
|
||||
self.scalars = Mock(return_value=SimpleNamespace(all=Mock(return_value=[])))
|
||||
|
||||
def __enter__(self) -> "_FakeSession":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
return None
|
||||
|
||||
def begin(self) -> "_FakeSession":
|
||||
return self
|
||||
|
||||
|
||||
def _build_provider_entity(provider: str = "openai") -> ProviderEntity:
|
||||
@@ -1157,72 +1166,19 @@ class TestPluginModelProviderCacheInvalidation:
|
||||
assert result is True
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session", [(Provider, ProviderCredential, TenantPreferredModelProvider)], indirect=True
|
||||
)
|
||||
def test_uninstall_existing_plugin_invalidates_cache_after_credential_cleanup(
|
||||
self, sqlite_session: Session
|
||||
) -> None:
|
||||
def test_uninstall_existing_plugin_invalidates_cache_after_credential_cleanup(self) -> None:
|
||||
"""Successful uninstall with plugin metadata also invalidates the mutated tenant provider cache."""
|
||||
plugin_id = "langgenius/openai"
|
||||
provider_name = f"{plugin_id}/openai"
|
||||
plugin = SimpleNamespace(
|
||||
installation_id="installation-1",
|
||||
plugin_id=plugin_id,
|
||||
plugin_id="langgenius/openai",
|
||||
plugin_unique_identifier="langgenius/openai:1.0.0",
|
||||
)
|
||||
credential = ProviderCredential(
|
||||
tenant_id=TENANT_ID,
|
||||
provider_name=provider_name,
|
||||
credential_name="Target credential",
|
||||
encrypted_config="{}",
|
||||
user_id=USER_ID,
|
||||
)
|
||||
other_credential = ProviderCredential(
|
||||
tenant_id=OTHER_TENANT_ID,
|
||||
provider_name=provider_name,
|
||||
credential_name="Other credential",
|
||||
encrypted_config="{}",
|
||||
user_id=USER_ID,
|
||||
)
|
||||
sqlite_session.add_all([credential, other_credential])
|
||||
sqlite_session.flush()
|
||||
provider = Provider(
|
||||
tenant_id=TENANT_ID,
|
||||
provider_name=provider_name,
|
||||
provider_type=ProviderType.CUSTOM,
|
||||
credential_id=credential.id,
|
||||
)
|
||||
other_provider = Provider(
|
||||
tenant_id=OTHER_TENANT_ID,
|
||||
provider_name=provider_name,
|
||||
provider_type=ProviderType.CUSTOM,
|
||||
credential_id=other_credential.id,
|
||||
)
|
||||
preferred_provider = TenantPreferredModelProvider(
|
||||
tenant_id=TENANT_ID,
|
||||
provider_name=provider_name,
|
||||
preferred_provider_type=ProviderType.CUSTOM,
|
||||
)
|
||||
other_preferred_provider = TenantPreferredModelProvider(
|
||||
tenant_id=OTHER_TENANT_ID,
|
||||
provider_name=provider_name,
|
||||
preferred_provider_type=ProviderType.CUSTOM,
|
||||
)
|
||||
sqlite_session.add_all([provider, other_provider, preferred_provider, other_preferred_provider])
|
||||
sqlite_session.commit()
|
||||
credential_id = credential.id
|
||||
other_credential_id = other_credential.id
|
||||
provider_id = provider.id
|
||||
other_provider_id = other_provider.id
|
||||
preferred_provider_id = preferred_provider.id
|
||||
other_preferred_provider_id = other_preferred_provider.id
|
||||
|
||||
session = _FakeSession()
|
||||
with (
|
||||
patch(f"{MODULE}.db", SimpleNamespace(engine=sqlite_session.get_bind())),
|
||||
patch(f"{MODULE}.db", SimpleNamespace(engine=object())),
|
||||
patch(f"{MODULE}.dify_config") as mock_config,
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.ProviderCredentialsCache") as credentials_cache,
|
||||
patch(f"{MODULE}.Session", return_value=session),
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = False
|
||||
@@ -1232,26 +1188,8 @@ class TestPluginModelProviderCacheInvalidation:
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.uninstall(TENANT_ID, "installation-1")
|
||||
result = PluginService.uninstall("tenant-1", "installation-1")
|
||||
|
||||
assert result is True
|
||||
installer.uninstall.assert_called_once_with(TENANT_ID, "installation-1")
|
||||
invalidate_cache.assert_called_once_with(TENANT_ID)
|
||||
credentials_cache.assert_called_once_with(
|
||||
tenant_id=TENANT_ID,
|
||||
identity_id=provider_id,
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
credentials_cache.return_value.delete.assert_called_once_with()
|
||||
|
||||
sqlite_session.expunge_all()
|
||||
assert sqlite_session.get(ProviderCredential, credential_id) is None
|
||||
persisted_provider = sqlite_session.get(Provider, provider_id)
|
||||
assert persisted_provider is not None
|
||||
assert persisted_provider.credential_id is None
|
||||
assert sqlite_session.get(TenantPreferredModelProvider, preferred_provider_id) is None
|
||||
assert sqlite_session.get(ProviderCredential, other_credential_id) is not None
|
||||
persisted_other_provider = sqlite_session.get(Provider, other_provider_id)
|
||||
assert persisted_other_provider is not None
|
||||
assert persisted_other_provider.credential_id == other_credential_id
|
||||
assert sqlite_session.get(TenantPreferredModelProvider, other_preferred_provider_id) is not None
|
||||
installer.uninstall.assert_called_once_with("tenant-1", "installation-1")
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
@@ -8,7 +8,6 @@ verification, marketplace upgrade flows, and uninstall with credential cleanup.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -20,6 +19,7 @@ from sqlalchemy.orm import Session
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.entities.plugin_daemon import PluginVerification
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from models import ProviderType
|
||||
from models.engine import db
|
||||
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
|
||||
@@ -27,16 +27,20 @@ from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import (
|
||||
PluginInstallationPermissionModel,
|
||||
PluginInstallationScope,
|
||||
SystemFeatureModel,
|
||||
)
|
||||
|
||||
|
||||
def _make_permission(
|
||||
def _make_features(
|
||||
restrict_to_marketplace: bool = False,
|
||||
scope: PluginInstallationScope = PluginInstallationScope.ALL,
|
||||
) -> PluginInstallationPermissionModel:
|
||||
return PluginInstallationPermissionModel(
|
||||
restrict_to_marketplace_only=restrict_to_marketplace,
|
||||
plugin_installation_scope=scope,
|
||||
) -> SystemFeatureModel:
|
||||
return SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
plugin_installation_permission=PluginInstallationPermissionModel(
|
||||
restrict_to_marketplace_only=restrict_to_marketplace,
|
||||
plugin_installation_scope=scope,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -115,31 +119,22 @@ class TestFetchLatestPluginVersion:
|
||||
class TestCheckMarketplaceOnlyPermission:
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_raises_when_restricted(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=True)
|
||||
mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=True)
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError):
|
||||
PluginService._check_marketplace_only_permission()
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_passes_when_not_restricted(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=False)
|
||||
mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=False)
|
||||
|
||||
PluginService._check_marketplace_only_permission() # should not raise
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_raises_when_scope_denies_all(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE)
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError, match="not allowed"):
|
||||
PluginService._check_marketplace_only_permission()
|
||||
|
||||
|
||||
class TestCheckPluginInstallationScope:
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_only_allows_langgenius(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
|
||||
scope=PluginInstallationScope.OFFICIAL_ONLY
|
||||
)
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
|
||||
verification = MagicMock()
|
||||
verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
|
||||
|
||||
@@ -147,16 +142,14 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_only_rejects_third_party(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
|
||||
scope=PluginInstallationScope.OFFICIAL_ONLY
|
||||
)
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError):
|
||||
PluginService._check_plugin_installation_scope(None)
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_and_partners_allows_partner(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
|
||||
mock_fs.get_system_features.return_value = _make_features(
|
||||
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
|
||||
)
|
||||
verification = MagicMock()
|
||||
@@ -166,7 +159,7 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_and_partners_rejects_none(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(
|
||||
mock_fs.get_system_features.return_value = _make_features(
|
||||
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
|
||||
)
|
||||
|
||||
@@ -175,7 +168,7 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_none_scope_always_raises(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE)
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.NONE)
|
||||
verification = MagicMock()
|
||||
verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius
|
||||
|
||||
@@ -184,19 +177,10 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_all_scope_passes_any(self, mock_fs):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.ALL)
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.ALL)
|
||||
|
||||
PluginService._check_plugin_installation_scope(None) # should not raise
|
||||
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_unknown_scope_always_raises(self, mock_fs):
|
||||
permission = _make_permission()
|
||||
permission.plugin_installation_scope = cast(PluginInstallationScope, "unknown-scope")
|
||||
mock_fs.get_plugin_installation_permission.return_value = permission
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError, match="policy is invalid"):
|
||||
PluginService._check_plugin_installation_scope(None)
|
||||
|
||||
|
||||
class TestGetPluginIconUrl:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
@@ -264,7 +248,7 @@ class TestUpgradePluginWithMarketplace:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_skips_download_when_already_installed(self, mock_config, mock_installer_cls, mock_fs, mock_marketplace):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.return_value = MagicMock()
|
||||
installer.upgrade_plugin.return_value = MagicMock()
|
||||
@@ -280,7 +264,7 @@ class TestUpgradePluginWithMarketplace:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_downloads_when_not_installed(self, mock_config, mock_installer_cls, mock_fs, mock_download):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.side_effect = RuntimeError("not found")
|
||||
mock_download.return_value = b"pkg-bytes"
|
||||
@@ -299,7 +283,7 @@ class TestUpgradePluginWithGithub:
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_checks_marketplace_permission_and_delegates(self, mock_installer_cls: MagicMock, mock_fs: MagicMock):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.upgrade_plugin.return_value = MagicMock()
|
||||
|
||||
@@ -314,7 +298,7 @@ class TestUploadPkg:
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_runs_permission_and_scope_checks(self, mock_installer_cls: MagicMock, mock_fs: MagicMock):
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
upload_resp = MagicMock()
|
||||
upload_resp.verification = None
|
||||
mock_installer_cls.return_value.upload_pkg.return_value = upload_resp
|
||||
@@ -338,7 +322,7 @@ class TestInstallFromMarketplacePkg:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_downloads_when_not_cached(self, mock_config, mock_installer_cls, mock_fs, mock_download):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.side_effect = RuntimeError("not found")
|
||||
mock_download.return_value = b"pkg"
|
||||
@@ -360,7 +344,7 @@ class TestInstallFromMarketplacePkg:
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_uses_cached_when_already_downloaded(self, mock_config, mock_installer_cls: MagicMock, mock_fs: MagicMock):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_plugin_installation_permission.return_value = _make_permission()
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.return_value = MagicMock()
|
||||
decode_resp = MagicMock()
|
||||
|
||||
+270
-213
@@ -10,19 +10,14 @@ import io
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import Mock, create_autospec, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import Column, Engine, Integer, MetaData, String, Table, delete, event, func, select
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy import Column, Integer, MetaData, String, Table
|
||||
|
||||
from libs.archive_storage import ArchiveStorageNotConfiguredError
|
||||
from models.enums import CreatorUserRole
|
||||
from models.trigger import WorkflowTriggerLog
|
||||
from models.workflow import (
|
||||
WorkflowAppLog,
|
||||
@@ -33,7 +28,6 @@ from models.workflow import (
|
||||
WorkflowPauseReason,
|
||||
WorkflowRun,
|
||||
)
|
||||
from services.retention.workflow_run import restore_archived_workflow_run as restore_module
|
||||
from services.retention.workflow_run.restore_archived_workflow_run import (
|
||||
SCHEMA_MAPPERS,
|
||||
TABLE_MODELS,
|
||||
@@ -42,49 +36,24 @@ from services.retention.workflow_run.restore_archived_workflow_run import (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Database:
|
||||
"""Explicit SQLite engine, caller session, and real service-owned session factory."""
|
||||
|
||||
engine: Engine
|
||||
session: Session
|
||||
session_maker: sessionmaker[Session]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Database]:
|
||||
WorkflowRun.metadata.create_all(
|
||||
sqlite_engine,
|
||||
tables=[WorkflowRun.__table__, WorkflowAppLog.__table__, WorkflowArchiveLog.__table__],
|
||||
)
|
||||
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
with session_maker() as session:
|
||||
database = Database(engine=sqlite_engine, session=session, session_maker=session_maker)
|
||||
monkeypatch.setattr(restore_module, "db", database)
|
||||
# Production constructs PostgreSQL's equivalent statement; SQLite's
|
||||
# dialect keeps the conflict behavior executable in these tests.
|
||||
monkeypatch.setattr(restore_module, "pg_insert", sqlite_insert)
|
||||
yield database
|
||||
|
||||
|
||||
class WorkflowRunRestoreTestDataFactory:
|
||||
"""
|
||||
Factory for creating persisted-model-compatible test data.
|
||||
Factory for creating test data and mock objects.
|
||||
|
||||
Provides reusable methods to create consistent mock objects for testing
|
||||
workflow run restore operations.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_run(
|
||||
def create_workflow_run_mock(
|
||||
run_id: str = "run-123",
|
||||
tenant_id: str = "tenant-123",
|
||||
app_id: str = "app-123",
|
||||
created_at: datetime | None = None,
|
||||
**kwargs,
|
||||
) -> WorkflowRun:
|
||||
) -> Mock:
|
||||
"""
|
||||
Create a concrete WorkflowRun object.
|
||||
Create a mock WorkflowRun object.
|
||||
|
||||
Args:
|
||||
run_id: Unique identifier for the workflow run
|
||||
@@ -94,44 +63,27 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
WorkflowRun object with specified attributes
|
||||
Mock WorkflowRun object with specified attributes
|
||||
"""
|
||||
attrs = {
|
||||
"id": run_id,
|
||||
"tenant_id": tenant_id,
|
||||
"app_id": app_id,
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"graph": None,
|
||||
"inputs": None,
|
||||
"status": "succeeded",
|
||||
"outputs": "{}",
|
||||
"error": None,
|
||||
"elapsed_time": 0,
|
||||
"total_tokens": 0,
|
||||
"total_steps": 0,
|
||||
"created_by_role": CreatorUserRole.ACCOUNT,
|
||||
"created_by": "user-123",
|
||||
"created_at": created_at or datetime(2024, 1, 1, 12, 0, 0),
|
||||
"finished_at": None,
|
||||
"exceptions_count": 0,
|
||||
}
|
||||
attrs.update(kwargs)
|
||||
run = WorkflowRun(**attrs)
|
||||
run = create_autospec(WorkflowRun, instance=True)
|
||||
run.id = run_id
|
||||
run.tenant_id = tenant_id
|
||||
run.app_id = app_id
|
||||
run.created_at = created_at or datetime(2024, 1, 1, 12, 0, 0)
|
||||
for key, value in kwargs.items():
|
||||
setattr(run, key, value)
|
||||
return run
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_archive_log(
|
||||
def create_workflow_archive_log_mock(
|
||||
run_id: str = "run-123",
|
||||
tenant_id: str = "tenant-123",
|
||||
app_id: str = "app-123",
|
||||
created_at: datetime | None = None,
|
||||
**kwargs,
|
||||
) -> WorkflowArchiveLog:
|
||||
) -> Mock:
|
||||
"""
|
||||
Create a concrete WorkflowArchiveLog object.
|
||||
Create a mock WorkflowArchiveLog object.
|
||||
|
||||
Args:
|
||||
run_id: Unique identifier for the workflow run
|
||||
@@ -141,32 +93,16 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
WorkflowArchiveLog object with specified attributes
|
||||
Mock WorkflowArchiveLog object with specified attributes
|
||||
"""
|
||||
attrs = {
|
||||
"tenant_id": tenant_id,
|
||||
"app_id": app_id,
|
||||
"workflow_id": "workflow-123",
|
||||
"workflow_run_id": run_id,
|
||||
"created_by_role": CreatorUserRole.ACCOUNT,
|
||||
"created_by": "user-123",
|
||||
"log_id": None,
|
||||
"log_created_at": None,
|
||||
"log_created_from": None,
|
||||
"run_version": "1",
|
||||
"run_status": "succeeded",
|
||||
"run_triggered_from": "app-run",
|
||||
"run_error": None,
|
||||
"run_elapsed_time": 0,
|
||||
"run_total_tokens": 0,
|
||||
"run_total_steps": 0,
|
||||
"run_created_at": created_at or datetime(2024, 1, 1, 12, 0, 0),
|
||||
"run_finished_at": None,
|
||||
"run_exceptions_count": 0,
|
||||
"trigger_metadata": None,
|
||||
}
|
||||
attrs.update(kwargs)
|
||||
return WorkflowArchiveLog(**attrs)
|
||||
archive_log = create_autospec(WorkflowArchiveLog, instance=True)
|
||||
archive_log.workflow_run_id = run_id
|
||||
archive_log.tenant_id = tenant_id
|
||||
archive_log.app_id = app_id
|
||||
archive_log.run_created_at = created_at or datetime(2024, 1, 1, 12, 0, 0)
|
||||
for key, value in kwargs.items():
|
||||
setattr(archive_log, key, value)
|
||||
return archive_log
|
||||
|
||||
@staticmethod
|
||||
def create_archive_zip_mock(
|
||||
@@ -201,7 +137,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app-run",
|
||||
"triggered_from": "app",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -215,7 +151,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"workflow_run_id": "run-123",
|
||||
"created_from": "service-api",
|
||||
"created_from": "app",
|
||||
"created_by_role": "account",
|
||||
"created_by": "user-123",
|
||||
},
|
||||
@@ -225,7 +161,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"workflow_run_id": "run-123",
|
||||
"created_from": "service-api",
|
||||
"created_from": "app",
|
||||
"created_by_role": "account",
|
||||
"created_by": "user-123",
|
||||
},
|
||||
@@ -289,10 +225,14 @@ class TestGetWorkflowRunRepo:
|
||||
"""Tests for WorkflowRunRestore._get_workflow_run_repo method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.DifyAPIRepositoryFactory")
|
||||
def test_first_call_creates_repo(self, mock_factory, database: Database):
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.db")
|
||||
def test_first_call_creates_repo(self, mock_db, mock_sessionmaker, mock_factory):
|
||||
"""First call should create and cache repository."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
mock_session = Mock()
|
||||
mock_sessionmaker.return_value = mock_session
|
||||
mock_repo = Mock()
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
|
||||
|
||||
@@ -300,9 +240,8 @@ class TestGetWorkflowRunRepo:
|
||||
|
||||
assert result is mock_repo
|
||||
assert restore.workflow_run_repo is mock_repo
|
||||
session_maker = mock_factory.create_api_workflow_run_repository.call_args.args[0]
|
||||
assert isinstance(session_maker, sessionmaker)
|
||||
assert session_maker.kw["bind"] is database.engine
|
||||
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
|
||||
mock_factory.create_api_workflow_run_repository.assert_called_once_with(mock_session)
|
||||
|
||||
def test_cached_repo_returned(self):
|
||||
"""Subsequent calls should return cached repository."""
|
||||
@@ -553,27 +492,47 @@ class TestGetModelColumnInfo:
|
||||
class TestRestoreTableRecords:
|
||||
"""Tests for WorkflowRunRestore._restore_table_records method."""
|
||||
|
||||
def test_unknown_table_returns_zero(self, database: Database, caplog: pytest.LogCaptureFixture):
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.TABLE_MODELS")
|
||||
def test_unknown_table_returns_zero(self, mock_table_models, caplog: pytest.LogCaptureFixture):
|
||||
"""Should return 0 for unknown table."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_table_models.get.return_value = None
|
||||
|
||||
mock_session = Mock()
|
||||
records = [{"id": "test"}]
|
||||
caplog.set_level(logging.WARNING, logger="services.retention.workflow_run.restore_archived_workflow_run")
|
||||
|
||||
result = restore._restore_table_records(database.session, "unknown_table", records, schema_version="1.0")
|
||||
result = restore._restore_table_records(mock_session, "unknown_table", records, schema_version="1.0")
|
||||
|
||||
assert result == 0
|
||||
assert "Unknown table: unknown_table" in caplog.messages
|
||||
|
||||
def test_empty_records_returns_zero(self, database: Database):
|
||||
def test_empty_records_returns_zero(self):
|
||||
"""Should return 0 for empty records list."""
|
||||
restore = WorkflowRunRestore()
|
||||
result = restore._restore_table_records(database.session, "workflow_runs", [], schema_version="1.0")
|
||||
mock_session = Mock()
|
||||
|
||||
result = restore._restore_table_records(mock_session, "workflow_runs", [], schema_version="1.0")
|
||||
assert result == 0
|
||||
|
||||
def test_successful_restore(self, database: Database):
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.cast")
|
||||
def test_successful_restore(self, mock_cast, mock_pg_insert):
|
||||
"""Should successfully restore records."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
# Mock session and execution
|
||||
mock_session = Mock()
|
||||
mock_result = Mock()
|
||||
mock_result.rowcount = 2
|
||||
mock_session.execute.return_value = mock_result
|
||||
mock_cast.return_value = mock_result
|
||||
|
||||
# Mock insert statement
|
||||
mock_stmt = Mock()
|
||||
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
|
||||
mock_pg_insert.return_value = mock_stmt
|
||||
|
||||
records = [
|
||||
{
|
||||
"id": "test1",
|
||||
@@ -581,7 +540,7 @@ class TestRestoreTableRecords:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app-run",
|
||||
"triggered_from": "app",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -593,7 +552,7 @@ class TestRestoreTableRecords:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app-run",
|
||||
"triggered_from": "app",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -601,20 +560,38 @@ class TestRestoreTableRecords:
|
||||
},
|
||||
]
|
||||
|
||||
result = restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0")
|
||||
result = restore._restore_table_records(mock_session, "workflow_runs", records, schema_version="1.0")
|
||||
|
||||
assert result == 2
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 2
|
||||
assert restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0") == 0
|
||||
mock_session.execute.assert_called_once()
|
||||
|
||||
def test_missing_required_columns_raises_error(self, database: Database):
|
||||
def test_missing_required_columns_raises_error(self):
|
||||
"""Should raise ValueError for missing required columns."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
records = [{"id": "test"}]
|
||||
mock_session = Mock()
|
||||
# Use a dedicated mock model to isolate required-column validation behavior.
|
||||
mock_model = Mock()
|
||||
|
||||
with pytest.raises(ValueError, match="Missing required columns for workflow_runs"):
|
||||
restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0")
|
||||
# Mock a required column
|
||||
required_column = Mock()
|
||||
required_column.key = "required_field"
|
||||
required_column.nullable = False
|
||||
required_column.default = None
|
||||
required_column.server_default = None
|
||||
required_column.autoincrement = False
|
||||
required_column.type = Mock()
|
||||
|
||||
# Mock the __table__ attribute properly
|
||||
mock_table = Mock()
|
||||
mock_table.columns = [required_column]
|
||||
mock_model.__table__ = mock_table
|
||||
|
||||
records = [{"name": "test"}] # Missing required 'required_field'
|
||||
|
||||
with patch.dict(TABLE_MODELS, {"test_table": mock_model}):
|
||||
with pytest.raises(ValueError, match="Missing required columns for test_table"):
|
||||
restore._restore_table_records(mock_session, "test_table", records, schema_version="1.0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -626,38 +603,38 @@ class TestRestoreFromRun:
|
||||
"""Tests for WorkflowRunRestore._restore_from_run method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_archive_storage_not_configured(self, mock_get_storage, database: Database):
|
||||
def test_archive_storage_not_configured(self, mock_get_storage):
|
||||
"""Should handle ArchiveStorageNotConfiguredError."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_get_storage.side_effect = ArchiveStorageNotConfiguredError("Storage not configured")
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
result = restore._restore_from_run(run, session_maker=lambda: Mock())
|
||||
|
||||
assert result.success is False
|
||||
assert "Storage not configured" in result.error
|
||||
assert result.elapsed_time > 0
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_archive_bundle_not_found(self, mock_get_storage, database: Database):
|
||||
def test_archive_bundle_not_found(self, mock_get_storage):
|
||||
"""Should handle FileNotFoundError when archive bundle is missing."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_storage = Mock()
|
||||
mock_storage.get_object.side_effect = FileNotFoundError("Bundle not found")
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
result = restore._restore_from_run(run, session_maker=lambda: Mock())
|
||||
|
||||
assert result.success is False
|
||||
assert "Archive bundle not found" in result.error
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_dry_run_mode(self, mock_get_storage, database: Database):
|
||||
def test_dry_run_mode(self, mock_get_storage):
|
||||
"""Should handle dry run mode correctly."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
@@ -667,16 +644,23 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
# Create a proper mock session with context manager support
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
result = restore._restore_from_run(run, session_maker=lambda: mock_session)
|
||||
|
||||
assert result.success is True
|
||||
assert result.restored_counts["workflow_runs"] == 1
|
||||
assert result.restored_counts["workflow_app_logs"] == 2
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_successful_restore(self, mock_get_storage, database: Database):
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.cast")
|
||||
def test_successful_restore(self, mock_cast, mock_pg_insert, mock_get_storage):
|
||||
"""Should successfully restore from archive."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
@@ -686,57 +670,53 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
# Mock session with context manager support
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
def session_maker():
|
||||
return mock_session
|
||||
|
||||
# Mock database execution to return integer counts
|
||||
mock_result_workflow_runs = Mock()
|
||||
mock_result_workflow_runs.rowcount = 1
|
||||
mock_result_app_logs = Mock()
|
||||
mock_result_app_logs.rowcount = 2
|
||||
|
||||
# Configure session.execute to return different results based on the table
|
||||
def mock_execute(stmt):
|
||||
if "workflow_runs" in str(stmt):
|
||||
return mock_result_workflow_runs
|
||||
else:
|
||||
return mock_result_app_logs
|
||||
|
||||
mock_session.execute.side_effect = mock_execute
|
||||
mock_cast.return_value = mock_result_workflow_runs
|
||||
|
||||
# Mock insert statement
|
||||
mock_stmt = Mock()
|
||||
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
|
||||
mock_pg_insert.return_value = mock_stmt
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
|
||||
# Mock repository methods
|
||||
with patch.object(restore, "_get_workflow_run_repo") as mock_get_repo:
|
||||
mock_repo = Mock()
|
||||
mock_repo.delete_archive_log_by_run_id.side_effect = lambda session, run_id: session.execute(
|
||||
delete(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id)
|
||||
)
|
||||
mock_get_repo.return_value = mock_repo
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
result = restore._restore_from_run(run, session_maker=session_maker)
|
||||
|
||||
assert result.success is True
|
||||
assert result.restored_counts["workflow_runs"] == 1
|
||||
assert result.restored_counts["workflow_app_logs"] == 2
|
||||
database.session.expire_all()
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1
|
||||
assert database.session.scalar(select(func.count(WorkflowAppLog.id))) == 2
|
||||
assert database.session.scalar(select(func.count(WorkflowArchiveLog.id))) == 0
|
||||
assert result.restored_counts["workflow_app_logs"] >= 1 # Just check it's restored
|
||||
mock_session.commit.assert_called_once()
|
||||
mock_repo.delete_archive_log_by_run_id.assert_called_once_with(mock_session, run.id)
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_insert_failure_rolls_back_all_tables(self, mock_get_storage, database: Database):
|
||||
"""A later table failure must roll back earlier restored rows."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_storage = Mock()
|
||||
mock_storage.get_object.return_value = WorkflowRunRestoreTestDataFactory.create_archive_zip_mock()
|
||||
mock_get_storage.return_value = mock_storage
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
def fail_app_log_insert(_connection, _cursor, statement, _parameters, _context, _executemany):
|
||||
if statement.startswith("INSERT INTO workflow_app_logs"):
|
||||
raise RuntimeError("forced app-log insert failure")
|
||||
|
||||
event.listen(database.engine, "before_cursor_execute", fail_app_log_insert)
|
||||
try:
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click"):
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
finally:
|
||||
event.remove(database.engine, "before_cursor_execute", fail_app_log_insert)
|
||||
|
||||
assert result.success is False
|
||||
assert result.error == "forced app-log insert failure"
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 0
|
||||
assert database.session.scalar(select(func.count(WorkflowAppLog.id))) == 0
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_invalid_archive_bundle(self, mock_get_storage, database: Database):
|
||||
def test_invalid_archive_bundle(self, mock_get_storage):
|
||||
"""Should handle invalid archive bundle."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
@@ -745,17 +725,22 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = b"invalid zip data"
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
|
||||
# Create proper mock session
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
result = restore._restore_from_run(run, session_maker=lambda: mock_session)
|
||||
|
||||
assert result.success is False
|
||||
# The error message comes from zipfile.BadZipFile which says "File is not a zip file"
|
||||
assert "File is not a zip file" in result.error
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_workflow_archive_log_input(self, mock_get_storage, database: Database):
|
||||
def test_workflow_archive_log_input(self, mock_get_storage):
|
||||
"""Should handle WorkflowArchiveLog input correctly."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
@@ -765,11 +750,14 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
|
||||
result = restore._restore_from_run(archive_log, session_maker=database.session_maker)
|
||||
# Create proper mock session
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
result = restore._restore_from_run(archive_log, session_maker=lambda: mock_session)
|
||||
|
||||
assert result.success is True
|
||||
assert result.run_id == archive_log.workflow_run_id
|
||||
@@ -784,29 +772,39 @@ class TestRestoreFromRun:
|
||||
class TestRestoreBatch:
|
||||
"""Tests for WorkflowRunRestore.restore_batch method."""
|
||||
|
||||
def test_empty_tenant_ids_returns_empty(self, database: Database):
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_empty_tenant_ids_returns_empty(self, mock_sessionmaker):
|
||||
"""Should return empty list when tenant_ids is empty list."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
result = restore.restore_batch(
|
||||
tenant_ids=[],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
# Mock db.engine to avoid SQLAlchemy issues
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
result = restore.restore_batch(
|
||||
tenant_ids=[],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
|
||||
assert result == []
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
|
||||
def test_successful_batch_restore(self, mock_executor, database: Database):
|
||||
def test_successful_batch_restore(self, mock_executor):
|
||||
"""Should successfully restore batch of workflow runs."""
|
||||
restore = WorkflowRunRestore(workers=2)
|
||||
|
||||
# Mock session that supports context manager protocol
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
# Mock session factory that returns context manager sessions
|
||||
mock_session_factory = Mock(return_value=mock_session)
|
||||
|
||||
# Mock repository and archive logs
|
||||
mock_repo = Mock()
|
||||
archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-1")
|
||||
archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-2")
|
||||
database.session.add_all([archive_log1, archive_log2])
|
||||
database.session.commit()
|
||||
archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-1")
|
||||
archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-2")
|
||||
mock_repo.get_archived_logs_by_time_range.return_value = [archive_log1, archive_log2]
|
||||
|
||||
# Mock restore results
|
||||
@@ -823,25 +821,38 @@ class TestRestoreBatch:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", side_effect=[result1, result2]):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
|
||||
with patch(
|
||||
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
|
||||
) as mock_sessionmaker:
|
||||
mock_sessionmaker.return_value = mock_session_factory
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0].run_id == "run-1"
|
||||
assert results[1].run_id == "run-2"
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
|
||||
def test_dry_run_batch_restore(self, mock_executor, database: Database):
|
||||
def test_dry_run_batch_restore(self, mock_executor):
|
||||
"""Should handle dry run mode for batch restore."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
# Mock session that supports context manager protocol
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
# Mock session factory that returns context manager sessions
|
||||
mock_session_factory = Mock(return_value=mock_session)
|
||||
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
mock_repo.get_archived_logs_by_time_range.return_value = [archive_log]
|
||||
|
||||
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={"workflow_runs": 1})
|
||||
@@ -856,11 +867,18 @@ class TestRestoreBatch:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", return_value=result):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
|
||||
with patch(
|
||||
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
|
||||
) as mock_sessionmaker:
|
||||
mock_sessionmaker.return_value = mock_session_factory
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].success is True
|
||||
@@ -889,14 +907,16 @@ class TestRestoreByRunId:
|
||||
assert "not found" in result.error
|
||||
assert result.run_id == "nonexistent-run"
|
||||
|
||||
def test_successful_restore_by_id(self, database: Database):
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_successful_restore_by_id(self, mock_sessionmaker):
|
||||
"""Should successfully restore by run ID."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
mock_session = Mock()
|
||||
mock_sessionmaker.return_value = mock_session
|
||||
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
mock_repo.get_archived_log_by_run_id.return_value = archive_log
|
||||
|
||||
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={})
|
||||
@@ -904,19 +924,24 @@ class TestRestoreByRunId:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", return_value=result):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
# Mock db.engine to avoid SQLAlchemy issues
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
|
||||
assert actual_result.success is True
|
||||
assert actual_result.run_id == "run-1"
|
||||
|
||||
def test_dry_run_restore_by_id(self, database: Database):
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_dry_run_restore_by_id(self, mock_sessionmaker):
|
||||
"""Should handle dry run mode for restore by ID."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
mock_session = Mock()
|
||||
mock_sessionmaker.return_value = mock_session
|
||||
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
mock_repo.get_archived_log_by_run_id.return_value = archive_log
|
||||
|
||||
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={"workflow_runs": 1})
|
||||
@@ -924,7 +949,10 @@ class TestRestoreByRunId:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", return_value=result):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
# Mock db.engine to avoid SQLAlchemy issues
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
|
||||
assert actual_result.success is True
|
||||
assert actual_result.run_id == "run-1"
|
||||
@@ -1010,7 +1038,8 @@ class TestIntegration:
|
||||
"""Integration tests combining multiple components."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_full_restore_flow(self, mock_get_storage, database: Database):
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
|
||||
def test_full_restore_flow(self, mock_executor, mock_get_storage):
|
||||
"""Test complete restore flow with all components."""
|
||||
restore = WorkflowRunRestore(workers=1)
|
||||
|
||||
@@ -1030,7 +1059,7 @@ class TestIntegration:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app-run",
|
||||
"triggered_from": "app",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -1043,20 +1072,48 @@ class TestIntegration:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
# Mock session that supports context manager protocol
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
# Mock session factory that returns context manager sessions
|
||||
mock_session_factory = Mock(return_value=mock_session)
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.rowcount = 1
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
# Mock repository
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
mock_repo.get_archived_log_by_run_id.return_value = archive_log
|
||||
mock_repo.delete_archive_log_by_run_id.side_effect = lambda session, run_id: session.execute(
|
||||
delete(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id)
|
||||
)
|
||||
|
||||
# Mock ThreadPoolExecutor (not actually used in restore_by_run_id but needed for patch)
|
||||
mock_executor_instance = Mock()
|
||||
mock_executor_instance.__enter__ = Mock(return_value=mock_executor_instance)
|
||||
mock_executor_instance.__exit__ = Mock(return_value=None)
|
||||
mock_executor_instance.map = Mock(return_value=[])
|
||||
mock_executor.return_value = mock_executor_instance
|
||||
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click"):
|
||||
result = restore.restore_by_run_id("run-123")
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert") as mock_insert:
|
||||
mock_stmt = Mock()
|
||||
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
|
||||
mock_insert.return_value = mock_stmt
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.cast") as mock_cast:
|
||||
mock_cast.return_value = mock_result
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
|
||||
with patch(
|
||||
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
|
||||
) as mock_sessionmaker:
|
||||
mock_sessionmaker.return_value = mock_session_factory
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
result = restore.restore_by_run_id("run-123")
|
||||
|
||||
assert result.success is True
|
||||
assert result.restored_counts.get("workflow_runs") == 1
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1
|
||||
|
||||
@@ -63,7 +63,6 @@ def _target(
|
||||
) -> AgentConfigTarget:
|
||||
agent_soul = soul or _soul()
|
||||
return AgentConfigTarget(
|
||||
tenant_id=TENANT,
|
||||
agent_id=AGENT,
|
||||
version_id=version_id,
|
||||
kind=kind,
|
||||
@@ -509,9 +508,7 @@ def test_manifest_uses_items_shape_without_download_urls() -> None:
|
||||
),
|
||||
)
|
||||
|
||||
with patch(f"{MODULE}.SkillManagementService") as skill_management_service:
|
||||
skill_management_service.return_value.list_runtime_agent_skills.return_value = []
|
||||
manifest = AgentConfigService._manifest_for_target(target)
|
||||
manifest = AgentConfigService._manifest_for_target(target)
|
||||
|
||||
assert manifest == {
|
||||
"agent_id": AGENT,
|
||||
@@ -560,9 +557,7 @@ def test_manifest_preserves_missing_config_assets_and_pull_rejects_them() -> Non
|
||||
target = _target(kind=AgentConfigVersionKind.DRAFT, writable=False, soul=soul)
|
||||
service = AgentConfigService()
|
||||
|
||||
with patch(f"{MODULE}.SkillManagementService") as skill_management_service:
|
||||
skill_management_service.return_value.list_runtime_agent_skills.return_value = []
|
||||
manifest = service._manifest_for_target(target)
|
||||
manifest = service._manifest_for_target(target)
|
||||
|
||||
assert manifest["skills"]["items"][0]["is_missing"] is True # type: ignore[index]
|
||||
assert manifest["files"]["items"][0]["is_missing"] is True # type: ignore[index]
|
||||
@@ -611,42 +606,6 @@ def test_config_asset_refs_require_file_id_unless_marked_missing() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_appends_published_workspace_skills() -> None:
|
||||
target = _target(
|
||||
kind=AgentConfigVersionKind.DRAFT,
|
||||
writable=False,
|
||||
soul=_soul(
|
||||
config_skills=[AgentConfigSkillRefConfig(name="alpha", description="Alpha skill", file_id="tool-file-1")]
|
||||
),
|
||||
)
|
||||
|
||||
with patch(f"{MODULE}.SkillManagementService") as skill_management_service:
|
||||
skill_management_service.return_value.list_runtime_agent_skills.return_value = [
|
||||
{
|
||||
"id": "workspace-skill-id",
|
||||
"name": "beta",
|
||||
"file_id": "tool-file-2",
|
||||
"description": "Beta workspace skill",
|
||||
"size": 123,
|
||||
"hash": "sha256:beta",
|
||||
"mime_type": "application/zip",
|
||||
},
|
||||
{
|
||||
"id": "duplicate",
|
||||
"name": "alpha",
|
||||
"file_id": "tool-file-ignored",
|
||||
"description": "Duplicate workspace skill",
|
||||
"size": 456,
|
||||
"hash": "sha256:ignored",
|
||||
"mime_type": "application/zip",
|
||||
},
|
||||
]
|
||||
manifest = AgentConfigService._manifest_for_target(target)
|
||||
|
||||
assert [item["name"] for item in manifest["skills"]["items"]] == ["alpha", "beta"]
|
||||
assert manifest["skills"]["items"][1]["file_id"] == "tool-file-2"
|
||||
|
||||
|
||||
def test_preview_skill_file_returns_text_preview() -> None:
|
||||
service = AgentConfigService()
|
||||
target = _target(
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from services import feature_service as feature_service_module
|
||||
from services.feature_service import FeatureService, PluginInstallationScope, SystemFeatureModel
|
||||
|
||||
|
||||
def test_get_plugin_installation_permission_defaults_to_all_for_non_enterprise(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", False)
|
||||
|
||||
permission = FeatureService.get_plugin_installation_permission()
|
||||
|
||||
assert permission.plugin_installation_scope is PluginInstallationScope.ALL
|
||||
assert permission.restrict_to_marketplace_only is False
|
||||
|
||||
|
||||
def test_get_plugin_installation_permission_parses_enterprise_policy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", True)
|
||||
monkeypatch.setattr(
|
||||
feature_service_module.EnterpriseService,
|
||||
"get_info",
|
||||
staticmethod(
|
||||
lambda: {
|
||||
"PluginInstallationPermission": {
|
||||
"pluginInstallationScope": "official_only",
|
||||
"restrictToMarketplaceOnly": True,
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
permission = FeatureService.get_plugin_installation_permission()
|
||||
|
||||
assert permission.plugin_installation_scope is PluginInstallationScope.OFFICIAL_ONLY
|
||||
assert permission.restrict_to_marketplace_only is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_permission",
|
||||
[
|
||||
{
|
||||
"pluginInstallationScope": "unknown-scope",
|
||||
"restrictToMarketplaceOnly": False,
|
||||
},
|
||||
{
|
||||
"pluginInstallationScope": "all",
|
||||
"restrictToMarketplaceOnly": "false",
|
||||
},
|
||||
],
|
||||
ids=["unknown_scope", "non_boolean_marketplace_restriction"],
|
||||
)
|
||||
def test_invalid_enterprise_policy_denies_all_plugin_installations(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
invalid_permission: dict[str, object],
|
||||
) -> None:
|
||||
with caplog.at_level(logging.ERROR, logger="services.feature_service"):
|
||||
permission = FeatureService._resolve_plugin_installation_permission(
|
||||
{"PluginInstallationPermission": invalid_permission}
|
||||
)
|
||||
|
||||
assert permission.plugin_installation_scope is PluginInstallationScope.NONE
|
||||
assert permission.restrict_to_marketplace_only is True
|
||||
assert "denying all plugin installations" in caplog.text
|
||||
|
||||
|
||||
def test_system_features_exposes_only_validated_plugin_installation_policy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
feature_service_module.EnterpriseService,
|
||||
"get_info",
|
||||
staticmethod(
|
||||
lambda: {
|
||||
"PluginInstallationPermission": {
|
||||
"pluginInstallationScope": "unknown-scope",
|
||||
"restrictToMarketplaceOnly": False,
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
features = SystemFeatureModel(deployment_edition=DeploymentEdition.ENTERPRISE)
|
||||
|
||||
FeatureService._fulfill_params_from_enterprise(features)
|
||||
|
||||
assert features.plugin_installation_permission.plugin_installation_scope is PluginInstallationScope.NONE
|
||||
assert features.plugin_installation_permission.restrict_to_marketplace_only is True
|
||||
@@ -1,8 +1,6 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -11,8 +9,6 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from models.base import TypeBase
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import Account, EndUser, UploadFile
|
||||
from services.errors.file import BlockedFileExtensionError, FileTooLargeError, UnsupportedFileTypeError
|
||||
@@ -21,54 +17,31 @@ from services.file_service import FileService
|
||||
|
||||
class TestFileService:
|
||||
@pytest.fixture
|
||||
def sqlite_session_maker(self, sqlite_engine: Engine) -> sessionmaker[Session]:
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[UploadFile.__tablename__]])
|
||||
return sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
def mock_db_session(self):
|
||||
session = MagicMock(spec=Session)
|
||||
# Mock context manager behavior
|
||||
session.__enter__.return_value = session
|
||||
return session
|
||||
|
||||
@pytest.fixture
|
||||
def db_session(self, sqlite_session_maker: sessionmaker[Session]) -> Iterator[Session]:
|
||||
with sqlite_session_maker() as session:
|
||||
yield session
|
||||
def mock_session_maker(self, mock_db_session):
|
||||
maker = MagicMock(spec=sessionmaker)
|
||||
maker.return_value = mock_db_session
|
||||
return maker
|
||||
|
||||
@pytest.fixture
|
||||
def file_service(self, sqlite_session_maker: sessionmaker[Session]) -> FileService:
|
||||
return FileService(session_factory=sqlite_session_maker)
|
||||
def file_service(self, mock_session_maker):
|
||||
return FileService(session_factory=mock_session_maker)
|
||||
|
||||
@staticmethod
|
||||
def _persist_upload_file(
|
||||
session: Session,
|
||||
*,
|
||||
file_id: str = "file_id",
|
||||
tenant_id: str = "tenant_id",
|
||||
extension: str = "txt",
|
||||
mime_type: str = "text/plain",
|
||||
key: str = "key",
|
||||
) -> UploadFile:
|
||||
upload_file = UploadFile(
|
||||
tenant_id=tenant_id,
|
||||
storage_type=StorageType.LOCAL,
|
||||
key=key,
|
||||
name=f"test.{extension}",
|
||||
size=10,
|
||||
extension=extension,
|
||||
mime_type=mime_type,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="user_id",
|
||||
created_at=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
used=False,
|
||||
)
|
||||
upload_file.id = file_id
|
||||
session.add(upload_file)
|
||||
session.commit()
|
||||
return upload_file
|
||||
|
||||
def test_init_with_engine(self, sqlite_engine: Engine):
|
||||
service = FileService(session_factory=sqlite_engine)
|
||||
def test_init_with_engine(self):
|
||||
engine = MagicMock(spec=Engine)
|
||||
service = FileService(session_factory=engine)
|
||||
assert isinstance(service._session_maker, sessionmaker)
|
||||
|
||||
def test_init_with_sessionmaker(self, sqlite_session_maker: sessionmaker[Session]):
|
||||
service = FileService(session_factory=sqlite_session_maker)
|
||||
assert service._session_maker == sqlite_session_maker
|
||||
def test_init_with_sessionmaker(self):
|
||||
maker = MagicMock(spec=sessionmaker)
|
||||
service = FileService(session_factory=maker)
|
||||
assert service._session_maker == maker
|
||||
|
||||
def test_init_invalid_factory(self):
|
||||
with pytest.raises(AssertionError, match="must be a sessionmaker or an Engine."):
|
||||
@@ -79,11 +52,11 @@ class TestFileService:
|
||||
@patch("services.file_service.extract_tenant_id")
|
||||
@patch("services.file_service.file_helpers.get_signed_file_url")
|
||||
def test_upload_file_success(
|
||||
self, mock_get_url, mock_tenant_id, mock_now, mock_storage, file_service: FileService, db_session: Session
|
||||
self, mock_get_url, mock_tenant_id, mock_now, mock_storage, file_service: FileService, mock_db_session
|
||||
):
|
||||
# Setup
|
||||
mock_tenant_id.return_value = "tenant_id"
|
||||
mock_now.return_value = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
mock_now.return_value = "2024-01-01"
|
||||
mock_get_url.return_value = "http://signed-url"
|
||||
|
||||
user = MagicMock(spec=Account)
|
||||
@@ -108,9 +81,8 @@ class TestFileService:
|
||||
assert result.source_url == "http://signed-url"
|
||||
|
||||
mock_storage.save.assert_called_once()
|
||||
persisted = db_session.get(UploadFile, result.id)
|
||||
assert persisted is not None
|
||||
assert persisted.hash == result.hash
|
||||
mock_db_session.add.assert_called_once_with(result)
|
||||
mock_db_session.commit.assert_called_once()
|
||||
|
||||
def test_upload_file_uses_explicit_resource_tenant(self, file_service: FileService):
|
||||
user = MagicMock(spec=Account)
|
||||
@@ -137,7 +109,7 @@ class TestFileService:
|
||||
with pytest.raises(ValueError, match="Filename contains invalid characters"):
|
||||
file_service.upload_file(filename="invalid/file.txt", content=b"", mimetype="text/plain", user=MagicMock())
|
||||
|
||||
def test_upload_file_long_filename(self, file_service: FileService, db_session: Session):
|
||||
def test_upload_file_long_filename(self, file_service: FileService, mock_db_session):
|
||||
# Setup
|
||||
long_name = "a" * 210 + ".txt"
|
||||
user = MagicMock(spec=Account)
|
||||
@@ -152,7 +124,6 @@ class TestFileService:
|
||||
result = file_service.upload_file(filename=long_name, content=b"test", mimetype="text/plain", user=user)
|
||||
assert len(result.name) <= 205 # 200 + . + extension
|
||||
assert result.name.endswith(".txt")
|
||||
assert db_session.get(UploadFile, result.id) is not None
|
||||
|
||||
def test_upload_file_blocked_extension(self, file_service):
|
||||
with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", "exe"):
|
||||
@@ -174,7 +145,7 @@ class TestFileService:
|
||||
with pytest.raises(FileTooLargeError):
|
||||
file_service.upload_file(filename="test.jpg", content=content, mimetype="image/jpeg", user=MagicMock())
|
||||
|
||||
def test_upload_file_end_user(self, file_service: FileService, db_session: Session):
|
||||
def test_upload_file_end_user(self, file_service: FileService, mock_db_session):
|
||||
user = MagicMock(spec=EndUser)
|
||||
user.id = "end_user_id"
|
||||
|
||||
@@ -186,7 +157,6 @@ class TestFileService:
|
||||
mock_tenant.return_value = "tenant"
|
||||
result = file_service.upload_file(filename="test.txt", content=b"test", mimetype="text/plain", user=user)
|
||||
assert result.created_by_role == CreatorUserRole.END_USER
|
||||
assert db_session.get(UploadFile, result.id) is not None
|
||||
|
||||
def test_is_file_size_within_limit(self):
|
||||
with (
|
||||
@@ -211,8 +181,12 @@ class TestFileService:
|
||||
assert FileService.is_file_size_within_limit(extension="txt", file_size=5 * 1024 * 1024) is True
|
||||
assert FileService.is_file_size_within_limit(extension="pdf", file_size=6 * 1024 * 1024) is False
|
||||
|
||||
def test_get_file_base64_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, key="test_key")
|
||||
def test_get_file_base64_success(self, file_service: FileService, mock_db_session):
|
||||
# Setup
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.key = "test_key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
mock_storage.load_once.return_value = b"test content"
|
||||
@@ -224,17 +198,16 @@ class TestFileService:
|
||||
assert result == base64.b64encode(b"test content").decode()
|
||||
mock_storage.load_once.assert_called_once_with("test_key")
|
||||
|
||||
def test_get_file_base64_not_found(self, file_service: FileService):
|
||||
def test_get_file_base64_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
file_service.get_file_base64("non_existent")
|
||||
|
||||
def test_get_file_presigned_url_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(
|
||||
db_session,
|
||||
extension="png",
|
||||
mime_type="image/png",
|
||||
key="upload_files/tenant_id/icon.png",
|
||||
)
|
||||
def test_get_file_presigned_url_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.key = "upload_files/tenant_id/icon.png"
|
||||
upload_file.mime_type = "image/png"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
|
||||
with (
|
||||
patch.object(dify_config, "FILES_ACCESS_TIMEOUT", 300),
|
||||
@@ -251,11 +224,13 @@ class TestFileService:
|
||||
content_type="image/png",
|
||||
)
|
||||
|
||||
def test_get_file_presigned_url_not_found(self, file_service: FileService):
|
||||
def test_get_file_presigned_url_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
file_service.get_file_presigned_url(file_id="file_id", tenant_id="tenant_id")
|
||||
|
||||
def test_upload_text_success(self, file_service: FileService, db_session: Session):
|
||||
def test_upload_text_success(self, file_service: FileService, mock_db_session):
|
||||
# Setup
|
||||
text = "sample text"
|
||||
text_name = "test.txt"
|
||||
@@ -274,17 +249,21 @@ class TestFileService:
|
||||
assert result.used is True
|
||||
assert result.extension == "txt"
|
||||
mock_storage.save.assert_called_once()
|
||||
assert db_session.get(UploadFile, result.id) is not None
|
||||
mock_db_session.add.assert_called_once()
|
||||
mock_db_session.commit.assert_called_once()
|
||||
|
||||
def test_upload_text_long_name(self, file_service: FileService, db_session: Session):
|
||||
def test_upload_text_long_name(self, file_service: FileService, mock_db_session):
|
||||
long_name = "a" * 210
|
||||
with patch("services.file_service.storage"):
|
||||
result = file_service.upload_text("text", long_name, "user", "tenant")
|
||||
assert len(result.name) == 200
|
||||
assert db_session.get(UploadFile, result.id) is not None
|
||||
|
||||
def test_get_file_preview_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="pdf", mime_type="application/pdf")
|
||||
def test_get_file_preview_success(self, file_service: FileService, mock_db_session):
|
||||
# Setup
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "pdf"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
|
||||
with patch("services.file_service.ExtractProcessor.load_from_upload_file") as mock_extract:
|
||||
mock_extract.return_value = "Extracted text content"
|
||||
@@ -295,17 +274,27 @@ class TestFileService:
|
||||
# Assert
|
||||
assert result == "Extracted text content"
|
||||
|
||||
def test_get_file_preview_not_found(self, file_service: FileService):
|
||||
def test_get_file_preview_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
file_service.get_file_preview("non_existent", "tenant_id")
|
||||
|
||||
def test_get_file_preview_unsupported_type(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="exe", mime_type="application/octet-stream")
|
||||
def test_get_file_preview_unsupported_type(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "exe"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
with pytest.raises(UnsupportedFileTypeError):
|
||||
file_service.get_file_preview("file_id", "tenant_id")
|
||||
|
||||
def test_get_image_preview_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="jpg", mime_type="image/jpeg")
|
||||
def test_get_image_preview_success(self, file_service: FileService, mock_db_session):
|
||||
# Setup
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "jpg"
|
||||
upload_file.mime_type = "image/jpeg"
|
||||
upload_file.key = "key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
|
||||
with (
|
||||
patch("services.file_service.file_helpers.verify_image_signature") as mock_verify,
|
||||
@@ -327,21 +316,28 @@ class TestFileService:
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_image_preview("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_image_preview_not_found(self, file_service: FileService):
|
||||
def test_get_image_preview_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
with patch("services.file_service.file_helpers.verify_image_signature") as mock_verify:
|
||||
mock_verify.return_value = True
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_image_preview("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_image_preview_unsupported_type(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
def test_get_image_preview_unsupported_type(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "txt"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
with patch("services.file_service.file_helpers.verify_image_signature") as mock_verify:
|
||||
mock_verify.return_value = True
|
||||
with pytest.raises(UnsupportedFileTypeError):
|
||||
file_service.get_image_preview("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_file_generator_by_file_id_success(self, file_service: FileService, db_session: Session):
|
||||
upload_file = self._persist_upload_file(db_session)
|
||||
def test_get_file_generator_by_file_id_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.key = "key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
|
||||
with (
|
||||
patch("services.file_service.file_helpers.verify_file_signature") as mock_verify,
|
||||
@@ -352,8 +348,7 @@ class TestFileService:
|
||||
|
||||
gen, file = file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
|
||||
assert list(gen) == [b"chunk"]
|
||||
assert file.id == upload_file.id
|
||||
assert file.key == upload_file.key
|
||||
assert file == upload_file
|
||||
|
||||
def test_get_file_generator_by_file_id_invalid_sig(self, file_service):
|
||||
with patch("services.file_service.file_helpers.verify_file_signature") as mock_verify:
|
||||
@@ -361,14 +356,20 @@ class TestFileService:
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_file_generator_by_file_id_not_found(self, file_service: FileService):
|
||||
def test_get_file_generator_by_file_id_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
with patch("services.file_service.file_helpers.verify_file_signature") as mock_verify:
|
||||
mock_verify.return_value = True
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
|
||||
|
||||
def test_get_public_image_preview_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session, extension="png", mime_type="image/png")
|
||||
def test_get_public_image_preview_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "png"
|
||||
upload_file.mime_type = "image/png"
|
||||
upload_file.key = "key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
mock_storage.load.return_value = b"image content"
|
||||
@@ -376,56 +377,66 @@ class TestFileService:
|
||||
assert gen == b"image content"
|
||||
assert mime == "image/png"
|
||||
|
||||
def test_get_public_image_preview_not_found(self, file_service: FileService):
|
||||
def test_get_public_image_preview_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
file_service.get_public_image_preview("file_id")
|
||||
|
||||
def test_get_public_image_preview_unsupported_type(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
def test_get_public_image_preview_unsupported_type(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "txt"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
with pytest.raises(UnsupportedFileTypeError):
|
||||
file_service.get_public_image_preview("file_id")
|
||||
|
||||
def test_get_file_content_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
def test_get_file_content_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.key = "key"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
mock_storage.load.return_value = b"hello world"
|
||||
result = file_service.get_file_content("file_id")
|
||||
assert result == "hello world"
|
||||
|
||||
def test_get_file_content_not_found(self, file_service: FileService):
|
||||
def test_get_file_content_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
file_service.get_file_content("file_id")
|
||||
|
||||
def test_delete_file_success(self, file_service: FileService, db_session: Session):
|
||||
self._persist_upload_file(db_session)
|
||||
def test_delete_file_success(self, file_service: FileService, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.key = "key"
|
||||
# For session.scalar(select(...))
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
file_service.delete_file("file_id")
|
||||
mock_storage.delete.assert_called_once_with("key")
|
||||
db_session.expire_all()
|
||||
assert db_session.get(UploadFile, "file_id") is None
|
||||
mock_db_session.delete.assert_called_once_with(upload_file)
|
||||
|
||||
def test_delete_file_not_found(self, file_service: FileService):
|
||||
def test_delete_file_not_found(self, file_service: FileService, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
file_service.delete_file("file_id")
|
||||
# Should return without doing anything
|
||||
|
||||
def test_get_upload_files_by_ids_empty(self, db_session: Session):
|
||||
result = FileService.get_upload_files_by_ids("tenant_id", [], session=db_session)
|
||||
def test_get_upload_files_by_ids_empty(self):
|
||||
session = MagicMock()
|
||||
result = FileService.get_upload_files_by_ids("tenant_id", [], session=session)
|
||||
assert result == {}
|
||||
|
||||
def test_get_upload_files_by_ids(self, db_session: Session):
|
||||
upload_file = self._persist_upload_file(db_session, file_id="550e8400-e29b-41d4-a716-446655440000")
|
||||
self._persist_upload_file(
|
||||
db_session,
|
||||
file_id="550e8400-e29b-41d4-a716-446655440001",
|
||||
tenant_id="other-tenant",
|
||||
)
|
||||
def test_get_upload_files_by_ids(self):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
upload_file.tenant_id = "tenant_id"
|
||||
session = MagicMock()
|
||||
session.scalars().all.return_value = [upload_file]
|
||||
|
||||
result = FileService.get_upload_files_by_ids(
|
||||
"tenant_id",
|
||||
["550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440001"],
|
||||
session=db_session,
|
||||
"tenant_id", ["550e8400-e29b-41d4-a716-446655440000"], session=session
|
||||
)
|
||||
assert result["550e8400-e29b-41d4-a716-446655440000"] == upload_file
|
||||
|
||||
@@ -442,8 +453,10 @@ class TestFileService:
|
||||
used.add("a (1).txt")
|
||||
assert FileService._dedupe_zip_entry_name("a.txt", used) == "a (2).txt"
|
||||
|
||||
def test_build_upload_files_zip_tempfile(self, db_session: Session):
|
||||
upload_file = self._persist_upload_file(db_session)
|
||||
def test_build_upload_files_zip_tempfile(self):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.name = "test.txt"
|
||||
upload_file.key = "key"
|
||||
|
||||
with (
|
||||
patch("services.file_service.storage") as mock_storage,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import dataclasses
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -41,13 +42,14 @@ from services.human_input_service import (
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unbound_session_factory() -> sessionmaker[Session]:
|
||||
"""Supply the required constructor dependency without enabling database access."""
|
||||
return sessionmaker()
|
||||
def sqlite_session_factory(sqlite_engine: Engine) -> Iterator[tuple[sessionmaker[Session], Session]]:
|
||||
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
with factory() as session:
|
||||
yield factory, session
|
||||
|
||||
|
||||
def _make_app(mode: AppMode) -> App:
|
||||
return App(
|
||||
def _persist_app(sqlite_session: Session, mode: AppMode) -> App:
|
||||
app = App(
|
||||
id="app-id",
|
||||
tenant_id="tenant-id",
|
||||
name="Test App",
|
||||
@@ -58,6 +60,9 @@ def _make_app(mode: AppMode) -> App:
|
||||
enable_api=True,
|
||||
max_active_requests=0,
|
||||
)
|
||||
sqlite_session.add(app)
|
||||
sqlite_session.commit()
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -92,11 +97,14 @@ def sample_form_record():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_enqueue_resume_dispatches_task_for_workflow(
|
||||
mocker: MockerFixture,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -108,8 +116,7 @@ def test_enqueue_resume_dispatches_task_for_workflow(
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
|
||||
with sqlite_session_factory.begin() as arrange_session:
|
||||
arrange_session.add(_make_app(AppMode.WORKFLOW))
|
||||
_persist_app(sqlite_session, AppMode.WORKFLOW)
|
||||
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
@@ -121,9 +128,10 @@ def test_enqueue_resume_dispatches_task_for_workflow(
|
||||
|
||||
|
||||
def test_ensure_form_active_respects_global_timeout(
|
||||
monkeypatch, sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
monkeypatch, sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
expired_record = dataclasses.replace(
|
||||
sample_form_record,
|
||||
created_at=naive_utc_now() - timedelta(hours=2),
|
||||
@@ -135,11 +143,14 @@ def test_ensure_form_active_respects_global_timeout(
|
||||
service.ensure_form_active(Form(expired_record))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_enqueue_resume_dispatches_task_for_advanced_chat(
|
||||
mocker: MockerFixture,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -151,8 +162,7 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat(
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
|
||||
with sqlite_session_factory.begin() as arrange_session:
|
||||
arrange_session.add(_make_app(AppMode.ADVANCED_CHAT))
|
||||
_persist_app(sqlite_session, AppMode.ADVANCED_CHAT)
|
||||
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
@@ -163,11 +173,14 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat(
|
||||
assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_enqueue_resume_skips_unsupported_app_mode(
|
||||
mocker: MockerFixture,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -179,8 +192,7 @@ def test_enqueue_resume_skips_unsupported_app_mode(
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
|
||||
with sqlite_session_factory.begin() as arrange_session:
|
||||
arrange_session.add(_make_app(AppMode.COMPLETION))
|
||||
_persist_app(sqlite_session, AppMode.COMPLETION)
|
||||
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
@@ -190,13 +202,14 @@ def test_enqueue_resume_skips_unsupported_app_mode(
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_for_console_uses_repository(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
console_record = dataclasses.replace(sample_form_record, recipient_type=RecipientType.CONSOLE)
|
||||
repo.get_by_token.return_value = console_record
|
||||
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
form = service.get_form_definition_by_token_for_console("token")
|
||||
|
||||
repo.get_by_token.assert_called_once_with("token")
|
||||
@@ -232,8 +245,9 @@ def _build_resumption_context_state(*, options: list[str], workflow_run_id: str)
|
||||
|
||||
|
||||
def test_resolve_form_inputs_uses_runtime_select_options(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
configured_input = SelectInputConfig(
|
||||
output_variable_name="decision",
|
||||
option_source=StringListSource(
|
||||
@@ -258,7 +272,7 @@ def test_resolve_form_inputs_uses_runtime_select_options(
|
||||
"services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
resolved_inputs = service.resolve_form_inputs(Form(record))
|
||||
|
||||
@@ -270,12 +284,13 @@ def test_resolve_form_inputs_uses_runtime_select_options(
|
||||
|
||||
|
||||
def test_submit_form_by_token_calls_repository_and_enqueue(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
repo.mark_submitted.return_value = sample_form_record
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(
|
||||
@@ -298,10 +313,11 @@ def test_submit_form_by_token_calls_repository_and_enqueue(
|
||||
|
||||
|
||||
def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form(
|
||||
sample_form_record, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record, sqlite_session_factory, mocker: MockerFixture
|
||||
):
|
||||
# ENG-635: a conversation-owned (Agent v2 chat) form routes to the chat
|
||||
# resume, not the workflow resume.
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
conversation_record = dataclasses.replace(
|
||||
sample_form_record,
|
||||
@@ -310,7 +326,7 @@ def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form(
|
||||
)
|
||||
repo.get_by_token.return_value = conversation_record
|
||||
repo.mark_submitted.return_value = conversation_record
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
workflow_enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
chat_enqueue_spy = mocker.patch.object(service, "enqueue_agent_app_resume")
|
||||
|
||||
@@ -327,8 +343,9 @@ def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form(
|
||||
|
||||
|
||||
def test_submit_form_by_token_skips_enqueue_for_delivery_test(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
test_record = dataclasses.replace(
|
||||
sample_form_record,
|
||||
@@ -337,7 +354,7 @@ def test_submit_form_by_token_skips_enqueue_for_delivery_test(
|
||||
)
|
||||
repo.get_by_token.return_value = test_record
|
||||
repo.mark_submitted.return_value = test_record
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(
|
||||
@@ -351,12 +368,13 @@ def test_submit_form_by_token_skips_enqueue_for_delivery_test(
|
||||
|
||||
|
||||
def test_submit_form_by_token_passes_submission_user_id(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
repo.mark_submitted.return_value = sample_form_record
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(
|
||||
@@ -373,10 +391,11 @@ def test_submit_form_by_token_passes_submission_user_id(
|
||||
enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id)
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError) as exc_info:
|
||||
service.submit_form_by_token(
|
||||
@@ -390,7 +409,8 @@ def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormR
|
||||
repo.mark_submitted.assert_not_called()
|
||||
|
||||
|
||||
def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
|
||||
definition_with_input = FormDefinition(
|
||||
@@ -402,7 +422,7 @@ def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormR
|
||||
)
|
||||
form_with_input = dataclasses.replace(sample_form_record, definition=definition_with_input)
|
||||
repo.get_by_token.return_value = form_with_input
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError) as exc_info:
|
||||
service.submit_form_by_token(
|
||||
@@ -416,6 +436,42 @@ def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormR
|
||||
repo.mark_submitted.assert_not_called()
|
||||
|
||||
|
||||
def test_validate_human_input_submission_accepts_select_file_and_file_list(sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
definition = FormDefinition.model_validate(
|
||||
{
|
||||
"form_content": "Pick one and upload files",
|
||||
"inputs": [
|
||||
{
|
||||
"type": "select",
|
||||
"output_variable_name": "decision",
|
||||
"option_source": {
|
||||
"type": "constant",
|
||||
"value": ["approve", "reject"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"output_variable_name": "attachment",
|
||||
"allowed_file_types": ["document"],
|
||||
"allowed_file_upload_methods": ["remote_url"],
|
||||
},
|
||||
{
|
||||
"type": "file-list",
|
||||
"output_variable_name": "attachments",
|
||||
"allowed_file_types": ["document"],
|
||||
"allowed_file_upload_methods": ["remote_url"],
|
||||
"number_limits": 3,
|
||||
},
|
||||
],
|
||||
"user_actions": [{"id": "submit", "title": "Submit"}],
|
||||
"rendered_content": "<p>Pick one and upload files</p>",
|
||||
"expiration_time": naive_utc_now() + timedelta(hours=1),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_definition", "submitted_value", "expected_message"),
|
||||
[
|
||||
@@ -466,11 +522,12 @@ def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormR
|
||||
)
|
||||
def test_validate_human_input_submission_rejects_invalid_select_and_file_payloads(
|
||||
sample_form_record,
|
||||
unbound_session_factory,
|
||||
sqlite_session_factory,
|
||||
input_definition,
|
||||
submitted_value,
|
||||
expected_message,
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition.model_validate(
|
||||
{
|
||||
@@ -482,7 +539,7 @@ def test_validate_human_input_submission_rejects_invalid_select_and_file_payload
|
||||
}
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError) as exc_info:
|
||||
service.submit_form_by_token(
|
||||
@@ -512,7 +569,7 @@ def test_form_properties(sample_form_record: HumanInputFormRecord):
|
||||
|
||||
def test_form_submitted_error_init():
|
||||
error = FormSubmittedError(form_id="test-form")
|
||||
assert error.description == "This form has already been submitted by another user, form_id=test-form"
|
||||
assert "form_id=test-form" in error.description
|
||||
assert error.code == 412
|
||||
|
||||
|
||||
@@ -523,55 +580,61 @@ def test_human_input_service_init_with_engine(sqlite_engine: Engine):
|
||||
assert service._session_factory.kw["bind"] is sqlite_engine
|
||||
|
||||
|
||||
def test_get_form_by_token_none(unbound_session_factory):
|
||||
def test_get_form_by_token_none(sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = None
|
||||
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
assert service.get_form_by_token("invalid") is None
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_mismatch(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
def test_get_form_definition_by_token_mismatch(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
# RecipientType mismatch
|
||||
assert service.get_form_definition_by_token(RecipientType.CONSOLE, "token") is None
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_success(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
def test_get_form_definition_by_token_success(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
form = service.get_form_definition_by_token(RecipientType.STANDALONE_WEB_APP, "token")
|
||||
assert form is not None
|
||||
assert form.id == sample_form_record.form_id
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_for_console_mismatch(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record # is STANDALONE_WEB_APP
|
||||
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
assert service.get_form_definition_by_token_for_console("token") is None
|
||||
|
||||
|
||||
def test_submit_form_by_token_delivery_not_enabled(unbound_session_factory):
|
||||
def test_submit_form_by_token_delivery_not_enabled(sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = None
|
||||
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
with pytest.raises(human_input_service_module.WebAppDeliveryNotEnabledError):
|
||||
service.submit_form_by_token(RecipientType.STANDALONE_WEB_APP, "token", "action", {})
|
||||
|
||||
|
||||
def test_submit_form_by_token_no_workflow_run_id(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
@@ -579,15 +642,16 @@ def test_submit_form_by_token_no_workflow_run_id(
|
||||
result_record = dataclasses.replace(sample_form_record, workflow_run_id=None)
|
||||
repo.mark_submitted.return_value = result_record
|
||||
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(RecipientType.STANDALONE_WEB_APP, "token", "submit", {})
|
||||
enqueue_spy.assert_not_called()
|
||||
|
||||
|
||||
def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
# Submitted
|
||||
submitted_record = dataclasses.replace(sample_form_record, submitted_at=naive_utc_now())
|
||||
@@ -607,16 +671,18 @@ def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, unb
|
||||
service.ensure_form_active(Form(expired_time_record))
|
||||
|
||||
|
||||
def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
submitted_record = dataclasses.replace(sample_form_record, submitted_at=naive_utc_now())
|
||||
|
||||
with pytest.raises(human_input_service_module.FormSubmittedError):
|
||||
service._ensure_not_submitted(Form(submitted_record))
|
||||
|
||||
|
||||
def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, unbound_session_factory):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, sqlite_session_factory):
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
workflow_run_repo = MagicMock()
|
||||
workflow_run_repo.get_workflow_run_by_id_without_tenant.return_value = None
|
||||
@@ -630,12 +696,15 @@ def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, unbound_sessio
|
||||
assert "WorkflowRun not found" in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_enqueue_resume_app_not_found(
|
||||
mocker,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
sqlite_session_factory,
|
||||
sqlite_session: Session,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
workflow_run.app_id = "app-id"
|
||||
@@ -646,31 +715,26 @@ def test_enqueue_resume_app_not_found(
|
||||
"services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
|
||||
return_value=workflow_run_repo,
|
||||
)
|
||||
resume_task = mocker.patch("services.human_input_service.resume_app_execution")
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="services.human_input_service"):
|
||||
service.enqueue_resume("workflow-run-id")
|
||||
|
||||
assert (
|
||||
"services.human_input_service",
|
||||
logging.ERROR,
|
||||
"App not found for WorkflowRun, workflow_run_id=workflow-run-id, app_id=app-id",
|
||||
) in caplog.record_tuples
|
||||
resume_task.apply_async.assert_not_called()
|
||||
assert any(r.levelno >= logging.ERROR for r in caplog.records)
|
||||
|
||||
|
||||
def test_is_globally_expired_zero_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch, sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
monkeypatch: pytest.MonkeyPatch, sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
):
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
session_factory, _ = sqlite_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0)
|
||||
assert service._is_globally_expired(Form(sample_form_record)) is False
|
||||
|
||||
|
||||
def test_submit_form_by_token_normalizes_select_and_files(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -689,7 +753,7 @@ def test_submit_form_by_token_normalizes_select_and_files(
|
||||
form_with_inputs = dataclasses.replace(sample_form_record, definition=definition)
|
||||
repo.get_by_token.return_value = form_with_inputs
|
||||
repo.mark_submitted.return_value = form_with_inputs
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
single_file = File(
|
||||
file_id="file-1",
|
||||
@@ -751,8 +815,9 @@ def test_submit_form_by_token_normalizes_select_and_files(
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_select_value(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -767,7 +832,7 @@ def test_submit_form_by_token_invalid_select_value(
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError, match="Invalid value for select input 'decision'"):
|
||||
service.submit_form_by_token(
|
||||
@@ -779,8 +844,9 @@ def test_submit_form_by_token_invalid_select_value(
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_file_list_item(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -790,7 +856,7 @@ def test_submit_form_by_token_invalid_file_list_item(
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(
|
||||
InvalidFormDataError,
|
||||
@@ -805,8 +871,9 @@ def test_submit_form_by_token_invalid_file_list_item(
|
||||
|
||||
|
||||
def test_submit_form_by_token_rejects_cross_tenant_file(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -816,7 +883,7 @@ def test_submit_form_by_token_rejects_cross_tenant_file(
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
mocker.patch("services.human_input_service.build_from_mapping", side_effect=ValueError("Invalid upload file"))
|
||||
|
||||
with pytest.raises(InvalidFormDataError, match="Invalid value for file input 'attachment'"):
|
||||
@@ -837,8 +904,9 @@ def test_submit_form_by_token_rejects_cross_tenant_file(
|
||||
|
||||
|
||||
def test_submit_form_by_token_rejects_cross_tenant_file_list(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture
|
||||
) -> None:
|
||||
session_factory, _ = sqlite_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
@@ -848,7 +916,7 @@ def test_submit_form_by_token_rejects_cross_tenant_file_list(
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
mocker.patch("services.human_input_service.build_from_mappings", side_effect=ValueError("Invalid upload file"))
|
||||
|
||||
with pytest.raises(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,88 +0,0 @@
|
||||
"""Unit tests for workflow app log views and trigger metadata helpers."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from models.enums import AppTriggerType, CreatorUserRole
|
||||
from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom
|
||||
from services.workflow_app_service import LogView, WorkflowAppService
|
||||
|
||||
|
||||
class TestLogView:
|
||||
def test_details_and_proxy_attributes(self) -> None:
|
||||
log = WorkflowAppLog(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id="run-1",
|
||||
created_from=WorkflowAppLogCreatedFrom.WEB_APP,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-1",
|
||||
)
|
||||
log.id = "log-1"
|
||||
|
||||
view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}})
|
||||
|
||||
assert view.details == {"trigger_metadata": {"type": "plugin"}}
|
||||
assert view.id == "log-1"
|
||||
|
||||
|
||||
class TestHandleTriggerMetadata:
|
||||
def test_returns_empty_dict_when_metadata_missing(self) -> None:
|
||||
assert WorkflowAppService().handle_trigger_metadata("tenant-1", None) == {}
|
||||
|
||||
def test_enriches_plugin_icons(self) -> None:
|
||||
metadata = {
|
||||
"type": AppTriggerType.TRIGGER_PLUGIN.value,
|
||||
"icon_filename": "light.png",
|
||||
"icon_dark_filename": "dark.png",
|
||||
}
|
||||
with patch(
|
||||
"services.workflow_app_service.PluginService.get_plugin_icon_url",
|
||||
side_effect=["https://cdn/light.png", "https://cdn/dark.png"],
|
||||
) as mock_icon:
|
||||
result = WorkflowAppService().handle_trigger_metadata("tenant-1", json.dumps(metadata))
|
||||
|
||||
assert result["icon"] == "https://cdn/light.png"
|
||||
assert result["icon_dark"] == "https://cdn/dark.png"
|
||||
assert mock_icon.call_count == 2
|
||||
|
||||
def test_non_plugin_metadata_without_icon_lookup(self) -> None:
|
||||
metadata = {"type": AppTriggerType.TRIGGER_WEBHOOK.value}
|
||||
with patch("services.workflow_app_service.PluginService.get_plugin_icon_url") as mock_icon:
|
||||
result = WorkflowAppService().handle_trigger_metadata("tenant-1", json.dumps(metadata))
|
||||
|
||||
assert result["type"] == AppTriggerType.TRIGGER_WEBHOOK.value
|
||||
mock_icon.assert_not_called()
|
||||
|
||||
|
||||
class TestSafeJsonLoads:
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
('{"k":"v"}', {"k": "v"}),
|
||||
("not-json", None),
|
||||
({"raw": True}, {"raw": True}),
|
||||
],
|
||||
)
|
||||
def test_handles_various_inputs(self, value, expected) -> None:
|
||||
assert WorkflowAppService._safe_json_loads(value) == expected
|
||||
|
||||
|
||||
class TestSafeParseUuid:
|
||||
def test_returns_none_for_short_or_invalid_values(self) -> None:
|
||||
assert WorkflowAppService._safe_parse_uuid("short") is None
|
||||
assert WorkflowAppService._safe_parse_uuid("x" * 40) is None
|
||||
|
||||
def test_returns_uuid_for_valid_string(self) -> None:
|
||||
raw = str(uuid.uuid4())
|
||||
|
||||
result = WorkflowAppService._safe_parse_uuid(raw)
|
||||
|
||||
assert result is not None
|
||||
assert str(result) == raw
|
||||
@@ -1,57 +1,178 @@
|
||||
"""Tests for the session lifecycle owned by ``WorkflowRunService``."""
|
||||
"""Comprehensive unit tests for WorkflowRunService class.
|
||||
|
||||
from unittest.mock import create_autospec, patch
|
||||
This test suite covers all pause state management operations including:
|
||||
- Retrieving pause state for workflow runs
|
||||
- Saving pause state with file uploads
|
||||
- Marking paused workflows as resumed
|
||||
- Error handling and edge cases
|
||||
- Database transaction management
|
||||
- Repository-based approach testing
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, create_autospec, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, text
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models.workflow import WorkflowPause
|
||||
from repositories.api_workflow_run_repository import APIWorkflowRunRepository
|
||||
from services.workflow_run_service import WorkflowRunService
|
||||
from repositories.sqlalchemy_api_workflow_run_repository import _PrivateWorkflowPauseEntity
|
||||
from services.workflow_run_service import (
|
||||
WorkflowRunService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_session_factory(sqlite_engine: Engine) -> sessionmaker[Session]:
|
||||
"""Return a real factory whose sessions are bound to the isolated SQLite engine."""
|
||||
return sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
class TestDataFactory:
|
||||
"""Factory class for creating test data objects."""
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_run_mock(
|
||||
id: str = "workflow-run-123",
|
||||
tenant_id: str = "tenant-456",
|
||||
app_id: str = "app-789",
|
||||
workflow_id: str = "workflow-101",
|
||||
status: str | WorkflowExecutionStatus = "paused",
|
||||
**kwargs,
|
||||
) -> MagicMock:
|
||||
"""Create a mock WorkflowRun object."""
|
||||
mock_run = MagicMock()
|
||||
mock_run.id = id
|
||||
mock_run.tenant_id = tenant_id
|
||||
mock_run.app_id = app_id
|
||||
mock_run.workflow_id = workflow_id
|
||||
mock_run.status = status
|
||||
|
||||
for key, value in kwargs.items():
|
||||
setattr(mock_run, key, value)
|
||||
|
||||
return mock_run
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_pause_mock(
|
||||
id: str = "pause-123",
|
||||
tenant_id: str = "tenant-456",
|
||||
app_id: str = "app-789",
|
||||
workflow_id: str = "workflow-101",
|
||||
workflow_execution_id: str = "workflow-execution-123",
|
||||
state_file_id: str = "file-456",
|
||||
resumed_at: datetime | None = None,
|
||||
**kwargs,
|
||||
) -> MagicMock:
|
||||
"""Create a mock WorkflowPauseModel object."""
|
||||
mock_pause = MagicMock(spec=WorkflowPause)
|
||||
mock_pause.id = id
|
||||
mock_pause.tenant_id = tenant_id
|
||||
mock_pause.app_id = app_id
|
||||
mock_pause.workflow_id = workflow_id
|
||||
mock_pause.workflow_execution_id = workflow_execution_id
|
||||
mock_pause.state_file_id = state_file_id
|
||||
mock_pause.resumed_at = resumed_at
|
||||
|
||||
for key, value in kwargs.items():
|
||||
setattr(mock_pause, key, value)
|
||||
|
||||
return mock_pause
|
||||
|
||||
@staticmethod
|
||||
def create_pause_entity_mock(
|
||||
pause_model: MagicMock | None = None,
|
||||
) -> _PrivateWorkflowPauseEntity:
|
||||
"""Create a mock _PrivateWorkflowPauseEntity object."""
|
||||
if pause_model is None:
|
||||
pause_model = TestDataFactory.create_workflow_pause_mock()
|
||||
|
||||
return _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=[], human_input_form=[])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workflow_run_repository():
|
||||
"""Keep the repository boundary mocked while exercising real session construction."""
|
||||
return create_autospec(APIWorkflowRunRepository)
|
||||
class TestWorkflowRunService:
|
||||
"""Comprehensive unit tests for WorkflowRunService class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_factory(self):
|
||||
"""Create a mock session factory with proper session management."""
|
||||
mock_session = create_autospec(Session)
|
||||
|
||||
def test_init_with_session_factory(
|
||||
sqlite_session_factory: sessionmaker[Session], workflow_run_repository: APIWorkflowRunRepository
|
||||
) -> None:
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as repository_factory:
|
||||
repository_factory.create_api_workflow_run_repository.return_value = workflow_run_repository
|
||||
# Create a mock context manager for the session
|
||||
mock_session_cm = MagicMock()
|
||||
mock_session_cm.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_session_cm.__exit__ = MagicMock(return_value=None)
|
||||
|
||||
service = WorkflowRunService(sqlite_session_factory)
|
||||
# Create a mock context manager for the transaction
|
||||
mock_transaction_cm = MagicMock()
|
||||
mock_transaction_cm.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_transaction_cm.__exit__ = MagicMock(return_value=None)
|
||||
|
||||
assert service._session_factory is sqlite_session_factory
|
||||
repository_factory.create_api_workflow_run_repository.assert_called_once_with(sqlite_session_factory)
|
||||
with service._session_factory() as session:
|
||||
assert session.scalar(text("SELECT 1")) == 1
|
||||
mock_session.begin = MagicMock(return_value=mock_transaction_cm)
|
||||
|
||||
# Create mock factory that returns the context manager
|
||||
mock_factory = MagicMock(spec=sessionmaker)
|
||||
mock_factory.return_value = mock_session_cm
|
||||
|
||||
def test_init_with_engine_creates_bound_session_factory(
|
||||
sqlite_engine: Engine, workflow_run_repository: APIWorkflowRunRepository
|
||||
) -> None:
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as repository_factory:
|
||||
repository_factory.create_api_workflow_run_repository.return_value = workflow_run_repository
|
||||
return mock_factory, mock_session
|
||||
|
||||
service = WorkflowRunService(sqlite_engine)
|
||||
@pytest.fixture
|
||||
def mock_workflow_run_repository(self):
|
||||
"""Create a mock APIWorkflowRunRepository."""
|
||||
mock_repo = create_autospec(APIWorkflowRunRepository)
|
||||
return mock_repo
|
||||
|
||||
assert service._session_factory.kw["bind"] is sqlite_engine
|
||||
assert service._session_factory.kw["expire_on_commit"] is False
|
||||
repository_factory.create_api_workflow_run_repository.assert_called_once_with(service._session_factory)
|
||||
with service._session_factory() as session:
|
||||
assert session.scalar(text("SELECT 1")) == 1
|
||||
@pytest.fixture
|
||||
def workflow_run_service(self, mock_session_factory, mock_workflow_run_repository):
|
||||
"""Create WorkflowRunService instance with mocked dependencies."""
|
||||
session_factory, _ = mock_session_factory
|
||||
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory:
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository
|
||||
service = WorkflowRunService(session_factory)
|
||||
return service
|
||||
|
||||
def test_init_with_default_repository_dependencies(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
service = WorkflowRunService(sqlite_session_factory)
|
||||
@pytest.fixture
|
||||
def workflow_run_service_with_engine(self, mock_session_factory, mock_workflow_run_repository):
|
||||
"""Create WorkflowRunService instance with Engine input."""
|
||||
mock_engine = create_autospec(Engine)
|
||||
session_factory, _ = mock_session_factory
|
||||
|
||||
assert service._session_factory is sqlite_session_factory
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory:
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository
|
||||
service = WorkflowRunService(mock_engine)
|
||||
return service
|
||||
|
||||
# ==================== Initialization Tests ====================
|
||||
|
||||
def test_init_with_session_factory(self, mock_session_factory, mock_workflow_run_repository):
|
||||
"""Test WorkflowRunService initialization with session_factory."""
|
||||
session_factory, _ = mock_session_factory
|
||||
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory:
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository
|
||||
service = WorkflowRunService(session_factory)
|
||||
|
||||
assert service._session_factory == session_factory
|
||||
mock_factory.create_api_workflow_run_repository.assert_called_once_with(session_factory)
|
||||
|
||||
def test_init_with_engine(self, mock_session_factory, mock_workflow_run_repository):
|
||||
"""Test WorkflowRunService initialization with Engine (should convert to sessionmaker)."""
|
||||
mock_engine = create_autospec(Engine)
|
||||
session_factory, _ = mock_session_factory
|
||||
|
||||
with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory:
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository
|
||||
with patch(
|
||||
"services.workflow_run_service.sessionmaker", return_value=session_factory, autospec=True
|
||||
) as mock_sessionmaker:
|
||||
service = WorkflowRunService(mock_engine)
|
||||
|
||||
mock_sessionmaker.assert_called_once_with(bind=mock_engine, expire_on_commit=False)
|
||||
assert service._session_factory == session_factory
|
||||
mock_factory.create_api_workflow_run_repository.assert_called_once_with(session_factory)
|
||||
|
||||
def test_init_with_default_dependencies(self, mock_session_factory):
|
||||
"""Test WorkflowRunService initialization with default dependencies."""
|
||||
session_factory, _ = mock_session_factory
|
||||
|
||||
service = WorkflowRunService(session_factory)
|
||||
|
||||
assert service._session_factory == session_factory
|
||||
|
||||
@@ -135,19 +135,6 @@ class TestMCPToolInvoke:
|
||||
values = {m.message.variable_name: m.message.variable_value for m in var_msgs}
|
||||
assert values == {"a": 1, "b": "x"}
|
||||
|
||||
def test_invoke_yields_json_when_structured_content_has_no_output_schema(self, orm_session: Session) -> None:
|
||||
tool = _make_mcp_tool()
|
||||
result = CallToolResult(content=[], structuredContent={"a": 1, "b": "x"})
|
||||
|
||||
with patch.object(tool, "invoke_remote_mcp_tool", return_value=result):
|
||||
messages = list(tool._invoke(session=orm_session, user_id="test_user", tool_parameters={}))
|
||||
|
||||
assert len(messages) == 1
|
||||
msg = messages[0]
|
||||
assert msg.type == ToolInvokeMessage.MessageType.JSON
|
||||
assert isinstance(msg.message, ToolInvokeMessage.JsonMessage)
|
||||
assert msg.message.json_object == {"a": 1, "b": "x"}
|
||||
|
||||
|
||||
class TestMCPToolUsageExtraction:
|
||||
"""Test usage metadata extraction from MCP tool results."""
|
||||
|
||||
Generated
+2
-2
@@ -1281,7 +1281,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-agent"
|
||||
version = "1.16.1"
|
||||
version = "1.16.0"
|
||||
source = { editable = "../dify-agent" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -1331,7 +1331,7 @@ docs = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.16.1"
|
||||
version = "1.16.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
"channel": "alpha",
|
||||
"compat": {
|
||||
"minDify": "1.16.0",
|
||||
"maxDify": "1.16.1"
|
||||
"maxDify": "1.16.0"
|
||||
},
|
||||
"release": {
|
||||
"tagPrefix": "difyctl-v",
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./install-cli.sh', import.meta.url))
|
||||
|
||||
function pickAsset(target: string, releaseJson: string): string {
|
||||
return execFileSync('sh', ['-c', `. "${SCRIPT}"; pick_asset "$1"`, 'sh', target], {
|
||||
input: releaseJson,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' },
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function assetVersion(name: string, target: string): string {
|
||||
return execFileSync('sh', ['-c', `. "${SCRIPT}"; asset_version "$1" "$2"`, 'sh', name, target], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' },
|
||||
}).trim()
|
||||
}
|
||||
|
||||
// Stubs the only network primitive (fetch_json) so resolution logic runs fully
|
||||
// offline. Routes by URL; release bodies come from env (TAG_<tag-with-._->_>),
|
||||
// the latest release from LATEST_JSON, the listing from LIST_JSON. A missing
|
||||
// fixture returns 22 to mimic `curl -f` on a 4xx.
|
||||
/* oxlint-disable no-template-curly-in-string -- shell parameter expansions, not JS template literals */
|
||||
const FETCH_STUB = [
|
||||
'fetch_json() {',
|
||||
' case "$1" in',
|
||||
' *"/releases/latest") [ -n "${LATEST_JSON:-}" ] || return 22; printf "%s" "$LATEST_JSON" ;;',
|
||||
' *"/releases?per_page=100") [ -n "${LIST_JSON:-}" ] || return 22; printf "%s" "$LIST_JSON" ;;',
|
||||
' *"/releases/tags/"*)',
|
||||
' _t=${1##*/releases/tags/};',
|
||||
' _k=$(printf "TAG_%s" "$_t" | tr ".-" "__");',
|
||||
' eval "_v=\\${$_k:-}";',
|
||||
' [ -n "$_v" ] || return 22;',
|
||||
' printf "%s" "$_v" ;;',
|
||||
' *) return 22 ;;',
|
||||
' esac',
|
||||
'}',
|
||||
].join('\n')
|
||||
/* oxlint-enable no-template-curly-in-string */
|
||||
|
||||
function runLib(
|
||||
program: string,
|
||||
env: Record<string, string> = {},
|
||||
): { code: number; stdout: string; stderr: string } {
|
||||
const full = `. "${SCRIPT}"\n${FETCH_STUB}\n${program}`
|
||||
const r = spawnSync('sh', ['-c', full], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
DIFYCTL_INSTALL_LIB: '1',
|
||||
DIFY_VERSION: '',
|
||||
DIFYCTL_VERSION: '',
|
||||
...env,
|
||||
},
|
||||
})
|
||||
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
|
||||
}
|
||||
|
||||
// Like runLib but with a caller-supplied fetch_json stub, so we can drive the
|
||||
// real rate_limit_hint / maybe_ratelimit_exit / fetch_hit_ratelimit (which the
|
||||
// script defines) by writing a classified reason to FETCH_ERR_FILE.
|
||||
function runLibStub(
|
||||
stub: string,
|
||||
program: string,
|
||||
env: Record<string, string> = {},
|
||||
): { code: number; stderr: string } {
|
||||
const full = `. "${SCRIPT}"\n${stub}\n${program}`
|
||||
const r = spawnSync('sh', ['-c', full], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
DIFYCTL_INSTALL_LIB: '1',
|
||||
DIFY_VERSION: '',
|
||||
DIFYCTL_VERSION: '',
|
||||
...env,
|
||||
},
|
||||
})
|
||||
return { code: r.status ?? 1, stderr: r.stderr ?? '' }
|
||||
}
|
||||
|
||||
// A fetch_json that always fails with the given classification, mimicking the
|
||||
// real one writing to FETCH_ERR_FILE from inside a command-substitution subshell.
|
||||
function failStub(reason: string): string {
|
||||
return `fetch_json() { printf '%s' '${reason}' > "$FETCH_ERR_FILE"; return 1; }`
|
||||
}
|
||||
|
||||
const REL_1142 = JSON.stringify({
|
||||
tag_name: '1.14.2',
|
||||
assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }],
|
||||
})
|
||||
const REL_1150 = JSON.stringify({
|
||||
tag_name: '1.15.0',
|
||||
assets: [{ name: 'difyctl-v0.3.0-linux-x64' }],
|
||||
})
|
||||
const LIST_NEWEST_FIRST = JSON.stringify({
|
||||
releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }],
|
||||
})
|
||||
|
||||
const RELEASE = JSON.stringify({
|
||||
tag_name: '1.14.2',
|
||||
name: 'Dify 1.14.2',
|
||||
assets: [
|
||||
{ name: 'difyctl-v0.1.0-rc.1-linux-x64' },
|
||||
{ name: 'difyctl-v0.2.0-linux-x64' },
|
||||
{ name: 'difyctl-v0.2.0-linux-arm64' },
|
||||
{ name: 'difyctl-v0.2.0-darwin-arm64' },
|
||||
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
|
||||
{ name: 'difyctl-v0.2.0-checksums.txt' },
|
||||
{ name: 'some-other-asset.zip' },
|
||||
],
|
||||
})
|
||||
|
||||
describe('install-cli pick_asset', () => {
|
||||
it('picks the highest difyctl version for a linux target', () => {
|
||||
expect(pickAsset('linux-x64', RELEASE)).toBe('difyctl-v0.2.0-linux-x64')
|
||||
})
|
||||
|
||||
it('matches the windows .exe asset', () => {
|
||||
expect(pickAsset('windows-x64', RELEASE)).toBe('difyctl-v0.2.0-windows-x64.exe')
|
||||
})
|
||||
|
||||
it('matches an arm64 target exactly (no x64 bleed-through)', () => {
|
||||
expect(pickAsset('darwin-arm64', RELEASE)).toBe('difyctl-v0.2.0-darwin-arm64')
|
||||
})
|
||||
|
||||
it('excludes the checksums asset', () => {
|
||||
expect(pickAsset('linux-x64', RELEASE)).not.toContain('checksums')
|
||||
})
|
||||
|
||||
it('yields empty when no asset matches the target', () => {
|
||||
expect(pickAsset('darwin-x64', RELEASE)).toBe('')
|
||||
})
|
||||
|
||||
it('picks the highest semver when several difyctl versions are present', () => {
|
||||
const many = JSON.stringify({
|
||||
assets: [
|
||||
{ name: 'difyctl-v0.2.0-linux-x64' },
|
||||
{ name: 'difyctl-v0.10.0-linux-x64' },
|
||||
{ name: 'difyctl-v0.9.0-linux-x64' },
|
||||
],
|
||||
})
|
||||
expect(pickAsset('linux-x64', many)).toBe('difyctl-v0.10.0-linux-x64')
|
||||
})
|
||||
})
|
||||
|
||||
describe('install-cli asset_version', () => {
|
||||
it('extracts the version from a posix asset name', () => {
|
||||
expect(assetVersion('difyctl-v0.2.0-linux-x64', 'linux-x64')).toBe('0.2.0')
|
||||
})
|
||||
|
||||
it('extracts the version from a windows .exe asset name', () => {
|
||||
expect(assetVersion('difyctl-v0.2.0-windows-x64.exe', 'windows-x64')).toBe('0.2.0')
|
||||
})
|
||||
|
||||
it('extracts a prerelease version', () => {
|
||||
expect(assetVersion('difyctl-v0.1.0-rc.1-linux-x64', 'linux-x64')).toBe('0.1.0-rc.1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('install-cli resolve_release', () => {
|
||||
it('DIFY_VERSION pins the release directly', () => {
|
||||
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
|
||||
DIFY_VERSION: '1.14.2',
|
||||
TAG_1_14_2: REL_1142,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
|
||||
it('DIFY_VERSION that does not exist dies with a clear message', () => {
|
||||
const r = runLib('resolve_release linux-x64', { DIFY_VERSION: '9.9.9' })
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('Dify release 9.9.9 not found')
|
||||
})
|
||||
|
||||
it('blank resolves to the latest stable release', () => {
|
||||
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
|
||||
LATEST_JSON: REL_1150,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.15.0')
|
||||
})
|
||||
|
||||
it('blank dies when the latest query fails (no silent fallback)', () => {
|
||||
const r = runLib('resolve_release linux-x64')
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('failed to query latest Dify release')
|
||||
})
|
||||
|
||||
it('DIFYCTL_VERSION resolves to the release hosting that build', () => {
|
||||
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
|
||||
DIFYCTL_VERSION: '0.2.0',
|
||||
LIST_JSON: LIST_NEWEST_FIRST,
|
||||
TAG_1_15_0: REL_1150,
|
||||
TAG_1_14_2: REL_1142,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
|
||||
it('DIFYCTL_VERSION not hosted anywhere dies', () => {
|
||||
const r = runLib('resolve_release linux-x64', {
|
||||
DIFYCTL_VERSION: '9.9.9',
|
||||
LIST_JSON: LIST_NEWEST_FIRST,
|
||||
TAG_1_15_0: REL_1150,
|
||||
TAG_1_14_2: REL_1142,
|
||||
})
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('difyctl 9.9.9 not found on any Dify release')
|
||||
})
|
||||
})
|
||||
|
||||
describe('install-cli find_release_for_difyctl', () => {
|
||||
it('returns the newest release whose assets host the wanted build', () => {
|
||||
const r = runLib('find_release_for_difyctl 0.2.0 linux-x64', {
|
||||
LIST_JSON: LIST_NEWEST_FIRST,
|
||||
TAG_1_15_0: REL_1150,
|
||||
TAG_1_14_2: REL_1142,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
|
||||
it('dies (not false-negative) when the releases listing fails', () => {
|
||||
const r = runLib('find_release_for_difyctl 0.2.0 linux-x64')
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('failed to query')
|
||||
})
|
||||
|
||||
it('warns and skips a release whose fetch fails, then finds it later', () => {
|
||||
const r = runLib('find_release_for_difyctl 0.2.0 linux-x64', {
|
||||
LIST_JSON: LIST_NEWEST_FIRST,
|
||||
TAG_1_14_2: REL_1142,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
expect(r.stderr).toContain('fetch failed for 1.15.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('install-cli rate limit', () => {
|
||||
const futureReset = String(Math.floor(Date.now() / 1000) + 1800)
|
||||
|
||||
it('latest: reports the rate limit with reset ETA and remediation, not a generic error', () => {
|
||||
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64')
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('rate limit exceeded')
|
||||
expect(r.stderr).toContain('resets in ~')
|
||||
expect(r.stderr).toContain('GITHUB_TOKEN')
|
||||
expect(r.stderr).not.toContain('failed to query latest')
|
||||
})
|
||||
|
||||
it('DIFY_VERSION: rate limit wins over the misleading "not found" message', () => {
|
||||
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', {
|
||||
DIFY_VERSION: '1.15.0',
|
||||
})
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('rate limit exceeded')
|
||||
expect(r.stderr).not.toContain('not found')
|
||||
})
|
||||
|
||||
it('DIFYCTL_VERSION: rate limit surfaces from the nested subshell, not "not found"', () => {
|
||||
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', {
|
||||
DIFYCTL_VERSION: '0.2.0',
|
||||
})
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('rate limit exceeded')
|
||||
expect(r.stderr).not.toContain('not found')
|
||||
})
|
||||
|
||||
it('omits the ETA line when the reset epoch is missing', () => {
|
||||
const r = runLibStub(failStub('ratelimit:'), 'resolve_release linux-x64')
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('rate limit exceeded')
|
||||
expect(r.stderr).not.toContain('resets in ~')
|
||||
})
|
||||
|
||||
it('a non-rate-limit HTTP error falls back to the generic message (no false hint)', () => {
|
||||
const r = runLibStub(failStub('http:500'), 'resolve_release linux-x64')
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('failed to query latest')
|
||||
expect(r.stderr).not.toContain('rate limit exceeded')
|
||||
})
|
||||
})
|
||||
|
||||
// A stand-in for curl that honours the flags fetch_json passes (-D/-o/-w/-H) and
|
||||
// fabricates a response per FAKE_MODE, so the tests exercise the REAL fetch_json
|
||||
// (its curl invocation, header parsing, classification and token handling) rather
|
||||
// than a stub. Header names it emits are lowercase, as HTTP/2 delivers them.
|
||||
const FAKE_CURL = `#!/bin/sh
|
||||
hdr=""; body=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-D) hdr="$2"; shift 2 ;;
|
||||
-o) body="$2"; shift 2 ;;
|
||||
-H) printf '%s\\n' "$2" >> "\${FAKE_HDR_LOG:-/dev/null}"; shift 2 ;;
|
||||
-w) shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
case "\${FAKE_MODE:-ok}" in
|
||||
ok)
|
||||
[ -n "$hdr" ] && printf 'HTTP/2 200\\r\\n\\r\\n' > "$hdr"
|
||||
[ -n "$body" ] && printf '%s' "\${FAKE_BODY:-}" > "$body"
|
||||
printf '200' ;;
|
||||
ratelimit)
|
||||
[ -n "$hdr" ] && printf 'HTTP/2 403\\r\\nx-ratelimit-remaining: 0\\r\\nx-ratelimit-reset: %s\\r\\n\\r\\n' "\${FAKE_RESET:-9999999999}" > "$hdr"
|
||||
printf '403' ;;
|
||||
perm403)
|
||||
[ -n "$hdr" ] && printf 'HTTP/2 403\\r\\nx-ratelimit-remaining: 59\\r\\n\\r\\n' > "$hdr"
|
||||
printf '403' ;;
|
||||
notfound)
|
||||
[ -n "$hdr" ] && printf 'HTTP/2 404\\r\\n\\r\\n' > "$hdr"
|
||||
printf '404' ;;
|
||||
net) exit 6 ;;
|
||||
esac
|
||||
`
|
||||
|
||||
// Drive the real fetch_json with FAKE_CURL first on PATH. Returns "OK|<body>" or
|
||||
// "FAIL|<FETCH_ERR_FILE contents>", plus any -H lines the fake curl received.
|
||||
function runRealFetch(
|
||||
mode: string,
|
||||
env: Record<string, string> = {},
|
||||
): { result: string; headers: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-fakecurl-'))
|
||||
const hdrLog = join(dir, 'hdrlog')
|
||||
writeFileSync(join(dir, 'curl'), FAKE_CURL)
|
||||
chmodSync(join(dir, 'curl'), 0o755)
|
||||
const program =
|
||||
'if body=$(fetch_json "https://api.github.com/repos/x/releases/latest"); then printf \'OK|%s\' "$body"; else printf \'FAIL|%s\' "$(cat "$FETCH_ERR_FILE" 2>/dev/null)"; fi'
|
||||
const r = spawnSync('sh', ['-c', `. "${SCRIPT}"\n${program}`], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${dir}:${process.env.PATH ?? ''}`,
|
||||
DIFYCTL_INSTALL_LIB: '1',
|
||||
DIFY_VERSION: '',
|
||||
DIFYCTL_VERSION: '',
|
||||
FAKE_MODE: mode,
|
||||
FAKE_HDR_LOG: hdrLog,
|
||||
...env,
|
||||
},
|
||||
})
|
||||
let headers = ''
|
||||
try {
|
||||
headers = readFileSync(hdrLog, 'utf8')
|
||||
} catch {
|
||||
/* no headers logged */
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
return { result: (r.stdout ?? '').trim(), headers }
|
||||
}
|
||||
|
||||
describe('install-cli fetch_json (real, fake curl on PATH)', () => {
|
||||
it('returns the response body on 200', () => {
|
||||
expect(runRealFetch('ok', { FAKE_BODY: '{"tag_name":"1.15.0"}' }).result).toBe(
|
||||
'OK|{"tag_name":"1.15.0"}',
|
||||
)
|
||||
})
|
||||
|
||||
it('classifies a 403 with x-ratelimit-remaining:0 as a rate limit and captures the reset', () => {
|
||||
expect(runRealFetch('ratelimit', { FAKE_RESET: '1893456000' }).result).toBe(
|
||||
'FAIL|ratelimit:1893456000',
|
||||
)
|
||||
})
|
||||
|
||||
it('classifies a 403 with tokens left as a plain http error, not a rate limit', () => {
|
||||
expect(runRealFetch('perm403').result).toBe('FAIL|http:403')
|
||||
})
|
||||
|
||||
it('classifies a 404 as an http error', () => {
|
||||
expect(runRealFetch('notfound').result).toBe('FAIL|http:404')
|
||||
})
|
||||
|
||||
it('classifies a curl transport failure as a network error', () => {
|
||||
expect(runRealFetch('net').result).toBe('FAIL|network')
|
||||
})
|
||||
|
||||
it('sends an Authorization bearer header when GITHUB_TOKEN is set', () => {
|
||||
expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: 'ghp_secret' }).headers).toContain(
|
||||
'Authorization: Bearer ghp_secret',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to GH_TOKEN when GITHUB_TOKEN is unset', () => {
|
||||
expect(
|
||||
runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: 'gho_fallback' }).headers,
|
||||
).toContain('Authorization: Bearer gho_fallback')
|
||||
})
|
||||
|
||||
it('sends no Authorization header when neither token is set', () => {
|
||||
expect(
|
||||
runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: '' }).headers,
|
||||
).not.toContain('Authorization')
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./install-r2.ps1', import.meta.url))
|
||||
const hasPwsh = spawnSync('pwsh', ['-v'], { encoding: 'utf8' }).status === 0
|
||||
const d = hasPwsh ? describe : describe.skip
|
||||
|
||||
const MANIFEST = JSON.stringify({
|
||||
schema: 1,
|
||||
name: 'difyctl',
|
||||
channel: 'edge',
|
||||
version: '0.1.0-edge.2fd7b82',
|
||||
baseUrl: 'https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82',
|
||||
targets: {
|
||||
'windows-x64': { asset: 'difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe', sha256: 'deadbeef' },
|
||||
},
|
||||
})
|
||||
|
||||
function pwsh(program: string): { code: number; stdout: string; stderr: string } {
|
||||
const full = `. '${SCRIPT}'\n${program}`
|
||||
const r = spawnSync('pwsh', ['-NoProfile', '-Command', full], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' },
|
||||
})
|
||||
return {
|
||||
code: r.status ?? 1,
|
||||
stdout: (r.stdout ?? '').replace(/\r\n/g, '\n').trim(),
|
||||
stderr: r.stderr ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
d('install-r2.ps1', () => {
|
||||
it('parses a target asset + sha from the manifest', () => {
|
||||
const prog =
|
||||
`$m = ConvertFrom-Json @'\n${MANIFEST}\n'@\n` +
|
||||
`Write-Output (Get-TargetField $m 'windows-x64' 'asset')\n` +
|
||||
`Write-Output (Get-TargetField $m 'windows-x64' 'sha256')`
|
||||
const { stdout } = pwsh(prog)
|
||||
expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe\ndeadbeef')
|
||||
})
|
||||
|
||||
it('errors when DIFYCTL_R2_BASE is unset', () => {
|
||||
const r = spawnSync('pwsh', ['-NoProfile', '-File', SCRIPT], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DIFYCTL_R2_BASE: '' },
|
||||
})
|
||||
if (hasPwsh) {
|
||||
expect(r.status).not.toBe(0)
|
||||
expect(r.stderr + r.stdout).toMatch(/DIFYCTL_R2_BASE/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,149 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./install-r2.sh', import.meta.url))
|
||||
|
||||
const MANIFEST = [
|
||||
'{',
|
||||
' "schema": 1,',
|
||||
' "name": "difyctl",',
|
||||
' "channel": "edge",',
|
||||
' "version": "0.1.0-edge.2fd7b82",',
|
||||
' "commit": "abc1234",',
|
||||
' "buildDate": "2026-06-14T12:00:00Z",',
|
||||
' "compat": {"minDify":"1.14.0","maxDify":"1.15.0"},',
|
||||
' "baseUrl": "https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82",',
|
||||
' "targets": {',
|
||||
' "linux-x64": { "asset": "difyctl-v0.1.0-edge.2fd7b82-linux-x64", "sha256": "deadbeef" },',
|
||||
' "darwin-arm64": { "asset": "difyctl-v0.1.0-edge.2fd7b82-darwin-arm64", "sha256": "cafef00d" }',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
const INDEX = [
|
||||
'{',
|
||||
' "schema": 1,',
|
||||
' "channel": "edge",',
|
||||
' "updated": "2026-06-15T00:00:00Z",',
|
||||
' "builds": [',
|
||||
' {',
|
||||
' "version": "0.1.0-edge.ce4af868",',
|
||||
' "commit": "ce4af868d653f405070fabb3be3303430cc030ad",',
|
||||
' "buildDate": "2026-06-15T00:00:00Z",',
|
||||
' "dir": "0.1.0-edge.ce4af868"',
|
||||
' },',
|
||||
' {',
|
||||
' "version": "0.1.0-edge.aaaa111",',
|
||||
' "commit": "aaaa111bbbbcccc000011112222333344445555",',
|
||||
' "buildDate": "2026-06-14T00:00:00Z",',
|
||||
' "dir": "0.1.0-edge.aaaa111"',
|
||||
' }',
|
||||
' ]',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
const CHECKSUMS = [
|
||||
'deadbeef difyctl-v0.1.0-edge.ce4af868-linux-x64',
|
||||
'cafef00d difyctl-v0.1.0-edge.ce4af868-darwin-arm64',
|
||||
'beadc0de difyctl-v0.1.0-edge.ce4af868-windows-x64.exe',
|
||||
].join('\n')
|
||||
|
||||
function lib(
|
||||
program: string,
|
||||
env: Record<string, string> = {},
|
||||
): { code: number; stdout: string; stderr: string } {
|
||||
const full = `. "${SCRIPT}"\n${program}`
|
||||
const r = spawnSync('sh', ['-c', full], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', ...env },
|
||||
})
|
||||
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
|
||||
}
|
||||
|
||||
describe('install-r2 manifest parsing', () => {
|
||||
// install-r2.sh is POSIX-only; under git-bash on Windows `uname -s` is MINGW*,
|
||||
// so detect_target intentionally dies (Windows installs go through install-r2.ps1).
|
||||
it.skipIf(process.platform === 'win32')('detect_target maps to one of the 5 ids', () => {
|
||||
const { stdout } = lib('detect_target')
|
||||
expect(['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']).toContain(
|
||||
stdout,
|
||||
)
|
||||
})
|
||||
|
||||
it('manifest_str reads a top-level string field', () => {
|
||||
const { stdout } = lib(
|
||||
`printf '%s' '${MANIFEST}' > "$tmp_m"; manifest_str "$tmp_m" channel`,
|
||||
{},
|
||||
)
|
||||
expect(stdout).toBe('edge')
|
||||
})
|
||||
|
||||
it('manifest_target_field extracts per-target values from a single line', () => {
|
||||
const prog =
|
||||
`printf '%s' '${MANIFEST}' > "$tmp_m"\n` +
|
||||
'manifest_target_field "$tmp_m" darwin-arm64 asset\n' +
|
||||
'manifest_target_field "$tmp_m" darwin-arm64 sha256'
|
||||
const { stdout } = lib(prog)
|
||||
expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-darwin-arm64\ncafef00d')
|
||||
})
|
||||
|
||||
it('requires DIFYCTL_R2_BASE when run as the installer (not lib)', () => {
|
||||
const r = spawnSync('sh', [SCRIPT], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DIFYCTL_R2_BASE: '' },
|
||||
})
|
||||
expect(r.status).not.toBe(0)
|
||||
expect(r.stderr).toMatch(/DIFYCTL_R2_BASE/)
|
||||
})
|
||||
|
||||
it('sha256_check aborts on a checksum mismatch', () => {
|
||||
const r = lib('f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" deadbeef')
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toMatch(/checksum mismatch/)
|
||||
})
|
||||
|
||||
it('sha256_check passes on the correct hash', () => {
|
||||
// sha256('hello') = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
|
||||
const r = lib(
|
||||
'f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 && echo OK',
|
||||
)
|
||||
expect(r.stdout).toBe('OK')
|
||||
})
|
||||
})
|
||||
|
||||
describe('install-r2 version/commit pin', () => {
|
||||
it('index_resolve matches a build by exact version', () => {
|
||||
const r = lib(
|
||||
`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" version 0.1.0-edge.aaaa111`,
|
||||
)
|
||||
expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111')
|
||||
})
|
||||
|
||||
it('index_resolve matches a build by commit prefix', () => {
|
||||
const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit ce4af868`)
|
||||
expect(r.stdout).toBe('0.1.0-edge.ce4af868\t0.1.0-edge.ce4af868')
|
||||
})
|
||||
|
||||
it('index_resolve matches the full 40-char commit too', () => {
|
||||
const r = lib(
|
||||
`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit aaaa111bbbbcccc000011112222333344445555`,
|
||||
)
|
||||
expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111')
|
||||
})
|
||||
|
||||
it('index_resolve prints nothing when no build matches', () => {
|
||||
const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit ffffff`)
|
||||
expect(r.stdout).toBe('')
|
||||
})
|
||||
|
||||
it('checksums_target extracts sha and asset for a posix target', () => {
|
||||
const r = lib(`printf '%s' '${CHECKSUMS}' > "$tmp_m"; checksums_target "$tmp_m" darwin-arm64`)
|
||||
expect(r.stdout).toBe('cafef00d\tdifyctl-v0.1.0-edge.ce4af868-darwin-arm64')
|
||||
})
|
||||
|
||||
it('checksums_target does not bleed x64 into arm64', () => {
|
||||
const r = lib(`printf '%s' '${CHECKSUMS}' > "$tmp_m"; checksums_target "$tmp_m" linux-x64`)
|
||||
expect(r.stdout).toBe('deadbeef\tdifyctl-v0.1.0-edge.ce4af868-linux-x64')
|
||||
})
|
||||
})
|
||||
@@ -1,297 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./install.ps1', import.meta.url))
|
||||
|
||||
function hasPwsh(): boolean {
|
||||
const r = spawnSync(
|
||||
'pwsh',
|
||||
['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
},
|
||||
)
|
||||
return r.status === 0
|
||||
}
|
||||
|
||||
const PWSH = hasPwsh()
|
||||
|
||||
const STUB = [
|
||||
'function Invoke-RestMethod {',
|
||||
' param([string]$Uri, $Headers)',
|
||||
" if ($Uri -like '*/releases/latest') {",
|
||||
" if (-not $env:HX_LATEST) { throw 'mock 404' }",
|
||||
' return ($env:HX_LATEST | ConvertFrom-Json)',
|
||||
' }',
|
||||
" elseif ($Uri -like '*/releases?per_page=100') {",
|
||||
" if (-not $env:HX_LIST) { throw 'mock 404' }",
|
||||
' return ($env:HX_LIST | ConvertFrom-Json)',
|
||||
' }',
|
||||
" elseif ($Uri -like '*/releases/tags/*') {",
|
||||
" $t = $Uri -replace '.*/releases/tags/', ''",
|
||||
" $k = 'HX_TAG_' + ($t -replace '[.\\-]', '_')",
|
||||
' $v = [Environment]::GetEnvironmentVariable($k)',
|
||||
" if (-not $v) { throw 'mock 404' }",
|
||||
' return ($v | ConvertFrom-Json)',
|
||||
' }',
|
||||
' throw "unexpected uri $Uri"',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
type Run = { code: number; stdout: string; stderr: string }
|
||||
|
||||
function runPwsh(body: string, env: Record<string, string> = {}): Run {
|
||||
const script = `$ErrorActionPreference='Stop'\n${STUB}\n. '${SCRIPT}'\n${body}`
|
||||
const r = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
DIFYCTL_INSTALL_LIB: '1',
|
||||
DIFY_VERSION: '',
|
||||
DIFYCTL_VERSION: '',
|
||||
LOCALAPPDATA: process.env.LOCALAPPDATA || '/tmp',
|
||||
TEMP: process.env.TEMP || '/tmp',
|
||||
...env,
|
||||
},
|
||||
})
|
||||
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
|
||||
}
|
||||
|
||||
const REL_1142 = JSON.stringify({
|
||||
tag_name: '1.14.2',
|
||||
assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }],
|
||||
})
|
||||
const REL_1150 = JSON.stringify({
|
||||
tag_name: '1.15.0',
|
||||
assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }],
|
||||
})
|
||||
const LIST_NEWEST_FIRST = JSON.stringify([
|
||||
{ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }] },
|
||||
{ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }] },
|
||||
])
|
||||
|
||||
describe.skipIf(!PWSH)('install.ps1 Get-AssetSemver', () => {
|
||||
it('extracts the version from a windows .exe asset name', () => {
|
||||
const r = runPwsh("(Get-AssetSemver 'difyctl-v0.2.0-windows-x64.exe').Version")
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('0.2.0')
|
||||
})
|
||||
|
||||
it('extracts a prerelease version and its rc number', () => {
|
||||
const r = runPwsh(
|
||||
'$a = Get-AssetSemver \'difyctl-v0.1.0-rc.1-windows-x64.exe\'; "$($a.Version) $($a.Rc)"',
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('0.1.0-rc.1 1')
|
||||
})
|
||||
|
||||
it('rejects a non-windows asset (returns null)', () => {
|
||||
const r = runPwsh(
|
||||
"if ($null -eq (Get-AssetSemver 'difyctl-v0.2.0-linux-x64')) { 'NULL' } else { 'OBJ' }",
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
|
||||
it('rejects a malformed core version (returns null)', () => {
|
||||
const r = runPwsh(
|
||||
"if ($null -eq (Get-AssetSemver 'difyctl-vx.y.z-windows-x64.exe')) { 'NULL' } else { 'OBJ' }",
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
|
||||
it('picks the highest semver among several windows builds', () => {
|
||||
const rel = JSON.stringify({
|
||||
assets: [
|
||||
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
|
||||
{ name: 'difyctl-v0.10.0-windows-x64.exe' },
|
||||
{ name: 'difyctl-v0.9.0-windows-x64.exe' },
|
||||
],
|
||||
})
|
||||
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('0.10.0')
|
||||
})
|
||||
|
||||
it('prefers the stable release over an rc of the same core', () => {
|
||||
const rel = JSON.stringify({
|
||||
assets: [
|
||||
{ name: 'difyctl-v0.2.0-rc.1-windows-x64.exe' },
|
||||
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
|
||||
],
|
||||
})
|
||||
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('0.2.0')
|
||||
})
|
||||
|
||||
it('ignores checksums and non-windows assets', () => {
|
||||
const rel = JSON.stringify({
|
||||
assets: [
|
||||
{ name: 'difyctl-v0.2.0-linux-x64' },
|
||||
{ name: 'difyctl-v0.2.0-checksums.txt' },
|
||||
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
|
||||
{ name: 'some-other-asset.zip' },
|
||||
],
|
||||
})
|
||||
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Name`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('difyctl-v0.2.0-windows-x64.exe')
|
||||
})
|
||||
|
||||
it('yields null when no windows asset is present', () => {
|
||||
const rel = JSON.stringify({ assets: [{ name: 'difyctl-v0.2.0-linux-x64' }] })
|
||||
const r = runPwsh(
|
||||
`if ($null -eq (Select-Asset ('${rel}' | ConvertFrom-Json))) { 'NULL' } else { 'OBJ' }`,
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
|
||||
it('DIFY_VERSION pins the release directly', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name', {
|
||||
DIFY_VERSION: '1.14.2',
|
||||
HX_TAG_1_14_2: REL_1142,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
|
||||
it('DIFY_VERSION that does not exist throws a clear message', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name', { DIFY_VERSION: '9.9.9' })
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('Dify release 9.9.9 not found')
|
||||
})
|
||||
|
||||
it('blank resolves to the latest release', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name', { HX_LATEST: REL_1150 })
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.15.0')
|
||||
})
|
||||
|
||||
it('blank throws when the latest query fails (no silent fallback)', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name')
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('failed to query latest Dify release')
|
||||
})
|
||||
|
||||
it('DIFYCTL_VERSION resolves to the release hosting that build', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name', {
|
||||
DIFYCTL_VERSION: '0.2.0',
|
||||
HX_LIST: LIST_NEWEST_FIRST,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
|
||||
it('DIFYCTL_VERSION not hosted anywhere throws', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name', {
|
||||
DIFYCTL_VERSION: '9.9.9',
|
||||
HX_LIST: LIST_NEWEST_FIRST,
|
||||
})
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('difyctl 9.9.9 not found on any Dify release')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!PWSH)('install.ps1 Find-ReleaseForDifyctl', () => {
|
||||
it('returns the newest release whose assets host the wanted build', () => {
|
||||
const r = runPwsh("(Find-ReleaseForDifyctl '0.2.0').tag_name", { HX_LIST: LIST_NEWEST_FIRST })
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
|
||||
it('returns nothing when no release hosts the wanted build', () => {
|
||||
const r = runPwsh(
|
||||
"$x = Find-ReleaseForDifyctl '9.9.9'; if ($null -eq $x) { 'NULL' } else { $x.tag_name }",
|
||||
{ HX_LIST: LIST_NEWEST_FIRST },
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
})
|
||||
|
||||
// Build a fake ErrorRecord whose Exception.Response is a real HttpResponseMessage
|
||||
// carrying the given status + headers, matching what Get-RateLimitInfo inspects.
|
||||
function fakeErr(status: number, headers: Record<string, string>): string {
|
||||
const adds = Object.entries(headers)
|
||||
.map(([k, v]) => `$resp.Headers.TryAddWithoutValidation('${k}','${v}') | Out-Null`)
|
||||
.join('\n')
|
||||
return [
|
||||
`$resp = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]${status})`,
|
||||
adds,
|
||||
'$err = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = $resp } }',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const futureReset = String(Math.floor(Date.now() / 1000) + 1800)
|
||||
|
||||
describe.skipIf(!PWSH)('install.ps1 rate limit', () => {
|
||||
it('classifies a 403 with x-ratelimit-remaining:0 as rate-limited, returning the reset', () => {
|
||||
const r =
|
||||
runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': futureReset })}
|
||||
$i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { $i.Reset }`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe(futureReset)
|
||||
})
|
||||
|
||||
it('does not classify a 403 with remaining tokens as rate-limited', () => {
|
||||
const r = runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '59' })}
|
||||
$i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { 'LIMITED' }`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
|
||||
it('always treats a 429 as rate-limited', () => {
|
||||
const r = runPwsh(`${fakeErr(429, {})}
|
||||
$i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { 'LIMITED' }`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('LIMITED')
|
||||
})
|
||||
|
||||
it('returns null for an error without a response (e.g. a plain string throw)', () => {
|
||||
const r = runPwsh(
|
||||
"$err = [pscustomobject]@{ Exception = [pscustomobject]@{} }; if ($null -eq (Get-RateLimitInfo $err)) { 'NULL' } else { 'OBJ' }",
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
|
||||
it('Write-RateLimitHint prints cause, ETA, and remediation to stderr', () => {
|
||||
const r = runPwsh(`Write-RateLimitHint '${futureReset}'`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stderr).toContain('rate limit exceeded')
|
||||
expect(r.stderr).toContain('resets in ~')
|
||||
expect(r.stderr).toContain('GITHUB_TOKEN')
|
||||
})
|
||||
|
||||
it('Write-RateLimitHint omits the ETA line when the reset epoch is missing', () => {
|
||||
const r = runPwsh("Write-RateLimitHint ''")
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stderr).toContain('rate limit exceeded')
|
||||
expect(r.stderr).not.toContain('resets in ~')
|
||||
})
|
||||
|
||||
it('sends an Authorization header when GITHUB_TOKEN is set', () => {
|
||||
const r = runPwsh('$headers.Authorization', { GITHUB_TOKEN: 'ghp_secret123' })
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('Bearer ghp_secret123')
|
||||
})
|
||||
|
||||
it('Resolve-Release surfaces the rate-limit hint and exits, not "not found"', () => {
|
||||
const stub = `function Invoke-RestMethod { throw [Microsoft.PowerShell.Commands.HttpResponseException]::new('rate limited', $script:rlResp) }`
|
||||
const setup = `$script:rlResp = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]403)
|
||||
$script:rlResp.Headers.TryAddWithoutValidation('x-ratelimit-remaining','0') | Out-Null
|
||||
$script:rlResp.Headers.TryAddWithoutValidation('x-ratelimit-reset','${futureReset}') | Out-Null`
|
||||
const r = runPwsh(`${setup}\n${stub}\nResolve-Release`, { DIFY_VERSION: '1.15.0' })
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('rate limit exceeded')
|
||||
expect(r.stderr).not.toContain('not found')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { assetNameFor } from './release-rules.mjs'
|
||||
|
||||
// checksums lines are "<sha256> <assetName>"
|
||||
export function parseChecksums(text) {
|
||||
const map = new Map()
|
||||
for (const line of text.split('\n')) {
|
||||
const m = line.match(/^([0-9a-f]{64})\s+(\S+)$/i)
|
||||
if (m) map.set(m[2], m[1])
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// Newline-delimited dir names of binaries that still exist in R2.
|
||||
export function parseDirList(text) {
|
||||
const set = new Set()
|
||||
for (const line of text.split('\n')) {
|
||||
const d = line.trim()
|
||||
if (d) set.add(d)
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
export function resolveTargets(release, version, shas) {
|
||||
const targets = []
|
||||
const missing = []
|
||||
for (const t of release.targets) {
|
||||
const asset = assetNameFor(release, version, t.id)
|
||||
const sha = shas.get(asset)
|
||||
if (sha) targets.push({ id: t.id, asset, sha })
|
||||
else missing.push(asset)
|
||||
}
|
||||
return { targets, missing }
|
||||
}
|
||||
|
||||
export function renderManifest({
|
||||
binName,
|
||||
channel,
|
||||
version,
|
||||
commit,
|
||||
buildDate,
|
||||
compat,
|
||||
baseUrl,
|
||||
targets,
|
||||
}) {
|
||||
const head = {
|
||||
schema: 1,
|
||||
name: binName,
|
||||
channel,
|
||||
version,
|
||||
commit,
|
||||
buildDate,
|
||||
compat: { minDify: compat.minDify, maxDify: compat.maxDify },
|
||||
baseUrl,
|
||||
}
|
||||
const headLines = Object.entries(head)
|
||||
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
||||
.join(',\n')
|
||||
const targetLines = targets
|
||||
.map(
|
||||
(t) =>
|
||||
` ${JSON.stringify(t.id)}: { "asset": ${JSON.stringify(t.asset)}, "sha256": ${JSON.stringify(t.sha)} }`,
|
||||
)
|
||||
.join(',\n')
|
||||
return `{\n${headLines},\n "targets": {\n${targetLines}\n }\n}\n`
|
||||
}
|
||||
|
||||
// `current` is the parsed ledger or null (first publish). `existingDirs` is a
|
||||
// Set of build dirs still present in R2, or null when the caller could not list
|
||||
// them — lifecycle/TTL on the bin prefix is the only deletion mechanism, so the
|
||||
// ledger must never advertise a build whose binary is gone. The new build is
|
||||
// always kept (just uploaded). No count cap.
|
||||
export function buildIndex({ channel, version, commit, buildDate, current, existingDirs }) {
|
||||
const entry = { version, commit, buildDate, dir: version }
|
||||
const kept = (current?.builds ?? []).filter((b) => b.version !== entry.version)
|
||||
let builds = [entry, ...kept]
|
||||
if (existingDirs) builds = builds.filter((b) => b.dir === entry.dir || existingDirs.has(b.dir))
|
||||
return { schema: 1, channel, updated: buildDate, builds }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildIndex,
|
||||
parseChecksums,
|
||||
parseDirList,
|
||||
renderManifest,
|
||||
resolveTargets,
|
||||
} from './edge-manifest.mjs'
|
||||
|
||||
const RELEASE = {
|
||||
tagPrefix: 'testctl-v',
|
||||
binName: 'testctl',
|
||||
checksumsSuffix: '.sums',
|
||||
targets: [
|
||||
{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: false },
|
||||
{ id: 'windows-x64', bunTarget: 'bun-windows-x64', exe: true },
|
||||
],
|
||||
}
|
||||
|
||||
const VERSION = '7.7.7-edge.2fd7b82'
|
||||
const SHA_LINUX = 'a'.repeat(64)
|
||||
const SHA_WINDOWS = 'b'.repeat(64)
|
||||
|
||||
const CHECKSUMS = [
|
||||
`${SHA_LINUX} testctl-v${VERSION}-linux-x64`,
|
||||
`${SHA_WINDOWS} testctl-v${VERSION}-windows-x64.exe`,
|
||||
].join('\n')
|
||||
|
||||
describe('parseChecksums', () => {
|
||||
it('maps asset name to sha256', () => {
|
||||
const map = parseChecksums(CHECKSUMS)
|
||||
expect(map.get(`testctl-v${VERSION}-linux-x64`)).toBe(SHA_LINUX)
|
||||
expect(map.get(`testctl-v${VERSION}-windows-x64.exe`)).toBe(SHA_WINDOWS)
|
||||
})
|
||||
|
||||
it('ignores blank and malformed lines', () => {
|
||||
expect(parseChecksums('\nnot a checksum line\n\n').size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDirList', () => {
|
||||
it('collects non-blank trimmed lines', () => {
|
||||
expect([...parseDirList(' a \n\n b\n')]).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('yields an empty set for empty input', () => {
|
||||
expect(parseDirList('').size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTargets', () => {
|
||||
it('pairs every target with its sha', () => {
|
||||
const { targets, missing } = resolveTargets(RELEASE, VERSION, parseChecksums(CHECKSUMS))
|
||||
expect(missing).toEqual([])
|
||||
expect(targets).toEqual([
|
||||
{ id: 'linux-x64', asset: `testctl-v${VERSION}-linux-x64`, sha: SHA_LINUX },
|
||||
{ id: 'windows-x64', asset: `testctl-v${VERSION}-windows-x64.exe`, sha: SHA_WINDOWS },
|
||||
])
|
||||
})
|
||||
|
||||
it('reports assets with no checksum', () => {
|
||||
const partial = parseChecksums(`${SHA_LINUX} testctl-v${VERSION}-linux-x64`)
|
||||
const { targets, missing } = resolveTargets(RELEASE, VERSION, partial)
|
||||
expect(targets).toHaveLength(1)
|
||||
expect(missing).toEqual([`testctl-v${VERSION}-windows-x64.exe`])
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderManifest', () => {
|
||||
const manifest = () =>
|
||||
renderManifest({
|
||||
binName: RELEASE.binName,
|
||||
channel: 'edge',
|
||||
version: VERSION,
|
||||
commit: 'abc1234',
|
||||
buildDate: '2026-06-14T12:00:00Z',
|
||||
compat: { minDify: '2.0.0', maxDify: '2.5.0' },
|
||||
baseUrl: 'https://example.r2.dev/testctl/edge',
|
||||
targets: resolveTargets(RELEASE, VERSION, parseChecksums(CHECKSUMS)).targets,
|
||||
})
|
||||
|
||||
it('emits the pointer fields', () => {
|
||||
const json = JSON.parse(manifest())
|
||||
expect(json).toMatchObject({
|
||||
schema: 1,
|
||||
name: 'testctl',
|
||||
channel: 'edge',
|
||||
version: VERSION,
|
||||
commit: 'abc1234',
|
||||
buildDate: '2026-06-14T12:00:00Z',
|
||||
baseUrl: 'https://example.r2.dev/testctl/edge',
|
||||
})
|
||||
})
|
||||
|
||||
it('carries both compat bounds through unswapped', () => {
|
||||
expect(JSON.parse(manifest()).compat).toEqual({ minDify: '2.0.0', maxDify: '2.5.0' })
|
||||
})
|
||||
|
||||
it('lists each target with asset name and sha256', () => {
|
||||
expect(JSON.parse(manifest()).targets).toEqual({
|
||||
'linux-x64': { asset: `testctl-v${VERSION}-linux-x64`, sha256: SHA_LINUX },
|
||||
'windows-x64': { asset: `testctl-v${VERSION}-windows-x64.exe`, sha256: SHA_WINDOWS },
|
||||
})
|
||||
})
|
||||
|
||||
it('renders each target on a single line (install-r2.sh greps it)', () => {
|
||||
expect(manifest()).toMatch(/^ {4}"linux-x64": \{ "asset": ".*", "sha256": ".*" \}/m)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildIndex', () => {
|
||||
const B1 = { version: '7.7.7-edge.aaaaaaa', commit: 'aaaaaaa', buildDate: '2026-06-14T09:00:00Z' }
|
||||
const B2 = { version: '7.7.7-edge.bbbbbbb', commit: 'bbbbbbb', buildDate: '2026-06-14T10:00:00Z' }
|
||||
const entryOf = (b: typeof B1) => ({ ...b, dir: b.version })
|
||||
const build = (over = {}) =>
|
||||
buildIndex({ channel: 'edge', ...B2, current: null, existingDirs: null, ...over })
|
||||
|
||||
it('creates a fresh ledger when there is no current index', () => {
|
||||
const index = build()
|
||||
expect(index).toMatchObject({ schema: 1, channel: 'edge', updated: B2.buildDate })
|
||||
expect(index.builds).toEqual([entryOf(B2)])
|
||||
})
|
||||
|
||||
it('prepends the new build, newest first', () => {
|
||||
const index = build({ current: { builds: [entryOf(B1)] } })
|
||||
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
|
||||
})
|
||||
|
||||
it('replaces an existing entry for the same version rather than duplicating', () => {
|
||||
const stale = { ...entryOf(B2), commit: 'oldsha' }
|
||||
const index = build({ current: { builds: [stale, entryOf(B1)] } })
|
||||
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
|
||||
})
|
||||
|
||||
it('drops builds whose binaries no longer exist in R2', () => {
|
||||
const index = build({
|
||||
current: { builds: [entryOf(B1)] },
|
||||
existingDirs: new Set<string>(),
|
||||
})
|
||||
expect(index.builds).toEqual([entryOf(B2)])
|
||||
})
|
||||
|
||||
it('keeps builds that still exist in R2', () => {
|
||||
const index = build({
|
||||
current: { builds: [entryOf(B1)] },
|
||||
existingDirs: new Set([B1.version]),
|
||||
})
|
||||
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
|
||||
})
|
||||
|
||||
it('reconciles nothing when the caller could not list R2', () => {
|
||||
const index = build({ current: { builds: [entryOf(B1)] }, existingDirs: null })
|
||||
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
// Pure release naming and version rules. Nothing here reads a file, prints, or
|
||||
// exits — the CLI shells own all of that.
|
||||
|
||||
const BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/
|
||||
const SEMVER_CORE_LEN = 3
|
||||
|
||||
// Add channels here: { name, prerelease, versionForm }.
|
||||
export const CHANNELS = [
|
||||
{ name: 'stable', prerelease: false, versionForm: /^\d+\.\d+\.\d+(\+[0-9A-Z.-]+)?$/i },
|
||||
{ name: 'alpha', prerelease: true, versionForm: /^\d+\.\d+\.\d+-alpha(\.\d+)?$/ },
|
||||
{ name: 'rc', prerelease: true, versionForm: /^\d+\.\d+\.\d+-rc\.\d+$/ },
|
||||
{ name: 'edge', prerelease: true, versionForm: /^\d+\.\d+\.\d+-edge\.[0-9a-f]{7,40}$/ },
|
||||
]
|
||||
|
||||
export const EDGE_SHA_RE = /^[0-9a-f]{7,40}$/
|
||||
|
||||
export const channelByName = (name) => CHANNELS.find((c) => c.name === name)
|
||||
export const channelNames = () => CHANNELS.map((c) => c.name).join(', ')
|
||||
|
||||
export function versionCore(v) {
|
||||
return String(v).replace(/^v/, '').replace(/\+.*$/, '').split('-')[0]
|
||||
}
|
||||
|
||||
// Compat ordering compares the numeric A.B.C core only: prerelease and build
|
||||
// suffixes are stripped first, so 1.16.0-rc.1 ranks equal to 1.16.0. This
|
||||
// matches the runtime check in src/version/compat.ts.
|
||||
function compareCore(a, b) {
|
||||
const A = versionCore(a).split('.').map(Number)
|
||||
const B = versionCore(b).split('.').map(Number)
|
||||
for (let i = 0; i < SEMVER_CORE_LEN; i++) {
|
||||
const x = A[i] ?? 0
|
||||
const y = B[i] ?? 0
|
||||
if (x !== y) return x < y ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function isWithinCompatWindow(version, { minDify, maxDify }) {
|
||||
return compareCore(version, minDify) >= 0 && compareCore(version, maxDify) <= 0
|
||||
}
|
||||
|
||||
// Returns a problem string if `version` cannot be resolved under `channel`,
|
||||
// else null.
|
||||
export function channelVersionProblem(version, channel) {
|
||||
if (typeof version !== 'string' || version.length === 0)
|
||||
return 'version must be a non-empty string'
|
||||
const ch = channelByName(channel)
|
||||
if (!ch) return `unknown channel: ${channel} (expected one of: ${channelNames()})`
|
||||
if (!ch.versionForm.test(version))
|
||||
return `version ${version} does not match the ${channel} channel form`
|
||||
return null
|
||||
}
|
||||
|
||||
// Returns a problem string if the edge base cannot be derived, else null.
|
||||
export function edgeVersionProblem(version, sha) {
|
||||
if (!EDGE_SHA_RE.test(sha ?? '')) return 'edge-version requires a git short sha (7-40 hex chars)'
|
||||
if (!/^\d+\.\d+\.\d+$/.test(versionCore(version)))
|
||||
return `cannot derive edge base from version: ${version}`
|
||||
return null
|
||||
}
|
||||
|
||||
export function edgeVersionFrom(version, sha) {
|
||||
return `${versionCore(version)}-edge.${sha}`
|
||||
}
|
||||
|
||||
export function tagName(release, version) {
|
||||
return `${release.tagPrefix}${version}`
|
||||
}
|
||||
|
||||
export function checksumsName(release, version) {
|
||||
return `${release.tagPrefix}${version}${release.checksumsSuffix}`
|
||||
}
|
||||
|
||||
export function assetNameFor(release, version, id) {
|
||||
const target = release.targets.find((t) => t.id === id)
|
||||
if (!target) return null
|
||||
return `${release.tagPrefix}${version}-${id}${target.exe ? '.exe' : ''}`
|
||||
}
|
||||
|
||||
export function compatProblems(compat) {
|
||||
const str = (v) => typeof v === 'string' && v.length > 0
|
||||
const problems = []
|
||||
if (!str(compat?.minDify)) problems.push('compat.minDify must be a non-empty string')
|
||||
if (!str(compat?.maxDify)) problems.push('compat.maxDify must be a non-empty string')
|
||||
if (problems.length === 0 && compareCore(compat.minDify, compat.maxDify) > 0)
|
||||
problems.push(
|
||||
`compat.minDify ${compat.minDify} must not exceed compat.maxDify ${compat.maxDify}`,
|
||||
)
|
||||
return problems
|
||||
}
|
||||
|
||||
export function releaseConfigProblems(release) {
|
||||
const problems = []
|
||||
const str = (v) => typeof v === 'string' && v.length > 0
|
||||
if (!str(release.tagPrefix)) problems.push('tagPrefix must be a non-empty string')
|
||||
if (!str(release.binName)) problems.push('binName must be a non-empty string')
|
||||
if (!str(release.checksumsSuffix)) problems.push('checksumsSuffix must be a non-empty string')
|
||||
if (!Array.isArray(release.targets) || release.targets.length === 0) {
|
||||
problems.push('targets must be a non-empty array')
|
||||
return problems
|
||||
}
|
||||
const seen = new Set()
|
||||
for (const t of release.targets) {
|
||||
const label = t?.id ?? JSON.stringify(t)
|
||||
if (!str(t?.id)) problems.push(`target ${label}: id must be a non-empty string`)
|
||||
else if (seen.has(t.id)) problems.push(`duplicate target id: ${t.id}`)
|
||||
else seen.add(t.id)
|
||||
if (!str(t?.bunTarget) || !BUN_TARGET_RE.test(t.bunTarget))
|
||||
problems.push(`target ${label}: bunTarget must match ${BUN_TARGET_RE}`)
|
||||
if (typeof t?.exe !== 'boolean') problems.push(`target ${label}: exe must be a boolean`)
|
||||
else if (str(t?.bunTarget) && t.exe !== t.bunTarget.startsWith('bun-windows-'))
|
||||
problems.push(`target ${label}: exe must be true iff bunTarget is bun-windows-*`)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assetNameFor,
|
||||
channelVersionProblem,
|
||||
checksumsName,
|
||||
compatProblems,
|
||||
edgeVersionFrom,
|
||||
edgeVersionProblem,
|
||||
isWithinCompatWindow,
|
||||
releaseConfigProblems,
|
||||
tagName,
|
||||
} from './release-rules.mjs'
|
||||
|
||||
const RELEASE = {
|
||||
tagPrefix: 'testctl-v',
|
||||
binName: 'testctl',
|
||||
checksumsSuffix: '.sums',
|
||||
targets: [
|
||||
{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: false },
|
||||
{ id: 'windows-x64', bunTarget: 'bun-windows-x64', exe: true },
|
||||
],
|
||||
}
|
||||
|
||||
const WINDOW = { minDify: '2.0.0', maxDify: '2.5.0' }
|
||||
|
||||
describe('isWithinCompatWindow', () => {
|
||||
it.each([
|
||||
['2.3.0', true, 'inside the window'],
|
||||
['2.0.0', true, 'the inclusive lower bound'],
|
||||
['2.5.0', true, 'the inclusive upper bound'],
|
||||
['v2.3.0', true, 'a v-prefixed tag'],
|
||||
['1.9.9', false, 'below the lower bound'],
|
||||
['2.5.1', false, 'above the upper bound'],
|
||||
])('%s -> %s (%s)', (version, expected) => {
|
||||
expect(isWithinCompatWindow(version, WINDOW)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['2.0.0-rc.1', true, 'a prerelease of the lower bound ranks equal to it'],
|
||||
['2.5.0-rc.1', true, 'a prerelease of the upper bound ranks equal to it'],
|
||||
['2.3.0-alpha.7+build9', true, 'both suffixes stripped before comparing'],
|
||||
['2.5.0+build123', true, 'build metadata on the upper bound'],
|
||||
['2.5.1-rc.1', false, 'a prerelease whose core is above the window'],
|
||||
['1.9.9-rc.1', false, 'a prerelease whose core is below the window'],
|
||||
['2.5.1+build123', false, 'build metadata out of range'],
|
||||
])('ignores suffixes: %s -> %s (%s)', (version, expected) => {
|
||||
expect(isWithinCompatWindow(version, WINDOW)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('channelVersionProblem', () => {
|
||||
it.each([
|
||||
['1.2.3', 'stable'],
|
||||
['1.2.3+build', 'stable'],
|
||||
['1.2.3-alpha', 'alpha'],
|
||||
['1.2.3-alpha.4', 'alpha'],
|
||||
['1.2.3-rc.4', 'rc'],
|
||||
['1.2.3-edge.2fd7b82', 'edge'],
|
||||
])('accepts %s on the %s channel', (version, channel) => {
|
||||
expect(channelVersionProblem(version, channel)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['1.2.3-rc.1', 'edge'],
|
||||
['1.2.3-alpha', 'stable'],
|
||||
['1.2.3', 'rc'],
|
||||
])('rejects %s on the %s channel', (version, channel) => {
|
||||
expect(channelVersionProblem(version, channel)).toMatch(/does not match the .* channel form/)
|
||||
})
|
||||
|
||||
it('rejects an unknown channel', () => {
|
||||
expect(channelVersionProblem('1.2.3', 'nightly')).toMatch(/unknown channel/)
|
||||
})
|
||||
|
||||
it('rejects an empty version', () => {
|
||||
expect(channelVersionProblem('', 'stable')).toMatch(/non-empty string/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge version derivation', () => {
|
||||
it('derives <core>-edge.<sha>, dropping the prerelease', () => {
|
||||
expect(edgeVersionProblem('3.4.5-alpha', '2fd7b82')).toBeNull()
|
||||
expect(edgeVersionFrom('3.4.5-alpha', '2fd7b82')).toBe('3.4.5-edge.2fd7b82')
|
||||
})
|
||||
|
||||
it('accepts a 40-char sha', () => {
|
||||
const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000'
|
||||
expect(edgeVersionProblem('3.4.5-alpha', sha)).toBeNull()
|
||||
expect(edgeVersionFrom('3.4.5-alpha', sha)).toBe(`3.4.5-edge.${sha}`)
|
||||
})
|
||||
|
||||
it.each([['nothex!'], [''], ['abc'], [undefined]])('rejects sha %s', (sha) => {
|
||||
expect(edgeVersionProblem('3.4.5-alpha', sha)).toMatch(/git short sha/)
|
||||
})
|
||||
|
||||
it('rejects a version with no derivable core', () => {
|
||||
expect(edgeVersionProblem('not-a-version', '2fd7b82')).toMatch(/cannot derive edge base/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('artifact names', () => {
|
||||
it('builds the tag and checksums names from the prefix', () => {
|
||||
expect(tagName(RELEASE, '9.9.9')).toBe('testctl-v9.9.9')
|
||||
expect(checksumsName(RELEASE, '9.9.9')).toBe('testctl-v9.9.9.sums')
|
||||
})
|
||||
|
||||
it('appends .exe only for targets flagged exe', () => {
|
||||
expect(assetNameFor(RELEASE, '9.9.9', 'linux-x64')).toBe('testctl-v9.9.9-linux-x64')
|
||||
expect(assetNameFor(RELEASE, '9.9.9', 'windows-x64')).toBe('testctl-v9.9.9-windows-x64.exe')
|
||||
})
|
||||
|
||||
it('returns null for an unknown target id', () => {
|
||||
expect(assetNameFor(RELEASE, '9.9.9', 'solaris-sparc')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('compatProblems', () => {
|
||||
it('accepts a well-formed window', () => {
|
||||
expect(compatProblems(WINDOW)).toEqual([])
|
||||
expect(compatProblems({ minDify: '2.0.0', maxDify: '2.0.0' })).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[undefined, /minDify must be a non-empty string/],
|
||||
[{ maxDify: '2.5.0' }, /minDify must be a non-empty string/],
|
||||
[{ minDify: '2.0.0' }, /maxDify must be a non-empty string/],
|
||||
[{ minDify: '', maxDify: '' }, /minDify must be a non-empty string/],
|
||||
])('flags a missing bound', (compat, expected) => {
|
||||
expect(compatProblems(compat).join('\n')).toMatch(expected)
|
||||
})
|
||||
|
||||
it('compares bounds by core, so suffixes do not make a window inverted', () => {
|
||||
expect(compatProblems({ minDify: '2.0.0-rc.1', maxDify: '2.0.0' })).toEqual([])
|
||||
})
|
||||
|
||||
it('flags an inverted window', () => {
|
||||
expect(compatProblems({ minDify: '2.5.0', maxDify: '2.0.0' }).join('\n')).toMatch(
|
||||
/must not exceed/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('releaseConfigProblems', () => {
|
||||
it('accepts a well-formed config', () => {
|
||||
expect(releaseConfigProblems(RELEASE)).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ ...RELEASE, tagPrefix: '' }, /tagPrefix must be a non-empty string/],
|
||||
[{ ...RELEASE, binName: '' }, /binName must be a non-empty string/],
|
||||
[{ ...RELEASE, checksumsSuffix: '' }, /checksumsSuffix must be a non-empty string/],
|
||||
[{ ...RELEASE, targets: [] }, /targets must be a non-empty array/],
|
||||
[{ ...RELEASE, targets: undefined }, /targets must be a non-empty array/],
|
||||
])('flags a malformed field', (release, expected) => {
|
||||
expect(releaseConfigProblems(release).join('\n')).toMatch(expected)
|
||||
})
|
||||
|
||||
it('flags a duplicate target id', () => {
|
||||
const release = { ...RELEASE, targets: [RELEASE.targets[0], { ...RELEASE.targets[0] }] }
|
||||
expect(releaseConfigProblems(release).join('\n')).toMatch(/duplicate target id: linux-x64/)
|
||||
})
|
||||
|
||||
it('flags a bunTarget that is not a bun triple', () => {
|
||||
const release = { ...RELEASE, targets: [{ id: 'a', bunTarget: 'linux-x64', exe: false }] }
|
||||
expect(releaseConfigProblems(release).join('\n')).toMatch(/bunTarget must match/)
|
||||
})
|
||||
|
||||
it('flags exe disagreeing with a windows bunTarget', () => {
|
||||
const release = {
|
||||
...RELEASE,
|
||||
targets: [{ id: 'windows-x64', bunTarget: 'bun-windows-x64', exe: false }],
|
||||
}
|
||||
expect(releaseConfigProblems(release).join('\n')).toMatch(/exe must be true iff/)
|
||||
})
|
||||
|
||||
it('flags a non-boolean exe', () => {
|
||||
const release = {
|
||||
...RELEASE,
|
||||
targets: [{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: 'no' }],
|
||||
}
|
||||
expect(releaseConfigProblems(release).join('\n')).toMatch(/exe must be a boolean/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { main } from './release-naming.mjs'
|
||||
|
||||
const env = Object.fromEntries(
|
||||
main(['github-env'])
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line: string) => line.split(/=(.*)/s).slice(0, 2)),
|
||||
)
|
||||
|
||||
// Install scripts run standalone on an end user's machine — no repo, no node —
|
||||
// so they hardcode artifact names instead of asking release-naming.mjs. These
|
||||
// checks are what keeps the two copies from drifting: change the naming config
|
||||
// and they fail until the installers are updated to match.
|
||||
const INSTALLERS = ['install-cli.sh', 'install-r2.sh', 'install.ps1', 'install-r2.ps1']
|
||||
const installerText = (name: string) =>
|
||||
readFileSync(fileURLToPath(new URL(`./${name}`, import.meta.url)), 'utf8')
|
||||
|
||||
const CHECKSUMS_SUFFIX = main(['checksums', '1.2.3']).trim().replace(`${env.tagPrefix}1.2.3`, '')
|
||||
|
||||
const EXE_TARGET_ID = main(['targets'])
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line: string) => line.split('\t'))
|
||||
.find(([, , exe]: string[]) => exe === '1')?.[1]
|
||||
|
||||
describe('install scripts stay aligned with the release naming config', () => {
|
||||
it.each(INSTALLERS)('%s uses the configured tag prefix', (name) => {
|
||||
expect(installerText(name)).toContain(env.tagPrefix)
|
||||
})
|
||||
|
||||
it.each(INSTALLERS)('%s uses the configured checksums suffix', (name) => {
|
||||
expect(installerText(name)).toContain(CHECKSUMS_SUFFIX)
|
||||
})
|
||||
|
||||
it.each(['install.ps1', 'install-r2.ps1'])('%s targets the declared windows build', (name) => {
|
||||
expect(EXE_TARGET_ID).toBeTruthy()
|
||||
expect(installerText(name)).toContain(EXE_TARGET_ID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the shipped difyctl release config', () => {
|
||||
it('passes the release gate (`release-naming.mjs validate`)', () => {
|
||||
expect(main(['validate'])).toMatch(/^difyctl release valid:/)
|
||||
})
|
||||
|
||||
it('declares a usable, correctly ordered compat window', () => {
|
||||
expect(main(['compat-check', env.minDify])).toContain('compatible')
|
||||
expect(main(['compat-check', env.maxDify])).toContain('compatible')
|
||||
})
|
||||
|
||||
it('gates a Dify version outside the declared window', () => {
|
||||
const aboveMax = `${Number(env.maxDify.split('.')[0]) + 1}.0.0`
|
||||
expect(() => main(['compat-check', aboveMax])).toThrow('outside difyctl compatibility window')
|
||||
expect(() => main(['compat-check', '0.0.1'])).toThrow('outside difyctl compatibility window')
|
||||
})
|
||||
|
||||
it('declares a version valid for the channel it ships on', () => {
|
||||
expect(main(['validate-version', env.version, env.channel])).toContain('valid')
|
||||
})
|
||||
|
||||
it('can name an artifact for every target it declares', () => {
|
||||
const ids = main(['targets'])
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line: string) => line.split('\t')[1])
|
||||
expect(ids.length).toBeGreaterThan(0)
|
||||
for (const id of ids) expect(main(['asset', env.version, id])).toContain(id)
|
||||
})
|
||||
})
|
||||
+63
-172
@@ -1,9 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
// release-naming.mjs — single source of truth for difyctl release artifact
|
||||
// names and version/channel rules. Reads DATA from cli/package.json
|
||||
// `difyctl.release` (plus `version` and `difyctl.channel`) and owns the name
|
||||
// FORMAT and the per-channel version form. Producer scripts call this;
|
||||
// `validate` is the release gate.
|
||||
// `difyctl.release` (plus `version` and `difyctl.channel`); the name FORMAT and
|
||||
// the per-channel version form live in lib/release-rules.mjs. Producer scripts
|
||||
// call this; `validate` is the release gate.
|
||||
//
|
||||
// Subcommands:
|
||||
// tag <version> -> <tagPrefix><version>
|
||||
@@ -19,109 +18,31 @@
|
||||
|
||||
import { readFileSync, realpathSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
assetNameFor,
|
||||
channelByName,
|
||||
channelNames,
|
||||
CHANNELS,
|
||||
channelVersionProblem,
|
||||
checksumsName,
|
||||
compatProblems,
|
||||
edgeVersionFrom,
|
||||
edgeVersionProblem,
|
||||
isWithinCompatWindow,
|
||||
releaseConfigProblems,
|
||||
tagName,
|
||||
} from './lib/release-rules.mjs'
|
||||
|
||||
const BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/
|
||||
const SEMVER_CORE_LEN = 3
|
||||
const PKG_URL = new URL('../package.json', import.meta.url)
|
||||
|
||||
// Add channels here: { name, prerelease, versionForm }.
|
||||
const CHANNELS = [
|
||||
{ name: 'stable', prerelease: false, versionForm: /^\d+\.\d+\.\d+(\+[0-9A-Z.-]+)?$/i },
|
||||
{ name: 'alpha', prerelease: true, versionForm: /^\d+\.\d+\.\d+-alpha(\.\d+)?$/ },
|
||||
{ name: 'rc', prerelease: true, versionForm: /^\d+\.\d+\.\d+-rc\.\d+$/ },
|
||||
{ name: 'edge', prerelease: true, versionForm: /^\d+\.\d+\.\d+-edge\.[0-9a-f]{7,40}$/ },
|
||||
]
|
||||
class UsageError extends Error {}
|
||||
|
||||
const channelByName = (name) => CHANNELS.find((c) => c.name === name)
|
||||
const channelNames = () => CHANNELS.map((c) => c.name).join(', ')
|
||||
|
||||
function parsePrecedence(v) {
|
||||
const s = String(v).replace(/^v/, '').replace(/\+.*$/, '')
|
||||
const i = s.indexOf('-')
|
||||
const core = i === -1 ? s : s.slice(0, i)
|
||||
const pre = i === -1 ? '' : s.slice(i + 1)
|
||||
return { nums: core.split('.').map(Number), pre }
|
||||
// Arrow so TS infers `never`, keeping expression positions like `x ?? die(...)` typed.
|
||||
const die = (msg) => {
|
||||
throw new UsageError(msg)
|
||||
}
|
||||
|
||||
function versionCore(v) {
|
||||
return String(v).replace(/^v/, '').replace(/\+.*$/, '').split('-')[0]
|
||||
}
|
||||
|
||||
function edgeVersion(sha) {
|
||||
if (!/^[0-9a-f]{7,40}$/.test(sha ?? ''))
|
||||
die('edge-version requires a git short sha (7-40 hex chars)')
|
||||
const { version } = loadPkg()
|
||||
const core = versionCore(version)
|
||||
if (!/^\d+\.\d+\.\d+$/.test(core)) die(`cannot derive edge base from version: ${version}`)
|
||||
return `${core}-edge.${sha}`
|
||||
}
|
||||
|
||||
// Returns a problem string if `version` cannot be resolved under `channel`, else
|
||||
// null. Shared by validateVersionForChannel (die-now) and validateVersionChannel
|
||||
// (collect for the `validate` gate).
|
||||
function channelVersionProblem(version, channel) {
|
||||
if (typeof version !== 'string' || version.length === 0)
|
||||
return 'version must be a non-empty string'
|
||||
const ch = channelByName(channel)
|
||||
if (!ch) return `unknown channel: ${channel} (expected one of: ${channelNames()})`
|
||||
if (!ch.versionForm.test(version))
|
||||
return `version ${version} does not match the ${channel} channel form`
|
||||
return null
|
||||
}
|
||||
|
||||
function validateVersionForChannel(version, channelName) {
|
||||
const problem = channelVersionProblem(version, channelName)
|
||||
if (problem) die(problem)
|
||||
return `valid: ${version} is a ${channelName} version`
|
||||
}
|
||||
|
||||
function comparePre(a, b) {
|
||||
const aparts = a.split('.')
|
||||
const bparts = b.split('.')
|
||||
const len = Math.max(aparts.length, bparts.length)
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (aparts[i] === undefined) return -1
|
||||
if (bparts[i] === undefined) return 1
|
||||
const an = /^\d+$/.test(aparts[i])
|
||||
const bn = /^\d+$/.test(bparts[i])
|
||||
if (an && bn) {
|
||||
const d = Number(aparts[i]) - Number(bparts[i])
|
||||
if (d !== 0) return d < 0 ? -1 : 1
|
||||
} else if (an !== bn) {
|
||||
return an ? -1 : 1
|
||||
} else if (aparts[i] !== bparts[i]) {
|
||||
return aparts[i] < bparts[i] ? -1 : 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function comparePrecedence(a, b) {
|
||||
const A = parsePrecedence(a)
|
||||
const B = parsePrecedence(b)
|
||||
for (let i = 0; i < SEMVER_CORE_LEN; i++) {
|
||||
const x = A.nums[i] ?? 0
|
||||
const y = B.nums[i] ?? 0
|
||||
if (x !== y) return x < y ? -1 : 1
|
||||
}
|
||||
if (A.pre === B.pre) return 0
|
||||
if (A.pre === '') return 1
|
||||
if (B.pre === '') return -1
|
||||
return comparePre(A.pre, B.pre)
|
||||
}
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write(`release-naming: ${msg}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Tests point this at a fixture manifest so their assertions stay fixed while
|
||||
// the real version and compat window move with every release. The name is
|
||||
// mirrored in test/fixtures/pkg-manifest.ts rather than imported from here,
|
||||
// because this file's shebang breaks the Windows test runner.
|
||||
const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH'
|
||||
|
||||
function loadPkg() {
|
||||
const pkgPath = process.env[PKG_PATH_ENV] || new URL('../package.json', import.meta.url)
|
||||
function loadPkg(pkgPath = PKG_URL) {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||
if (!pkg.difyctl?.release) die('cli/package.json missing difyctl.release')
|
||||
return {
|
||||
@@ -142,7 +63,7 @@ function githubEnv() {
|
||||
minDify: compat.minDify,
|
||||
maxDify: compat.maxDify,
|
||||
tagPrefix: release.tagPrefix,
|
||||
difyctlTag: `${release.tagPrefix}${version}`,
|
||||
difyctlTag: tagName(release, version),
|
||||
}
|
||||
return Object.entries(fields)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
@@ -154,58 +75,19 @@ function requireVersion(version) {
|
||||
return version
|
||||
}
|
||||
|
||||
function assetName(release, version, id) {
|
||||
const target = release.targets.find((t) => t.id === id)
|
||||
if (!target) die(`unknown target id: ${id}`)
|
||||
const suffix = target.exe ? '.exe' : ''
|
||||
return `${release.tagPrefix}${version}-${id}${suffix}`
|
||||
}
|
||||
|
||||
function validateRelease(release) {
|
||||
const problems = []
|
||||
const str = (v) => typeof v === 'string' && v.length > 0
|
||||
if (!str(release.tagPrefix)) problems.push('tagPrefix must be a non-empty string')
|
||||
if (!str(release.binName)) problems.push('binName must be a non-empty string')
|
||||
if (!str(release.checksumsSuffix)) problems.push('checksumsSuffix must be a non-empty string')
|
||||
if (!Array.isArray(release.targets) || release.targets.length === 0) {
|
||||
problems.push('targets must be a non-empty array')
|
||||
return problems
|
||||
}
|
||||
const seen = new Set()
|
||||
for (const t of release.targets) {
|
||||
const label = t?.id ?? JSON.stringify(t)
|
||||
if (!str(t?.id)) problems.push(`target ${label}: id must be a non-empty string`)
|
||||
else if (seen.has(t.id)) problems.push(`duplicate target id: ${t.id}`)
|
||||
else seen.add(t.id)
|
||||
if (!str(t?.bunTarget) || !BUN_TARGET_RE.test(t.bunTarget))
|
||||
problems.push(`target ${label}: bunTarget must match ${BUN_TARGET_RE}`)
|
||||
if (typeof t?.exe !== 'boolean') problems.push(`target ${label}: exe must be a boolean`)
|
||||
else if (str(t?.bunTarget) && t.exe !== t.bunTarget.startsWith('bun-windows-'))
|
||||
problems.push(`target ${label}: exe must be true iff bunTarget is bun-windows-*`)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
function validateVersionChannel(version, channel) {
|
||||
const problem = channelVersionProblem(version, channel)
|
||||
return problem ? [problem] : []
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const [cmd, ...rest] = argv
|
||||
switch (cmd) {
|
||||
case 'tag':
|
||||
return `${loadPkg().release.tagPrefix}${requireVersion(rest[0])}`
|
||||
case 'asset':
|
||||
return assetName(
|
||||
loadPkg().release,
|
||||
requireVersion(rest[0]),
|
||||
rest[1] ?? die('target id is required'),
|
||||
)
|
||||
case 'checksums': {
|
||||
return tagName(loadPkg().release, requireVersion(rest[0]))
|
||||
case 'asset': {
|
||||
const { release } = loadPkg()
|
||||
return `${release.tagPrefix}${requireVersion(rest[0])}${release.checksumsSuffix}`
|
||||
const version = requireVersion(rest[0])
|
||||
const id = rest[1] ?? die('target id is required')
|
||||
return assetNameFor(release, version, id) ?? die(`unknown target id: ${id}`)
|
||||
}
|
||||
case 'checksums':
|
||||
return checksumsName(loadPkg().release, requireVersion(rest[0]))
|
||||
case 'tag-prefix':
|
||||
return loadPkg().release.tagPrefix
|
||||
case 'targets':
|
||||
@@ -221,10 +103,7 @@ function main(argv) {
|
||||
const difyVersion = requireVersion(rest[0])
|
||||
if (!compat.minDify || !compat.maxDify)
|
||||
die('cli/package.json missing difyctl.compat.minDify/maxDify')
|
||||
if (
|
||||
comparePrecedence(difyVersion, compat.minDify) < 0 ||
|
||||
comparePrecedence(difyVersion, compat.maxDify) > 0
|
||||
)
|
||||
if (!isWithinCompatWindow(difyVersion, compat))
|
||||
die(
|
||||
`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`,
|
||||
)
|
||||
@@ -236,19 +115,31 @@ function main(argv) {
|
||||
return String(ch.prerelease)
|
||||
}
|
||||
case 'validate': {
|
||||
const { version, channel, release } = loadPkg()
|
||||
const problems = [...validateRelease(release), ...validateVersionChannel(version, channel)]
|
||||
const { version, channel, compat, release } = loadPkg()
|
||||
const versionProblem = channelVersionProblem(version, channel)
|
||||
const problems = [
|
||||
...releaseConfigProblems(release),
|
||||
...compatProblems(compat),
|
||||
...(versionProblem ? [versionProblem] : []),
|
||||
]
|
||||
if (problems.length > 0)
|
||||
die(`invalid difyctl release config:\n - ${problems.join('\n - ')}`)
|
||||
return `difyctl release valid: version=${version} channel=${channel} targets=${release.targets.length}`
|
||||
}
|
||||
case 'edge-version':
|
||||
return edgeVersion(rest[0])
|
||||
case 'validate-version':
|
||||
return validateVersionForChannel(
|
||||
requireVersion(rest[0]),
|
||||
rest[1] ?? die('channel argument is required'),
|
||||
)
|
||||
case 'edge-version': {
|
||||
const { version } = loadPkg()
|
||||
const sha = rest[0]
|
||||
const problem = edgeVersionProblem(version, sha)
|
||||
if (problem) die(problem)
|
||||
return edgeVersionFrom(version, sha)
|
||||
}
|
||||
case 'validate-version': {
|
||||
const version = requireVersion(rest[0])
|
||||
const channel = rest[1] ?? die('channel argument is required')
|
||||
const problem = channelVersionProblem(version, channel)
|
||||
if (problem) die(problem)
|
||||
return `valid: ${version} is a ${channel} version`
|
||||
}
|
||||
default:
|
||||
die(`unknown subcommand: ${cmd ?? '(none)'}`)
|
||||
}
|
||||
@@ -256,14 +147,14 @@ function main(argv) {
|
||||
|
||||
const invokedDirectly =
|
||||
process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
if (invokedDirectly) process.stdout.write(`${main(process.argv.slice(2))}\n`)
|
||||
|
||||
export {
|
||||
assetName,
|
||||
channelByName,
|
||||
CHANNELS,
|
||||
edgeVersion,
|
||||
loadPkg,
|
||||
validateVersionForChannel,
|
||||
versionCore,
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
process.stdout.write(`${main(process.argv.slice(2))}\n`)
|
||||
} catch (e) {
|
||||
if (!(e instanceof UsageError)) throw e
|
||||
process.stderr.write(`release-naming: ${e.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
export { loadPkg, main }
|
||||
|
||||
@@ -1,122 +1,77 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FIXTURE_COMPAT, pkgManifestEnv } from '../test/fixtures/pkg-manifest'
|
||||
import { main } from './release-naming.mjs'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url))
|
||||
|
||||
function run(
|
||||
args: string[],
|
||||
env: Record<string, string> = {},
|
||||
): { code: number; stdout: string; stderr: string } {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...env },
|
||||
})
|
||||
return { code: 0, stdout, stderr: '' }
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; stdout?: string; stderr?: string }
|
||||
return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
|
||||
}
|
||||
}
|
||||
|
||||
describe('release-naming compat-check', () => {
|
||||
const { minDify, maxDify } = FIXTURE_COMPAT // 2.0.0 .. 2.5.0
|
||||
const pkgEnv = pkgManifestEnv()
|
||||
const compatCheck = (difyVersion?: string) =>
|
||||
run(difyVersion === undefined ? ['compat-check'] : ['compat-check', difyVersion], pkgEnv).code
|
||||
|
||||
it('accepts a version inside the window', () => {
|
||||
expect(compatCheck('2.3.0')).toBe(0)
|
||||
describe('release-naming argument handling', () => {
|
||||
it.each([
|
||||
[['tag'], 'version argument is required'],
|
||||
[['asset'], 'version argument is required'],
|
||||
[['checksums'], 'version argument is required'],
|
||||
[['compat-check'], 'version argument is required'],
|
||||
[['validate-version'], 'version argument is required'],
|
||||
[['prerelease'], 'channel argument is required'],
|
||||
[['edge-version'], 'git short sha'],
|
||||
[['bogus'], 'unknown subcommand'],
|
||||
[[], 'unknown subcommand'],
|
||||
])('rejects %j', (args, message) => {
|
||||
expect(() => main(args)).toThrow(message)
|
||||
})
|
||||
|
||||
it('accepts the inclusive lower bound', () => {
|
||||
expect(compatCheck(minDify)).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive upper bound', () => {
|
||||
expect(compatCheck(maxDify)).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts a v-prefixed tag', () => {
|
||||
expect(compatCheck('v2.3.0')).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version below the lower bound', () => {
|
||||
expect(compatCheck('1.9.9')).not.toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version above the upper bound', () => {
|
||||
expect(compatCheck('2.5.1')).not.toBe(0)
|
||||
})
|
||||
|
||||
it('treats a prerelease of the lower bound as below it', () => {
|
||||
expect(compatCheck(`${minDify}-rc1`)).not.toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata on the bound', () => {
|
||||
expect(compatCheck(`${maxDify}+build123`)).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata when out of range', () => {
|
||||
expect(compatCheck('2.5.1+build123')).not.toBe(0)
|
||||
})
|
||||
|
||||
it('requires a version argument', () => {
|
||||
expect(compatCheck()).not.toBe(0)
|
||||
it('rejects an unknown target id', () => {
|
||||
expect(() => main(['asset', '9.9.9', 'solaris-sparc'])).toThrow('unknown target id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('release-naming github-env', () => {
|
||||
it('emits difyctlTag = tagPrefix + version', () => {
|
||||
const { stdout } = run(['github-env'])
|
||||
expect(stdout).toMatch(/^difyctlTag=difyctl-v0\.2\.0-alpha$/m)
|
||||
describe('release-naming output shape', () => {
|
||||
it('emits every key CI reads from github-env', () => {
|
||||
const out = main(['github-env'])
|
||||
for (const key of [
|
||||
'version',
|
||||
'channel',
|
||||
'prerelease',
|
||||
'minDify',
|
||||
'maxDify',
|
||||
'tagPrefix',
|
||||
'difyctlTag',
|
||||
])
|
||||
expect(out).toMatch(new RegExp(`^${key}=.+$`, 'm'))
|
||||
expect(out).not.toMatch(/=undefined$/m)
|
||||
})
|
||||
|
||||
it('still emits the existing trace fields', () => {
|
||||
const { stdout } = run(['github-env'])
|
||||
for (const key of ['version', 'channel', 'prerelease', 'minDify', 'maxDify', 'tagPrefix'])
|
||||
expect(stdout).toMatch(new RegExp(`^${key}=`, 'm'))
|
||||
it('emits difyctlTag as tagPrefix immediately followed by version', () => {
|
||||
const env = Object.fromEntries(
|
||||
main(['github-env'])
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line: string) => line.split(/=(.*)/s).slice(0, 2)),
|
||||
)
|
||||
expect(env.difyctlTag).toBe(`${env.tagPrefix}${env.version}`)
|
||||
})
|
||||
|
||||
// The only assertion against the live manifest: the window must exist and be
|
||||
// well-formed, whatever release it currently points at.
|
||||
it('emits a well-formed compat window from the real cli/package.json', () => {
|
||||
const { stdout } = run(['github-env'])
|
||||
expect(stdout).toMatch(/^minDify=\d+\.\d+\.\d+$/m)
|
||||
expect(stdout).toMatch(/^maxDify=\d+\.\d+\.\d+$/m)
|
||||
it('lists one channel per line, including edge', () => {
|
||||
const out = main(['channels'])
|
||||
expect(out.trim().split('\n').length).toBeGreaterThan(1)
|
||||
expect(out).toMatch(/^edge$/m)
|
||||
})
|
||||
})
|
||||
|
||||
describe('release-naming edge channel', () => {
|
||||
it('lists edge among channels', () => {
|
||||
expect(run(['channels']).stdout).toMatch(/^edge$/m)
|
||||
it('emits targets as bunTarget<TAB>id<TAB>0|1 (release-build.sh parses this)', () => {
|
||||
for (const line of main(['targets']).trim().split('\n'))
|
||||
expect(line).toMatch(/^\S+\t\S+\t[01]$/)
|
||||
})
|
||||
|
||||
it('edge-version derives <pkgcore>-edge.<sha> from the package version', () => {
|
||||
// package.json version is 0.2.0-alpha -> core 0.2.0
|
||||
expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe('0.2.0-edge.2fd7b82')
|
||||
it('reports prerelease as a boolean per channel', () => {
|
||||
expect(main(['prerelease', 'stable']).trim()).toBe('false')
|
||||
expect(main(['prerelease', 'alpha']).trim()).toBe('true')
|
||||
expect(() => main(['prerelease', 'nightly'])).toThrow('unknown channel')
|
||||
})
|
||||
|
||||
it('edge-version accepts a 40-char sha', () => {
|
||||
const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000'
|
||||
expect(run(['edge-version', sha]).stdout.trim()).toBe(`0.2.0-edge.${sha}`)
|
||||
it('derives an edge version from the packaged version and a sha', () => {
|
||||
expect(main(['edge-version', '2fd7b82']).trim()).toMatch(/-edge\.2fd7b82$/)
|
||||
})
|
||||
|
||||
it('edge-version rejects a non-hex sha', () => {
|
||||
expect(run(['edge-version', 'nothex!']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('edge-version requires a sha argument', () => {
|
||||
expect(run(['edge-version']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('the edge version form matches a computed edge version', () => {
|
||||
expect(run(['validate-version', '0.1.0-edge.2fd7b82', 'edge']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('validate-version rejects an rc string under the edge channel', () => {
|
||||
expect(run(['validate-version', '0.1.0-rc.1', 'edge']).code).not.toBe(0)
|
||||
it('validates a version against a channel form', () => {
|
||||
expect(main(['validate-version', '0.1.0-edge.2fd7b82', 'edge'])).toContain('valid')
|
||||
expect(() => main(['validate-version', '0.1.0-rc.1', 'edge'])).toThrow(
|
||||
'does not match the edge channel form',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
// release-r2-edge.mjs — edge/R2 release metadata generator. Two subcommands:
|
||||
// manifest -> the per-channel pointer manifest.json (the installer reads this)
|
||||
// index -> the per-channel build-history ledger index.json
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { assetName, loadPkg, validateVersionForChannel } from './release-naming.mjs'
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
buildIndex,
|
||||
parseChecksums,
|
||||
parseDirList,
|
||||
renderManifest,
|
||||
resolveTargets,
|
||||
} from './lib/edge-manifest.mjs'
|
||||
import { channelVersionProblem } from './lib/release-rules.mjs'
|
||||
import { loadPkg } from './release-naming.mjs'
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write(`release-r2-edge: ${msg}\n`)
|
||||
process.exit(1)
|
||||
class UsageError extends Error {}
|
||||
|
||||
// Arrow so TS infers `never`, keeping expression positions like `return die(...)` typed.
|
||||
const die = (msg) => {
|
||||
throw new UsageError(msg)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
@@ -28,104 +38,83 @@ function requireArgs(args, keys) {
|
||||
}
|
||||
}
|
||||
|
||||
// checksums lines are "<sha256> <assetName>"
|
||||
function shaMap(checksumsPath) {
|
||||
const map = new Map()
|
||||
for (const line of readFileSync(checksumsPath, 'utf8').split('\n')) {
|
||||
const m = line.match(/^([0-9a-f]{64})\s+(\S+)$/i)
|
||||
if (m) map.set(m[2], m[1])
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function emitManifest(args) {
|
||||
requireArgs(args, ['channel', 'version', 'commit', 'build-date', 'base-url', 'checksums'])
|
||||
validateVersionForChannel(args.version, args.channel)
|
||||
const versionProblem = channelVersionProblem(args.version, args.channel)
|
||||
if (versionProblem) die(versionProblem)
|
||||
|
||||
const { release, compat } = loadPkg()
|
||||
const shas = shaMap(args.checksums)
|
||||
const shas = parseChecksums(readFileSync(args.checksums, 'utf8'))
|
||||
const { targets, missing } = resolveTargets(release, args.version, shas)
|
||||
if (missing.length > 0) die(`no sha256 for ${missing[0]} in ${args.checksums}`)
|
||||
|
||||
const targetLines = release.targets
|
||||
.map((t) => {
|
||||
const asset = assetName(release, args.version, t.id)
|
||||
const sha = shas.get(asset)
|
||||
if (!sha) die(`no sha256 for ${asset} in ${args.checksums}`)
|
||||
// one target per line: install-r2.sh grep/sed depends on this layout
|
||||
return ` ${JSON.stringify(t.id)}: { "asset": ${JSON.stringify(asset)}, "sha256": ${JSON.stringify(sha)} }`
|
||||
})
|
||||
.join(',\n')
|
||||
|
||||
const head = {
|
||||
schema: 1,
|
||||
name: release.binName,
|
||||
return renderManifest({
|
||||
binName: release.binName,
|
||||
channel: args.channel,
|
||||
version: args.version,
|
||||
commit: args.commit,
|
||||
buildDate: args['build-date'],
|
||||
compat: { minDify: compat.minDify, maxDify: compat.maxDify },
|
||||
compat,
|
||||
baseUrl: args['base-url'],
|
||||
}
|
||||
const headLines = Object.entries(head)
|
||||
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
||||
.join(',\n')
|
||||
process.stdout.write(`{\n${headLines},\n "targets": {\n${targetLines}\n }\n}\n`)
|
||||
targets,
|
||||
})
|
||||
}
|
||||
|
||||
// Newline-delimited dir names of binaries that still exist in R2. Absent file =
|
||||
// no reconciliation (caller could not list); empty file = no survivors.
|
||||
// empty / "-" / missing = no ledger yet (first publish)
|
||||
function loadCurrentIndex(path) {
|
||||
if (path === '-' || !existsSync(path)) return null
|
||||
const raw = readFileSync(path, 'utf8').trim()
|
||||
if (!raw || raw === '-') return null
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return die(`current index at ${path} is not valid JSON`)
|
||||
}
|
||||
}
|
||||
|
||||
// Absent file = no reconciliation (caller could not list); empty file = no survivors.
|
||||
function loadExistingDirs(path) {
|
||||
if (!path || !existsSync(path)) return null
|
||||
const set = new Set()
|
||||
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
||||
const d = line.trim()
|
||||
if (d) set.add(d)
|
||||
}
|
||||
return set
|
||||
return parseDirList(readFileSync(path, 'utf8'))
|
||||
}
|
||||
|
||||
function emitIndex(args) {
|
||||
requireArgs(args, ['current', 'channel', 'version', 'commit', 'build-date'])
|
||||
|
||||
// empty / "-" / missing = no ledger yet (first publish)
|
||||
let current = { schema: 1, channel: args.channel, builds: [] }
|
||||
if (args.current !== '-' && existsSync(args.current)) {
|
||||
const raw = readFileSync(args.current, 'utf8').trim()
|
||||
if (raw && raw !== '-') {
|
||||
try {
|
||||
current = JSON.parse(raw)
|
||||
} catch {
|
||||
die(`current index at ${args.current} is not valid JSON`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entry = {
|
||||
const index = buildIndex({
|
||||
channel: args.channel,
|
||||
version: args.version,
|
||||
commit: args.commit,
|
||||
buildDate: args['build-date'],
|
||||
dir: args.version,
|
||||
current: loadCurrentIndex(args.current),
|
||||
existingDirs: loadExistingDirs(args['existing-dirs']),
|
||||
})
|
||||
return `${JSON.stringify(index, null, 2)}\n`
|
||||
}
|
||||
|
||||
// Returns the exact bytes to write to stdout.
|
||||
function main(argv) {
|
||||
const [cmd, ...rest] = argv
|
||||
const args = parseArgs(rest)
|
||||
switch (cmd) {
|
||||
case 'manifest':
|
||||
return emitManifest(args)
|
||||
case 'index':
|
||||
return emitIndex(args)
|
||||
default:
|
||||
return die(`unknown subcommand: ${cmd ?? '(none)'} (expected: manifest | index)`)
|
||||
}
|
||||
const kept = (current.builds ?? []).filter((b) => b.version !== entry.version)
|
||||
let builds = [entry, ...kept]
|
||||
|
||||
// Reconcile to binaries that still exist in R2: lifecycle/TTL on the bin prefix
|
||||
// is the only deletion mechanism, so the ledger never advertises a build whose
|
||||
// binary is gone. The new build is always kept (just uploaded). No count cap.
|
||||
const existing = loadExistingDirs(args['existing-dirs'])
|
||||
if (existing) builds = builds.filter((b) => b.dir === entry.dir || existing.has(b.dir))
|
||||
|
||||
const index = { schema: 1, channel: args.channel, updated: args['build-date'], builds }
|
||||
process.stdout.write(`${JSON.stringify(index, null, 2)}\n`)
|
||||
}
|
||||
|
||||
const [cmd, ...rest] = process.argv.slice(2)
|
||||
const args = parseArgs(rest)
|
||||
switch (cmd) {
|
||||
case 'manifest':
|
||||
emitManifest(args)
|
||||
break
|
||||
case 'index':
|
||||
emitIndex(args)
|
||||
break
|
||||
default:
|
||||
die(`unknown subcommand: ${cmd ?? '(none)'} (expected: manifest | index)`)
|
||||
const invokedDirectly =
|
||||
process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
process.stdout.write(main(process.argv.slice(2)))
|
||||
} catch (e) {
|
||||
if (!(e instanceof UsageError)) throw e
|
||||
process.stderr.write(`release-r2-edge: ${e.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
export { main }
|
||||
|
||||
+108
-241
@@ -1,49 +1,28 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FIXTURE_COMPAT, pkgManifestEnv } from '../test/fixtures/pkg-manifest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./release-r2-edge.mjs', import.meta.url))
|
||||
|
||||
const PKG_ENV = pkgManifestEnv()
|
||||
|
||||
function run(args: string[]): { code: number; stdout: string; stderr: string } {
|
||||
try {
|
||||
return {
|
||||
code: 0,
|
||||
stdout: execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...PKG_ENV },
|
||||
}),
|
||||
stderr: '',
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; stdout?: string; stderr?: string }
|
||||
return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- manifest ----
|
||||
|
||||
function writeChecksums(version: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-'))
|
||||
const ids = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
|
||||
const lines = ids.map((id, i) => {
|
||||
const exe = id === 'windows-x64' ? '.exe' : ''
|
||||
const sha = String(i).repeat(64)
|
||||
return `${sha} difyctl-v${version}-${id}${exe}`
|
||||
})
|
||||
const file = join(dir, `difyctl-v${version}-checksums.txt`)
|
||||
writeFileSync(file, `${lines.join('\n')}\n`)
|
||||
return file
|
||||
}
|
||||
import { main as naming } from './release-naming.mjs'
|
||||
import { main } from './release-r2-edge.mjs'
|
||||
|
||||
const VERSION = '0.1.0-edge.2fd7b82'
|
||||
const BASE_URL = 'https://example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82'
|
||||
|
||||
const TARGET_IDS = naming(['targets'])
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line: string) => line.split('\t')[1])
|
||||
|
||||
const ASSETS = new Map(TARGET_IDS.map((id: string) => [id, naming(['asset', VERSION, id]).trim()]))
|
||||
const assetFor = (id: string) => ASSETS.get(id) ?? ''
|
||||
|
||||
const PKG_ENV = Object.fromEntries(
|
||||
naming(['github-env'])
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line: string) => line.split(/=(.*)/s).slice(0, 2)),
|
||||
)
|
||||
|
||||
type ManifestJson = {
|
||||
schema: number
|
||||
name: string
|
||||
@@ -56,28 +35,23 @@ type ManifestJson = {
|
||||
targets: Record<string, { asset: string; sha256: string }>
|
||||
}
|
||||
|
||||
type IndexBuild = {
|
||||
version: string
|
||||
commit: string
|
||||
buildDate: string
|
||||
dir: string
|
||||
}
|
||||
|
||||
type IndexJson = {
|
||||
schema: number
|
||||
channel: string
|
||||
updated: string
|
||||
builds: IndexBuild[]
|
||||
builds: { version: string; commit: string; buildDate: string; dir: string }[]
|
||||
}
|
||||
|
||||
function buildManifest(version = VERSION): {
|
||||
code: number
|
||||
json: ManifestJson
|
||||
stdout: string
|
||||
stderr: string
|
||||
} {
|
||||
const checksums = writeChecksums(version)
|
||||
const r = run([
|
||||
function writeChecksums(ids: string[]): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-'))
|
||||
const lines = ids.map((id, i) => `${String(i).repeat(64)} ${assetFor(id)}`)
|
||||
const file = join(dir, 'checksums.txt')
|
||||
writeFileSync(file, `${lines.join('\n')}\n`)
|
||||
return file
|
||||
}
|
||||
|
||||
function manifestArgs(version = VERSION, ids = TARGET_IDS): string[] {
|
||||
return [
|
||||
'manifest',
|
||||
'--channel',
|
||||
'edge',
|
||||
@@ -90,229 +64,122 @@ function buildManifest(version = VERSION): {
|
||||
'--base-url',
|
||||
BASE_URL,
|
||||
'--checksums',
|
||||
checksums,
|
||||
])
|
||||
return {
|
||||
code: r.code,
|
||||
json: (r.code === 0 ? JSON.parse(r.stdout) : null) as ManifestJson,
|
||||
stdout: r.stdout,
|
||||
stderr: r.stderr,
|
||||
}
|
||||
writeChecksums(ids),
|
||||
]
|
||||
}
|
||||
|
||||
const buildManifest = () => JSON.parse(main(manifestArgs())) as ManifestJson
|
||||
|
||||
describe('release-r2-edge manifest', () => {
|
||||
it('emits the core pointer fields', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(json.schema).toBe(1)
|
||||
expect(json.name).toBe('difyctl')
|
||||
expect(json.channel).toBe('edge')
|
||||
expect(json.version).toBe(VERSION)
|
||||
expect(json.commit).toBe('abc1234')
|
||||
expect(json.buildDate).toBe('2026-06-14T12:00:00Z')
|
||||
expect(json.baseUrl).toBe(BASE_URL)
|
||||
it('passes the CLI arguments straight through to the manifest', () => {
|
||||
expect(buildManifest()).toMatchObject({
|
||||
schema: 1,
|
||||
channel: 'edge',
|
||||
version: VERSION,
|
||||
commit: 'abc1234',
|
||||
buildDate: '2026-06-14T12:00:00Z',
|
||||
baseUrl: BASE_URL,
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the compat window from package.json', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(json.compat).toEqual(FIXTURE_COMPAT)
|
||||
it('carries the compat window through from cli/package.json', () => {
|
||||
expect(buildManifest().compat).toEqual({ minDify: PKG_ENV.minDify, maxDify: PKG_ENV.maxDify })
|
||||
})
|
||||
|
||||
it('lists all 5 targets with asset name + sha256 from the checksums file', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(Object.keys(json.targets).sort()).toEqual([
|
||||
'darwin-arm64',
|
||||
'darwin-x64',
|
||||
'linux-arm64',
|
||||
'linux-x64',
|
||||
'windows-x64',
|
||||
])
|
||||
expect(json.targets['linux-x64'].asset).toBe(`difyctl-v${VERSION}-linux-x64`)
|
||||
expect(json.targets['windows-x64'].asset).toBe(`difyctl-v${VERSION}-windows-x64.exe`)
|
||||
expect(json.targets['linux-x64'].sha256).toMatch(/^\d{64}$/)
|
||||
})
|
||||
|
||||
it('renders each target on a single line (installer greps it)', () => {
|
||||
const { stdout } = buildManifest()
|
||||
expect(stdout).toMatch(/^ {4}"linux-x64": \{ "asset": ".*", "sha256": ".*" \}/m)
|
||||
it('includes every target the release config declares', () => {
|
||||
expect(Object.keys(buildManifest().targets).sort()).toEqual([...TARGET_IDS].sort())
|
||||
})
|
||||
|
||||
it('rejects a version that does not match the channel form', () => {
|
||||
const { code } = buildManifest('0.1.0-rc.1')
|
||||
expect(code).not.toBe(0)
|
||||
expect(() => main(manifestArgs('0.1.0-rc.1'))).toThrow('does not match the edge channel form')
|
||||
})
|
||||
|
||||
it('dies when a target sha is missing from the checksums file', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-'))
|
||||
const file = join(dir, `difyctl-v${VERSION}-checksums.txt`)
|
||||
writeFileSync(file, `${'0'.repeat(64)} difyctl-v${VERSION}-linux-x64\n`) // only 1 of 5
|
||||
const r = run([
|
||||
'manifest',
|
||||
'--channel',
|
||||
'edge',
|
||||
'--version',
|
||||
VERSION,
|
||||
'--commit',
|
||||
'abc1234',
|
||||
'--build-date',
|
||||
'2026-06-14T12:00:00Z',
|
||||
'--base-url',
|
||||
BASE_URL,
|
||||
'--checksums',
|
||||
file,
|
||||
])
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(() => main(manifestArgs(VERSION, TARGET_IDS.slice(0, 1)))).toThrow('no sha256 for')
|
||||
})
|
||||
|
||||
it('rejects a malformed dropped-value argument (no silent misparse)', () => {
|
||||
// --version has no value; --commit must NOT be swallowed as the version
|
||||
const r = run([
|
||||
'manifest',
|
||||
'--channel',
|
||||
'edge',
|
||||
'--version',
|
||||
'--commit',
|
||||
'abc1234',
|
||||
'--build-date',
|
||||
'2026-06-14T12:00:00Z',
|
||||
'--base-url',
|
||||
'https://x',
|
||||
'--checksums',
|
||||
'/nonexistent',
|
||||
])
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(() =>
|
||||
main([
|
||||
'manifest',
|
||||
'--channel',
|
||||
'edge',
|
||||
'--version',
|
||||
'--commit',
|
||||
'abc1234',
|
||||
'--build-date',
|
||||
'2026-06-14T12:00:00Z',
|
||||
'--base-url',
|
||||
'https://x',
|
||||
'--checksums',
|
||||
'/nonexistent',
|
||||
]),
|
||||
).toThrow('malformed argument')
|
||||
})
|
||||
|
||||
it('rejects an unknown subcommand', () => {
|
||||
expect(() => main(['bogus'])).toThrow('unknown subcommand')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- index ----
|
||||
describe('release-r2-edge index', () => {
|
||||
const B1 = { version: '0.1.0-edge.aaaaaaa', commit: 'aaaaaaa', buildDate: '2026-06-14T09:00:00Z' }
|
||||
|
||||
function runIndex(
|
||||
currentContent: string | null,
|
||||
build: Record<string, string>,
|
||||
existingDirs?: string[],
|
||||
) {
|
||||
let currentArg = '-'
|
||||
if (currentContent !== null) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-index-'))
|
||||
currentArg = join(dir, 'index.json')
|
||||
writeFileSync(currentArg, currentContent)
|
||||
}
|
||||
const extra: string[] = []
|
||||
if (existingDirs !== undefined) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-existing-'))
|
||||
const f = join(dir, 'existing.txt')
|
||||
writeFileSync(f, `${existingDirs.join('\n')}\n`)
|
||||
extra.push('--existing-dirs', f)
|
||||
}
|
||||
const r = spawnSync(
|
||||
'node',
|
||||
[
|
||||
SCRIPT,
|
||||
function indexArgs(currentContent: string | null, existingDirs?: string[]): string[] {
|
||||
let currentArg = '-'
|
||||
if (currentContent !== null) {
|
||||
currentArg = join(mkdtempSync(join(tmpdir(), 'difyctl-index-')), 'index.json')
|
||||
writeFileSync(currentArg, currentContent)
|
||||
}
|
||||
const extra: string[] = []
|
||||
if (existingDirs !== undefined) {
|
||||
const f = join(mkdtempSync(join(tmpdir(), 'difyctl-existing-')), 'existing.txt')
|
||||
writeFileSync(f, `${existingDirs.join('\n')}\n`)
|
||||
extra.push('--existing-dirs', f)
|
||||
}
|
||||
return [
|
||||
'index',
|
||||
'--current',
|
||||
currentArg,
|
||||
'--channel',
|
||||
'edge',
|
||||
'--version',
|
||||
build.version,
|
||||
B1.version,
|
||||
'--commit',
|
||||
build.commit,
|
||||
B1.commit,
|
||||
'--build-date',
|
||||
build.buildDate,
|
||||
B1.buildDate,
|
||||
...extra,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
)
|
||||
return {
|
||||
code: r.status ?? 1,
|
||||
index: (r.status === 0 ? JSON.parse(r.stdout) : null) as IndexJson,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const B1 = { version: '0.1.0-edge.aaaaaaa', commit: 'aaaaaaa', buildDate: '2026-06-14T09:00:00Z' }
|
||||
const B2 = { version: '0.1.0-edge.bbbbbbb', commit: 'bbbbbbb', buildDate: '2026-06-14T10:00:00Z' }
|
||||
const buildIndex = (currentContent: string | null, existingDirs?: string[]) =>
|
||||
JSON.parse(main(indexArgs(currentContent, existingDirs))) as IndexJson
|
||||
|
||||
describe('release-r2-edge index', () => {
|
||||
it('creates a fresh index from a missing current (arg "-")', () => {
|
||||
const { index } = runIndex(null, B1)
|
||||
expect(index.schema).toBe(1)
|
||||
expect(index.channel).toBe('edge')
|
||||
expect(index.builds).toHaveLength(1)
|
||||
expect(index.builds[0]).toMatchObject({
|
||||
version: B1.version,
|
||||
commit: B1.commit,
|
||||
dir: B1.version,
|
||||
})
|
||||
it.each([
|
||||
['a missing current file (arg "-")', null],
|
||||
['an empty current file (first publish, curl wrote nothing)', ''],
|
||||
['a "-"-content current file (curl 404 fallback)', '-\n'],
|
||||
])('treats %s as a fresh ledger', (_label, content) => {
|
||||
const index = buildIndex(content)
|
||||
expect(index).toMatchObject({ schema: 1, channel: 'edge' })
|
||||
expect(index.builds).toEqual([{ ...B1, dir: B1.version }])
|
||||
})
|
||||
|
||||
it('treats an empty current file as fresh (first publish, curl wrote nothing)', () => {
|
||||
const { code, index } = runIndex('', B1)
|
||||
expect(code).toBe(0)
|
||||
expect(index.builds).toHaveLength(1)
|
||||
it('dies on a current index that is not valid JSON', () => {
|
||||
expect(() => main(indexArgs('{not json'))).toThrow('not valid JSON')
|
||||
})
|
||||
|
||||
it('treats a "-"-content current file as fresh (curl 404 fallback)', () => {
|
||||
const { code, index } = runIndex('-\n', B1)
|
||||
expect(code).toBe(0)
|
||||
expect(index.builds).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('prepends the new build (publish order; newest at [0])', () => {
|
||||
const current = JSON.stringify({
|
||||
schema: 1,
|
||||
channel: 'edge',
|
||||
builds: [
|
||||
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
|
||||
],
|
||||
})
|
||||
const { index } = runIndex(current, B2)
|
||||
expect(index.builds.map((b) => b.version)).toEqual([B2.version, B1.version])
|
||||
})
|
||||
|
||||
it('dedups a re-cut of the same version (no duplicate, moves to top)', () => {
|
||||
const current = JSON.stringify({
|
||||
schema: 1,
|
||||
channel: 'edge',
|
||||
builds: [
|
||||
{ version: B2.version, commit: B2.commit, buildDate: B2.buildDate, dir: B2.version },
|
||||
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
|
||||
],
|
||||
})
|
||||
const { index } = runIndex(current, B1) // re-cut B1
|
||||
expect(index.builds.map((b) => b.version)).toEqual([B1.version, B2.version])
|
||||
})
|
||||
|
||||
it('reconciles to surviving binary dirs (drops a build whose binary expired)', () => {
|
||||
const current = JSON.stringify({
|
||||
schema: 1,
|
||||
channel: 'edge',
|
||||
builds: [
|
||||
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
|
||||
],
|
||||
})
|
||||
// B1's binary is gone (not in existing); the new B2 is always kept.
|
||||
const { index } = runIndex(current, B2, [B2.version])
|
||||
expect(index.builds.map((b) => b.version)).toEqual([B2.version])
|
||||
})
|
||||
|
||||
it('keeps the new build even when it is absent from the existing-dirs list', () => {
|
||||
const { index } = runIndex(null, B1, []) // empty survivors, fresh ledger
|
||||
expect(index.builds.map((b) => b.version)).toEqual([B1.version])
|
||||
})
|
||||
|
||||
it('does not reconcile when no --existing-dirs is given (list unavailable)', () => {
|
||||
const current = JSON.stringify({
|
||||
schema: 1,
|
||||
channel: 'edge',
|
||||
builds: [
|
||||
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
|
||||
],
|
||||
})
|
||||
const { index } = runIndex(current, B2) // no existing-dirs → keep all
|
||||
expect(index.builds.map((b) => b.version)).toEqual([B2.version, B1.version])
|
||||
})
|
||||
|
||||
it('dies on a non-empty current file that is not valid JSON', () => {
|
||||
const { code } = runIndex('{not json', B1)
|
||||
expect(code).not.toBe(0)
|
||||
it('reads the existing-dirs file to reconcile the ledger', () => {
|
||||
const older = {
|
||||
version: '0.1.0-edge.bbbbbbb',
|
||||
commit: 'bbbbbbb',
|
||||
buildDate: '2026-06-14T08:00:00Z',
|
||||
dir: '0.1.0-edge.bbbbbbb',
|
||||
}
|
||||
const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [older] })
|
||||
expect(buildIndex(current, []).builds).toEqual([{ ...B1, dir: B1.version }])
|
||||
expect(buildIndex(current, [older.dir]).builds).toEqual([{ ...B1, dir: B1.version }, older])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./release-r2-publish.sh', import.meta.url))
|
||||
|
||||
// Stub `aws` + `curl` + `node` as shell functions that just log action verbs to
|
||||
// $ORDER_LOG, then run the publish `main` and assert the order of operations.
|
||||
function runPublish(): { code: number; order: string[]; stderr: string } {
|
||||
const stub = [
|
||||
'ORDER_LOG="$(mktemp)"',
|
||||
'aws() {',
|
||||
' case "$*" in',
|
||||
' *"list-objects-v2"*) echo list-survivors >>"$ORDER_LOG" ;;',
|
||||
' *" cp "*"/index.json"*) echo put-index >>"$ORDER_LOG" ;;',
|
||||
' *" cp "*"/manifest.json"*) echo put-manifest >>"$ORDER_LOG" ;;',
|
||||
' *" sync "*) echo sync-binaries >>"$ORDER_LOG" ;;',
|
||||
' *"head-object"*) echo head-verify >>"$ORDER_LOG" ;;',
|
||||
' *) : ;;',
|
||||
' esac',
|
||||
'}',
|
||||
'curl() { echo "{}"; }',
|
||||
'node() {',
|
||||
' case "$*" in',
|
||||
' *release-naming.mjs*targets*)',
|
||||
" printf 'bun-linux-x64\\tlinux-x64\\t0\\nbun-linux-arm64\\tlinux-arm64\\t0\\nbun-darwin-x64\\tdarwin-x64\\t0\\nbun-darwin-arm64\\tdarwin-arm64\\t0\\nbun-windows-x64\\twindows-x64\\t1\\n' ;;",
|
||||
" *release-naming.mjs*' asset '*) printf 'difyctl-vX\\n' ;;",
|
||||
" *release-r2-edge.mjs*' index '*) echo '{}' ;;",
|
||||
" *release-r2-edge.mjs*' manifest '*) echo '{}' ;;",
|
||||
' *) : ;;',
|
||||
' esac',
|
||||
'}',
|
||||
].join('\n')
|
||||
const program = [
|
||||
stub,
|
||||
`. "${SCRIPT}"`,
|
||||
'publish_main edge 0.1.0-edge.2fd7b82',
|
||||
'cat "$ORDER_LOG"',
|
||||
].join('\n')
|
||||
// bash, NOT sh: the script uses BASH_SOURCE + process substitution.
|
||||
const r = spawnSync('bash', ['-c', program], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
RELEASE_PUBLISH_LIB: '1',
|
||||
DIFYCTL_R2_S3_ENDPOINT: 'https://endpoint.example',
|
||||
DIFYCTL_R2_BUCKET: 'cli-dev',
|
||||
DIFYCTL_R2_PUBLIC_BASE: 'https://pub.example.r2.dev',
|
||||
DIST_DIR: '/tmp',
|
||||
},
|
||||
})
|
||||
return {
|
||||
code: r.status ?? 1,
|
||||
order: (r.stdout ?? '').trim().split('\n').filter(Boolean),
|
||||
stderr: r.stderr ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
describe('release-r2-publish order', () => {
|
||||
it('uploads binaries, verifies, lists survivors, then index, then manifest', () => {
|
||||
const { code, order } = runPublish()
|
||||
expect(code).toBe(0)
|
||||
expect(order.indexOf('sync-binaries')).toBeLessThan(order.indexOf('head-verify'))
|
||||
expect(order.indexOf('head-verify')).toBeLessThan(order.indexOf('list-survivors'))
|
||||
expect(order.indexOf('list-survivors')).toBeLessThan(order.indexOf('put-index'))
|
||||
expect(order.indexOf('put-index')).toBeLessThan(order.indexOf('put-manifest'))
|
||||
// pointer is never pruned here — deletion is owned by the R2 lifecycle rule
|
||||
expect(order).not.toContain('prune')
|
||||
})
|
||||
|
||||
it('exits non-zero when no targets resolve (head-verify safety gate)', () => {
|
||||
const stub = [
|
||||
'aws() { :; }',
|
||||
'curl() { echo "{}"; }',
|
||||
'node() { case "$*" in *release-naming.mjs*targets*) : ;; *) echo "{}" ;; esac; }',
|
||||
].join('\n')
|
||||
const program = [stub, `. "${SCRIPT}"`, 'publish_main edge 0.1.0-edge.2fd7b82'].join('\n')
|
||||
const r = spawnSync('bash', ['-c', program], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
RELEASE_PUBLISH_LIB: '1',
|
||||
DIFYCTL_R2_S3_ENDPOINT: 'https://endpoint.example',
|
||||
DIFYCTL_R2_BUCKET: 'cli-dev',
|
||||
DIFYCTL_R2_PUBLIC_BASE: 'https://pub.example.r2.dev',
|
||||
DIST_DIR: '/tmp',
|
||||
},
|
||||
})
|
||||
expect(r.status).not.toBe(0)
|
||||
})
|
||||
})
|
||||
Vendored
-57
@@ -1,57 +0,0 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Mirrors PKG_PATH_ENV in scripts/release-naming.mjs, which cannot be imported
|
||||
// here: its shebang breaks the Windows test runner. Divergence is self-
|
||||
// reporting, not silent — the script would fall back to the real
|
||||
// cli/package.json and every fixture-window assertion would fail.
|
||||
const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH'
|
||||
|
||||
// release-naming.mjs and release-r2-edge.mjs read their data from
|
||||
// cli/package.json. Tests spawn them against this fixture instead, so
|
||||
// assertions can name exact versions without tracking the live release.
|
||||
|
||||
// Deliberately far from any real Dify version, and min != max so "inside the
|
||||
// window" is a case distinct from either bound.
|
||||
export const FIXTURE_COMPAT = { minDify: '2.0.0', maxDify: '2.5.0' }
|
||||
|
||||
export const FIXTURE_TARGET_IDS = [
|
||||
'linux-x64',
|
||||
'linux-arm64',
|
||||
'darwin-x64',
|
||||
'darwin-arm64',
|
||||
'windows-x64',
|
||||
] as const
|
||||
|
||||
const FIXTURE_RELEASE = {
|
||||
tagPrefix: 'difyctl-v',
|
||||
binName: 'difyctl',
|
||||
checksumsSuffix: '-checksums.txt',
|
||||
targets: FIXTURE_TARGET_IDS.map((id) => ({
|
||||
id,
|
||||
bunTarget: `bun-${id}`,
|
||||
exe: id.startsWith('windows'),
|
||||
})),
|
||||
}
|
||||
|
||||
export type PkgManifestOverrides = {
|
||||
version?: string
|
||||
channel?: string
|
||||
compat?: { minDify: string; maxDify: string }
|
||||
}
|
||||
|
||||
// Returns the env additions that point a spawned script at the fixture.
|
||||
export function pkgManifestEnv(overrides: PkgManifestOverrides = {}): Record<string, string> {
|
||||
const manifest = {
|
||||
version: overrides.version ?? '0.2.0-alpha',
|
||||
difyctl: {
|
||||
channel: overrides.channel ?? 'alpha',
|
||||
compat: overrides.compat ?? FIXTURE_COMPAT,
|
||||
release: FIXTURE_RELEASE,
|
||||
},
|
||||
}
|
||||
const path = join(mkdtempSync(join(tmpdir(), 'difyctl-pkg-')), 'package.json')
|
||||
writeFileSync(path, JSON.stringify(manifest))
|
||||
return { [PKG_PATH_ENV]: path }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-agent"
|
||||
version = "1.16.1"
|
||||
version = "1.16.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<4.0"
|
||||
|
||||
Generated
+1
-1
@@ -581,7 +581,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-agent"
|
||||
version = "1.16.1"
|
||||
version = "1.16.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
|
||||
@@ -157,6 +157,9 @@ ENABLE_WEBSITE_JINAREADER=true
|
||||
ENABLE_WEBSITE_FIRECRAWL=true
|
||||
ENABLE_WEBSITE_WATERCRAWL=true
|
||||
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
|
||||
# Enable preview features still in development (currently the /create and
|
||||
# /refine slash commands in the "Go to Anything" command palette).
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true
|
||||
NEXT_PUBLIC_ENABLE_AGENT_V2=true
|
||||
EXPERIMENTAL_ENABLE_VINEXT=false
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ services:
|
||||
# API service
|
||||
api:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
environment:
|
||||
MODE: api
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -271,7 +271,7 @@ services:
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
profiles:
|
||||
- collaboration
|
||||
environment:
|
||||
@@ -297,7 +297,7 @@ services:
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.16.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
environment:
|
||||
MODE: worker
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -347,7 +347,7 @@ services:
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.16.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@@ -380,7 +380,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.16.1
|
||||
image: langgenius/dify-web:1.16.0
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/web.env
|
||||
@@ -542,7 +542,7 @@ services:
|
||||
# on port 3128, which only allows agent_backend /agent-stub/ and the Dify API
|
||||
# /files/* endpoints (see ssrf_proxy/squid-agent.conf.template).
|
||||
local_sandbox:
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.1
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.0
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/local-sandbox.env
|
||||
@@ -651,7 +651,7 @@ services:
|
||||
|
||||
# Dify Agent backend service.
|
||||
agent_backend:
|
||||
image: langgenius/dify-agent-backend:1.16.1
|
||||
image: langgenius/dify-agent-backend:1.16.0
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/dify-agent.env
|
||||
|
||||
@@ -226,7 +226,7 @@ services:
|
||||
# API service
|
||||
api:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
environment:
|
||||
MODE: api
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -277,7 +277,7 @@ services:
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
profiles:
|
||||
- collaboration
|
||||
environment:
|
||||
@@ -303,7 +303,7 @@ services:
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.16.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
environment:
|
||||
MODE: worker
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -353,7 +353,7 @@ services:
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.16.1
|
||||
image: langgenius/dify-api:1.16.0
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@@ -386,7 +386,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.16.1
|
||||
image: langgenius/dify-web:1.16.0
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/web.env
|
||||
@@ -548,7 +548,7 @@ services:
|
||||
# on port 3128, which only allows agent_backend /agent-stub/ and the Dify API
|
||||
# /files/* endpoints (see ssrf_proxy/squid-agent.conf.template).
|
||||
local_sandbox:
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.1
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.0
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/local-sandbox.env
|
||||
@@ -657,7 +657,7 @@ services:
|
||||
|
||||
# Dify Agent backend service.
|
||||
agent_backend:
|
||||
image: langgenius/dify-agent-backend:1.16.1
|
||||
image: langgenius/dify-agent-backend:1.16.0
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/dify-agent.env
|
||||
|
||||
@@ -997,15 +997,11 @@ export const zSandboxListResponse = z.object({
|
||||
* Validated metadata extracted from a Skill package.
|
||||
*/
|
||||
export const zSkillManifest = z.object({
|
||||
description: z.string().min(1).max(1024),
|
||||
description: z.string(),
|
||||
entry_path: z.string(),
|
||||
files: z.array(z.string()),
|
||||
hash: z.string(),
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
name: z.string(),
|
||||
size: z.int(),
|
||||
})
|
||||
|
||||
|
||||
@@ -1373,15 +1373,11 @@ export const zAgentLogMetaResponse = z.object({
|
||||
* Validated metadata extracted from a Skill package.
|
||||
*/
|
||||
export const zSkillManifest = z.object({
|
||||
description: z.string().min(1).max(1024),
|
||||
description: z.string(),
|
||||
entry_path: z.string(),
|
||||
files: z.array(z.string()),
|
||||
hash: z.string(),
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
name: z.string(),
|
||||
size: z.int(),
|
||||
})
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export type TagBindingRemovePayload = {
|
||||
type: TagType
|
||||
}
|
||||
|
||||
export type TagType = 'app' | 'knowledge' | 'skill' | 'snippet'
|
||||
export type TagType = 'app' | 'knowledge' | 'snippet'
|
||||
|
||||
export type PostTagBindingsData = {
|
||||
body: TagBindingPayload
|
||||
|
||||
@@ -14,7 +14,7 @@ export const zSimpleResultResponse = z.object({
|
||||
*
|
||||
* Tag type
|
||||
*/
|
||||
export const zTagType = z.enum(['app', 'knowledge', 'skill', 'snippet'])
|
||||
export const zTagType = z.enum(['app', 'knowledge', 'snippet'])
|
||||
|
||||
/**
|
||||
* TagBindingPayload
|
||||
|
||||
@@ -22,14 +22,14 @@ export type TagUpdateRequestPayload = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type TagType = 'app' | 'knowledge' | 'skill' | 'snippet'
|
||||
export type TagType = 'app' | 'knowledge' | 'snippet'
|
||||
|
||||
export type GetTagsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
keyword?: string
|
||||
type?: string
|
||||
type?: '' | 'app' | 'knowledge' | 'snippet'
|
||||
}
|
||||
url: '/tags'
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const zTagUpdateRequestPayload = z.object({
|
||||
*
|
||||
* Tag type
|
||||
*/
|
||||
export const zTagType = z.enum(['app', 'knowledge', 'skill', 'snippet'])
|
||||
export const zTagType = z.enum(['app', 'knowledge', 'snippet'])
|
||||
|
||||
/**
|
||||
* TagBasePayload
|
||||
@@ -41,7 +41,7 @@ export const zTagBasePayload = z.object({
|
||||
|
||||
export const zGetTagsQuery = z.object({
|
||||
keyword: z.string().optional(),
|
||||
type: z.string().optional().default(''),
|
||||
type: z.enum(['', 'app', 'knowledge', 'snippet']).optional().default(''),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,16 +32,6 @@ export type AgentProviderListResponse = Array<{
|
||||
[key: string]: unknown
|
||||
}>
|
||||
|
||||
export type AgentSkillBindingsResponse = {
|
||||
agent_id: string
|
||||
data?: Array<AgentSkillBindingItemResponse>
|
||||
skill_ids?: Array<string>
|
||||
}
|
||||
|
||||
export type AgentSkillBindingsPayload = {
|
||||
skill_ids?: Array<string>
|
||||
}
|
||||
|
||||
export type SnippetPaginationResponse = {
|
||||
data: Array<SnippetListItemResponse>
|
||||
has_more: boolean
|
||||
@@ -645,179 +635,6 @@ export type WorkspaceAccessMatrix = {
|
||||
pagination?: Pagination | null
|
||||
}
|
||||
|
||||
export type SkillListResponse = {
|
||||
data?: Array<SkillResponse>
|
||||
has_more?: boolean
|
||||
limit?: number
|
||||
page?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
export type SkillCreatePayload = {
|
||||
description?: string
|
||||
display_name?: string | null
|
||||
icon?: string
|
||||
name?: string | null
|
||||
tags?: Array<string>
|
||||
}
|
||||
|
||||
export type SkillDetailResponse = {
|
||||
created_at: number
|
||||
created_by?: string | null
|
||||
created_by_name?: string | null
|
||||
description: string
|
||||
display_name: string
|
||||
files?: Array<SkillFileResponse>
|
||||
icon: string
|
||||
id: string
|
||||
latest_published_version_id?: string | null
|
||||
name: string
|
||||
name_manually_edited?: boolean
|
||||
reference_count?: number
|
||||
tags?: Array<string>
|
||||
updated_at: number
|
||||
updated_by?: string | null
|
||||
updated_by_name?: string | null
|
||||
visibility: string
|
||||
}
|
||||
|
||||
export type SkillFileUploadResponse = {
|
||||
hash: string
|
||||
id: string
|
||||
mime_type: string
|
||||
name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export type SkillTagListResponse = {
|
||||
data?: Array<SkillTagResponse>
|
||||
}
|
||||
|
||||
export type SkillDeletePayload = {
|
||||
confirmation_name?: string | null
|
||||
}
|
||||
|
||||
export type SkillDeleteResponse = {
|
||||
deleted: boolean
|
||||
id: string
|
||||
}
|
||||
|
||||
export type SkillMetadataPayload = {
|
||||
display_name?: string | null
|
||||
expected_updated_at?: number | null
|
||||
icon?: string | null
|
||||
tags?: Array<string> | null
|
||||
}
|
||||
|
||||
export type SkillResponse = {
|
||||
created_at: number
|
||||
created_by?: string | null
|
||||
created_by_name?: string | null
|
||||
description: string
|
||||
display_name: string
|
||||
icon: string
|
||||
id: string
|
||||
latest_published_version_id?: string | null
|
||||
name: string
|
||||
name_manually_edited?: boolean
|
||||
reference_count?: number
|
||||
tags?: Array<string>
|
||||
updated_at: number
|
||||
updated_by?: string | null
|
||||
updated_by_name?: string | null
|
||||
visibility: string
|
||||
}
|
||||
|
||||
export type SkillAssistMessagePayload = {
|
||||
attachments?: Array<SkillAssistAttachmentPayload>
|
||||
message: string
|
||||
model?: SkillAssistModelPayload | null
|
||||
}
|
||||
|
||||
export type SkillDraftFileOperationPayload = {
|
||||
content?: string | null
|
||||
expected_updated_at?: number | null
|
||||
hash?: string | null
|
||||
mime_type?: string | null
|
||||
operation: SkillDraftFileOperation
|
||||
path: string
|
||||
size?: number | null
|
||||
target_path?: string | null
|
||||
tool_file_id?: string | null
|
||||
}
|
||||
|
||||
export type SkillDraftTreePayload = {
|
||||
expected_updated_at?: number | null
|
||||
files?: Array<SkillDraftTreeItemPayload>
|
||||
}
|
||||
|
||||
export type SkillFilePreviewResponse = {
|
||||
content: string
|
||||
hash: string
|
||||
mime_type: string
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export type SkillPublishPayload = {
|
||||
publish_note?: string
|
||||
version_name?: string | null
|
||||
}
|
||||
|
||||
export type SkillVersionResponse = {
|
||||
archive_size: number
|
||||
created_at: number
|
||||
hash_code: string
|
||||
id: string
|
||||
is_latest?: boolean
|
||||
publish_note: string
|
||||
published_by?: string | null
|
||||
published_by_name?: string | null
|
||||
skill_id: string
|
||||
version_name: string
|
||||
version_number: number
|
||||
}
|
||||
|
||||
export type SkillReferenceListResponse = {
|
||||
data?: Array<SkillReferenceResponse>
|
||||
}
|
||||
|
||||
export type SkillRestorePayload = {
|
||||
publish_note?: string
|
||||
version_id: string
|
||||
version_name?: string | null
|
||||
}
|
||||
|
||||
export type SkillVersionListResponse = {
|
||||
data?: Array<SkillVersionResponse>
|
||||
}
|
||||
|
||||
export type SkillVersionDeleteResponse = {
|
||||
deleted: boolean
|
||||
id: string
|
||||
latest_published_version_id?: string | null
|
||||
}
|
||||
|
||||
export type SkillVersionDetailResponse = {
|
||||
archive_size: number
|
||||
created_at: number
|
||||
files?: Array<SkillFileResponse>
|
||||
hash_code: string
|
||||
id: string
|
||||
is_latest?: boolean
|
||||
publish_note: string
|
||||
published_by?: string | null
|
||||
published_by_name?: string | null
|
||||
skill_id: string
|
||||
version_name: string
|
||||
version_number: number
|
||||
}
|
||||
|
||||
export type SkillVersionUpdatePayload = {
|
||||
publish_note?: string
|
||||
version_name?: string | null
|
||||
}
|
||||
|
||||
export type ToolLabelListResponse = Array<ToolLabel>
|
||||
|
||||
export type ApiToolProviderAddPayload = {
|
||||
@@ -1225,21 +1042,6 @@ export type WorkspaceCustomConfigResponse = {
|
||||
replace_webapp_logo?: string | null
|
||||
}
|
||||
|
||||
export type AgentSkillBindingItemResponse = {
|
||||
description: string
|
||||
display_name: string
|
||||
file_count: number
|
||||
icon: string
|
||||
id: string
|
||||
latest_published_at?: number | null
|
||||
latest_published_version_id?: string | null
|
||||
name: string
|
||||
priority: number
|
||||
status: string
|
||||
tags?: Array<string>
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export type SnippetListItemResponse = {
|
||||
author_name: string | null
|
||||
created_at: number
|
||||
@@ -1700,76 +1502,6 @@ export type AccessPolicyRole = {
|
||||
role_tag?: string
|
||||
}
|
||||
|
||||
export type SkillFileResponse = {
|
||||
content?: string | null
|
||||
hash?: string | null
|
||||
id?: string | null
|
||||
kind: string
|
||||
mime_type?: string | null
|
||||
path: string
|
||||
size?: number | null
|
||||
storage?: string | null
|
||||
tool_file_id?: string | null
|
||||
}
|
||||
|
||||
export type SkillTagResponse = {
|
||||
count: number
|
||||
tag: string
|
||||
}
|
||||
|
||||
export type SkillAssistAttachmentPayload = {
|
||||
mime_type?: string | null
|
||||
name: string
|
||||
size?: number | null
|
||||
tool_file_id: string
|
||||
}
|
||||
|
||||
export type SkillAssistModelPayload = {
|
||||
model: string
|
||||
model_settings?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
plugin_id?: string | null
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type SkillDraftFileOperation =
|
||||
| 'delete'
|
||||
| 'mkdir'
|
||||
| 'rename'
|
||||
| 'upsert_text'
|
||||
| 'upsert_tool_file'
|
||||
|
||||
export type SkillDraftTreeItemPayload = {
|
||||
content?: string | null
|
||||
hash?: string | null
|
||||
kind?: SkillFileKind
|
||||
mime_type?: string | null
|
||||
path: string
|
||||
size?: number | null
|
||||
storage?: SkillFileStorage | null
|
||||
tool_file_id?: string | null
|
||||
}
|
||||
|
||||
export type SkillReferenceResponse = {
|
||||
agent_icon?: string | null
|
||||
agent_icon_background?: string | null
|
||||
agent_icon_type?: string | null
|
||||
agent_id: string
|
||||
app_id?: string | null
|
||||
display_name: string
|
||||
name: string
|
||||
node_id?: string | null
|
||||
node_name?: string | null
|
||||
type: string
|
||||
workflow_icon?: string | null
|
||||
workflow_icon_background?: string | null
|
||||
workflow_icon_type?: string | null
|
||||
workflow_id?: string | null
|
||||
workflow_name?: string | null
|
||||
workflow_version?: string | null
|
||||
}
|
||||
|
||||
export type ToolLabel = {
|
||||
icon: string
|
||||
label: I18nObject
|
||||
@@ -2246,10 +1978,6 @@ export type PermissionCatalogItem = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type SkillFileKind = 'directory' | 'file'
|
||||
|
||||
export type SkillFileStorage = 'text' | 'tool_file'
|
||||
|
||||
export type ToolParameter = {
|
||||
auto_generate?: PluginParameterAutoGenerate | null
|
||||
default?:
|
||||
@@ -2745,38 +2473,6 @@ export type GetWorkspacesCurrentAgentProvidersResponses = {
|
||||
export type GetWorkspacesCurrentAgentProvidersResponse =
|
||||
GetWorkspacesCurrentAgentProvidersResponses[keyof GetWorkspacesCurrentAgentProvidersResponses]
|
||||
|
||||
export type GetWorkspacesCurrentAgentsByAgentIdSkillsData = {
|
||||
body?: never
|
||||
path: {
|
||||
agent_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/agents/{agent_id}/skills'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentAgentsByAgentIdSkillsResponses = {
|
||||
200: AgentSkillBindingsResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentAgentsByAgentIdSkillsResponse =
|
||||
GetWorkspacesCurrentAgentsByAgentIdSkillsResponses[keyof GetWorkspacesCurrentAgentsByAgentIdSkillsResponses]
|
||||
|
||||
export type PutWorkspacesCurrentAgentsByAgentIdSkillsData = {
|
||||
body: AgentSkillBindingsPayload
|
||||
path: {
|
||||
agent_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/agents/{agent_id}/skills'
|
||||
}
|
||||
|
||||
export type PutWorkspacesCurrentAgentsByAgentIdSkillsResponses = {
|
||||
200: AgentSkillBindingsResponse
|
||||
}
|
||||
|
||||
export type PutWorkspacesCurrentAgentsByAgentIdSkillsResponse =
|
||||
PutWorkspacesCurrentAgentsByAgentIdSkillsResponses[keyof PutWorkspacesCurrentAgentsByAgentIdSkillsResponses]
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -4929,369 +4625,6 @@ export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses = {
|
||||
export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse =
|
||||
GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
keyword?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
tag?: Array<string>
|
||||
}
|
||||
url: '/workspaces/current/skills'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsResponses = {
|
||||
200: SkillListResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsResponse =
|
||||
GetWorkspacesCurrentSkillsResponses[keyof GetWorkspacesCurrentSkillsResponses]
|
||||
|
||||
export type PostWorkspacesCurrentSkillsData = {
|
||||
body: SkillCreatePayload
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workspaces/current/skills'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsResponses = {
|
||||
201: SkillDetailResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsResponse =
|
||||
PostWorkspacesCurrentSkillsResponses[keyof PostWorkspacesCurrentSkillsResponses]
|
||||
|
||||
export type PostWorkspacesCurrentSkillsFilesUploadData = {
|
||||
body: {
|
||||
file: Blob | File
|
||||
}
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/files/upload'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsFilesUploadResponses = {
|
||||
201: SkillFileUploadResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsFilesUploadResponse =
|
||||
PostWorkspacesCurrentSkillsFilesUploadResponses[keyof PostWorkspacesCurrentSkillsFilesUploadResponses]
|
||||
|
||||
export type PostWorkspacesCurrentSkillsImportData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/import'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsImportResponses = {
|
||||
201: SkillDetailResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsImportResponse =
|
||||
PostWorkspacesCurrentSkillsImportResponses[keyof PostWorkspacesCurrentSkillsImportResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsTagsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/tags'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsTagsResponses = {
|
||||
200: SkillTagListResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsTagsResponse =
|
||||
GetWorkspacesCurrentSkillsTagsResponses[keyof GetWorkspacesCurrentSkillsTagsResponses]
|
||||
|
||||
export type DeleteWorkspacesCurrentSkillsBySkillIdData = {
|
||||
body: SkillDeletePayload
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}'
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesCurrentSkillsBySkillIdResponses = {
|
||||
200: SkillDeleteResponse
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesCurrentSkillsBySkillIdResponse =
|
||||
DeleteWorkspacesCurrentSkillsBySkillIdResponses[keyof DeleteWorkspacesCurrentSkillsBySkillIdResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdResponses = {
|
||||
200: SkillDetailResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdResponse =
|
||||
GetWorkspacesCurrentSkillsBySkillIdResponses[keyof GetWorkspacesCurrentSkillsBySkillIdResponses]
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdData = {
|
||||
body: SkillMetadataPayload
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}'
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdResponses = {
|
||||
200: SkillResponse
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdResponse =
|
||||
PatchWorkspacesCurrentSkillsBySkillIdResponses[keyof PatchWorkspacesCurrentSkillsBySkillIdResponses]
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdAssistMessagesData = {
|
||||
body: SkillAssistMessagePayload
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/assist/messages'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponse =
|
||||
PostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponses[keyof PostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponses]
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdDuplicateData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/duplicate'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdDuplicateResponses = {
|
||||
201: SkillDetailResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdDuplicateResponse =
|
||||
PostWorkspacesCurrentSkillsBySkillIdDuplicateResponses[keyof PostWorkspacesCurrentSkillsBySkillIdDuplicateResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdExportData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/export'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdExportResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdExportResponse =
|
||||
GetWorkspacesCurrentSkillsBySkillIdExportResponses[keyof GetWorkspacesCurrentSkillsBySkillIdExportResponses]
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdFilesData = {
|
||||
body: SkillDraftFileOperationPayload
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/files'
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdFilesResponses = {
|
||||
200: SkillDetailResponse
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdFilesResponse =
|
||||
PatchWorkspacesCurrentSkillsBySkillIdFilesResponses[keyof PatchWorkspacesCurrentSkillsBySkillIdFilesResponses]
|
||||
|
||||
export type PutWorkspacesCurrentSkillsBySkillIdFilesData = {
|
||||
body: SkillDraftTreePayload
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/files'
|
||||
}
|
||||
|
||||
export type PutWorkspacesCurrentSkillsBySkillIdFilesResponses = {
|
||||
200: SkillDetailResponse
|
||||
}
|
||||
|
||||
export type PutWorkspacesCurrentSkillsBySkillIdFilesResponse =
|
||||
PutWorkspacesCurrentSkillsBySkillIdFilesResponses[keyof PutWorkspacesCurrentSkillsBySkillIdFilesResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdFilesContentData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query: {
|
||||
download?: string
|
||||
path: string
|
||||
version_id?: string
|
||||
}
|
||||
url: '/workspaces/current/skills/{skill_id}/files/content'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdFilesContentResponses = {
|
||||
200: BinaryFileResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdFilesContentResponse =
|
||||
GetWorkspacesCurrentSkillsBySkillIdFilesContentResponses[keyof GetWorkspacesCurrentSkillsBySkillIdFilesContentResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdFilesPreviewData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query: {
|
||||
path: string
|
||||
version_id?: string
|
||||
}
|
||||
url: '/workspaces/current/skills/{skill_id}/files/preview'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponses = {
|
||||
200: SkillFilePreviewResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponse =
|
||||
GetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponses[keyof GetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponses]
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdPublishData = {
|
||||
body: SkillPublishPayload
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/publish'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdPublishResponses = {
|
||||
200: SkillVersionResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdPublishResponse =
|
||||
PostWorkspacesCurrentSkillsBySkillIdPublishResponses[keyof PostWorkspacesCurrentSkillsBySkillIdPublishResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdReferencesData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/references'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdReferencesResponses = {
|
||||
200: SkillReferenceListResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdReferencesResponse =
|
||||
GetWorkspacesCurrentSkillsBySkillIdReferencesResponses[keyof GetWorkspacesCurrentSkillsBySkillIdReferencesResponses]
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdRestoreData = {
|
||||
body: SkillRestorePayload
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/restore'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdRestoreResponses = {
|
||||
200: SkillVersionResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdRestoreResponse =
|
||||
PostWorkspacesCurrentSkillsBySkillIdRestoreResponses[keyof PostWorkspacesCurrentSkillsBySkillIdRestoreResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdVersionsData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/versions'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdVersionsResponses = {
|
||||
200: SkillVersionListResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdVersionsResponse =
|
||||
GetWorkspacesCurrentSkillsBySkillIdVersionsResponses[keyof GetWorkspacesCurrentSkillsBySkillIdVersionsResponses]
|
||||
|
||||
export type DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
version_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/versions/{version_id}'
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses = {
|
||||
200: SkillVersionDeleteResponse
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
|
||||
DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses[keyof DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
version_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/versions/{version_id}'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses = {
|
||||
200: SkillVersionDetailResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
|
||||
GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses[keyof GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses]
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdData = {
|
||||
body: SkillVersionUpdatePayload
|
||||
path: {
|
||||
skill_id: string
|
||||
version_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/versions/{version_id}'
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses = {
|
||||
200: SkillVersionResponse
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
|
||||
PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses[keyof PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses]
|
||||
|
||||
export type GetWorkspacesCurrentToolLabelsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@@ -12,13 +12,6 @@ export const zAgentProviderResponse = z.record(z.string(), z.unknown())
|
||||
*/
|
||||
export const zAgentProviderListResponse = z.array(z.record(z.string(), z.unknown()))
|
||||
|
||||
/**
|
||||
* AgentSkillBindingsPayload
|
||||
*/
|
||||
export const zAgentSkillBindingsPayload = z.object({
|
||||
skill_ids: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SnippetImportPayload
|
||||
*
|
||||
@@ -443,155 +436,6 @@ export const zReplaceBindingsRequest = z.object({
|
||||
role_ids: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillCreatePayload
|
||||
*/
|
||||
export const zSkillCreatePayload = z.object({
|
||||
description: z.string().optional().default(''),
|
||||
display_name: z.string().nullish(),
|
||||
icon: z.string().optional().default('📄'),
|
||||
name: z.string().nullish(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillFileUploadResponse
|
||||
*/
|
||||
export const zSkillFileUploadResponse = z.object({
|
||||
hash: z.string(),
|
||||
id: z.string(),
|
||||
mime_type: z.string(),
|
||||
name: z.string(),
|
||||
size: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillDeletePayload
|
||||
*/
|
||||
export const zSkillDeletePayload = z.object({
|
||||
confirmation_name: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillDeleteResponse
|
||||
*/
|
||||
export const zSkillDeleteResponse = z.object({
|
||||
deleted: z.boolean(),
|
||||
id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillMetadataPayload
|
||||
*/
|
||||
export const zSkillMetadataPayload = z.object({
|
||||
display_name: z.string().nullish(),
|
||||
expected_updated_at: z.int().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillResponse
|
||||
*/
|
||||
export const zSkillResponse = z.object({
|
||||
created_at: z.int(),
|
||||
created_by: z.string().nullish(),
|
||||
created_by_name: z.string().nullish(),
|
||||
description: z.string(),
|
||||
display_name: z.string(),
|
||||
icon: z.string(),
|
||||
id: z.string(),
|
||||
latest_published_version_id: z.string().nullish(),
|
||||
name: z.string(),
|
||||
name_manually_edited: z.boolean().optional().default(false),
|
||||
reference_count: z.int().optional().default(0),
|
||||
tags: z.array(z.string()).optional(),
|
||||
updated_at: z.int(),
|
||||
updated_by: z.string().nullish(),
|
||||
updated_by_name: z.string().nullish(),
|
||||
visibility: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillListResponse
|
||||
*/
|
||||
export const zSkillListResponse = z.object({
|
||||
data: z.array(zSkillResponse).optional(),
|
||||
has_more: z.boolean().optional().default(false),
|
||||
limit: z.int().optional().default(20),
|
||||
page: z.int().optional().default(1),
|
||||
total: z.int().optional().default(0),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillFilePreviewResponse
|
||||
*/
|
||||
export const zSkillFilePreviewResponse = z.object({
|
||||
content: z.string(),
|
||||
hash: z.string(),
|
||||
mime_type: z.string(),
|
||||
path: z.string(),
|
||||
size: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillPublishPayload
|
||||
*/
|
||||
export const zSkillPublishPayload = z.object({
|
||||
publish_note: z.string().max(1024).optional().default(''),
|
||||
version_name: z.string().max(128).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillVersionResponse
|
||||
*/
|
||||
export const zSkillVersionResponse = z.object({
|
||||
archive_size: z.int(),
|
||||
created_at: z.int(),
|
||||
hash_code: z.string(),
|
||||
id: z.string(),
|
||||
is_latest: z.boolean().optional().default(false),
|
||||
publish_note: z.string(),
|
||||
published_by: z.string().nullish(),
|
||||
published_by_name: z.string().nullish(),
|
||||
skill_id: z.string(),
|
||||
version_name: z.string(),
|
||||
version_number: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillRestorePayload
|
||||
*/
|
||||
export const zSkillRestorePayload = z.object({
|
||||
publish_note: z.string().max(1024).optional().default(''),
|
||||
version_id: z.string(),
|
||||
version_name: z.string().max(128).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillVersionListResponse
|
||||
*/
|
||||
export const zSkillVersionListResponse = z.object({
|
||||
data: z.array(zSkillVersionResponse).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillVersionDeleteResponse
|
||||
*/
|
||||
export const zSkillVersionDeleteResponse = z.object({
|
||||
deleted: z.boolean(),
|
||||
id: z.string(),
|
||||
latest_published_version_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillVersionUpdatePayload
|
||||
*/
|
||||
export const zSkillVersionUpdatePayload = z.object({
|
||||
publish_note: z.string().max(1024).optional().default(''),
|
||||
version_name: z.string().max(128).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ApiToolProviderDeletePayload
|
||||
*/
|
||||
@@ -812,33 +656,6 @@ export const zSwitchWorkspaceResponse = z.object({
|
||||
result: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSkillBindingItemResponse
|
||||
*/
|
||||
export const zAgentSkillBindingItemResponse = z.object({
|
||||
description: z.string(),
|
||||
display_name: z.string(),
|
||||
file_count: z.int(),
|
||||
icon: z.string(),
|
||||
id: z.string(),
|
||||
latest_published_at: z.int().nullish(),
|
||||
latest_published_version_id: z.string().nullish(),
|
||||
name: z.string(),
|
||||
priority: z.int(),
|
||||
status: z.string(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
updated_at: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSkillBindingsResponse
|
||||
*/
|
||||
export const zAgentSkillBindingsResponse = z.object({
|
||||
agent_id: z.string(),
|
||||
data: z.array(zAgentSkillBindingItemResponse).optional(),
|
||||
skill_ids: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* IconInfo
|
||||
*
|
||||
@@ -1427,163 +1244,6 @@ export const zWorkspaceAccessMatrix = z.object({
|
||||
pagination: zPagination.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillFileResponse
|
||||
*/
|
||||
export const zSkillFileResponse = z.object({
|
||||
content: z.string().nullish(),
|
||||
hash: z.string().nullish(),
|
||||
id: z.string().nullish(),
|
||||
kind: z.string(),
|
||||
mime_type: z.string().nullish(),
|
||||
path: z.string(),
|
||||
size: z.int().nullish(),
|
||||
storage: z.string().nullish(),
|
||||
tool_file_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillDetailResponse
|
||||
*/
|
||||
export const zSkillDetailResponse = z.object({
|
||||
created_at: z.int(),
|
||||
created_by: z.string().nullish(),
|
||||
created_by_name: z.string().nullish(),
|
||||
description: z.string(),
|
||||
display_name: z.string(),
|
||||
files: z.array(zSkillFileResponse).optional(),
|
||||
icon: z.string(),
|
||||
id: z.string(),
|
||||
latest_published_version_id: z.string().nullish(),
|
||||
name: z.string(),
|
||||
name_manually_edited: z.boolean().optional().default(false),
|
||||
reference_count: z.int().optional().default(0),
|
||||
tags: z.array(z.string()).optional(),
|
||||
updated_at: z.int(),
|
||||
updated_by: z.string().nullish(),
|
||||
updated_by_name: z.string().nullish(),
|
||||
visibility: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillVersionDetailResponse
|
||||
*/
|
||||
export const zSkillVersionDetailResponse = z.object({
|
||||
archive_size: z.int(),
|
||||
created_at: z.int(),
|
||||
files: z.array(zSkillFileResponse).optional(),
|
||||
hash_code: z.string(),
|
||||
id: z.string(),
|
||||
is_latest: z.boolean().optional().default(false),
|
||||
publish_note: z.string(),
|
||||
published_by: z.string().nullish(),
|
||||
published_by_name: z.string().nullish(),
|
||||
skill_id: z.string(),
|
||||
version_name: z.string(),
|
||||
version_number: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillTagResponse
|
||||
*/
|
||||
export const zSkillTagResponse = z.object({
|
||||
count: z.int(),
|
||||
tag: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillTagListResponse
|
||||
*/
|
||||
export const zSkillTagListResponse = z.object({
|
||||
data: z.array(zSkillTagResponse).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillAssistAttachmentPayload
|
||||
*/
|
||||
export const zSkillAssistAttachmentPayload = z.object({
|
||||
mime_type: z.string().min(1).max(255).nullish(),
|
||||
name: z.string().min(1).max(255),
|
||||
size: z.int().gte(0).nullish(),
|
||||
tool_file_id: z.string().min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillAssistModelPayload
|
||||
*/
|
||||
export const zSkillAssistModelPayload = z.object({
|
||||
model: z.string().min(1).max(255),
|
||||
model_settings: z.record(z.string(), z.unknown()).nullish(),
|
||||
plugin_id: z.string().min(1).max(255).nullish(),
|
||||
provider: z.string().min(1).max(255),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillAssistMessagePayload
|
||||
*
|
||||
* One user message and optional uploaded context for the read-only Skill Authoring assistant.
|
||||
*/
|
||||
export const zSkillAssistMessagePayload = z.object({
|
||||
attachments: z.array(zSkillAssistAttachmentPayload).max(10).optional(),
|
||||
message: z.string().min(1).max(8000),
|
||||
model: zSkillAssistModelPayload.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillDraftFileOperation
|
||||
*/
|
||||
export const zSkillDraftFileOperation = z.enum([
|
||||
'delete',
|
||||
'mkdir',
|
||||
'rename',
|
||||
'upsert_text',
|
||||
'upsert_tool_file',
|
||||
])
|
||||
|
||||
/**
|
||||
* SkillDraftFileOperationPayload
|
||||
*/
|
||||
export const zSkillDraftFileOperationPayload = z.object({
|
||||
content: z.string().nullish(),
|
||||
expected_updated_at: z.int().nullish(),
|
||||
hash: z.string().nullish(),
|
||||
mime_type: z.string().nullish(),
|
||||
operation: zSkillDraftFileOperation,
|
||||
path: z.string(),
|
||||
size: z.int().gte(0).nullish(),
|
||||
target_path: z.string().nullish(),
|
||||
tool_file_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillReferenceResponse
|
||||
*/
|
||||
export const zSkillReferenceResponse = z.object({
|
||||
agent_icon: z.string().nullish(),
|
||||
agent_icon_background: z.string().nullish(),
|
||||
agent_icon_type: z.string().nullish(),
|
||||
agent_id: z.string(),
|
||||
app_id: z.string().nullish(),
|
||||
display_name: z.string(),
|
||||
name: z.string(),
|
||||
node_id: z.string().nullish(),
|
||||
node_name: z.string().nullish(),
|
||||
type: z.string(),
|
||||
workflow_icon: z.string().nullish(),
|
||||
workflow_icon_background: z.string().nullish(),
|
||||
workflow_icon_type: z.string().nullish(),
|
||||
workflow_id: z.string().nullish(),
|
||||
workflow_name: z.string().nullish(),
|
||||
workflow_version: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillReferenceListResponse
|
||||
*/
|
||||
export const zSkillReferenceListResponse = z.object({
|
||||
data: z.array(zSkillReferenceResponse).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ToolEmojiIcon
|
||||
*/
|
||||
@@ -2436,42 +2096,6 @@ export const zPermissionCatalogResponse = z.object({
|
||||
groups: z.array(zPermissionCatalogGroup).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillFileKind
|
||||
*
|
||||
* Draft file entry kind.
|
||||
*/
|
||||
export const zSkillFileKind = z.enum(['directory', 'file'])
|
||||
|
||||
/**
|
||||
* SkillFileStorage
|
||||
*
|
||||
* How a draft file's content is stored.
|
||||
*/
|
||||
export const zSkillFileStorage = z.enum(['text', 'tool_file'])
|
||||
|
||||
/**
|
||||
* SkillDraftTreeItemPayload
|
||||
*/
|
||||
export const zSkillDraftTreeItemPayload = z.object({
|
||||
content: z.string().nullish(),
|
||||
hash: z.string().nullish(),
|
||||
kind: zSkillFileKind.optional().default('file'),
|
||||
mime_type: z.string().nullish(),
|
||||
path: z.string(),
|
||||
size: z.int().gte(0).nullish(),
|
||||
storage: zSkillFileStorage.nullish(),
|
||||
tool_file_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillDraftTreePayload
|
||||
*/
|
||||
export const zSkillDraftTreePayload = z.object({
|
||||
expected_updated_at: z.int().nullish(),
|
||||
files: z.array(zSkillDraftTreeItemPayload).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Option
|
||||
*/
|
||||
@@ -3797,26 +3421,6 @@ export const zGetWorkspacesCurrentAgentProviderByProviderNameResponse = zAgentPr
|
||||
*/
|
||||
export const zGetWorkspacesCurrentAgentProvidersResponse = zAgentProviderListResponse
|
||||
|
||||
export const zGetWorkspacesCurrentAgentsByAgentIdSkillsPath = z.object({
|
||||
agent_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Agent Skill bindings
|
||||
*/
|
||||
export const zGetWorkspacesCurrentAgentsByAgentIdSkillsResponse = zAgentSkillBindingsResponse
|
||||
|
||||
export const zPutWorkspacesCurrentAgentsByAgentIdSkillsBody = zAgentSkillBindingsPayload
|
||||
|
||||
export const zPutWorkspacesCurrentAgentsByAgentIdSkillsPath = z.object({
|
||||
agent_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Agent Skill bindings replaced
|
||||
*/
|
||||
export const zPutWorkspacesCurrentAgentsByAgentIdSkillsResponse = zAgentSkillBindingsResponse
|
||||
|
||||
export const zGetWorkspacesCurrentCustomizedSnippetsQuery = z.object({
|
||||
creators: z.array(z.string()).optional(),
|
||||
is_published: z.boolean().optional(),
|
||||
@@ -5090,234 +4694,6 @@ export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdR
|
||||
*/
|
||||
export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse = zWorkspaceAccessMatrix
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsQuery = z.object({
|
||||
keyword: z.string().optional(),
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
page: z.int().gte(1).lte(99999).optional().default(1),
|
||||
tag: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Workspace skills
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsResponse = zSkillListResponse
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBody = zSkillCreatePayload
|
||||
|
||||
/**
|
||||
* Skill created
|
||||
*/
|
||||
export const zPostWorkspacesCurrentSkillsResponse = zSkillDetailResponse
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsFilesUploadBody = z.object({
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill draft file uploaded
|
||||
*/
|
||||
export const zPostWorkspacesCurrentSkillsFilesUploadResponse = zSkillFileUploadResponse
|
||||
|
||||
/**
|
||||
* Skill imported
|
||||
*/
|
||||
export const zPostWorkspacesCurrentSkillsImportResponse = zSkillDetailResponse
|
||||
|
||||
/**
|
||||
* Workspace Skill tags
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsTagsResponse = zSkillTagListResponse
|
||||
|
||||
export const zDeleteWorkspacesCurrentSkillsBySkillIdBody = zSkillDeletePayload
|
||||
|
||||
export const zDeleteWorkspacesCurrentSkillsBySkillIdPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill deleted
|
||||
*/
|
||||
export const zDeleteWorkspacesCurrentSkillsBySkillIdResponse = zSkillDeleteResponse
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill detail
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdResponse = zSkillDetailResponse
|
||||
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdBody = zSkillMetadataPayload
|
||||
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill updated
|
||||
*/
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdResponse = zSkillResponse
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdAssistMessagesBody = zSkillAssistMessagePayload
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdAssistMessagesPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill Authoring assistant event stream
|
||||
*/
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdDuplicatePath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill duplicated
|
||||
*/
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdDuplicateResponse = zSkillDetailResponse
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdExportPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Published Skill zip archive
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdExportResponse = z.record(z.string(), z.unknown())
|
||||
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdFilesBody = zSkillDraftFileOperationPayload
|
||||
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdFilesPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Draft file operation applied
|
||||
*/
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdFilesResponse = zSkillDetailResponse
|
||||
|
||||
export const zPutWorkspacesCurrentSkillsBySkillIdFilesBody = zSkillDraftTreePayload
|
||||
|
||||
export const zPutWorkspacesCurrentSkillsBySkillIdFilesPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Draft files replaced
|
||||
*/
|
||||
export const zPutWorkspacesCurrentSkillsBySkillIdFilesResponse = zSkillDetailResponse
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdFilesContentPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdFilesContentQuery = z.object({
|
||||
download: z.string().optional(),
|
||||
path: z.string(),
|
||||
version_id: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill file content
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdFilesContentResponse = zBinaryFileResponse
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdFilesPreviewPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdFilesPreviewQuery = z.object({
|
||||
path: z.string(),
|
||||
version_id: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill file text preview
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponse = zSkillFilePreviewResponse
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdPublishBody = zSkillPublishPayload
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdPublishPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill published
|
||||
*/
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdPublishResponse = zSkillVersionResponse
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdReferencesPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill references
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdReferencesResponse = zSkillReferenceListResponse
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdRestoreBody = zSkillRestorePayload
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdRestorePath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill version restored
|
||||
*/
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdRestoreResponse = zSkillVersionResponse
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdVersionsPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill versions
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdVersionsResponse = zSkillVersionListResponse
|
||||
|
||||
export const zDeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdPath = z.object({
|
||||
skill_id: z.string(),
|
||||
version_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill version deleted
|
||||
*/
|
||||
export const zDeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
|
||||
zSkillVersionDeleteResponse
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdPath = z.object({
|
||||
skill_id: z.string(),
|
||||
version_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill version detail
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
|
||||
zSkillVersionDetailResponse
|
||||
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdBody =
|
||||
zSkillVersionUpdatePayload
|
||||
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdPath = z.object({
|
||||
skill_id: z.string(),
|
||||
version_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill version updated
|
||||
*/
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
|
||||
zSkillVersionResponse
|
||||
|
||||
/**
|
||||
* Tool labels retrieved successfully
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,6 @@ The current Flask-RESTX generator still emits these response entries under `appl
|
||||
| service | GET | `/files/{file_id}/preview` | Original file MIME type, optionally attachment | `BinaryFileResponse` |
|
||||
| console | GET | `/workspaces/current/plugin/icon` | Plugin asset MIME type | `BinaryFileResponse` |
|
||||
| console | GET | `/workspaces/current/plugin/asset` | `application/octet-stream` | `BinaryFileResponse` |
|
||||
| console | GET | `/workspaces/current/skills/{skill_id}/files/content` | Skill file MIME type, optionally attachment | `BinaryFileResponse` |
|
||||
| console | GET | `/workspaces/current/tool-provider/builtin/{provider}/icon` | Tool icon MIME type | `BinaryFileResponse` |
|
||||
| console | GET | `/workspaces/current/trigger-provider/{provider}/icon` | Trigger icon response | `BinaryFileResponse` |
|
||||
| console | GET | `/workspaces/{tenant_id}/model-providers/{provider}/{icon_type}/{lang}` | Model provider icon MIME type | `BinaryFileResponse` |
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import SkillDetailPage from '@/features/skills/detail-page'
|
||||
|
||||
export default function Page() {
|
||||
return <SkillDetailPage />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import SkillsPage from '@/features/skills/page'
|
||||
|
||||
export default function Page() {
|
||||
return <SkillsPage />
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
@@ -68,16 +67,11 @@ describe('ArchivedLogsNotice', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should show an accessible notice for paid workspace managers', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('should show notice for paid workspace managers', () => {
|
||||
renderNotice()
|
||||
|
||||
const notice = screen.getByRole('status')
|
||||
expect(notice).toHaveAttribute('aria-live', 'polite')
|
||||
expect(notice).toHaveAttribute('aria-atomic', 'true')
|
||||
expect(within(notice).getByText('appLog.archives.notice.description')).toBeInTheDocument()
|
||||
|
||||
await user.click(within(notice).getByRole('button', { name: 'appLog.archives.notice.action' }))
|
||||
expect(screen.getByText('appLog.archives.notice.description')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appLog.archives.notice.action' }))
|
||||
expect(setShowAccountSettingModal).toHaveBeenCalledWith({
|
||||
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
|
||||
})
|
||||
|
||||
@@ -1,37 +1,9 @@
|
||||
import type { QueryParam } from '../index'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Filter, { TIME_PERIOD_MAPPING } from '../filter'
|
||||
|
||||
let mockAnnotationsCountLoading = false
|
||||
let mockAnnotationsCountData: { count: number } | null = { count: 10 }
|
||||
const mockRuntime = vi.hoisted(() => ({
|
||||
deploymentEdition: 'CLOUD',
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
isFetchedPlanInfo: true,
|
||||
planType: 'professional',
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: () => ({
|
||||
enableBilling: mockRuntime.enableBilling,
|
||||
isFetchedPlan: mockRuntime.isFetchedPlan,
|
||||
isFetchedPlanInfo: mockRuntime.isFetchedPlanInfo,
|
||||
plan: { type: mockRuntime.planType },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-log', () => ({
|
||||
useAnnotationsCount: () => ({
|
||||
@@ -40,43 +12,28 @@ vi.mock('@/service/use-log', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chip', async () => {
|
||||
const { useState } = await import('react')
|
||||
|
||||
return {
|
||||
default: function MockChip({
|
||||
items,
|
||||
value,
|
||||
onSelect,
|
||||
onClear,
|
||||
}: {
|
||||
items: Array<{ value: string; name: string }>
|
||||
value?: string
|
||||
onSelect: (item: { value: string; name: string }) => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const currentItem = items.find((item) => item.value === value) ?? items[0]
|
||||
return (
|
||||
<div>
|
||||
<div>{currentItem?.name}</div>
|
||||
<button aria-label={`open-options-${items[0]?.value}`} onClick={() => setIsOpen(true)}>
|
||||
open-chip
|
||||
</button>
|
||||
{isOpen && (
|
||||
<ul aria-label={`options-${items[0]?.value}`}>
|
||||
{items.map((item) => (
|
||||
<li key={item.value}>{item.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<button onClick={() => onSelect(items.at(-1)!)}>{`select-${items.at(-1)?.value}`}</button>
|
||||
<button onClick={onClear}>clear-chip</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@/app/components/base/chip', () => ({
|
||||
default: ({
|
||||
items,
|
||||
value,
|
||||
onSelect,
|
||||
onClear,
|
||||
}: {
|
||||
items: Array<{ value: string; name: string }>
|
||||
value?: string
|
||||
onSelect: (item: { value: string; name: string }) => void
|
||||
onClear: () => void
|
||||
}) => {
|
||||
const currentItem = items.find((item) => item.value === value) ?? items[0]
|
||||
return (
|
||||
<div>
|
||||
<div>{currentItem?.name}</div>
|
||||
<button onClick={() => onSelect(items.at(-1)!)}>{`select-${items.at(-1)?.value}`}</button>
|
||||
<button onClick={onClear}>clear-chip</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/sort', () => ({
|
||||
default: ({ onSelect }: { onSelect: (value: string) => void }) => (
|
||||
@@ -102,11 +59,6 @@ describe('Filter', () => {
|
||||
vi.clearAllMocks()
|
||||
mockAnnotationsCountLoading = false
|
||||
mockAnnotationsCountData = { count: 10 }
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.enableBilling = true
|
||||
mockRuntime.isFetchedPlan = true
|
||||
mockRuntime.isFetchedPlanInfo = true
|
||||
mockRuntime.planType = 'professional'
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -172,77 +124,6 @@ describe('Filter', () => {
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should only show supported periods for Cloud sandbox workspaces', () => {
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem').map((item) => item.textContent)).toEqual([
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.today(?=$|:)/),
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/),
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/),
|
||||
])
|
||||
})
|
||||
|
||||
it('should only show supported periods while the Cloud plan is pending', () => {
|
||||
mockRuntime.isFetchedPlan = false
|
||||
mockRuntime.isFetchedPlanInfo = false
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem').map((item) => item.textContent)).toEqual([
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.today(?=$|:)/),
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/),
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/),
|
||||
])
|
||||
})
|
||||
|
||||
it('should keep all periods when Cloud billing is known to be disabled', () => {
|
||||
mockRuntime.enableBilling = false
|
||||
mockRuntime.isFetchedPlan = false
|
||||
mockRuntime.isFetchedPlanInfo = true
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem')).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('should keep all periods for sandbox workspaces outside Cloud', () => {
|
||||
mockRuntime.deploymentEdition = 'COMMUNITY'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem')).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('should reset the Cloud sandbox period to today when cleared', () => {
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getAllByText('clear-chip')[0]!)
|
||||
|
||||
expect(mockSetQueryParams).toHaveBeenCalledWith({
|
||||
...defaultQueryParams,
|
||||
period: '1',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update keyword when typing in search input', () => {
|
||||
render(<Filter {...defaultProps} />)
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import type { CloudSandboxPlanState } from '../cloud-sandbox-retention'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import dayjs from 'dayjs'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import Logs from '../index'
|
||||
@@ -14,27 +11,11 @@ vi.mock('@/context/i18n', () => ({
|
||||
const mockReplace = vi.fn()
|
||||
const mockUseChatConversations = vi.fn()
|
||||
const mockUseCompletionConversations = vi.fn()
|
||||
const mockPlanState = vi.hoisted(() => ({
|
||||
value: 'unrestricted' as CloudSandboxPlanState,
|
||||
}))
|
||||
const mockDebouncedPeriod = vi.hoisted(() => ({
|
||||
value: null as string | null,
|
||||
}))
|
||||
|
||||
let mockSearchParams = new URLSearchParams()
|
||||
vi.mock('ahooks', async () => {
|
||||
return {
|
||||
useDebounce: <T,>(value: T) => {
|
||||
if (
|
||||
mockDebouncedPeriod.value === null ||
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
!('period' in value)
|
||||
)
|
||||
return value
|
||||
|
||||
return { ...value, period: mockDebouncedPeriod.value }
|
||||
},
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -52,19 +33,28 @@ vi.mock('@/next/navigation', () => ({
|
||||
vi.mock('@/service/use-log', () => ({
|
||||
useChatConversations: (...args: unknown[]) => mockUseChatConversations(...args),
|
||||
useCompletionConversations: (...args: unknown[]) => mockUseCompletionConversations(...args),
|
||||
useAnnotationsCount: () => ({
|
||||
data: { count: 0 },
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../cloud-sandbox-retention', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../cloud-sandbox-retention')>()
|
||||
return {
|
||||
...actual,
|
||||
useCloudSandboxPlanStatus: () => mockPlanState.value,
|
||||
}
|
||||
})
|
||||
vi.mock('../filter', () => ({
|
||||
TIME_PERIOD_MAPPING: {
|
||||
2: { value: 7 },
|
||||
9: { value: 0 },
|
||||
},
|
||||
default: ({ setQueryParams }: { setQueryParams: (next: Record<string, string>) => void }) => (
|
||||
<button
|
||||
onClick={() =>
|
||||
setQueryParams({
|
||||
period: '9',
|
||||
annotation_status: 'all',
|
||||
sort_by: '-created_at',
|
||||
keyword: 'hello',
|
||||
})
|
||||
}
|
||||
>
|
||||
filter-controls
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../list', () => ({
|
||||
default: ({ logs }: { logs: { total?: number } }) => (
|
||||
@@ -79,10 +69,6 @@ vi.mock('../empty-element', () => ({
|
||||
default: () => <div>empty-logs</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../retention-upgrade-notice', () => ({
|
||||
RetentionUpgradeNotice: () => <div>retention-upgrade-notice</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/loading', () => ({
|
||||
default: () => <div>loading-logs</div>,
|
||||
}))
|
||||
@@ -99,8 +85,6 @@ describe('Logs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSearchParams = new URLSearchParams()
|
||||
mockPlanState.value = 'unrestricted'
|
||||
mockDebouncedPeriod.value = null
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: undefined,
|
||||
refetch: vi.fn(),
|
||||
@@ -133,7 +117,6 @@ describe('Logs', () => {
|
||||
expect(
|
||||
screen.getByRole('link', { name: /(?:^|\.)operation\.learnMore(?=$|:)/ }),
|
||||
).toHaveAttribute('href', 'https://docs.example.com/use-dify/monitor/logs')
|
||||
expect(screen.getByText('retention-upgrade-notice')).toBeInTheDocument()
|
||||
expect(screen.getByText('loading-logs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -183,101 +166,4 @@ describe('Logs', () => {
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps/app-1/logs?page=2', { scroll: false })
|
||||
})
|
||||
|
||||
it('should query the last 30 days when a Sandbox user selects the longest period', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPlanState.value = 'sandbox'
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: { total: 0 },
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
render(
|
||||
<Logs
|
||||
appDetail={
|
||||
{
|
||||
id: 'app-sandbox-last-30-days',
|
||||
mode: AppModeEnum.CHAT,
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: /appLog\.filter\.period\.last7days/ }))
|
||||
await user.click(await screen.findByText(/appLog\.filter\.period\.last30days/))
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: /appLog\.filter\.period\.last30days/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should use a valid period for the real Chip and request when a cached period settles to Sandbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
const appDetail = {
|
||||
id: 'app-period-transition',
|
||||
mode: AppModeEnum.CHAT,
|
||||
} as any
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: { total: 0 },
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
const unrestrictedRender = render(<Logs appDetail={appDetail} />)
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: /appLog\.filter\.period\.last7days/ }))
|
||||
await user.click(await screen.findByText(/appLog\.filter\.period\.allTime/))
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.not.objectContaining({
|
||||
start: expect.anything(),
|
||||
end: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
unrestrictedRender.unmount()
|
||||
|
||||
mockPlanState.value = 'pending'
|
||||
mockDebouncedPeriod.value = '9'
|
||||
const pendingRender = render(<Logs appDetail={appDetail} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: /appLog\.filter\.period\.today/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /common\.operation\.clear appLog\.filter\.period\.today/,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
end: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
mockPlanState.value = 'sandbox'
|
||||
pendingRender.rerender(<Logs appDetail={appDetail} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: /appLog\.filter\.period\.today/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
end: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import { RetentionUpgradeNotice } from '../retention-upgrade-notice'
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/modal-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/modal-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useModalContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseModalContext = vi.mocked(useModalContext)
|
||||
|
||||
describe('RetentionUpgradeNotice', () => {
|
||||
const setShowPricingModal = vi.fn()
|
||||
|
||||
function mockProvider({
|
||||
enableBilling = true,
|
||||
isFetchedPlan = true,
|
||||
isFetchedPlanInfo = true,
|
||||
planType = Plan.sandbox,
|
||||
}: {
|
||||
enableBilling?: boolean
|
||||
isFetchedPlan?: boolean
|
||||
isFetchedPlanInfo?: boolean
|
||||
planType?: Plan
|
||||
} = {}) {
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling,
|
||||
isFetchedPlan,
|
||||
isFetchedPlanInfo,
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
type: planType,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function renderNotice(deploymentEdition: DeploymentEdition = 'CLOUD') {
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
})
|
||||
return render(<RetentionUpgradeNotice />, { wrapper })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockProvider()
|
||||
mockUseModalContext.mockReturnValue({
|
||||
setShowPricingModal,
|
||||
} as unknown as ReturnType<typeof useModalContext>)
|
||||
})
|
||||
|
||||
it('should show accessible upgrade guidance for Cloud sandbox workspaces', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderNotice()
|
||||
|
||||
const notice = screen.getByRole('status')
|
||||
expect(notice).toHaveAttribute('aria-live', 'polite')
|
||||
expect(notice).toHaveAttribute('aria-atomic', 'true')
|
||||
expect(within(notice).getByText('appLog.retention.upgradeTip.description')).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
within(notice).getByRole('button', { name: 'billing.upgradeBtn.encourageShort' }),
|
||||
)
|
||||
expect(setShowPricingModal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'paid Cloud workspaces',
|
||||
provider: { planType: Plan.professional },
|
||||
deploymentEdition: 'CLOUD',
|
||||
},
|
||||
{
|
||||
name: 'self-hosted sandbox workspaces',
|
||||
provider: { planType: Plan.sandbox },
|
||||
deploymentEdition: 'COMMUNITY',
|
||||
},
|
||||
{
|
||||
name: 'workspaces without billing',
|
||||
provider: { enableBilling: false },
|
||||
deploymentEdition: 'CLOUD',
|
||||
},
|
||||
{
|
||||
name: 'workspaces before plan loading completes',
|
||||
provider: { isFetchedPlan: false, isFetchedPlanInfo: false },
|
||||
deploymentEdition: 'CLOUD',
|
||||
},
|
||||
] as const)('should not show guidance for $name', ({ provider, deploymentEdition }) => {
|
||||
mockProvider(provider)
|
||||
|
||||
renderNotice(deploymentEdition)
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -32,27 +31,16 @@ export function ArchivedLogsNotice() {
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="relative mb-3 shrink-0 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute -inset-px bg-linear-to-r from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent opacity-40"
|
||||
<div className="mb-3 flex items-start gap-2 rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-3 py-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-0.5 i-ri-information-line size-4 shrink-0 text-util-colors-warning-warning-600"
|
||||
/>
|
||||
<div className="relative flex items-center gap-3 px-3 py-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-information-2-fill size-5 shrink-0 text-text-accent"
|
||||
/>
|
||||
<p className="min-w-0 flex-1 system-sm-semibold wrap-break-word text-text-primary">
|
||||
{t(($) => $['archives.notice.description'], { ns: 'appLog' })}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="shrink-0"
|
||||
<div className="min-w-0 flex-1 system-xs-regular text-util-colors-warning-warning-700">
|
||||
{t(($) => $['archives.notice.description'], { ns: 'appLog' })}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 system-xs-semibold text-util-colors-warning-warning-700 underline underline-offset-2 hover:text-text-primary"
|
||||
onClick={() =>
|
||||
setShowAccountSettingModal({
|
||||
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
|
||||
@@ -60,7 +48,7 @@ export function ArchivedLogsNotice() {
|
||||
}
|
||||
>
|
||||
{t(($) => $['archives.notice.action'], { ns: 'appLog' })}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
|
||||
export const CLOUD_SANDBOX_TIME_PERIOD_KEYS = new Set(['1', '2', '3'])
|
||||
export const CLOUD_SANDBOX_CLEARED_TIME_PERIOD = '1'
|
||||
|
||||
const CLOUD_SANDBOX_LONGEST_TIME_PERIOD = '3'
|
||||
const CLOUD_SANDBOX_LONGEST_TIME_PERIOD_OPTION = {
|
||||
value: 30,
|
||||
name: 'last30days',
|
||||
} as const
|
||||
|
||||
export type CloudSandboxPlanState = 'pending' | 'sandbox' | 'unrestricted'
|
||||
|
||||
export function isLogTimePeriodRestricted(planState: CloudSandboxPlanState) {
|
||||
return planState !== 'unrestricted'
|
||||
}
|
||||
|
||||
export function resolveLogTimePeriod(period: string, planState: CloudSandboxPlanState) {
|
||||
if (!isLogTimePeriodRestricted(planState) || CLOUD_SANDBOX_TIME_PERIOD_KEYS.has(period))
|
||||
return period
|
||||
|
||||
return CLOUD_SANDBOX_CLEARED_TIME_PERIOD
|
||||
}
|
||||
|
||||
export function resolveLogTimePeriodOption<T extends { value: number; name: string }>(
|
||||
period: string,
|
||||
option: T,
|
||||
planState: CloudSandboxPlanState,
|
||||
) {
|
||||
if (isLogTimePeriodRestricted(planState) && period === CLOUD_SANDBOX_LONGEST_TIME_PERIOD)
|
||||
return CLOUD_SANDBOX_LONGEST_TIME_PERIOD_OPTION
|
||||
|
||||
return option
|
||||
}
|
||||
|
||||
export function useCloudSandboxPlanStatus(): CloudSandboxPlanState {
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const { enableBilling, isFetchedPlan, isFetchedPlanInfo, plan } = useProviderContext()
|
||||
|
||||
if (deploymentEdition !== 'CLOUD') return 'unrestricted'
|
||||
if (!isFetchedPlanInfo) return 'pending'
|
||||
if (!enableBilling) return 'unrestricted'
|
||||
if (!isFetchedPlan) return 'pending'
|
||||
|
||||
return plan.type === Plan.sandbox ? 'sandbox' : 'unrestricted'
|
||||
}
|
||||
@@ -11,13 +11,6 @@ import Chip from '@/app/components/base/chip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Sort from '@/app/components/base/sort'
|
||||
import { useAnnotationsCount } from '@/service/use-log'
|
||||
import {
|
||||
CLOUD_SANDBOX_CLEARED_TIME_PERIOD,
|
||||
CLOUD_SANDBOX_TIME_PERIOD_KEYS,
|
||||
isLogTimePeriodRestricted,
|
||||
resolveLogTimePeriodOption,
|
||||
useCloudSandboxPlanStatus,
|
||||
} from './cloud-sandbox-retention'
|
||||
|
||||
dayjs.extend(quarterOfYear)
|
||||
|
||||
@@ -52,12 +45,6 @@ const Filter: FC<IFilterProps> = ({
|
||||
}: IFilterProps) => {
|
||||
const { data, isLoading } = useAnnotationsCount(appId)
|
||||
const { t } = useTranslation()
|
||||
const planState = useCloudSandboxPlanStatus()
|
||||
const isTimePeriodRestricted = isLogTimePeriodRestricted(planState)
|
||||
const timePeriodEntries = Object.entries(TIME_PERIOD_MAPPING)
|
||||
.filter(([key]) => !isTimePeriodRestricted || CLOUD_SANDBOX_TIME_PERIOD_KEYS.has(key))
|
||||
.map(([key, option]) => [key, resolveLogTimePeriodOption(key, option, planState)] as const)
|
||||
|
||||
if (isLoading || !data) return null
|
||||
return (
|
||||
<div className="mb-2 flex flex-row flex-wrap items-center gap-2">
|
||||
@@ -69,13 +56,8 @@ const Filter: FC<IFilterProps> = ({
|
||||
onSelect={(item) => {
|
||||
setQueryParams({ ...queryParams, period: item.value })
|
||||
}}
|
||||
onClear={() =>
|
||||
setQueryParams({
|
||||
...queryParams,
|
||||
period: isTimePeriodRestricted ? CLOUD_SANDBOX_CLEARED_TIME_PERIOD : '9',
|
||||
})
|
||||
}
|
||||
items={timePeriodEntries.map(([k, v]) => ({
|
||||
onClear={() => setQueryParams({ ...queryParams, period: '9' })}
|
||||
items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({
|
||||
value: k,
|
||||
name: t(($) => $[`filter.period.${v.name}`], { ns: 'appLog' }),
|
||||
}))}
|
||||
|
||||
@@ -15,15 +15,9 @@ import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useChatConversations, useCompletionConversations } from '@/service/use-log'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import PageTitle from '../log-annotation/page-title'
|
||||
import {
|
||||
resolveLogTimePeriod,
|
||||
resolveLogTimePeriodOption,
|
||||
useCloudSandboxPlanStatus,
|
||||
} from './cloud-sandbox-retention'
|
||||
import EmptyElement from './empty-element'
|
||||
import Filter, { TIME_PERIOD_MAPPING } from './filter'
|
||||
import List from './list'
|
||||
import { RetentionUpgradeNotice } from './retention-upgrade-notice'
|
||||
|
||||
type ILogsProps = {
|
||||
appDetail: App
|
||||
@@ -63,7 +57,6 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
return pageParam - 1
|
||||
}, [searchParams])
|
||||
const cachedState = logsStateCache.get(appDetail.id)
|
||||
const cloudSandboxPlanState = useCloudSandboxPlanStatus()
|
||||
const [queryParams, setQueryParams] = useState<QueryParam>(
|
||||
cachedState?.queryParams ?? defaultQueryParams,
|
||||
)
|
||||
@@ -71,15 +64,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
() => cachedState?.currPage ?? getPageFromParams(),
|
||||
)
|
||||
const [limit, setLimit] = React.useState<number>(cachedState?.limit ?? APP_PAGE_LIMIT)
|
||||
const effectivePeriod = resolveLogTimePeriod(queryParams.period, cloudSandboxPlanState)
|
||||
const effectiveQueryParams = { ...queryParams, period: effectivePeriod }
|
||||
const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
|
||||
const requestQueryParams = { ...debouncedQueryParams, period: effectivePeriod }
|
||||
const requestTimePeriod = resolveLogTimePeriodOption(
|
||||
requestQueryParams.period,
|
||||
TIME_PERIOD_MAPPING[requestQueryParams.period]!,
|
||||
cloudSandboxPlanState,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const pageFromParams = getPageFromParams()
|
||||
@@ -100,17 +85,17 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
const query = {
|
||||
page: currPage + 1,
|
||||
limit,
|
||||
...(requestQueryParams.period !== '9'
|
||||
...(debouncedQueryParams.period !== '9'
|
||||
? {
|
||||
start: dayjs()
|
||||
.subtract(requestTimePeriod.value, 'day')
|
||||
.subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period]!.value, 'day')
|
||||
.startOf('day')
|
||||
.format('YYYY-MM-DD HH:mm'),
|
||||
end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
}
|
||||
: {}),
|
||||
...(isChatMode ? { sort_by: requestQueryParams.sort_by } : {}),
|
||||
...omit(requestQueryParams, ['period']),
|
||||
...(isChatMode ? { sort_by: debouncedQueryParams.sort_by } : {}),
|
||||
...omit(debouncedQueryParams, ['period']),
|
||||
}
|
||||
|
||||
// When the details are obtained, proceed to the next request
|
||||
@@ -158,10 +143,9 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
<Filter
|
||||
isChatMode={isChatMode}
|
||||
appId={appDetail.id}
|
||||
queryParams={effectiveQueryParams}
|
||||
queryParams={queryParams}
|
||||
setQueryParams={handleQueryParamsChange}
|
||||
/>
|
||||
<RetentionUpgradeNotice />
|
||||
{total === undefined ? (
|
||||
<Loading type="app" />
|
||||
) : total > 0 ? (
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import { useCloudSandboxPlanStatus } from './cloud-sandbox-retention'
|
||||
|
||||
export function RetentionUpgradeNotice() {
|
||||
const { t } = useTranslation()
|
||||
const planState = useCloudSandboxPlanStatus()
|
||||
|
||||
if (planState !== 'sandbox') return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="relative mb-3 shrink-0 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute -inset-px bg-linear-to-r from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent opacity-40"
|
||||
/>
|
||||
<div className="relative flex items-center gap-3 px-3 py-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-components-button-primary-bg"
|
||||
>
|
||||
<span className="i-ri-file-list-3-fill size-4 text-components-button-primary-text" />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 system-sm-medium wrap-break-word text-text-primary">
|
||||
{t(($) => $['retention.upgradeTip.description'], { ns: 'appLog' })}
|
||||
</p>
|
||||
<UpgradeBtn
|
||||
isShort
|
||||
size="custom"
|
||||
className="h-8! shrink-0 rounded-lg! px-2"
|
||||
loc="logs-retention"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user