mirror of
https://github.com/langgenius/dify.git
synced 2026-06-11 02:31:13 +08:00
fix(plugin): align plugin list endpoint counts with live endpoint state (#37179)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
4fb3210f9a
commit
9ac71329a4
@ -19,6 +19,7 @@ from controllers.console.wraps import (
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
with_current_user_id,
|
||||
)
|
||||
from core.plugin.impl.exc import PluginDaemonClientSideError
|
||||
from core.plugin.plugin_service import PluginService
|
||||
@ -224,11 +225,12 @@ class PluginListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
def get(self, tenant_id: str, user_id: str):
|
||||
args = ParserList.model_validate(request.args.to_dict(flat=True))
|
||||
try:
|
||||
plugins_with_total = PluginService.list_with_total(tenant_id, args.page, args.page_size)
|
||||
plugins_with_total = PluginService.list_with_total(tenant_id, user_id, args.page, args.page_size)
|
||||
except PluginDaemonClientSideError as e:
|
||||
return {"code": "plugin_error", "message": e.description}, 400
|
||||
|
||||
|
||||
@ -36,7 +36,10 @@ class PluginEndpointClient(BasePluginClient):
|
||||
|
||||
def list_endpoints(self, tenant_id: str, user_id: str, page: int, page_size: int):
|
||||
"""
|
||||
List all endpoints for the given tenant and user.
|
||||
List all endpoints for the given tenant.
|
||||
|
||||
The daemon list route binds only tenant and pagination fields; user_id is
|
||||
retained in this client signature for consistency with endpoint services.
|
||||
"""
|
||||
return self._request_with_plugin_daemon_response(
|
||||
"GET",
|
||||
@ -47,7 +50,10 @@ class PluginEndpointClient(BasePluginClient):
|
||||
|
||||
def list_endpoints_for_single_plugin(self, tenant_id: str, user_id: str, plugin_id: str, page: int, page_size: int):
|
||||
"""
|
||||
List all endpoints for the given tenant, user and plugin.
|
||||
List all endpoints for the given tenant and plugin.
|
||||
|
||||
The daemon list route binds tenant, plugin and pagination fields; user_id
|
||||
is retained in this client signature for consistency with endpoint services.
|
||||
"""
|
||||
return self._request_with_plugin_daemon_response(
|
||||
"GET",
|
||||
|
||||
@ -4,6 +4,12 @@ This module owns plugin daemon management calls that are shared by API services
|
||||
and core runtimes. Plugin model provider discovery is cached here, alongside
|
||||
plugin install, uninstall, and upgrade invalidation, so all cache mutations for
|
||||
plugin-owned provider metadata stay tenant-scoped and in one place.
|
||||
|
||||
The console plugin list also normalizes endpoint setup counters against live
|
||||
endpoint records. Some plugin daemon builds return stale ``endpoints_*``
|
||||
aggregates in ``management/list`` even while plugin-scoped endpoint queries are
|
||||
current, so the API reconciles those counts before serving workspace plugin
|
||||
metadata.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@ -38,6 +44,7 @@ from core.plugin.entities.plugin_daemon import (
|
||||
)
|
||||
from core.plugin.impl.asset import PluginAssetManager
|
||||
from core.plugin.impl.debugging import PluginDebuggingClient
|
||||
from core.plugin.impl.endpoint import PluginEndpointClient
|
||||
from core.plugin.impl.model import PluginModelClient
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from extensions.ext_database import db
|
||||
@ -69,6 +76,9 @@ class PluginService:
|
||||
REDIS_TTL = 60 * 5 # 5 minutes
|
||||
PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX = "plugin_model_providers:tenant_id:"
|
||||
PLUGIN_INSTALL_TASK_TERMINAL_STATUSES = (PluginInstallTaskStatus.Success, PluginInstallTaskStatus.Failed)
|
||||
# Mirror the detail-panel endpoint query size so list reconciliation and
|
||||
# the visible endpoint drawer exercise the same daemon pagination path.
|
||||
ENDPOINT_RECONCILIATION_PAGE_SIZE = 100
|
||||
|
||||
@classmethod
|
||||
def _get_plugin_model_providers_cache_key(cls, tenant_id: str) -> str:
|
||||
@ -287,14 +297,104 @@ class PluginService:
|
||||
return plugins
|
||||
|
||||
@staticmethod
|
||||
def list_with_total(tenant_id: str, page: int, page_size: int) -> PluginListResponse:
|
||||
"""
|
||||
list all plugins of the tenant
|
||||
def list_with_total(tenant_id: str, user_id: str, page: int, page_size: int) -> PluginListResponse:
|
||||
"""List tenant plugins with endpoint counts reconciled from live records.
|
||||
|
||||
The plugin daemon's ``management/list`` payload is tenant-scoped, but
|
||||
some daemon builds undercount or stale-cache plugin endpoint aggregates.
|
||||
The list response therefore refreshes counters from the daemon's
|
||||
tenant-scoped endpoint records before returning workspace plugin metadata.
|
||||
"""
|
||||
manager = PluginInstaller()
|
||||
plugins = manager.list_plugins_with_total(tenant_id, page, page_size)
|
||||
PluginService._reconcile_endpoint_counts(tenant_id, user_id, plugins.list)
|
||||
return plugins
|
||||
|
||||
@staticmethod
|
||||
def _normalize_endpoint_count(value: object) -> int:
|
||||
"""Convert daemon endpoint counters to safe non-negative integers.
|
||||
|
||||
Some daemon builds use ``-1`` as an "unknown / not synced yet" sentinel
|
||||
for endpoint counters. That value is acceptable internally as a daemon
|
||||
transport detail, but it must never leak through the console API because
|
||||
the UI displays these counters directly.
|
||||
"""
|
||||
if value is None:
|
||||
return 0
|
||||
|
||||
if isinstance(value, bool):
|
||||
return int(value)
|
||||
|
||||
if isinstance(value, int):
|
||||
return max(0, value)
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return max(0, int(value))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def _normalize_plugin_endpoint_counts(cls, plugin: PluginEntity) -> None:
|
||||
"""Clamp endpoint counters on plugin entities before returning them."""
|
||||
plugin.endpoints_setups = cls._normalize_endpoint_count(plugin.endpoints_setups)
|
||||
plugin.endpoints_active = cls._normalize_endpoint_count(plugin.endpoints_active)
|
||||
|
||||
@classmethod
|
||||
def _reconcile_endpoint_counts(cls, tenant_id: str, user_id: str, plugins: Sequence[PluginEntity]) -> None:
|
||||
"""Refresh endpoint counters from live plugin endpoint records.
|
||||
|
||||
``management/list`` is the source of truth for plugin installations, but
|
||||
some daemon versions lag when populating ``endpoints_setups`` and
|
||||
``endpoints_active``. The plugin-scoped endpoint listing is the same
|
||||
tenant-scoped source the console detail panel uses after reinstall flows,
|
||||
so the list view recomputes counts per plugin instead of trusting stale
|
||||
daemon aggregates.
|
||||
"""
|
||||
endpoint_client = PluginEndpointClient()
|
||||
|
||||
for plugin in plugins:
|
||||
cls._normalize_plugin_endpoint_counts(plugin)
|
||||
|
||||
if plugin.declaration.endpoint is None:
|
||||
continue
|
||||
|
||||
page = 1
|
||||
endpoints_setups = 0
|
||||
endpoints_active = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
endpoints = endpoint_client.list_endpoints_for_single_plugin(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
plugin_id=plugin.plugin_id,
|
||||
page=page,
|
||||
page_size=cls.ENDPOINT_RECONCILIATION_PAGE_SIZE,
|
||||
)
|
||||
endpoints_setups += len(endpoints)
|
||||
endpoints_active += sum(int(endpoint.enabled) for endpoint in endpoints)
|
||||
|
||||
if len(endpoints) < cls.ENDPOINT_RECONCILIATION_PAGE_SIZE:
|
||||
break
|
||||
page += 1
|
||||
except Exception:
|
||||
logger.warning(
|
||||
(
|
||||
"Failed to reconcile live endpoint counters for tenant %s plugin %s; "
|
||||
"falling back to daemon plugin stats."
|
||||
),
|
||||
tenant_id,
|
||||
plugin.plugin_id,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
plugin.endpoints_setups = cls._normalize_endpoint_count(endpoints_setups)
|
||||
plugin.endpoints_active = cls._normalize_endpoint_count(endpoints_active)
|
||||
|
||||
@staticmethod
|
||||
def list_installations_from_ids(tenant_id: str, ids: Sequence[str]) -> Sequence[PluginInstallation]:
|
||||
"""
|
||||
|
||||
@ -131,11 +131,15 @@ class TestPluginListApi:
|
||||
|
||||
with (
|
||||
app.test_request_context("/?page=1&page_size=10"),
|
||||
patch("controllers.console.workspace.plugin.PluginService.list_with_total", return_value=mock_list),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.PluginService.list_with_total",
|
||||
return_value=mock_list,
|
||||
) as mock_list_with_total,
|
||||
):
|
||||
result = method(api, "t1")
|
||||
result = method(api, "t1", "u1")
|
||||
|
||||
assert result["total"] == 1
|
||||
mock_list_with_total.assert_called_once_with("t1", "u1", 1, 10)
|
||||
|
||||
|
||||
class TestPluginIconApi:
|
||||
|
||||
@ -37,7 +37,11 @@ class TestPluginEndpointClientImpl:
|
||||
|
||||
assert result == ["endpoint"]
|
||||
assert request_mock.call_args.args[1] == "plugin/tenant-1/endpoint/list/plugin"
|
||||
assert request_mock.call_args.kwargs["params"] == {"plugin_id": "org/plugin", "page": 1, "page_size": 10}
|
||||
assert request_mock.call_args.kwargs["params"] == {
|
||||
"plugin_id": "org/plugin",
|
||||
"page": 1,
|
||||
"page_size": 10,
|
||||
}
|
||||
|
||||
def test_update_endpoint(self, mocker: MockerFixture):
|
||||
client = PluginEndpointClient()
|
||||
|
||||
@ -221,6 +221,101 @@ class TestPluginModelProviderCache:
|
||||
redis_client.delete.assert_called_once_with("plugin_model_providers:tenant_id:tenant-1")
|
||||
|
||||
|
||||
class TestPluginListEndpointCounts:
|
||||
def test_list_with_total_reconciles_live_endpoint_counts_for_endpoint_plugins(self) -> None:
|
||||
"""Endpoint-enabled plugins use plugin-scoped live endpoint records instead of stale daemon aggregates."""
|
||||
wecom_plugin = SimpleNamespace(
|
||||
plugin_id="langgenius/wecom-bot",
|
||||
endpoints_setups=0,
|
||||
endpoints_active=0,
|
||||
declaration=SimpleNamespace(endpoint=object()),
|
||||
)
|
||||
tool_plugin = SimpleNamespace(
|
||||
plugin_id="langgenius/openai",
|
||||
endpoints_setups=0,
|
||||
endpoints_active=0,
|
||||
declaration=SimpleNamespace(endpoint=None),
|
||||
)
|
||||
paged_plugins = SimpleNamespace(list=[wecom_plugin, tool_plugin], total=2)
|
||||
endpoints = [
|
||||
SimpleNamespace(plugin_id="langgenius/wecom-bot", enabled=True),
|
||||
SimpleNamespace(plugin_id="langgenius/wecom-bot", enabled=True),
|
||||
SimpleNamespace(plugin_id="langgenius/wecom-bot", enabled=True),
|
||||
]
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginEndpointClient") as endpoint_client_cls,
|
||||
):
|
||||
installer_cls.return_value.list_plugins_with_total.return_value = paged_plugins
|
||||
endpoint_client_cls.return_value.list_endpoints_for_single_plugin.return_value = endpoints
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.list_with_total("tenant-1", "user-1", 1, 100)
|
||||
|
||||
assert result is paged_plugins
|
||||
assert wecom_plugin.endpoints_setups == 3
|
||||
assert wecom_plugin.endpoints_active == 3
|
||||
assert tool_plugin.endpoints_setups == 0
|
||||
assert tool_plugin.endpoints_active == 0
|
||||
endpoint_client_cls.return_value.list_endpoints_for_single_plugin.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
plugin_id="langgenius/wecom-bot",
|
||||
page=1,
|
||||
page_size=PluginService.ENDPOINT_RECONCILIATION_PAGE_SIZE,
|
||||
)
|
||||
|
||||
def test_list_with_total_falls_back_to_sanitized_daemon_counts_when_reconciliation_fails(self) -> None:
|
||||
"""Best-effort reconciliation still clamps daemon sentinel values before returning the list response."""
|
||||
wecom_plugin = SimpleNamespace(
|
||||
plugin_id="langgenius/wecom-bot",
|
||||
endpoints_setups=-1,
|
||||
endpoints_active=-1,
|
||||
declaration=SimpleNamespace(endpoint=object()),
|
||||
)
|
||||
paged_plugins = SimpleNamespace(list=[wecom_plugin], total=1)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginEndpointClient") as endpoint_client_cls,
|
||||
):
|
||||
installer_cls.return_value.list_plugins_with_total.return_value = paged_plugins
|
||||
endpoint_client_cls.return_value.list_endpoints_for_single_plugin.side_effect = RuntimeError(
|
||||
"endpoint daemon unavailable"
|
||||
)
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.list_with_total("tenant-1", "user-1", 1, 100)
|
||||
|
||||
assert result is paged_plugins
|
||||
assert wecom_plugin.endpoints_setups == 0
|
||||
assert wecom_plugin.endpoints_active == 0
|
||||
|
||||
def test_list_with_total_clamps_negative_daemon_counts_for_plugins_without_endpoints(self) -> None:
|
||||
"""Plugins that do not expose endpoint setup APIs must still never return negative counters."""
|
||||
tool_plugin = SimpleNamespace(
|
||||
plugin_id="langgenius/openai",
|
||||
endpoints_setups=-1,
|
||||
endpoints_active=-1,
|
||||
declaration=SimpleNamespace(endpoint=None),
|
||||
)
|
||||
paged_plugins = SimpleNamespace(list=[tool_plugin], total=1)
|
||||
|
||||
with patch(f"{MODULE}.PluginInstaller") as installer_cls:
|
||||
installer_cls.return_value.list_plugins_with_total.return_value = paged_plugins
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.list_with_total("tenant-1", "user-1", 1, 100)
|
||||
|
||||
assert result is paged_plugins
|
||||
assert tool_plugin.endpoints_setups == 0
|
||||
assert tool_plugin.endpoints_active == 0
|
||||
|
||||
|
||||
class TestPluginModelProviderCacheInvalidation:
|
||||
def test_fetch_install_task_invalidates_model_provider_cache_when_finished(self) -> None:
|
||||
"""Finished plugin install tasks invalidate tenant provider cache."""
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import EndpointList from '../endpoint-list'
|
||||
|
||||
@ -18,6 +18,7 @@ const mockEndpoints = [
|
||||
let mockEndpointListData: { endpoints: typeof mockEndpoints } | undefined
|
||||
|
||||
const mockInvalidateEndpointList = vi.fn()
|
||||
const mockInvalidateInstalledPluginList = vi.fn()
|
||||
const mockCreateEndpoint = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-endpoints', () => ({
|
||||
@ -31,6 +32,10 @@ vi.mock('@/service/use-endpoints', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
||||
toolCredentialToFormSchemas: (schemas: unknown[]) => schemas,
|
||||
}))
|
||||
@ -178,7 +183,10 @@ describe('EndpointList', () => {
|
||||
fireEvent.click(getAddButton())
|
||||
fireEvent.click(screen.getByTestId('modal-save'))
|
||||
|
||||
expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin')
|
||||
return waitFor(() => {
|
||||
expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin')
|
||||
expect(mockInvalidateInstalledPluginList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass correct params to createEndpoint', () => {
|
||||
|
||||
@ -2,11 +2,6 @@ import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import {
|
||||
RiAddLine,
|
||||
RiApps2AddLine,
|
||||
RiBookOpenLine,
|
||||
} from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
@ -19,6 +14,7 @@ import {
|
||||
useEndpointList,
|
||||
useInvalidateEndpointList,
|
||||
} from '@/service/use-endpoints'
|
||||
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
|
||||
import EndpointCard from './endpoint-card'
|
||||
import EndpointModal from './endpoint-modal'
|
||||
import { NAME_FIELD } from './utils'
|
||||
@ -34,6 +30,7 @@ const EndpointList = ({ detail }: Props) => {
|
||||
const showTopBorder = detail.declaration.tool
|
||||
const { data } = useEndpointList(detail.plugin_id)
|
||||
const invalidateEndpointList = useInvalidateEndpointList()
|
||||
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
|
||||
|
||||
const [isShowEndpointModal, {
|
||||
setTrue: showEndpointModal,
|
||||
@ -47,6 +44,7 @@ const EndpointList = ({ detail }: Props) => {
|
||||
const { mutate: createEndpoint } = useCreateEndpoint({
|
||||
onSuccess: async () => {
|
||||
await invalidateEndpointList(detail.plugin_id)
|
||||
invalidateInstalledPluginList()
|
||||
hideEndpointModal()
|
||||
},
|
||||
onError: () => {
|
||||
@ -86,7 +84,7 @@ const EndpointList = ({ detail }: Props) => {
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
|
||||
<RiApps2AddLine className="size-4 text-text-tertiary" />
|
||||
<span aria-hidden className="i-ri-apps-2-add-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{t('detailPanel.endpointsTip', { ns: 'plugin' })}</div>
|
||||
<a
|
||||
@ -95,7 +93,7 @@ const EndpointList = ({ detail }: Props) => {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex cursor-pointer items-center gap-1 system-xs-regular text-text-accent"
|
||||
>
|
||||
<RiBookOpenLine className="size-3" />
|
||||
<span aria-hidden className="i-ri-book-open-line size-3" />
|
||||
{t('detailPanel.endpointsDocLink', { ns: 'plugin' })}
|
||||
</a>
|
||||
</div>
|
||||
@ -106,18 +104,21 @@ const EndpointList = ({ detail }: Props) => {
|
||||
aria-label={t('detailPanel.endpointModalTitle', { ns: 'plugin' })}
|
||||
onClick={showEndpointModal}
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
<span aria-hidden className="i-ri-add-line size-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
{data.endpoints.length === 0 && (
|
||||
<div className="mb-1 flex justify-center rounded-[10px] bg-background-section p-3 system-xs-regular text-text-tertiary">{t('detailPanel.endpointsEmpty', { ns: 'plugin' })}</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.endpoints.map((item, index) => (
|
||||
{data.endpoints.map(item => (
|
||||
<EndpointCard
|
||||
key={index}
|
||||
key={item.id}
|
||||
data={item}
|
||||
handleChange={() => invalidateEndpointList(detail.plugin_id)}
|
||||
handleChange={() => {
|
||||
invalidateEndpointList(detail.plugin_id)
|
||||
invalidateInstalledPluginList()
|
||||
}}
|
||||
pluginDetail={detail}
|
||||
/>
|
||||
))}
|
||||
|
||||
100
web/service/use-plugins.spec.tsx
Normal file
100
web/service/use-plugins.spec.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { InstalledPluginListWithTotalResponse } from '@/app/components/plugins/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useInstalledPluginList } from './use-plugins'
|
||||
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./base', () => ({
|
||||
get: mockGet,
|
||||
getMarketplace: vi.fn(),
|
||||
post: vi.fn(),
|
||||
postMarketplace: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./common', () => ({
|
||||
fetchModelProviderModelList: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./plugins', () => ({
|
||||
fetchPluginInfoFromMarketPlace: vi.fn(),
|
||||
uninstallPlugin: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./use-tools', () => ({
|
||||
useInvalidateAllBuiltInTools: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({ refreshPluginList: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/utils', () => ({
|
||||
getFormattedPlugin: vi.fn((plugin: unknown) => plugin),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
|
||||
default: () => ({ canManagement: true }),
|
||||
}))
|
||||
|
||||
const createResponse = (endpointsActive: number): InstalledPluginListWithTotalResponse => ({
|
||||
plugins: [
|
||||
{
|
||||
plugin_id: 'plugin-1',
|
||||
endpoints_active: endpointsActive,
|
||||
endpoints_setups: endpointsActive,
|
||||
} as unknown as InstalledPluginListWithTotalResponse['plugins'][number],
|
||||
],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const createWrapper = (queryClient: QueryClient) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useInstalledPluginList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('drops cached plugin list data after unmount so remount fetches fresh counts', async () => {
|
||||
const queryClient = createQueryClient()
|
||||
const wrapper = createWrapper(queryClient)
|
||||
|
||||
mockGet
|
||||
.mockResolvedValueOnce(createResponse(0))
|
||||
.mockResolvedValueOnce(createResponse(1))
|
||||
|
||||
const { result, unmount } = renderHook(() => useInstalledPluginList(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data?.plugins[0]?.endpoints_active).toBe(0)
|
||||
})
|
||||
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryClient.getQueryCache().find({ queryKey: ['plugins', 'installedPluginList'] })).toBeUndefined()
|
||||
})
|
||||
|
||||
const { result: remountedResult } = renderHook(() => useInstalledPluginList(), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(remountedResult.current.data?.plugins[0]?.endpoints_active).toBe(1)
|
||||
})
|
||||
expect(mockGet).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@ -146,6 +146,7 @@ export const useInstalledPluginList = (disable?: boolean, pageSize = 100) => {
|
||||
isSuccess,
|
||||
} = useInfiniteQuery({
|
||||
enabled: !disable,
|
||||
gcTime: 0,
|
||||
queryKey: useInstalledPluginListKey,
|
||||
queryFn: fetchPlugins,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
@ -159,6 +160,7 @@ export const useInstalledPluginList = (disable?: boolean, pageSize = 100) => {
|
||||
return currentPage + 1
|
||||
},
|
||||
initialPageParam: 1,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const plugins = data?.pages.flatMap(page => page.plugins) ?? []
|
||||
|
||||
Loading…
Reference in New Issue
Block a user