mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-08-31 02:41:34 +00:00
1149 lines
45 KiB
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)
|
|
})
|
|
}
|
|
}
|