Files
new-api/controller/task_plugin_test.go

1149 lines
45 KiB
Go

package controller
import (
"crypto/sha256"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/plugins"
"github.com/QuantumNous/new-api/setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupTaskPluginControllerTest(t *testing.T) {
t.Helper()
originalDB := model.DB
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, database.AutoMigrate(&model.TaskPlugin{}, &model.Channel{}, &model.Ability{}, &model.Task{}, &model.Option{}))
model.DB = database
t.Cleanup(func() { model.DB = originalDB })
}
const lifecyclePluginSource = `
export const meta = {apiVersion: 1, key: "lifecycle-only", name: "Lifecycle", version: "1.0.0", author: {name: "Test"}, models: ["doc"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
func taskPluginControllerTestSource(key, version string) string {
return fmt.Sprintf(`
export const meta = {apiVersion: 1, key: %q, name: "Test", version: %q, author: {name: "Test"}, models: ["doc-1"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`, key, version)
}
func taskPluginControllerChannelSource(key, version string, channelType int) string {
return fmt.Sprintf(`
export const meta = {apiVersion: 1, key: %q, name: "Test", version: %q, author: {name: "Test"}, channelTypes: [%d], models: ["doc-1"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`, key, version, channelType)
}
func cleanupTaskPluginControllerRuntime(t *testing.T, key string) {
t.Helper()
t.Cleanup(func() {
jsplugin.DefaultRegistry.Unregister(key)
taskPluginSyncState.Lock()
delete(taskPluginSyncState.hashes, key)
delete(taskPluginSyncState.errors, key)
taskPluginSyncState.Unlock()
})
}
func TestDeleteThirdPartyPluginReportsAssociatedChannelsAndInFlightTasks(t *testing.T) {
setupTaskPluginControllerTest(t)
loaded, err := jsplugin.DefaultRegistry.Register(lifecyclePluginSource, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("lifecycle-only") })
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: loaded.Meta.Key, APIVersion: 1, Version: "1", Source: lifecyclePluginSource, SourceHash: "hash", Enabled: true}))
baseURL := "https://example.com"
setting := `{"task_plugin_key":"lifecycle-only"}`
channel := model.Channel{Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "linked", Models: "doc", Group: "default", BaseURL: &baseURL, Setting: &setting}
require.NoError(t, channel.Insert())
require.NoError(t, model.DB.Create(&model.Task{Platform: "lifecycle-only", Status: model.TaskStatusInProgress}).Error)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: "lifecycle-only"}, {Key: "version", Value: "1"}}
context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/lifecycle-only/versions/1", nil)
DeleteTaskPluginVersion(context)
assert.Contains(t, recorder.Body.String(), `"name":"linked"`)
assert.Contains(t, recorder.Body.String(), `"in_flight_count":1`)
}
func TestDisableThirdPartyPluginSupportsCascadeAndForce(t *testing.T) {
setupTaskPluginControllerTest(t)
loaded, err := jsplugin.DefaultRegistry.Register(lifecyclePluginSource, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("lifecycle-only") })
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: loaded.Meta.Key, APIVersion: 1, Version: "1", Source: lifecyclePluginSource, SourceHash: "hash", Enabled: true}))
baseURL := "https://example.com"
setting := `{"task_plugin_key":"lifecycle-only"}`
channel := model.Channel{Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "linked", Models: "doc", Group: "default", BaseURL: &baseURL, Setting: &setting}
require.NoError(t, channel.Insert())
require.NoError(t, model.DB.Create(&model.Task{Platform: "lifecycle-only", Status: model.TaskStatusSubmitted}).Error)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: "lifecycle-only"}}
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task/lifecycle-only/status?cascade=true&force=true", strings.NewReader(`{"enabled":false}`))
context.Request.Header.Set("Content-Type", "application/json")
SetTaskPluginStatus(context)
assert.Contains(t, recorder.Body.String(), `"success":true`)
updated, err := model.GetChannelById(channel.Id, true)
require.NoError(t, err)
assert.Equal(t, common.ChannelStatusManuallyDisabled, updated.Status)
}
func setupTaskPluginFactoryDisableTest(t *testing.T) {
t.Helper()
setupTaskPluginControllerTest(t)
originalMap := common.OptionMap
common.OptionMapRWMutex.Lock()
common.OptionMap = map[string]string{}
common.OptionMapRWMutex.Unlock()
t.Cleanup(func() {
jsplugin.DefaultRegistry.SetDisabledFactoryKeys(nil)
common.OptionMapRWMutex.Lock()
common.OptionMap = originalMap
common.OptionMapRWMutex.Unlock()
})
}
func postTaskPluginStatus(t *testing.T, key, query, body string) *httptest.ResponseRecorder {
t.Helper()
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: key}}
path := "/api/plugin/task/" + key + "/status"
if query != "" {
path += "?" + query
}
context.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
context.Request.Header.Set("Content-Type", "application/json")
SetTaskPluginStatus(context)
return recorder
}
func listTaskPluginItem(t *testing.T, key string) taskPluginListItem {
t.Helper()
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task", nil)
ListTaskPlugins(context)
var response struct {
Success bool `json:"success"`
Data []taskPluginListItem `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
for _, item := range response.Data {
if item.Meta.Key == key {
return item
}
}
t.Fatalf("task plugin %q not found", key)
return taskPluginListItem{}
}
func taskPluginOptionsHasKey(t *testing.T, key string) bool {
t.Helper()
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/task_plugin_options", nil)
GetTaskPluginOptions(context)
var response struct {
Success bool `json:"success"`
Data []struct {
Key string `json:"key"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
for _, option := range response.Data {
if option.Key == key {
return true
}
}
return false
}
func TestDisableFactoryPluginPersistsOptionAndHidesFromBindOptions(t *testing.T) {
setupTaskPluginFactoryDisableTest(t)
const key = "kling"
recorder := postTaskPluginStatus(t, key, "", `{"enabled":false}`)
assert.Contains(t, recorder.Body.String(), `"success":true`)
assert.Equal(t, []string{key}, setting.GetTaskPluginDisabledFactoryKeys())
var stored model.Option
require.NoError(t, model.DB.Where("key = ?", setting.TaskPluginDisabledFactoryKeysKey).First(&stored).Error)
assert.Equal(t, `["kling"]`, stored.Value)
item := listTaskPluginItem(t, key)
assert.Equal(t, "factory", item.Source)
assert.False(t, item.Enabled)
assert.Equal(t, "disabled", item.RuntimeStatus)
assert.False(t, taskPluginOptionsHasKey(t, key))
_, ok := jsplugin.DefaultRegistry.Get(key)
assert.False(t, ok)
recorder = postTaskPluginStatus(t, key, "", `{"enabled":true}`)
assert.Contains(t, recorder.Body.String(), `"success":true`)
assert.Empty(t, setting.GetTaskPluginDisabledFactoryKeys())
item = listTaskPluginItem(t, key)
assert.True(t, item.Enabled)
assert.Equal(t, "registered", item.RuntimeStatus)
assert.True(t, taskPluginOptionsHasKey(t, key))
_, ok = jsplugin.DefaultRegistry.Get(key)
assert.True(t, ok)
}
func TestDisableFactoryPluginRespectsInUseGuard(t *testing.T) {
setupTaskPluginFactoryDisableTest(t)
const key = "kling"
baseURL := "https://example.com"
channelSetting := `{"task_plugin_key":"kling"}`
channel := model.Channel{Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "linked-factory", Models: "doc", Group: "default", BaseURL: &baseURL, Setting: &channelSetting}
require.NoError(t, channel.Insert())
recorder := postTaskPluginStatus(t, key, "", `{"enabled":false}`)
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), "task plugin is still in use")
assert.Empty(t, setting.GetTaskPluginDisabledFactoryKeys())
_, ok := jsplugin.DefaultRegistry.Get(key)
assert.True(t, ok)
}
func TestDisableFactoryOverrideRowKeepsEnabledFlagPath(t *testing.T) {
setupTaskPluginFactoryDisableTest(t)
factorySource, err := plugins.Source("kling")
require.NoError(t, err)
overrideSource := strings.Replace(factorySource, `version: "1.0.0"`, `version: "1.0.0-test-factory-status"`, 1)
loaded, err := jsplugin.DefaultRegistry.Register(overrideSource, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("kling") })
plugin := model.TaskPlugin{
Key: "kling", APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
Source: overrideSource, SourceHash: "test-hash", Enabled: true,
}
require.NoError(t, model.SaveTaskPlugin(&plugin))
require.NoError(t, syncTaskPluginsOnce())
recorder := postTaskPluginStatus(t, "kling", "", `{"enabled":false}`)
assert.Contains(t, recorder.Body.String(), `"success":true`)
assert.Empty(t, setting.GetTaskPluginDisabledFactoryKeys())
row, err := model.GetTaskPluginVersion("kling", "")
require.NoError(t, err)
assert.False(t, row.Enabled)
item := listTaskPluginItem(t, "kling")
assert.Equal(t, "override_over_factory", item.Source)
assert.False(t, item.Enabled)
assert.Equal(t, "disabled_fallback", item.RuntimeStatus)
assert.True(t, taskPluginOptionsHasKey(t, "kling"))
got, ok := jsplugin.DefaultRegistry.Get("kling")
require.True(t, ok)
assert.Equal(t, "1.0.0", got.Meta.Version)
}
func TestListTaskPluginsIncludesFactoryWithoutDatabaseRows(t *testing.T) {
setupTaskPluginControllerTest(t)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task", nil)
ListTaskPlugins(context)
var response struct {
Success bool `json:"success"`
Data []taskPluginListItem `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
var factoryItem *taskPluginListItem
for i := range response.Data {
if response.Data[i].Meta.Key == "kling" {
factoryItem = &response.Data[i]
break
}
}
require.NotNil(t, factoryItem)
assert.Equal(t, "factory", factoryItem.Source)
assert.Equal(t, "registered", factoryItem.RuntimeStatus)
assert.NotEmpty(t, factoryItem.SourceHash)
}
func TestMasterSwitchEmptiesOptionsAndKeepsList(t *testing.T) {
setupTaskPluginControllerTest(t)
originalEnabled := constant.TaskPluginEnabled
jsplugin.DefaultRegistry.SetEnabled(false)
t.Cleanup(func() {
constant.TaskPluginEnabled = originalEnabled
jsplugin.DefaultRegistry.SetEnabled(originalEnabled)
})
assert.False(t, taskPluginOptionsHasKey(t, "kling"))
item := listTaskPluginItem(t, "kling")
assert.Equal(t, "factory", item.Source)
assert.Equal(t, "kling", item.Meta.Key)
}
func TestGetTaskPluginOptionsIncludesUsageSchema(t *testing.T) {
const key = "usage-options-probe"
source := `
export const meta = {
apiVersion: 1, key: "usage-options-probe", name: "Usage Options", version: "1.0.0", author: {name: "Test"},
models: ["usage-options-model"], fetchMode: "per_task",
usageSchema: {seconds: {type: "number", unit: "second", description: "Generated media duration."}}
};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/task_plugin_options", nil)
GetTaskPluginOptions(context)
var response struct {
Success bool `json:"success"`
Data []struct {
Key string `json:"key"`
UsageSchema map[string]jsplugin.UsageFieldSchema `json:"usageSchema"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
for _, option := range response.Data {
if option.Key != key {
continue
}
assert.Equal(t, "second", option.UsageSchema["seconds"].Unit)
assert.Equal(t, "Generated media duration.", option.UsageSchema["seconds"].Description["en"])
return
}
t.Fatal("task plugin option not found")
}
func TestListTaskPluginsShowsDisabledFallbackWhenOverridesAreDisabled(t *testing.T) {
setupTaskPluginControllerTest(t)
factorySource, err := plugins.Source("kling")
require.NoError(t, err)
overrideSource := strings.Replace(factorySource, `version: "1.0.0"`, `version: "1.0.0-test-disabled-override"`, 1)
loaded, err := jsplugin.DefaultRegistry.Register(overrideSource, jsplugin.Options{})
require.NoError(t, err)
plugin := model.TaskPlugin{
Key: "kling", APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
Source: overrideSource, SourceHash: "test-hash", Enabled: true,
}
require.NoError(t, model.SaveTaskPlugin(&plugin))
originalEnabled := constant.TaskPluginOverrideEnabled
constant.TaskPluginOverrideEnabled = false
jsplugin.DefaultRegistry.SetOverrideEnabled(false)
t.Cleanup(func() {
constant.TaskPluginOverrideEnabled = originalEnabled
jsplugin.DefaultRegistry.SetOverrideEnabled(originalEnabled)
jsplugin.DefaultRegistry.Unregister("kling")
})
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task", nil)
ListTaskPlugins(context)
var response struct {
Success bool `json:"success"`
Data []taskPluginListItem `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
for _, item := range response.Data {
if item.Meta.Key == "kling" {
assert.Equal(t, "disabled_fallback", item.RuntimeStatus)
return
}
}
t.Fatal("kling plugin not found")
}
func TestDeleteActiveOverrideFallsBackToFactoryAndDeletesRecord(t *testing.T) {
setupTaskPluginControllerTest(t)
factorySource, err := plugins.Source("kling")
require.NoError(t, err)
overrideSource := strings.Replace(factorySource, `version: "1.0.0"`, `version: "1.0.0-test-override"`, 1)
loaded, err := jsplugin.DefaultRegistry.Register(overrideSource, jsplugin.Options{Key: "kling", Version: "test-override"})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("kling") })
plugin := model.TaskPlugin{
Key: "kling", APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
Source: overrideSource, SourceHash: "test-hash", Enabled: true,
}
require.NoError(t, model.SaveTaskPlugin(&plugin))
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: "kling"}, {Key: "version", Value: loaded.Meta.Version}}
context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/kling/versions/"+loaded.Meta.Version, nil)
DeleteTaskPluginVersion(context)
assert.Contains(t, recorder.Body.String(), `"success":true`)
versions, err := model.ListTaskPluginVersions("kling")
require.NoError(t, err)
assert.Empty(t, versions)
runtimePlugin, ok := jsplugin.DefaultRegistry.Get("kling")
require.True(t, ok)
assert.NotEqual(t, loaded.Meta.Version, runtimePlugin.Meta.Version)
}
func TestDeleteActiveTaskPluginPromotesEnabledVersionInRuntime(t *testing.T) {
setupTaskPluginControllerTest(t)
key := "delete-promote-probe"
cleanupTaskPluginControllerRuntime(t, key)
v1Source := taskPluginControllerTestSource(key, "1.0.0")
v2Source := taskPluginControllerTestSource(key, "2.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "hash-v1", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "hash-v2", Enabled: true,
}))
_, err := jsplugin.DefaultRegistry.Register(v1Source, jsplugin.Options{Key: key, Version: "1.0.0"})
require.NoError(t, err)
taskPluginSyncState.Lock()
taskPluginSyncState.hashes[key] = "hash-v1"
taskPluginSyncState.errors[key] = "stale compile error"
taskPluginSyncState.Unlock()
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: key}, {Key: "version", Value: "1.0.0"}}
context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/"+key+"/versions/1.0.0", nil)
DeleteTaskPluginVersion(context)
assert.Contains(t, recorder.Body.String(), `"success":true`)
active, err := model.GetTaskPluginVersion(key, "")
require.NoError(t, err)
assert.Equal(t, "2.0.0", active.Version)
runtimePlugin, ok := jsplugin.DefaultRegistry.Get(key)
require.True(t, ok)
assert.Equal(t, "2.0.0", runtimePlugin.Meta.Version)
taskPluginSyncState.Lock()
syncedHash := taskPluginSyncState.hashes[key]
_, hasSyncError := taskPluginSyncState.errors[key]
taskPluginSyncState.Unlock()
assert.Equal(t, "hash-v2", syncedHash)
assert.False(t, hasSyncError)
}
func TestUploadTaskPluginRefreshesRuntimeSyncState(t *testing.T) {
setupTaskPluginControllerTest(t)
key := "upload-sync-probe"
cleanupTaskPluginControllerRuntime(t, key)
source := taskPluginControllerTestSource(key, "1.0.0")
taskPluginSyncState.Lock()
taskPluginSyncState.hashes[key] = "stale-hash"
taskPluginSyncState.errors[key] = "stale compile error"
taskPluginSyncState.Unlock()
body, err := common.Marshal(map[string]any{"source": source})
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", strings.NewReader(string(body)))
context.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(context)
assert.Contains(t, recorder.Body.String(), `"success":true`)
stored, err := model.GetTaskPluginVersion(key, "")
require.NoError(t, err)
taskPluginSyncState.Lock()
syncedHash := taskPluginSyncState.hashes[key]
_, hasSyncError := taskPluginSyncState.errors[key]
taskPluginSyncState.Unlock()
assert.Equal(t, stored.SourceHash, syncedHash)
assert.False(t, hasSyncError)
}
func TestActivateTaskPluginRefreshesRuntimeSyncState(t *testing.T) {
setupTaskPluginControllerTest(t)
key := "activate-sync-probe"
cleanupTaskPluginControllerRuntime(t, key)
v1Source := taskPluginControllerTestSource(key, "1.0.0")
v2Source := taskPluginControllerTestSource(key, "2.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "hash-v1", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "hash-v2", Enabled: true,
}))
_, err := jsplugin.DefaultRegistry.Register(v1Source, jsplugin.Options{Key: key, Version: "1.0.0"})
require.NoError(t, err)
taskPluginSyncState.Lock()
taskPluginSyncState.hashes[key] = "hash-v1"
taskPluginSyncState.errors[key] = "stale compile error"
taskPluginSyncState.Unlock()
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: key}}
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task/"+key+"/activate", strings.NewReader(`{"version":"2.0.0"}`))
context.Request.Header.Set("Content-Type", "application/json")
ActivateTaskPlugin(context)
assert.Contains(t, recorder.Body.String(), `"success":true`)
runtimePlugin, ok := jsplugin.DefaultRegistry.Get(key)
require.True(t, ok)
assert.Equal(t, "2.0.0", runtimePlugin.Meta.Version)
taskPluginSyncState.Lock()
syncedHash := taskPluginSyncState.hashes[key]
_, hasSyncError := taskPluginSyncState.errors[key]
taskPluginSyncState.Unlock()
assert.Equal(t, "hash-v2", syncedHash)
assert.False(t, hasSyncError)
}
func TestSyncTaskPluginsPublishesOneGenerationForWholeBatch(t *testing.T) {
setupTaskPluginControllerTest(t)
firstKey := "batch-sync-first"
secondKey := "batch-sync-second"
cleanupTaskPluginControllerRuntime(t, firstKey)
cleanupTaskPluginControllerRuntime(t, secondKey)
firstSource := taskPluginControllerTestSource(firstKey, "1.0.0")
secondSource := taskPluginControllerTestSource(secondKey, "1.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: firstKey, APIVersion: 1, Version: "1.0.0", Source: firstSource, SourceHash: "first-hash", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: secondKey, APIVersion: 1, Version: "1.0.0", Source: secondSource, SourceHash: "second-hash", Enabled: true,
}))
before := jsplugin.DefaultRegistry.Generation().Number
SyncTaskPluginsOnce()
assert.Equal(t, before+1, jsplugin.DefaultRegistry.Generation().Number)
_, firstRegistered := jsplugin.DefaultRegistry.Get(firstKey)
_, secondRegistered := jsplugin.DefaultRegistry.Get(secondKey)
assert.True(t, firstRegistered)
assert.True(t, secondRegistered)
published := jsplugin.DefaultRegistry.Generation()
SyncTaskPluginsOnce()
assert.Same(t, published, jsplugin.DefaultRegistry.Generation())
}
func TestTaskPluginRuntimeExposesDatabaseRevisionAheadOfLocalGeneration(t *testing.T) {
setupTaskPluginControllerTest(t)
key := "runtime-revision-probe"
cleanupTaskPluginControllerRuntime(t, key)
taskPluginSyncState.Lock()
previousRebuild := taskPluginSyncState.lastRebuild
taskPluginSyncState.Unlock()
t.Cleanup(func() {
taskPluginSyncState.Lock()
taskPluginSyncState.lastRebuild = previousRebuild
taskPluginSyncState.Unlock()
})
v1Source := taskPluginControllerTestSource(key, "1.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0",
Source: v1Source, SourceHash: "runtime-v1", Enabled: true,
}))
require.NoError(t, syncTaskPluginsOnce())
localGeneration := jsplugin.DefaultRegistry.Generation().Number
taskPluginSyncState.Lock()
syncedRevision := taskPluginSyncState.lastRebuild.DatabaseRevision
taskPluginSyncState.Unlock()
v2Source := taskPluginControllerTestSource(key, "2.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "2.0.0",
Source: v2Source, SourceHash: "runtime-v2", Enabled: true,
}))
require.NoError(t, model.ActivateTaskPlugin(key, "2.0.0"))
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/runtime/status", nil)
GetTaskPluginRuntime(context)
var response struct {
Success bool `json:"success"`
Data taskPluginRuntimeStatus `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
assert.Equal(t, localGeneration, response.Data.CurrentGeneration)
assert.NotZero(t, response.Data.GenerationPublishedAt)
assert.NotEqual(t, syncedRevision, response.Data.DatabaseRevision)
assert.Equal(t, "success", response.Data.LastRebuild.Status)
assert.Equal(t, syncedRevision, response.Data.LastRebuild.DatabaseRevision)
assert.Empty(t, response.Data.PluginErrors)
active, ok := jsplugin.DefaultRegistry.Get(key)
require.True(t, ok)
assert.Equal(t, "1.0.0", active.Meta.Version)
}
func TestTaskPluginRuntimeReportsPluginLevelCompileErrors(t *testing.T) {
setupTaskPluginControllerTest(t)
key := "runtime-error-probe"
cleanupTaskPluginControllerRuntime(t, key)
taskPluginSyncState.Lock()
previousRebuild := taskPluginSyncState.lastRebuild
taskPluginSyncState.Unlock()
t.Cleanup(func() {
taskPluginSyncState.Lock()
taskPluginSyncState.lastRebuild = previousRebuild
taskPluginSyncState.Unlock()
})
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0",
Source: "export const meta = {", SourceHash: "broken-source", Enabled: true,
}))
require.NoError(t, syncTaskPluginsOnce())
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/runtime/status", nil)
GetTaskPluginRuntime(context)
var response struct {
Success bool `json:"success"`
Data taskPluginRuntimeStatus `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
assert.Equal(t, "partial", response.Data.LastRebuild.Status)
assert.Equal(t, response.Data.DatabaseRevision, response.Data.LastRebuild.DatabaseRevision)
assert.GreaterOrEqual(t, response.Data.LastRebuild.PluginErrorCount, 1)
assert.NotEmpty(t, response.Data.PluginErrors[key])
_, registered := jsplugin.DefaultRegistry.Get(key)
assert.False(t, registered)
}
func TestTaskPluginRuntimeSurvivesDatabaseSyncFailure(t *testing.T) {
setupTaskPluginControllerTest(t)
key := "runtime-database-failure"
cleanupTaskPluginControllerRuntime(t, key)
taskPluginSyncState.Lock()
previousRebuild := taskPluginSyncState.lastRebuild
taskPluginSyncState.Unlock()
t.Cleanup(func() {
taskPluginSyncState.Lock()
taskPluginSyncState.lastRebuild = previousRebuild
taskPluginSyncState.Unlock()
})
source := taskPluginControllerTestSource(key, "1.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0",
Source: source, SourceHash: "runtime-database-v1", Enabled: true,
}))
require.NoError(t, syncTaskPluginsOnce())
generation := jsplugin.DefaultRegistry.Generation().Number
taskPluginSyncState.Lock()
syncedRevision := taskPluginSyncState.lastRebuild.DatabaseRevision
taskPluginSyncState.Unlock()
sqlDatabase, err := model.DB.DB()
require.NoError(t, err)
require.NoError(t, sqlDatabase.Close())
require.Error(t, syncTaskPluginsOnce())
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/runtime/status", nil)
GetTaskPluginRuntime(context)
var response struct {
Success bool `json:"success"`
Data taskPluginRuntimeStatus `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
assert.Equal(t, generation, response.Data.CurrentGeneration)
assert.Equal(t, syncedRevision, response.Data.DatabaseRevision)
assert.Equal(t, "database snapshot unavailable", response.Data.DatabaseError)
assert.Equal(t, "failed", response.Data.LastRebuild.Status)
assert.Equal(t, syncedRevision, response.Data.LastRebuild.DatabaseRevision)
assert.Contains(t, response.Data.LastRebuild.Error, "sync task plugins")
}
func TestSyncTaskPluginsCachesRejectedDesiredSourceWithoutLosingIncumbent(t *testing.T) {
setupTaskPluginControllerTest(t)
pluginKey := "sync-retained-plugin"
ownerKey := "sync-retained-owner"
cleanupTaskPluginControllerRuntime(t, pluginKey)
cleanupTaskPluginControllerRuntime(t, ownerKey)
v1Source := taskPluginControllerChannelSource(pluginKey, "1.0.0", 9001)
v2Source := taskPluginControllerChannelSource(pluginKey, "2.0.0", 9002)
ownerSource := taskPluginControllerChannelSource(ownerKey, "1.0.0", 9002)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: pluginKey, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "retained-v1", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: ownerKey, APIVersion: 1, Version: "1.0.0", Source: ownerSource, SourceHash: "owner-v1", Enabled: true,
}))
require.NoError(t, syncTaskPluginsOnce())
incumbent, ok := jsplugin.DefaultRegistry.Get(pluginKey)
require.True(t, ok)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: pluginKey, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "retained-v2", Enabled: true,
}))
require.NoError(t, model.ActivateTaskPlugin(pluginKey, "2.0.0"))
require.NoError(t, syncTaskPluginsOnce())
rejectedGeneration := jsplugin.DefaultRegistry.Generation()
for range 2 {
require.NoError(t, syncTaskPluginsOnce())
active, found := jsplugin.DefaultRegistry.Get(pluginKey)
require.True(t, found)
assert.Same(t, incumbent, active)
assert.Equal(t, "2.0.0", jsplugin.DefaultRegistry.OverridePlugins()[pluginKey].Meta.Version)
assert.Same(t, incumbent, jsplugin.DefaultRegistry.ActiveOverridePlugins()[pluginKey])
assert.Contains(t, jsplugin.DefaultRegistry.RoutingErrors()[pluginKey], "channelType 9002 conflicts")
taskPluginSyncState.Lock()
assert.Equal(t, "retained-v2", taskPluginSyncState.hashes[pluginKey])
taskPluginSyncState.Unlock()
assert.Same(t, rejectedGeneration, jsplugin.DefaultRegistry.Generation())
}
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: ownerKey, APIVersion: 1, Version: "2.0.0",
Source: taskPluginControllerChannelSource(ownerKey, "2.0.0", 9003), SourceHash: "owner-v2", Enabled: true,
}))
require.NoError(t, model.ActivateTaskPlugin(ownerKey, "2.0.0"))
require.NoError(t, syncTaskPluginsOnce())
active, ok := jsplugin.DefaultRegistry.Get(pluginKey)
require.True(t, ok)
assert.Equal(t, "2.0.0", active.Meta.Version)
assert.NotContains(t, jsplugin.DefaultRegistry.RoutingErrors(), pluginKey)
}
func TestSyncTaskPluginsPreservesLastCompiledOverrideWhileOverridesAreDisabled(t *testing.T) {
setupTaskPluginControllerTest(t)
key := "sync-disabled-override"
cleanupTaskPluginControllerRuntime(t, key)
v1Source := taskPluginControllerTestSource(key, "1.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "disabled-v1", Enabled: true,
}))
require.NoError(t, syncTaskPluginsOnce())
jsplugin.DefaultRegistry.SetOverrideEnabled(false)
t.Cleanup(func() { jsplugin.DefaultRegistry.SetOverrideEnabled(true) })
disabledGeneration := jsplugin.DefaultRegistry.Generation()
require.NoError(t, syncTaskPluginsOnce())
assert.Same(t, disabledGeneration, jsplugin.DefaultRegistry.Generation())
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "2.0.0",
Source: "export const meta = {", SourceHash: "disabled-v2", Enabled: true,
}))
require.NoError(t, model.ActivateTaskPlugin(key, "2.0.0"))
require.NoError(t, syncTaskPluginsOnce())
assert.Equal(t, "1.0.0", jsplugin.DefaultRegistry.OverridePlugins()[key].Meta.Version)
taskPluginSyncState.Lock()
assert.Equal(t, "disabled-v1", taskPluginSyncState.hashes[key])
assert.NotEmpty(t, taskPluginSyncState.errors[key])
taskPluginSyncState.Unlock()
jsplugin.DefaultRegistry.SetOverrideEnabled(true)
active, ok := jsplugin.DefaultRegistry.Get(key)
require.True(t, ok)
assert.Equal(t, "1.0.0", active.Meta.Version)
}
const dryRunPluginSource = `
export const meta = {apiVersion: 1, key: "dryrun-probe", name: "DryRun", version: "1.0.0", author: {name: "Test"}, models: ["doc-1"], fetchMode: "per_task"};
export function buildSubmitRequest(payload) {
if (!payload || !payload.model) { throw new Error("model required"); }
return {model: payload.model};
}
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
export const native = { info: function(ctx, task) { return "task:" + task.id; } };
`
func runTaskPluginDryRun(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: "dryrun-probe"}}
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task/dryrun-probe/dryrun", strings.NewReader(body))
context.Request.Header.Set("Content-Type", "application/json")
DryRunTaskPlugin(context)
return recorder
}
func TestDryRunTaskPluginExecutesHookAndRendererMember(t *testing.T) {
setupTaskPluginControllerTest(t)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: "dryrun-probe", APIVersion: 1, Version: "1.0.0", Source: dryRunPluginSource, SourceHash: "hash", Enabled: true}))
hookRecorder := runTaskPluginDryRun(t, `{"hook":"buildSubmitRequest","args":[{"model":"doc-1"}]}`)
assert.Contains(t, hookRecorder.Body.String(), `"success":true`)
assert.Contains(t, hookRecorder.Body.String(), `"model":"doc-1"`)
memberRecorder := runTaskPluginDryRun(t, `{"hook":"native","member":"info","args":[{}, {"id":"t-1"}]}`)
assert.Contains(t, memberRecorder.Body.String(), `"success":true`)
assert.Contains(t, memberRecorder.Body.String(), "task:t-1")
}
func TestDryRunTaskPluginReportsUnknownHook(t *testing.T) {
setupTaskPluginControllerTest(t)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: "dryrun-probe", APIVersion: 1, Version: "1.0.0", Source: dryRunPluginSource, SourceHash: "hash", Enabled: true}))
recorder := runTaskPluginDryRun(t, `{"hook":"missingHook"}`)
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), `plugin export \"missingHook\" not found`)
}
func TestDryRunTaskPluginSurfacesBadArgumentErrors(t *testing.T) {
setupTaskPluginControllerTest(t)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: "dryrun-probe", APIVersion: 1, Version: "1.0.0", Source: dryRunPluginSource, SourceHash: "hash", Enabled: true}))
malformedRecorder := runTaskPluginDryRun(t, `{"hook":"buildSubmitRequest","args":[{`)
assert.Contains(t, malformedRecorder.Body.String(), `"success":false`)
rejectedRecorder := runTaskPluginDryRun(t, `{"hook":"buildSubmitRequest","args":[{}]}`)
assert.Contains(t, rejectedRecorder.Body.String(), `"success":false`)
assert.Contains(t, rejectedRecorder.Body.String(), "model required")
}
func TestUploadTaskPluginPreflightConflict(t *testing.T) {
setupTaskPluginControllerTest(t)
enabledFalse := false
tests := []struct {
name string
key string
enabled *bool
force bool
wantSuccess bool
wantError string
}{
{
name: "enabled conflict rejected",
key: "preflight-reject",
wantError: "channelType 50 conflicts",
},
{
name: "force saves despite conflict",
key: "preflight-force",
force: true,
wantSuccess: true,
},
{
name: "disabled skips preflight",
key: "preflight-disabled",
enabled: &enabledFalse,
wantSuccess: true,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
cleanupTaskPluginControllerRuntime(t, testCase.key)
source := taskPluginControllerChannelSource(testCase.key, "1.0.0", 50)
payload := map[string]any{"source": source}
if testCase.enabled != nil {
payload["enabled"] = *testCase.enabled
}
if testCase.force {
payload["force"] = true
}
body, err := common.Marshal(payload)
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", strings.NewReader(string(body)))
context.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(context)
if testCase.wantSuccess {
assert.Contains(t, recorder.Body.String(), `"success":true`)
_, err = model.GetTaskPluginVersion(testCase.key, "")
require.NoError(t, err)
return
}
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), testCase.wantError)
assert.Contains(t, recorder.Body.String(), "kling")
var count int64
require.NoError(t, model.DB.Model(&model.TaskPlugin{}).Where("key = ?", testCase.key).Count(&count).Error)
assert.Zero(t, count)
})
}
}
func TestUploadTaskPluginRejectsMetaViolatingV1Schema(t *testing.T) {
setupTaskPluginControllerTest(t)
cases := []struct {
name string
meta string
expectedError string
}{
{
name: "key with uppercase characters",
meta: `{apiVersion: 1, key: "Bad-Key", name: "Bad", version: "1.0.0", author: {name: "Test"}, models: ["doc-1"], fetchMode: "per_task"}`,
expectedError: "plugin meta key must match",
},
{
name: "version that is not semver",
meta: `{apiVersion: 1, key: "bad-plugin", name: "Bad", version: "one", author: {name: "Test"}, models: ["doc-1"], fetchMode: "per_task"}`,
expectedError: "plugin meta version must be semver",
},
{
name: "unsupported fetch mode",
meta: `{apiVersion: 1, key: "bad-plugin", name: "Bad", version: "1.0.0", author: {name: "Test"}, models: ["doc-1"], fetchMode: "sometimes"}`,
expectedError: "plugin meta fetchMode must be per_task or batch",
},
{
name: "empty model list",
meta: `{apiVersion: 1, key: "bad-plugin", name: "Bad", version: "1.0.0", author: {name: "Test"}, models: [], fetchMode: "per_task"}`,
expectedError: "plugin meta models must contain at least one model",
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
source := "export const meta = " + testCase.meta + `;
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
body, err := common.Marshal(map[string]any{"source": source})
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", strings.NewReader(string(body)))
context.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(context)
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), testCase.expectedError)
var count int64
require.NoError(t, model.DB.Model(&model.TaskPlugin{}).Count(&count).Error)
assert.Zero(t, count)
})
}
}
func TestDeletePureFactoryPluginIsRejected(t *testing.T) {
setupTaskPluginControllerTest(t)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: "kling"}, {Key: "version", Value: "1.0.0"}}
context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/kling/versions/1.0.0", nil)
DeleteTaskPluginVersion(context)
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), "factory plugins cannot be deleted")
_, ok := jsplugin.DefaultRegistry.Get("kling")
assert.True(t, ok)
}
func TestUploadTaskPluginSourceSha256(t *testing.T) {
setupTaskPluginControllerTest(t)
tests := []struct {
name string
key string
withHash bool
hash string
wantSuccess bool
wantError string
}{
{
name: "matching hash succeeds",
key: "sha256-match",
withHash: true,
wantSuccess: true,
},
{
name: "mismatching hash rejected",
key: "sha256-mismatch",
withHash: true,
hash: "deadbeef",
wantError: "plugin source sha256 mismatch",
},
{
name: "absent field unchanged",
key: "sha256-absent",
wantSuccess: true,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
cleanupTaskPluginControllerRuntime(t, testCase.key)
source := taskPluginControllerTestSource(testCase.key, "1.0.0")
payload := map[string]any{"source": source}
if testCase.withHash {
hash := testCase.hash
if hash == "" {
hash = " " + strings.ToUpper(fmt.Sprintf("%x", sha256.Sum256([]byte(source)))) + " "
}
payload["sourceSha256"] = hash
}
body, err := common.Marshal(payload)
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", strings.NewReader(string(body)))
context.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(context)
if testCase.wantSuccess {
assert.Contains(t, recorder.Body.String(), `"success":true`)
_, err = model.GetTaskPluginVersion(testCase.key, "")
require.NoError(t, err)
return
}
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), testCase.wantError)
var count int64
require.NoError(t, model.DB.Model(&model.TaskPlugin{}).Where("key = ?", testCase.key).Count(&count).Error)
assert.Zero(t, count)
})
}
}
func setupTaskPluginMarketplaceSourcesTest(t *testing.T) {
t.Helper()
setupTaskPluginControllerTest(t)
originalMap := common.OptionMap
common.OptionMapRWMutex.Lock()
common.OptionMap = map[string]string{}
common.OptionMapRWMutex.Unlock()
t.Cleanup(func() {
common.OptionMapRWMutex.Lock()
common.OptionMap = originalMap
common.OptionMapRWMutex.Unlock()
})
}
func TestGetTaskPluginMarketplaceSourcesDefaultWhenUnset(t *testing.T) {
setupTaskPluginMarketplaceSourcesTest(t)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/marketplace/sources", nil)
GetTaskPluginMarketplaceSources(context)
var response struct {
Success bool `json:"success"`
Data []setting.TaskPluginMarketplaceSource `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
require.Equal(t, []setting.TaskPluginMarketplaceSource{
{Name: "Official", IndexURL: "https://www.newapi.ai/api/v1/plugins/index.json"},
{Name: "GitHub", IndexURL: "https://raw.githubusercontent.com/QuantumNous/new-api-plugins/main/index.json"},
}, response.Data)
var count int64
require.NoError(t, model.DB.Model(&model.Option{}).Where("key = ?", setting.TaskPluginMarketplaceSourcesKey).Count(&count).Error)
assert.Zero(t, count)
}
func TestUpdateTaskPluginMarketplaceSourcesRoundTrip(t *testing.T) {
setupTaskPluginMarketplaceSourcesTest(t)
payload := []setting.TaskPluginMarketplaceSource{
{Name: "Mirror", IndexURL: "https://example.com/plugins/index.json"},
{Name: "Official", IndexURL: "https://www.newapi.ai/api/v1/plugins/index.json"},
}
body, err := common.Marshal(payload)
require.NoError(t, err)
putRecorder := httptest.NewRecorder()
putContext, _ := gin.CreateTestContext(putRecorder)
putContext.Request = httptest.NewRequest(http.MethodPut, "/api/plugin/task/marketplace/sources", strings.NewReader(string(body)))
putContext.Request.Header.Set("Content-Type", "application/json")
UpdateTaskPluginMarketplaceSources(putContext)
assert.Contains(t, putRecorder.Body.String(), `"success":true`)
getRecorder := httptest.NewRecorder()
getContext, _ := gin.CreateTestContext(getRecorder)
getContext.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/marketplace/sources", nil)
GetTaskPluginMarketplaceSources(getContext)
var response struct {
Success bool `json:"success"`
Data []setting.TaskPluginMarketplaceSource `json:"data"`
}
require.NoError(t, common.Unmarshal(getRecorder.Body.Bytes(), &response))
require.True(t, response.Success)
assert.Equal(t, payload, response.Data)
}
func TestUpdateTaskPluginMarketplaceSourcesValidation(t *testing.T) {
setupTaskPluginMarketplaceSourcesTest(t)
tests := []struct {
name string
body string
wantErr string
}{
{
name: "empty name",
body: `[{"name":"","index_url":"https://example.com/index.json"}]`,
wantErr: "marketplace source name is required",
},
{
name: "invalid URL",
body: `[{"name":"Local","index_url":"not-a-url"}]`,
wantErr: "marketplace source index_url must be an absolute http(s) URL",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPut, "/api/plugin/task/marketplace/sources", strings.NewReader(testCase.body))
context.Request.Header.Set("Content-Type", "application/json")
UpdateTaskPluginMarketplaceSources(context)
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), testCase.wantErr)
assert.Empty(t, common.OptionMap[setting.TaskPluginMarketplaceSourcesKey])
var count int64
require.NoError(t, model.DB.Model(&model.Option{}).Where("key = ?", setting.TaskPluginMarketplaceSourcesKey).Count(&count).Error)
assert.Zero(t, count)
})
}
}