mirror of
https://github.com/langgenius/dify.git
synced 2026-07-14 17:07:03 +08:00
1176 lines
55 KiB
Python
1176 lines
55 KiB
Python
import datetime
|
|
import uuid
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, Mock, call, patch
|
|
|
|
import pytest
|
|
import zstandard
|
|
from pydantic import TypeAdapter
|
|
from redis import RedisError
|
|
|
|
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
|
|
|
|
MODULE = "core.plugin.plugin_service"
|
|
|
|
|
|
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:
|
|
return ProviderEntity(
|
|
provider=f"langgenius/{provider}/{provider}",
|
|
label=I18nObject(en_US=provider.title()),
|
|
supported_model_types=[],
|
|
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
|
)
|
|
|
|
|
|
def _build_plugin_model_provider(*, tenant_id: str = "tenant-1", provider: str = "openai") -> PluginModelProviderEntity:
|
|
return PluginModelProviderEntity(
|
|
id=uuid.uuid4().hex,
|
|
created_at=datetime.datetime.now(),
|
|
updated_at=datetime.datetime.now(),
|
|
provider=provider,
|
|
tenant_id=tenant_id,
|
|
plugin_unique_identifier=f"langgenius/{provider}/{provider}",
|
|
plugin_id=f"langgenius/{provider}",
|
|
declaration=ProviderEntity(
|
|
provider=provider,
|
|
label=I18nObject(en_US=provider.title()),
|
|
supported_model_types=[],
|
|
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
|
),
|
|
)
|
|
|
|
|
|
def _build_install_task(*, task_id: str = "task-1", status: PluginInstallTaskStatus) -> PluginInstallTask:
|
|
now = datetime.datetime.now()
|
|
return PluginInstallTask(
|
|
id=task_id,
|
|
created_at=now,
|
|
updated_at=now,
|
|
status=status,
|
|
total_plugins=1,
|
|
completed_plugins=1 if status != PluginInstallTaskStatus.Pending else 0,
|
|
plugins=[],
|
|
)
|
|
|
|
|
|
def _build_remote_model_plugin(
|
|
*, plugin_id: str = "langgenius/debug-model", plugin_unique_identifier: str = "langgenius/debug-model:1.0.0"
|
|
) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
plugin_id=plugin_id,
|
|
plugin_unique_identifier=plugin_unique_identifier,
|
|
source=PluginInstallationSource.Remote,
|
|
)
|
|
|
|
|
|
def _provider_cache_key(tenant_id: str, generation: int | None = None) -> str:
|
|
if generation is None:
|
|
return f"plugin_model_providers:tenant_id:{tenant_id}"
|
|
|
|
return f"plugin_model_providers:tenant_id:{tenant_id}:generation:{generation}"
|
|
|
|
|
|
def _provider_generation_key(tenant_id: str) -> str:
|
|
return f"plugin_model_providers_generation:tenant_id:{tenant_id}"
|
|
|
|
|
|
class TestFetchLatestPluginVersion:
|
|
def test_skips_marketplace_fetch_when_disabled(self) -> None:
|
|
"""Cache misses stay None; marketplace is never called when disabled."""
|
|
with (
|
|
patch(f"{MODULE}.dify_config") as mock_cfg,
|
|
patch(f"{MODULE}.redis_client") as mock_redis,
|
|
patch(f"{MODULE}.marketplace") as mock_marketplace,
|
|
):
|
|
mock_cfg.MARKETPLACE_ENABLED = False
|
|
mock_redis.get.return_value = None # all cache misses
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_latest_plugin_version(["langgenius/openai", "langgenius/anthropic"])
|
|
|
|
mock_marketplace.batch_fetch_plugin_manifests.assert_not_called()
|
|
assert result == {"langgenius/openai": None, "langgenius/anthropic": None}
|
|
|
|
def test_calls_marketplace_fetch_when_enabled(self) -> None:
|
|
"""Cache misses trigger marketplace fetch when enabled."""
|
|
manifest = MagicMock()
|
|
manifest.plugin_id = "langgenius/openai"
|
|
manifest.latest_version = "1.0.0"
|
|
manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc"
|
|
manifest.status = "active"
|
|
manifest.deprecated_reason = ""
|
|
manifest.alternative_plugin_id = ""
|
|
|
|
with (
|
|
patch(f"{MODULE}.dify_config") as mock_cfg,
|
|
patch(f"{MODULE}.redis_client") as mock_redis,
|
|
patch(f"{MODULE}.marketplace") as mock_marketplace,
|
|
):
|
|
mock_cfg.MARKETPLACE_ENABLED = True
|
|
mock_redis.get.return_value = None
|
|
mock_marketplace.batch_fetch_plugin_manifests.return_value = [manifest]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_latest_plugin_version(["langgenius/openai"])
|
|
|
|
# The list arg is mutated by remove() after the call, so check call count + result.
|
|
mock_marketplace.batch_fetch_plugin_manifests.assert_called_once()
|
|
assert result["langgenius/openai"] is not None
|
|
assert result["langgenius/openai"].version == "1.0.0"
|
|
|
|
|
|
class TestPluginModelProviderCache:
|
|
def test_store_cached_plugin_model_providers_compresses_large_payload(self) -> None:
|
|
"""Large provider metadata payloads are compressed before being stored in Redis."""
|
|
large_provider = _build_provider_entity()
|
|
large_provider.label = I18nObject(en_US="OpenAI " * 10000)
|
|
raw_payload = TypeAdapter(list[ProviderEntity]).dump_json([large_provider])
|
|
cache_key = _provider_cache_key("tenant-1", 0)
|
|
|
|
with (
|
|
patch(f"{MODULE}.redis_client") as redis_client,
|
|
patch(f"{MODULE}.dify_config") as mock_config,
|
|
):
|
|
mock_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL = 86400
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
PluginService._store_cached_plugin_model_providers("tenant-1", 0, [large_provider])
|
|
|
|
redis_client.setex.assert_called_once()
|
|
stored_key, ttl, stored_payload = redis_client.setex.call_args.args
|
|
assert stored_key == cache_key
|
|
assert ttl == 86400
|
|
assert isinstance(stored_payload, bytes)
|
|
prefix = PluginService.PLUGIN_MODEL_PROVIDERS_CACHE_COMPRESSION_PREFIX
|
|
assert stored_payload.startswith(prefix)
|
|
assert len(stored_payload) < len(raw_payload)
|
|
assert zstandard.decompress(stored_payload[len(prefix) :]) == raw_payload
|
|
|
|
def test_fetch_plugin_model_providers_reads_compressed_cached_provider_without_calling_daemon(self) -> None:
|
|
"""Compressed tenant cache entries are decoded before provider schema validation."""
|
|
cached_provider = _build_provider_entity()
|
|
cached_provider.label = I18nObject(en_US="OpenAI " * 10000)
|
|
cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider])
|
|
generation_key = _provider_generation_key("tenant-1")
|
|
cache_key = _provider_cache_key("tenant-1", 0)
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
compressed_payload = PluginService.PLUGIN_MODEL_PROVIDERS_CACHE_COMPRESSION_PREFIX + zstandard.compress(
|
|
cached_payload, level=1
|
|
)
|
|
|
|
with patch(f"{MODULE}.redis_client") as redis_client:
|
|
redis_client.get.return_value = None
|
|
redis_client.mget.return_value = [compressed_payload]
|
|
client = Mock()
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
|
assert result[0].label.en_us == "OpenAI " * 10000
|
|
client.fetch_model_providers.assert_not_called()
|
|
redis_client.setex.assert_not_called()
|
|
redis_client.get.assert_called_once_with(generation_key)
|
|
redis_client.mget.assert_called_once_with([cache_key])
|
|
|
|
def test_fetch_plugin_model_providers_returns_cached_provider_without_calling_daemon(self) -> None:
|
|
"""A valid tenant cache entry is reused across runtime calls without plugin daemon access."""
|
|
cached_provider = _build_provider_entity()
|
|
cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider])
|
|
generation_key = _provider_generation_key("tenant-1")
|
|
cache_key = _provider_cache_key("tenant-1", 0)
|
|
|
|
with patch(f"{MODULE}.redis_client") as redis_client:
|
|
redis_client.get.return_value = None
|
|
redis_client.mget.return_value = [cached_payload]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
client = Mock()
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
|
client.fetch_model_providers.assert_not_called()
|
|
redis_client.setex.assert_not_called()
|
|
redis_client.get.assert_called_once_with(generation_key)
|
|
redis_client.mget.assert_called_once_with([cache_key])
|
|
|
|
def test_fetch_plugin_model_providers_deletes_invalid_cache_and_refetches(self) -> None:
|
|
"""Invalid generation-scoped cache payloads are removed before falling back to the daemon."""
|
|
generation_key = _provider_generation_key("tenant-1")
|
|
cache_key = _provider_cache_key("tenant-1", 0)
|
|
with (
|
|
patch(f"{MODULE}.redis_client") as redis_client,
|
|
patch(f"{MODULE}.dify_config") as mock_config,
|
|
):
|
|
redis_client.get.side_effect = [None, None, None]
|
|
redis_client.mget.side_effect = [["not-json"], [None]]
|
|
mock_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL = 86400
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
redis_client.delete.assert_called_once_with(cache_key)
|
|
redis_client.setex.assert_called_once()
|
|
assert redis_client.setex.call_args.args[0] == cache_key
|
|
assert redis_client.setex.call_args.args[1] == 86400
|
|
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
|
redis_client.get.assert_has_calls([call(generation_key), call(generation_key), call(generation_key)])
|
|
assert redis_client.mget.call_args_list == [
|
|
call([cache_key]),
|
|
call([cache_key]),
|
|
]
|
|
|
|
def test_fetch_plugin_model_providers_refetches_when_cache_read_fails(self) -> None:
|
|
"""Redis read failures do not block provider discovery for the tenant."""
|
|
with patch(f"{MODULE}.redis_client") as redis_client:
|
|
redis_client.get.side_effect = RedisError("redis unavailable")
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
|
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
|
|
|
def test_fetch_plugin_model_providers_refetches_when_cached_payload_batch_read_fails(self) -> None:
|
|
"""Redis mget failures do not block provider discovery for the tenant."""
|
|
cache_key = _provider_cache_key("tenant-1", 0)
|
|
with patch(f"{MODULE}.redis_client") as redis_client:
|
|
redis_client.get.return_value = None
|
|
redis_client.mget.side_effect = RedisError("redis unavailable")
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
|
redis_client.mget.assert_called_once_with([cache_key])
|
|
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
|
|
|
def test_fetch_plugin_model_providers_returns_fresh_result_when_cache_write_fails(self) -> None:
|
|
"""Redis write failures are non-fatal after fresh provider data has been fetched."""
|
|
with patch(f"{MODULE}.redis_client") as redis_client:
|
|
redis_client.get.return_value = None
|
|
redis_client.mget.return_value = [None]
|
|
redis_client.setex.side_effect = RedisError("redis unavailable")
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
|
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
|
|
|
def test_fetch_plugin_model_providers_waits_for_concurrent_refresh_cache_fill(self) -> None:
|
|
"""A cache miss waits for the active tenant refresh instead of stampeding the daemon."""
|
|
cached_provider = _build_provider_entity()
|
|
cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider])
|
|
cache_key = _provider_cache_key("tenant-1", 0)
|
|
|
|
with (
|
|
patch(f"{MODULE}.redis_client") as redis_client,
|
|
patch(f"{MODULE}.time.monotonic", return_value=100.0),
|
|
):
|
|
redis_client.get.return_value = None
|
|
redis_client.mget.side_effect = [[None], [cached_payload]]
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = [_build_plugin_model_provider(provider="anthropic")]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
redis_client.lock.assert_called_once_with(
|
|
PluginService._get_plugin_model_providers_lock_key("tenant-1", 0),
|
|
timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
|
|
sleep=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL,
|
|
)
|
|
redis_client.lock.return_value.acquire.assert_called_once_with(
|
|
blocking=True,
|
|
blocking_timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT,
|
|
)
|
|
assert redis_client.mget.call_args_list == [
|
|
call([cache_key]),
|
|
call([cache_key]),
|
|
]
|
|
redis_client.lock.return_value.release.assert_called_once()
|
|
client.fetch_model_providers.assert_not_called()
|
|
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
|
|
|
def test_fetch_plugin_model_providers_falls_back_when_refresh_lock_wait_times_out(self) -> None:
|
|
"""A request should stop waiting and fetch directly instead of surfacing lock contention."""
|
|
cache_key = _provider_cache_key("tenant-1", 0)
|
|
with (
|
|
patch(f"{MODULE}.redis_client") as redis_client,
|
|
patch(f"{MODULE}.PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT", 0),
|
|
patch(f"{MODULE}.time.monotonic", return_value=100.0),
|
|
):
|
|
redis_client.get.return_value = None
|
|
redis_client.mget.return_value = [None]
|
|
redis_client.lock.return_value.acquire.return_value = False
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
redis_client.lock.assert_called_once_with(
|
|
PluginService._get_plugin_model_providers_lock_key("tenant-1", 0),
|
|
timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
|
|
sleep=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL,
|
|
)
|
|
redis_client.lock.return_value.acquire.assert_called_once_with(blocking=True, blocking_timeout=0)
|
|
redis_client.lock.return_value.release.assert_not_called()
|
|
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
|
redis_client.setex.assert_called_once()
|
|
assert redis_client.setex.call_args.args[0] == cache_key
|
|
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
|
|
|
def test_fetch_plugin_model_providers_restarts_lock_path_after_generation_changes(self) -> None:
|
|
"""Waiters re-read provider generation before trying to become the next refresh owner."""
|
|
generation_key = _provider_generation_key("tenant-1")
|
|
stale_cache_key = _provider_cache_key("tenant-1", 0)
|
|
new_cache_key = _provider_cache_key("tenant-1", 1)
|
|
with (
|
|
patch(f"{MODULE}.redis_client") as redis_client,
|
|
patch(f"{MODULE}.time.monotonic", side_effect=[100.0, 100.0, 100.5, 101.0]),
|
|
):
|
|
redis_client.get.side_effect = [None, b"1", b"1", b"1", b"1"]
|
|
redis_client.mget.side_effect = [[None], [None], [None], [None]]
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = [_build_plugin_model_provider(provider="anthropic")]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
assert redis_client.get.call_args_list == [
|
|
call(generation_key),
|
|
call(generation_key),
|
|
call(generation_key),
|
|
call(generation_key),
|
|
call(generation_key),
|
|
]
|
|
assert redis_client.mget.call_args_list == [
|
|
call([stale_cache_key]),
|
|
call([new_cache_key]),
|
|
call([new_cache_key]),
|
|
call([new_cache_key]),
|
|
]
|
|
assert redis_client.lock.call_args_list == [
|
|
call(
|
|
PluginService._get_plugin_model_providers_lock_key("tenant-1", 0),
|
|
timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
|
|
sleep=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL,
|
|
),
|
|
call(
|
|
PluginService._get_plugin_model_providers_lock_key("tenant-1", 1),
|
|
timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
|
|
sleep=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL,
|
|
),
|
|
]
|
|
assert redis_client.lock.return_value.acquire.call_args_list == [
|
|
call(blocking=True, blocking_timeout=2.0),
|
|
call(blocking=True, blocking_timeout=1.5),
|
|
]
|
|
assert redis_client.lock.return_value.release.call_count == 2
|
|
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
|
redis_client.setex.assert_called_once()
|
|
assert redis_client.setex.call_args.args[0] == new_cache_key
|
|
assert [provider.provider for provider in result] == ["langgenius/anthropic/anthropic"]
|
|
|
|
def test_fetch_plugin_model_providers_falls_back_when_generation_retries_exhaust_wait_budget(self) -> None:
|
|
"""Generation retry loops share one request-local lock wait deadline before direct fetch fallback."""
|
|
generation_key = _provider_generation_key("tenant-1")
|
|
stale_cache_key = _provider_cache_key("tenant-1", 0)
|
|
new_cache_key = _provider_cache_key("tenant-1", 1)
|
|
|
|
with (
|
|
patch(f"{MODULE}.redis_client") as redis_client,
|
|
patch(f"{MODULE}.time.monotonic", side_effect=[100.0, 100.0, 102.1]),
|
|
):
|
|
redis_client.get.side_effect = [None, b"1", b"1", b"1"]
|
|
redis_client.mget.side_effect = [[None], [None], [None]]
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = [_build_plugin_model_provider(provider="anthropic")]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
assert redis_client.get.call_args_list == [
|
|
call(generation_key),
|
|
call(generation_key),
|
|
call(generation_key),
|
|
call(generation_key),
|
|
]
|
|
assert redis_client.mget.call_args_list == [
|
|
call([stale_cache_key]),
|
|
call([new_cache_key]),
|
|
call([new_cache_key]),
|
|
]
|
|
redis_client.lock.assert_called_once_with(
|
|
PluginService._get_plugin_model_providers_lock_key("tenant-1", 0),
|
|
timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
|
|
sleep=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL,
|
|
)
|
|
redis_client.lock.return_value.acquire.assert_called_once_with(blocking=True, blocking_timeout=2.0)
|
|
redis_client.lock.return_value.release.assert_called_once()
|
|
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
|
redis_client.setex.assert_called_once()
|
|
assert redis_client.setex.call_args.args[0] == new_cache_key
|
|
assert [provider.provider for provider in result] == ["langgenius/anthropic/anthropic"]
|
|
|
|
def test_fetch_plugin_model_providers_releases_owned_refresh_lock_after_store(self) -> None:
|
|
"""The refresh owner releases only its token after storing provider metadata."""
|
|
cache_key = _provider_cache_key("tenant-1", 0)
|
|
|
|
with (
|
|
patch(f"{MODULE}.redis_client") as redis_client,
|
|
patch(f"{MODULE}.time.monotonic", return_value=100.0),
|
|
):
|
|
redis_client.get.return_value = None
|
|
redis_client.mget.return_value = [None]
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
redis_client.lock.assert_called_once_with(
|
|
PluginService._get_plugin_model_providers_lock_key("tenant-1", 0),
|
|
timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
|
|
sleep=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL,
|
|
)
|
|
redis_client.lock.return_value.acquire.assert_called_once_with(
|
|
blocking=True,
|
|
blocking_timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT,
|
|
)
|
|
assert redis_client.mget.call_args_list == [
|
|
call([cache_key]),
|
|
call([cache_key]),
|
|
]
|
|
redis_client.lock.return_value.release.assert_called_once()
|
|
redis_client.eval.assert_not_called()
|
|
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
|
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
|
|
|
def test_fetch_plugin_model_providers_returns_fresh_result_when_refresh_lock_release_fails(self) -> None:
|
|
"""Release failures are logged, not allowed to hide a successful daemon refresh."""
|
|
cache_key = _provider_cache_key("tenant-1", 0)
|
|
|
|
with (
|
|
patch(f"{MODULE}.redis_client") as redis_client,
|
|
patch(f"{MODULE}.time.monotonic", return_value=100.0),
|
|
):
|
|
redis_client.get.return_value = None
|
|
redis_client.mget.return_value = [None]
|
|
redis_client.lock.return_value.release.side_effect = RedisError("release failed")
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
assert redis_client.mget.call_args_list == [
|
|
call([cache_key]),
|
|
call([cache_key]),
|
|
]
|
|
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
|
redis_client.setex.assert_called_once()
|
|
redis_client.lock.return_value.release.assert_called_once()
|
|
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
|
|
|
def test_fetch_plugin_model_providers_releases_owned_refresh_lock_when_fetch_fails(self) -> None:
|
|
"""Release failures must not hide the daemon failure that happened while owning the lock."""
|
|
cache_key = _provider_cache_key("tenant-1", 0)
|
|
|
|
with (
|
|
patch(f"{MODULE}.redis_client") as redis_client,
|
|
patch(f"{MODULE}.time.monotonic", return_value=100.0),
|
|
):
|
|
redis_client.get.return_value = None
|
|
redis_client.mget.return_value = [None]
|
|
redis_client.lock.return_value.release.side_effect = RedisError("release failed")
|
|
client = Mock()
|
|
client.fetch_model_providers.side_effect = RuntimeError("daemon failed")
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
with pytest.raises(RuntimeError, match="daemon failed"):
|
|
PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
assert redis_client.mget.call_args_list == [
|
|
call([cache_key]),
|
|
call([cache_key]),
|
|
]
|
|
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
|
redis_client.setex.assert_not_called()
|
|
redis_client.lock.return_value.release.assert_called_once()
|
|
|
|
def test_fetch_plugin_model_providers_falls_back_when_refresh_lock_acquire_fails(self) -> None:
|
|
"""Redis acquire failures degrade to a direct daemon fetch instead of hiding provider data."""
|
|
with (
|
|
patch(f"{MODULE}.redis_client") as redis_client,
|
|
patch(f"{MODULE}.time.monotonic", return_value=100.0),
|
|
):
|
|
redis_client.get.return_value = None
|
|
redis_client.mget.return_value = [None]
|
|
redis_client.lock.return_value.acquire.side_effect = RedisError("redis unavailable")
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
redis_client.lock.return_value.acquire.assert_called_once()
|
|
redis_client.lock.return_value.release.assert_not_called()
|
|
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
|
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
|
|
|
def test_fetch_plugin_model_providers_skips_wait_when_refresh_lock_fails(self) -> None:
|
|
"""Lock API failures should fall back directly instead of adding timeout latency."""
|
|
with (
|
|
patch(f"{MODULE}.redis_client") as redis_client,
|
|
patch(f"{MODULE}.time.sleep") as sleep,
|
|
):
|
|
redis_client.get.return_value = None
|
|
redis_client.mget.return_value = [None]
|
|
redis_client.lock.side_effect = RedisError("redis unavailable")
|
|
redis_client.set.side_effect = AssertionError("raw redis set must not be used for refresh locks")
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
sleep.assert_not_called()
|
|
redis_client.lock.assert_called_once()
|
|
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
|
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
|
|
|
def test_fetch_plugin_model_providers_caches_empty_provider_list(self) -> None:
|
|
"""An empty provider list is still a valid refresh result for single-flight waiters."""
|
|
cache_key = _provider_cache_key("tenant-1", 0)
|
|
with patch(f"{MODULE}.redis_client") as redis_client:
|
|
redis_client.get.return_value = None
|
|
redis_client.mget.return_value = [None]
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = []
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
assert result == ()
|
|
redis_client.setex.assert_called_once()
|
|
assert redis_client.setex.call_args.args[0] == cache_key
|
|
redis_client.lock.return_value.release.assert_called_once()
|
|
|
|
def test_fetch_plugin_model_providers_skips_cache_write_when_generation_changes_during_refresh(self) -> None:
|
|
"""A refresh that started before invalidation must not populate the newer generation cache."""
|
|
with patch(f"{MODULE}.redis_client") as redis_client:
|
|
redis_client.get.side_effect = [None, None, "1"]
|
|
redis_client.mget.return_value = [None]
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = []
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
assert result == ()
|
|
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
|
redis_client.setex.assert_not_called()
|
|
redis_client.lock.return_value.release.assert_called_once()
|
|
|
|
def test_fetch_plugin_model_providers_reuses_cached_empty_provider_list(self) -> None:
|
|
"""A cached empty list should prevent repeated daemon fetches for tenants without plugin models."""
|
|
empty_payload = TypeAdapter(list[ProviderEntity]).dump_json([])
|
|
cache_key = _provider_cache_key("tenant-1", 0)
|
|
|
|
with patch(f"{MODULE}.redis_client") as redis_client:
|
|
redis_client.get.return_value = None
|
|
redis_client.mget.return_value = [empty_payload]
|
|
client = Mock()
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
assert result == ()
|
|
redis_client.mget.assert_called_once_with([cache_key])
|
|
client.fetch_model_providers.assert_not_called()
|
|
|
|
def test_fetch_plugin_model_providers_creates_default_client_on_cache_miss(self) -> None:
|
|
"""The service owns plugin daemon access when no runtime-provided client is injected."""
|
|
with (
|
|
patch(f"{MODULE}.redis_client") as redis_client,
|
|
patch(f"{MODULE}.PluginModelClient") as client_cls,
|
|
):
|
|
redis_client.get.return_value = None
|
|
redis_client.mget.return_value = [None]
|
|
client = client_cls.return_value
|
|
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1")
|
|
|
|
client_cls.assert_called_once_with()
|
|
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
|
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
|
|
|
def test_invalidate_plugin_model_providers_cache_uses_redis_pipeline(self) -> None:
|
|
with patch(f"{MODULE}.redis_client") as redis_client:
|
|
pipe = redis_client.pipeline.return_value
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
PluginService.invalidate_plugin_model_providers_cache("tenant-1")
|
|
|
|
redis_client.pipeline.assert_called_once_with(transaction=False)
|
|
pipe.delete.assert_called_once_with(_provider_cache_key("tenant-1"))
|
|
pipe.incr.assert_called_once_with(_provider_generation_key("tenant-1"))
|
|
pipe.execute.assert_called_once_with()
|
|
|
|
def test_invalidate_plugin_model_providers_cache_ignores_redis_pipeline_failure(self) -> None:
|
|
with patch(f"{MODULE}.redis_client") as redis_client:
|
|
pipe = redis_client.pipeline.return_value
|
|
pipe.execute.side_effect = RedisError("redis unavailable")
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
PluginService.invalidate_plugin_model_providers_cache("tenant-1")
|
|
|
|
redis_client.pipeline.assert_called_once_with(transaction=False)
|
|
pipe.delete.assert_called_once_with(_provider_cache_key("tenant-1"))
|
|
pipe.incr.assert_called_once_with(_provider_generation_key("tenant-1"))
|
|
pipe.execute.assert_called_once_with()
|
|
|
|
def test_fetch_plugin_model_providers_uses_new_generation_cache_after_generation_bump(self) -> None:
|
|
generation_key = _provider_generation_key("tenant-1")
|
|
new_cache_key = _provider_cache_key("tenant-1", 1)
|
|
with patch(f"{MODULE}.redis_client") as redis_client:
|
|
redis_client.get.side_effect = [b"1", b"1", b"1"]
|
|
redis_client.mget.return_value = [None]
|
|
client = Mock()
|
|
client.fetch_model_providers.return_value = [_build_plugin_model_provider(provider="anthropic")]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
|
|
|
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
|
redis_client.get.assert_has_calls([call(generation_key), call(generation_key), call(generation_key)])
|
|
assert redis_client.mget.call_args_list == [
|
|
call([new_cache_key]),
|
|
call([new_cache_key]),
|
|
]
|
|
redis_client.setex.assert_called_once()
|
|
assert redis_client.setex.call_args.args[0] == new_cache_key
|
|
redis_client.lock.return_value.acquire.assert_called_once()
|
|
redis_client.lock.return_value.release.assert_called_once()
|
|
assert [provider.provider for provider in result] == ["langgenius/anthropic/anthropic"]
|
|
|
|
|
|
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_get_debugging_key_does_not_invalidate_model_provider_cache(self) -> None:
|
|
"""Reading a debug key does not mean a debug runtime has registered a model provider."""
|
|
with (
|
|
patch(f"{MODULE}.PluginDebuggingClient") as debugging_client_cls,
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
):
|
|
debugging_client_cls.return_value.get_debugging_key.return_value = "debug-key"
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.get_debugging_key("tenant-1")
|
|
|
|
assert result == "debug-key"
|
|
debugging_client_cls.return_value.get_debugging_key.assert_called_once_with("tenant-1")
|
|
invalidate_cache.assert_not_called()
|
|
|
|
def test_list_model_category_invalidates_when_remote_model_plugin_is_missing_from_provider_cache(self) -> None:
|
|
"""Remote model plugins are daemon-registered, so category reads repair a stale provider cache."""
|
|
remote_plugin = _build_remote_model_plugin()
|
|
remote_plugin_marker = "langgenius/debug-model:langgenius/debug-model:1.0.0"
|
|
plugins = SimpleNamespace(list=[remote_plugin], has_more=False)
|
|
|
|
with (
|
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
|
patch(
|
|
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
|
|
return_value=remote_plugin_marker,
|
|
),
|
|
patch(
|
|
f"{MODULE}.PluginService._load_cached_plugin_model_provider_plugin_ids",
|
|
return_value={"langgenius/openai"},
|
|
),
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
|
|
):
|
|
installer_cls.return_value.list_plugins_by_category.return_value = plugins
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
|
|
|
|
assert result is plugins
|
|
installer_cls.return_value.list_plugins_by_category.assert_called_once_with(
|
|
"tenant-1", PluginCategory.Model, 1, 100
|
|
)
|
|
invalidate_cache.assert_called_once_with("tenant-1")
|
|
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
|
|
|
|
def test_list_model_category_invalidates_when_remote_model_plugin_identity_changes(self) -> None:
|
|
"""A debug model plugin can share plugin_id with an installed plugin, so identity changes bust cache too."""
|
|
remote_plugin = _build_remote_model_plugin(
|
|
plugin_id="langgenius/openai",
|
|
plugin_unique_identifier="langgenius/openai:debug",
|
|
)
|
|
remote_plugin_marker = "langgenius/openai:langgenius/openai:debug"
|
|
plugins = SimpleNamespace(list=[remote_plugin], has_more=False)
|
|
|
|
with (
|
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
|
patch(
|
|
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
|
|
return_value="langgenius/openai:langgenius/openai:1.0.0",
|
|
),
|
|
patch(
|
|
f"{MODULE}.PluginService._load_cached_plugin_model_provider_plugin_ids",
|
|
return_value={"langgenius/openai"},
|
|
) as load_cached_provider_plugin_ids,
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
|
|
):
|
|
installer_cls.return_value.list_plugins_by_category.return_value = plugins
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
|
|
|
|
assert result is plugins
|
|
invalidate_cache.assert_called_once_with("tenant-1")
|
|
load_cached_provider_plugin_ids.assert_not_called()
|
|
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
|
|
|
|
def test_list_model_category_keeps_provider_cache_when_remote_model_plugin_is_already_cached(self) -> None:
|
|
"""A connected remote model plugin should not force provider cache churn once represented."""
|
|
remote_plugin = _build_remote_model_plugin()
|
|
remote_plugin_marker = "langgenius/debug-model:langgenius/debug-model:1.0.0"
|
|
plugins = SimpleNamespace(list=[remote_plugin], has_more=False)
|
|
|
|
with (
|
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
|
patch(
|
|
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
|
|
return_value=remote_plugin_marker,
|
|
),
|
|
patch(
|
|
f"{MODULE}.PluginService._load_cached_plugin_model_provider_plugin_ids",
|
|
return_value={"langgenius/debug-model"},
|
|
),
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
|
|
):
|
|
installer_cls.return_value.list_plugins_by_category.return_value = plugins
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
|
|
|
|
assert result is plugins
|
|
invalidate_cache.assert_not_called()
|
|
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
|
|
|
|
def test_list_model_category_invalidates_when_remote_model_plugin_disconnects(self) -> None:
|
|
"""The current model category result clears provider cache when the previous debug model disappears."""
|
|
installed_plugin = SimpleNamespace(
|
|
plugin_id="langgenius/openai",
|
|
plugin_unique_identifier="langgenius/openai:1.0.0",
|
|
source=PluginInstallationSource.Marketplace,
|
|
)
|
|
plugins = SimpleNamespace(list=[installed_plugin], has_more=True)
|
|
|
|
with (
|
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
|
patch(
|
|
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
|
|
return_value="langgenius/debug-model:langgenius/debug-model:1.0.0",
|
|
),
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
|
|
):
|
|
installer_cls.return_value.list_plugins_by_category.return_value = plugins
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
|
|
|
|
assert result is plugins
|
|
invalidate_cache.assert_called_once_with("tenant-1")
|
|
store_marker.assert_called_once_with("tenant-1", None)
|
|
|
|
def test_fetch_install_task_invalidates_model_provider_cache_when_finished(self) -> None:
|
|
"""Finished plugin install tasks invalidate tenant provider cache."""
|
|
task = _build_install_task(status=PluginInstallTaskStatus.Success)
|
|
|
|
with (
|
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
):
|
|
installer_cls.return_value.fetch_plugin_installation_task.return_value = task
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_install_task("tenant-1", "task-1")
|
|
|
|
assert result is task
|
|
invalidate_cache.assert_called_once_with("tenant-1")
|
|
|
|
def test_fetch_install_tasks_invalidates_model_provider_cache_for_finished_tasks(self) -> None:
|
|
"""Finished tasks from task list polling also invalidate tenant provider cache."""
|
|
task = _build_install_task(status=PluginInstallTaskStatus.Success)
|
|
|
|
with (
|
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
):
|
|
installer_cls.return_value.fetch_plugin_installation_tasks.return_value = [task]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_install_tasks("tenant-1", 1, 256)
|
|
|
|
assert result == [task]
|
|
invalidate_cache.assert_called_once_with("tenant-1")
|
|
|
|
def test_fetch_install_tasks_ignores_running_model_provider_cache_tasks(self) -> None:
|
|
"""Running plugin install tasks do not invalidate provider cache until they reach a terminal state."""
|
|
task = _build_install_task(status=PluginInstallTaskStatus.Running)
|
|
|
|
with (
|
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
):
|
|
installer_cls.return_value.fetch_plugin_installation_tasks.return_value = [task]
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.fetch_install_tasks("tenant-1", 1, 256)
|
|
|
|
assert result == [task]
|
|
invalidate_cache.assert_not_called()
|
|
|
|
def test_upgrade_plugin_with_marketplace_invalidates_model_provider_cache_for_tenant(self) -> None:
|
|
"""Marketplace upgrades invalidate only the mutated tenant provider cache."""
|
|
with (
|
|
patch(f"{MODULE}.dify_config") as mock_config,
|
|
patch(f"{MODULE}.FeatureService") as feature_service,
|
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
|
patch(f"{MODULE}.marketplace") as marketplace,
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
):
|
|
mock_config.MARKETPLACE_ENABLED = True
|
|
feature_service.get_system_features.return_value = SimpleNamespace(
|
|
plugin_installation_permission=SimpleNamespace(restrict_to_marketplace_only=False)
|
|
)
|
|
installer = installer_cls.return_value
|
|
installer.fetch_plugin_manifest.return_value = MagicMock()
|
|
installer.upgrade_plugin.return_value = "task-id"
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.upgrade_plugin_with_marketplace("tenant-1", "old-uid", "new-uid")
|
|
|
|
assert result == "task-id"
|
|
marketplace.record_install_plugin_event.assert_called_once_with("new-uid")
|
|
invalidate_cache.assert_called_once_with("tenant-1")
|
|
|
|
def test_install_from_local_pkg_invalidates_model_provider_cache_for_tenant(self) -> None:
|
|
"""Starting a plugin install invalidates only the mutated tenant provider cache."""
|
|
with (
|
|
patch(f"{MODULE}.PluginService._check_marketplace_only_permission"),
|
|
patch(f"{MODULE}.PluginService._check_plugin_installation_scope"),
|
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
):
|
|
installer = installer_cls.return_value
|
|
decode_response = MagicMock()
|
|
decode_response.verification = None
|
|
installer.decode_plugin_from_identifier.return_value = decode_response
|
|
installer.install_from_identifiers.return_value = "task-id"
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.install_from_local_pkg(
|
|
"tenant-1",
|
|
[
|
|
"langgenius/openai:1.0.0",
|
|
"langgenius/tavily:1.0.0",
|
|
],
|
|
)
|
|
|
|
assert result == "task-id"
|
|
installer.install_from_identifiers.assert_called_once_with(
|
|
"tenant-1",
|
|
[
|
|
"langgenius/openai:1.0.0",
|
|
"langgenius/tavily:1.0.0",
|
|
],
|
|
PluginInstallationSource.Package,
|
|
[
|
|
{"plugin_unique_identifier": "langgenius/openai:1.0.0"},
|
|
{"plugin_unique_identifier": "langgenius/tavily:1.0.0"},
|
|
],
|
|
)
|
|
invalidate_cache.assert_called_once_with("tenant-1")
|
|
|
|
def test_upgrade_plugin_with_github_invalidates_model_provider_cache_for_tenant(self) -> None:
|
|
"""Starting a plugin upgrade invalidates only the mutated tenant provider cache."""
|
|
with (
|
|
patch(f"{MODULE}.PluginService._check_marketplace_only_permission"),
|
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
):
|
|
installer = installer_cls.return_value
|
|
installer.upgrade_plugin.return_value = "task-id"
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.upgrade_plugin_with_github(
|
|
"tenant-1", "old-uid", "new-uid", "langgenius/openai", "1.0.0", "openai.difypkg"
|
|
)
|
|
|
|
assert result == "task-id"
|
|
invalidate_cache.assert_called_once_with("tenant-1")
|
|
|
|
def test_install_from_github_invalidates_model_provider_cache_for_tenant(self) -> None:
|
|
"""GitHub installs invalidate only the mutated tenant provider cache."""
|
|
with (
|
|
patch(f"{MODULE}.PluginService._check_marketplace_only_permission"),
|
|
patch(f"{MODULE}.PluginService._check_plugin_installation_scope"),
|
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
):
|
|
installer = installer_cls.return_value
|
|
decode_response = MagicMock()
|
|
decode_response.verification = None
|
|
installer.decode_plugin_from_identifier.return_value = decode_response
|
|
installer.install_from_identifiers.return_value = "task-id"
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.install_from_github(
|
|
"tenant-1", "langgenius/openai:1.0.0", "langgenius/openai", "1.0.0", "openai.difypkg"
|
|
)
|
|
|
|
assert result == "task-id"
|
|
invalidate_cache.assert_called_once_with("tenant-1")
|
|
|
|
def test_install_from_marketplace_pkg_invalidates_model_provider_cache_for_tenant(self) -> None:
|
|
"""Marketplace package installs invalidate only the mutated tenant provider cache."""
|
|
with (
|
|
patch(f"{MODULE}.dify_config") as mock_config,
|
|
patch(f"{MODULE}.FeatureService") as feature_service,
|
|
patch(f"{MODULE}.PluginService._check_plugin_installation_scope"),
|
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
):
|
|
mock_config.MARKETPLACE_ENABLED = True
|
|
feature_service.get_system_features.return_value = SimpleNamespace(
|
|
plugin_installation_permission=SimpleNamespace(restrict_to_marketplace_only=False)
|
|
)
|
|
installer = installer_cls.return_value
|
|
installer.fetch_plugin_manifest.return_value = MagicMock()
|
|
decode_response = MagicMock()
|
|
decode_response.verification = None
|
|
installer.decode_plugin_from_identifier.return_value = decode_response
|
|
installer.install_from_identifiers.return_value = "task-id"
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.install_from_marketplace_pkg("tenant-1", ["langgenius/openai:1.0.0"])
|
|
|
|
assert result == "task-id"
|
|
invalidate_cache.assert_called_once_with("tenant-1")
|
|
|
|
def test_uninstall_invalidates_model_provider_cache_for_tenant(self) -> None:
|
|
"""Successful uninstall invalidates only the mutated tenant provider cache."""
|
|
with (
|
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
):
|
|
installer = installer_cls.return_value
|
|
installer.list_plugins.return_value = []
|
|
installer.uninstall.return_value = True
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.uninstall("tenant-1", "installation-1")
|
|
|
|
assert result is True
|
|
invalidate_cache.assert_called_once_with("tenant-1")
|
|
|
|
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 = SimpleNamespace(
|
|
installation_id="installation-1",
|
|
plugin_id="langgenius/openai",
|
|
plugin_unique_identifier="langgenius/openai:1.0.0",
|
|
)
|
|
session = _FakeSession()
|
|
with (
|
|
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}.Session", return_value=session),
|
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
|
):
|
|
mock_config.ENTERPRISE_ENABLED = False
|
|
installer = installer_cls.return_value
|
|
installer.list_plugins.return_value = [plugin]
|
|
installer.uninstall.return_value = True
|
|
|
|
from core.plugin.plugin_service import PluginService
|
|
|
|
result = PluginService.uninstall("tenant-1", "installation-1")
|
|
|
|
assert result is True
|
|
installer.uninstall.assert_called_once_with("tenant-1", "installation-1")
|
|
invalidate_cache.assert_called_once_with("tenant-1")
|