mirror of
https://github.com/langgenius/dify.git
synced 2026-06-23 12:31:13 +08:00
Co-authored-by: Jingyi-Dify <jingyi.qi@dify.ai> Co-authored-by: yyh <yuanyouhuilyz@gmail.com> Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: hjlarry <hjlarry@163.com> Co-authored-by: Bond Zhu <783504079@qq.com> Co-authored-by: Yansong Zhang <916125788@qq.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
604 lines
18 KiB
TypeScript
604 lines
18 KiB
TypeScript
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
|
import type { MutationFunctionContext } from '@tanstack/react-query'
|
|
import type { consoleQuery as ConsoleQuery } from './client'
|
|
import type { Tag } from '@/contract/console/tags'
|
|
import { QueryClient } from '@tanstack/react-query'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const loadGetBaseURL = async (isClientValue: boolean) => {
|
|
vi.resetModules()
|
|
vi.doMock('@/utils/client', () => ({ isClient: isClientValue, isServer: !isClientValue }))
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
const module = await import('./client')
|
|
warnSpy.mockClear()
|
|
return { getBaseURL: module.getBaseURL, warnSpy }
|
|
}
|
|
|
|
const loadConsoleQuery = async () => {
|
|
vi.resetModules()
|
|
vi.doMock('@/utils/client', () => ({ isClient: true, isServer: false }))
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
const module = await import('./client')
|
|
warnSpy.mockRestore()
|
|
return module.consoleQuery
|
|
}
|
|
|
|
const createMutationContext = (queryClient: QueryClient): MutationFunctionContext => ({
|
|
client: queryClient,
|
|
meta: undefined,
|
|
})
|
|
|
|
const createTag = (overrides: Partial<Tag> = {}): Tag => ({
|
|
id: 'tag-1',
|
|
name: 'Frontend',
|
|
type: 'app',
|
|
binding_count: 1,
|
|
...overrides,
|
|
})
|
|
|
|
const createApiBasedExtension = (overrides: Partial<ApiBasedExtensionResponse> = {}): ApiBasedExtensionResponse => ({
|
|
id: 'extension-1',
|
|
name: 'Weather',
|
|
api_endpoint: 'https://api.example.com/weather',
|
|
api_key: 'secret-key',
|
|
...overrides,
|
|
})
|
|
|
|
type AgentMutationResponse = Parameters<NonNullable<ReturnType<typeof ConsoleQuery.agent.post.mutationOptions>['onSuccess']>>[0]
|
|
type AgentComposerMutationResponse = Parameters<NonNullable<ReturnType<typeof ConsoleQuery.agent.byAgentId.composer.put.mutationOptions>['onSuccess']>>[0]
|
|
|
|
const createAgent = (overrides: Partial<AgentMutationResponse> = {}): AgentMutationResponse => ({
|
|
...overrides,
|
|
active_config_is_published: overrides.active_config_is_published ?? false,
|
|
enable_api: overrides.enable_api ?? true,
|
|
enable_site: overrides.enable_site ?? true,
|
|
description: overrides.description ?? 'Agent description',
|
|
id: overrides.id ?? 'agent-1',
|
|
icon_url: overrides.icon_url ?? null,
|
|
mode: overrides.mode ?? 'agent',
|
|
name: overrides.name ?? 'Agent',
|
|
role: overrides.role ?? 'Assistant',
|
|
})
|
|
|
|
const createComposerState = (overrides: Partial<AgentComposerMutationResponse> = {}): AgentComposerMutationResponse => ({
|
|
active_config_snapshot: {
|
|
id: 'snapshot-1',
|
|
version: 1,
|
|
},
|
|
agent: {
|
|
active_config_snapshot_id: 'snapshot-1',
|
|
description: 'Agent description',
|
|
id: 'agent-1',
|
|
name: 'Agent',
|
|
scope: 'roster',
|
|
status: 'active',
|
|
},
|
|
agent_soul: {
|
|
schema_version: 1,
|
|
},
|
|
save_options: ['save_to_current_version', 'save_as_new_version'],
|
|
variant: 'agent_app',
|
|
...overrides,
|
|
})
|
|
|
|
// Scenario: base URL selection and warnings.
|
|
describe('getBaseURL', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
// Scenario: client environment uses window origin.
|
|
it('should use window origin when running on the client', async () => {
|
|
// Arrange
|
|
const { origin } = window.location
|
|
const { getBaseURL, warnSpy } = await loadGetBaseURL(true)
|
|
|
|
// Act
|
|
const url = getBaseURL('/api')
|
|
|
|
// Assert
|
|
expect(url.href).toBe(`${origin}/api`)
|
|
expect(warnSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// Scenario: server environment falls back to localhost with warning.
|
|
it('should fall back to localhost and warn on the server', async () => {
|
|
// Arrange
|
|
const { getBaseURL, warnSpy } = await loadGetBaseURL(false)
|
|
|
|
// Act
|
|
const url = getBaseURL('/api')
|
|
|
|
// Assert
|
|
expect(url.href).toBe('http://localhost/api')
|
|
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
expect(warnSpy).toHaveBeenCalledWith('Using localhost as base URL in server environment, please configure accordingly.')
|
|
})
|
|
|
|
// Scenario: non-http protocols surface warnings.
|
|
it('should warn when protocol is not http or https', async () => {
|
|
// Arrange
|
|
const { getBaseURL, warnSpy } = await loadGetBaseURL(true)
|
|
|
|
// Act
|
|
const url = getBaseURL('localhost:5001/console/api')
|
|
|
|
// Assert
|
|
expect(url.protocol).toBe('localhost:')
|
|
expect(url.href).toBe('localhost:5001/console/api')
|
|
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'Unexpected protocol for API requests, expected http or https. Current protocol: localhost:. Please configure accordingly.',
|
|
)
|
|
})
|
|
|
|
// Scenario: absolute http URLs are preserved.
|
|
it('should keep absolute http URLs intact', async () => {
|
|
// Arrange
|
|
const { getBaseURL, warnSpy } = await loadGetBaseURL(true)
|
|
|
|
// Act
|
|
const url = getBaseURL('https://api.example.com/console/api')
|
|
|
|
// Assert
|
|
expect(url.href).toBe('https://api.example.com/console/api')
|
|
expect(warnSpy).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// Scenario: oRPC mutation defaults own shared Agent roster cache behavior.
|
|
describe('consoleQuery agent mutation defaults', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('should invalidate roster and invite option lists after creating an agent', async () => {
|
|
const consoleQuery = await loadConsoleQuery()
|
|
const queryClient = new QueryClient()
|
|
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
|
const createdAgent = createAgent()
|
|
|
|
const mutationOptions = consoleQuery.agent.post.mutationOptions()
|
|
await mutationOptions.onSuccess?.(
|
|
createdAgent,
|
|
{
|
|
body: {
|
|
name: createdAgent.name,
|
|
description: createdAgent.description,
|
|
role: createdAgent.role ?? 'Assistant',
|
|
},
|
|
},
|
|
undefined,
|
|
createMutationContext(queryClient),
|
|
)
|
|
|
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
|
queryKey: consoleQuery.agent.get.key(),
|
|
})
|
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
|
queryKey: consoleQuery.agent.inviteOptions.get.key(),
|
|
})
|
|
})
|
|
|
|
it('should invalidate invite option lists after updating an agent', async () => {
|
|
const consoleQuery = await loadConsoleQuery()
|
|
const queryClient = new QueryClient()
|
|
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
|
const updatedAgent = createAgent({ name: 'Updated Agent' })
|
|
|
|
const mutationOptions = consoleQuery.agent.byAgentId.put.mutationOptions()
|
|
await mutationOptions.onSuccess?.(
|
|
updatedAgent,
|
|
{
|
|
params: {
|
|
agent_id: updatedAgent.id,
|
|
},
|
|
body: {
|
|
name: updatedAgent.name,
|
|
role: updatedAgent.role ?? 'Assistant',
|
|
},
|
|
},
|
|
undefined,
|
|
createMutationContext(queryClient),
|
|
)
|
|
|
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
|
queryKey: consoleQuery.agent.inviteOptions.get.key(),
|
|
})
|
|
})
|
|
|
|
it('should invalidate roster and invite option lists after publishing an agent config', async () => {
|
|
const consoleQuery = await loadConsoleQuery()
|
|
const queryClient = new QueryClient()
|
|
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
|
const removeQueries = vi.spyOn(queryClient, 'removeQueries')
|
|
const inviteOptionsQueryKey = consoleQuery.agent.inviteOptions.get.queryKey({
|
|
input: {
|
|
query: {
|
|
app_id: 'app-1',
|
|
limit: 8,
|
|
page: 1,
|
|
},
|
|
},
|
|
})
|
|
queryClient.setQueryData(inviteOptionsQueryKey, {
|
|
data: [
|
|
{
|
|
active_config_is_published: true,
|
|
active_config_snapshot: null,
|
|
active_config_snapshot_id: 'snapshot-1',
|
|
agent_kind: 'dify_agent',
|
|
app_id: null,
|
|
archived_at: null,
|
|
archived_by: null,
|
|
created_at: 1,
|
|
created_by: null,
|
|
description: 'Agent description',
|
|
existing_node_ids: [],
|
|
icon: null,
|
|
icon_background: null,
|
|
icon_type: null,
|
|
id: 'agent-1',
|
|
in_current_workflow_count: 0,
|
|
is_in_current_workflow: false,
|
|
name: 'Agent',
|
|
published_node_reference_count: 0,
|
|
published_reference_count: 0,
|
|
published_references: [],
|
|
role: '',
|
|
scope: 'roster',
|
|
source: 'workflow',
|
|
status: 'active',
|
|
updated_at: 1,
|
|
updated_by: null,
|
|
workflow_id: null,
|
|
workflow_node_id: null,
|
|
},
|
|
],
|
|
has_more: false,
|
|
limit: 8,
|
|
page: 1,
|
|
total: 1,
|
|
})
|
|
|
|
const mutationOptions = consoleQuery.agent.byAgentId.composer.put.mutationOptions()
|
|
await mutationOptions.onSuccess?.(
|
|
createComposerState(),
|
|
{
|
|
params: {
|
|
agent_id: 'agent-1',
|
|
},
|
|
body: {
|
|
variant: 'agent_app',
|
|
save_strategy: 'save_as_new_version',
|
|
agent_soul: {
|
|
schema_version: 1,
|
|
},
|
|
},
|
|
},
|
|
undefined,
|
|
createMutationContext(queryClient),
|
|
)
|
|
|
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
|
queryKey: consoleQuery.agent.get.key(),
|
|
})
|
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
|
queryKey: consoleQuery.agent.inviteOptions.get.key(),
|
|
})
|
|
expect(removeQueries).toHaveBeenCalledWith({
|
|
queryKey: consoleQuery.agent.inviteOptions.get.key(),
|
|
})
|
|
expect(queryClient.getQueryData(inviteOptionsQueryKey)).toBeUndefined()
|
|
})
|
|
|
|
it('should keep roster and invite option lists stable after saving an agent draft', async () => {
|
|
const consoleQuery = await loadConsoleQuery()
|
|
const queryClient = new QueryClient()
|
|
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
|
|
|
const mutationOptions = consoleQuery.agent.byAgentId.composer.put.mutationOptions()
|
|
await mutationOptions.onSuccess?.(
|
|
createComposerState(),
|
|
{
|
|
params: {
|
|
agent_id: 'agent-1',
|
|
},
|
|
body: {
|
|
variant: 'agent_app',
|
|
save_strategy: 'save_to_current_version',
|
|
agent_soul: {
|
|
schema_version: 1,
|
|
},
|
|
},
|
|
},
|
|
undefined,
|
|
createMutationContext(queryClient),
|
|
)
|
|
|
|
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
|
queryKey: consoleQuery.agent.get.key(),
|
|
})
|
|
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
|
queryKey: consoleQuery.agent.inviteOptions.get.key(),
|
|
})
|
|
})
|
|
|
|
it('should invalidate invite option lists after deleting an agent', async () => {
|
|
const consoleQuery = await loadConsoleQuery()
|
|
const queryClient = new QueryClient()
|
|
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
|
const deletedAgent = createAgent()
|
|
|
|
const mutationOptions = consoleQuery.agent.byAgentId.delete.mutationOptions()
|
|
await mutationOptions.onSuccess?.(
|
|
{},
|
|
{
|
|
params: {
|
|
agent_id: deletedAgent.id,
|
|
},
|
|
},
|
|
undefined,
|
|
createMutationContext(queryClient),
|
|
)
|
|
|
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
|
queryKey: consoleQuery.agent.inviteOptions.get.key(),
|
|
})
|
|
})
|
|
})
|
|
|
|
// Scenario: oRPC mutation defaults own shared tag cache behavior.
|
|
describe('consoleQuery tag mutation defaults', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('should add created tags to the matching list query cache', async () => {
|
|
const consoleQuery = await loadConsoleQuery()
|
|
const queryClient = new QueryClient()
|
|
const appListKey = consoleQuery.tags.list.queryKey({
|
|
input: {
|
|
query: {
|
|
type: 'app',
|
|
},
|
|
},
|
|
})
|
|
const knowledgeListKey = consoleQuery.tags.list.queryKey({
|
|
input: {
|
|
query: {
|
|
type: 'knowledge',
|
|
},
|
|
},
|
|
})
|
|
const existingAppTag = createTag({ id: 'tag-1', name: 'Existing' })
|
|
const existingKnowledgeTag = createTag({
|
|
id: 'knowledge-tag-1',
|
|
name: 'Knowledge',
|
|
type: 'knowledge',
|
|
})
|
|
const createdTag = createTag({ id: 'tag-2', name: 'Created' })
|
|
|
|
queryClient.setQueryData(appListKey, [existingAppTag])
|
|
queryClient.setQueryData(knowledgeListKey, [existingKnowledgeTag])
|
|
|
|
const mutationOptions = consoleQuery.tags.create.mutationOptions()
|
|
await mutationOptions.onSuccess?.(
|
|
createdTag,
|
|
{
|
|
body: {
|
|
name: createdTag.name,
|
|
type: createdTag.type,
|
|
},
|
|
},
|
|
undefined,
|
|
createMutationContext(queryClient),
|
|
)
|
|
|
|
expect(queryClient.getQueryData(appListKey)).toEqual([createdTag, existingAppTag])
|
|
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([existingKnowledgeTag])
|
|
})
|
|
|
|
it('should update matching tags across cached list queries', async () => {
|
|
const consoleQuery = await loadConsoleQuery()
|
|
const queryClient = new QueryClient()
|
|
const appListKey = consoleQuery.tags.list.queryKey({
|
|
input: {
|
|
query: {
|
|
type: 'app',
|
|
},
|
|
},
|
|
})
|
|
const knowledgeListKey = consoleQuery.tags.list.queryKey({
|
|
input: {
|
|
query: {
|
|
type: 'knowledge',
|
|
},
|
|
},
|
|
})
|
|
const targetTag = createTag({ id: 'tag-1', name: 'Before' })
|
|
const otherTag = createTag({ id: 'tag-2', name: 'Other' })
|
|
const knowledgeTag = createTag({
|
|
id: 'knowledge-tag-1',
|
|
name: 'Knowledge',
|
|
type: 'knowledge',
|
|
})
|
|
|
|
queryClient.setQueryData(appListKey, [targetTag, otherTag])
|
|
queryClient.setQueryData(knowledgeListKey, [knowledgeTag])
|
|
|
|
const updatedTag = createTag({
|
|
...targetTag,
|
|
name: 'After',
|
|
binding_count: 5,
|
|
})
|
|
const mutationOptions = consoleQuery.tags.update.mutationOptions()
|
|
await mutationOptions.onSuccess?.(
|
|
updatedTag,
|
|
{
|
|
params: {
|
|
tagId: targetTag.id,
|
|
},
|
|
body: {
|
|
name: 'Ignored Client Name',
|
|
},
|
|
},
|
|
undefined,
|
|
createMutationContext(queryClient),
|
|
)
|
|
|
|
expect(queryClient.getQueryData(appListKey)).toEqual([
|
|
updatedTag,
|
|
otherTag,
|
|
])
|
|
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([knowledgeTag])
|
|
})
|
|
|
|
it('should remove deleted tags across cached list queries', async () => {
|
|
const consoleQuery = await loadConsoleQuery()
|
|
const queryClient = new QueryClient()
|
|
const appListKey = consoleQuery.tags.list.queryKey({
|
|
input: {
|
|
query: {
|
|
type: 'app',
|
|
},
|
|
},
|
|
})
|
|
const knowledgeListKey = consoleQuery.tags.list.queryKey({
|
|
input: {
|
|
query: {
|
|
type: 'knowledge',
|
|
},
|
|
},
|
|
})
|
|
const deletedTag = createTag({ id: 'tag-1', name: 'Delete me' })
|
|
const remainingTag = createTag({ id: 'tag-2', name: 'Keep me' })
|
|
const knowledgeTag = createTag({
|
|
id: 'knowledge-tag-1',
|
|
name: 'Knowledge',
|
|
type: 'knowledge',
|
|
})
|
|
|
|
queryClient.setQueryData(appListKey, [deletedTag, remainingTag])
|
|
queryClient.setQueryData(knowledgeListKey, [knowledgeTag])
|
|
|
|
const mutationOptions = consoleQuery.tags.delete.mutationOptions()
|
|
await mutationOptions.onSuccess?.(
|
|
undefined,
|
|
{
|
|
params: {
|
|
tagId: deletedTag.id,
|
|
},
|
|
},
|
|
undefined,
|
|
createMutationContext(queryClient),
|
|
)
|
|
|
|
expect(queryClient.getQueryData(appListKey)).toEqual([remainingTag])
|
|
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([knowledgeTag])
|
|
})
|
|
})
|
|
|
|
// Scenario: oRPC mutation defaults own shared API Extension cache behavior.
|
|
describe('consoleQuery apiBasedExtension mutation defaults', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('should add created API Extension to the list query cache', async () => {
|
|
const consoleQuery = await loadConsoleQuery()
|
|
const queryClient = new QueryClient()
|
|
const listKey = consoleQuery.apiBasedExtension.get.queryKey()
|
|
const existingExtension = createApiBasedExtension({ id: 'extension-1', name: 'Existing' })
|
|
const createdExtension = createApiBasedExtension({ id: 'extension-2', name: 'Created' })
|
|
|
|
queryClient.setQueryData(listKey, [existingExtension])
|
|
|
|
const mutationOptions = consoleQuery.apiBasedExtension.post.mutationOptions()
|
|
await mutationOptions.onSuccess?.(
|
|
createdExtension,
|
|
{
|
|
body: {
|
|
name: createdExtension.name,
|
|
api_endpoint: createdExtension.api_endpoint,
|
|
api_key: createdExtension.api_key,
|
|
},
|
|
},
|
|
undefined,
|
|
createMutationContext(queryClient),
|
|
)
|
|
|
|
expect(queryClient.getQueryData(listKey)).toEqual([createdExtension, existingExtension])
|
|
})
|
|
|
|
it('should update matching API Extension in the list query cache', async () => {
|
|
const consoleQuery = await loadConsoleQuery()
|
|
const queryClient = new QueryClient()
|
|
const listKey = consoleQuery.apiBasedExtension.get.queryKey()
|
|
const targetExtension = createApiBasedExtension({ id: 'extension-1', name: 'Before' })
|
|
const otherExtension = createApiBasedExtension({ id: 'extension-2', name: 'Other' })
|
|
const updatedExtension = createApiBasedExtension({ ...targetExtension, name: 'After' })
|
|
|
|
queryClient.setQueryData(listKey, [targetExtension, otherExtension])
|
|
|
|
const mutationOptions = consoleQuery.apiBasedExtension.byId.post.mutationOptions()
|
|
await mutationOptions.onSuccess?.(
|
|
updatedExtension,
|
|
{
|
|
params: {
|
|
id: targetExtension.id,
|
|
},
|
|
body: {
|
|
name: 'Ignored Client Name',
|
|
api_endpoint: targetExtension.api_endpoint,
|
|
api_key: '[__HIDDEN__]',
|
|
},
|
|
},
|
|
undefined,
|
|
createMutationContext(queryClient),
|
|
)
|
|
|
|
expect(queryClient.getQueryData(listKey)).toEqual([updatedExtension, otherExtension])
|
|
})
|
|
|
|
it('should remove deleted API Extension from the list query cache', async () => {
|
|
const consoleQuery = await loadConsoleQuery()
|
|
const queryClient = new QueryClient()
|
|
const listKey = consoleQuery.apiBasedExtension.get.queryKey()
|
|
const deletedExtension = createApiBasedExtension({ id: 'extension-1', name: 'Delete me' })
|
|
const remainingExtension = createApiBasedExtension({ id: 'extension-2', name: 'Keep me' })
|
|
|
|
queryClient.setQueryData(listKey, [deletedExtension, remainingExtension])
|
|
|
|
const mutationOptions = consoleQuery.apiBasedExtension.byId.delete.mutationOptions()
|
|
await mutationOptions.onSuccess?.(
|
|
{},
|
|
{
|
|
params: {
|
|
id: deletedExtension.id,
|
|
},
|
|
},
|
|
undefined,
|
|
createMutationContext(queryClient),
|
|
)
|
|
|
|
expect(queryClient.getQueryData(listKey)).toEqual([remainingExtension])
|
|
})
|
|
})
|