Files
new-api/controller/task_plugin_test.go
Calcium-Ion 128089fa7d feat(i18n): translate web console messages in the frontend and AI-client errors in the backend (#7712)
* feat(i18n): translate web console messages in the frontend and AI-client errors in the backend

Chinese text was hard-coded in API messages, stored log content, emails and
server logs, so it reached users of every interface language.

Text is now translated where its reader is:

- Web console APIs return the English source text as message_key with
  message_params, and the web console translates it in all seven languages.
  message keeps the English text for other API callers.
- Errors returned to AI clients, emails and notifications are translated by
  the backend (en, zh-CN, zh-TW) in the language saved on the account, then
  the language of the request's Accept-Language header.
- A reader who states no language still gets the original Chinese text of the
  messages that were Chinese before, word for word. Gateways that use another
  new-api as their upstream match that text, for example to disable a channel
  whose account ran out of quota.
- Usage, top-up and system log content is stored as content_parts (key and
  params) and rendered in the viewer's language. Task fail reasons and refund
  reasons written by the gateway are English keys. Rows written earlier are
  shown as before.
- Server logs are English.

Also:

- The saved interface language zhTW, and zh-HK, zh-MO and zh-Hant, now select
  Traditional Chinese in the backend instead of Simplified Chinese.
- The built-in group descriptions are translated; descriptions an
  administrator saved are shown as written.
- .agents/rules/i18n.md records the conventions. Tests in i18n/ fail on a
  non-English string literal, on a web console message without translations
  and on a locale key without a constant.

* feat(i18n): print server logs in Chinese when LOG_LANGUAGE asks for it

Server log lines are English by default. With LOG_LANGUAGE=zh-CN, the lines
that were Chinese before print their original Chinese text.

common.LogText formats a log line from its English format, which is also the
key of its Chinese text in common/log_text.zh-CN.json. A line without an entry
prints in English. go vet checks the arguments of every call, and
TestServerLogTexts checks that each entry still matches a call and has the
same verbs as its key.

* docs: list LOG_LANGUAGE in the docker compose example

* feat(i18n): translate every server log line and add DEFAULT_LANGUAGE

Every line the gateway prints goes through common.LogText. With
DEFAULT_LANGUAGE=zh-CN, the lines that have a Chinese text in
common/log_text.zh-CN.json print it and the others print English.

DEFAULT_LANGUAGE replaces LOG_LANGUAGE. It also sets the language of
backend messages, emails and notifications for a reader who states no
language: en, zh-CN or zh-TW, English when unset. The list of messages
that stayed Chinese for such readers is removed.

Review fixes:
- Channel test logs store the token name "Model test"; the console shows
  it in the interface language.
- Task fail reasons written by the gateway are fixed sentences the console
  translates. The HTTP status and the upstream detail go to the server log
  when a polled task is failed.
- The auto-disable reason of a slow channel is written in DEFAULT_LANGUAGE.

Also:
- Drop the repository-scanning lint tests, the i18n rules file and its
  read gate. AGENTS.md keeps the conventions in four lines.
- Payment error toasts keep their previous text and only add the
  translated message key.
- Remove guards and helpers that no caller needs.

* fix(i18n): address review on language lookup, notices and error responses

- Relay requests no longer load the user a second time to find the
  language: the settings TokenAuth put into the request are used as they
  are, also when they hold no language.
- Upstream model update notices are rendered in each recipient's saved
  language, DEFAULT_LANGUAGE when none.
- /api/usage/token and /api/log/token answer with backend-translated
  messages again, with the keys and status codes used before this PR.
  Their callers are scripts, not the web console.
- Webhook notifications fill {{value}} placeholders like the other
  senders. Running the rendered text through Sprintf appended
  %!(EXTRA ...) and broke %2F in links.
- RootUserLanguage reads the setting of the root user it already loaded.
- zh_TW and zh_HK are read as Traditional Chinese.
- Add common.ApiErrorStatus for error responses under another HTTP status
  and use it where those responses were built by hand.
- Remove middleware.GetLanguage, which had no callers.
2026-10-09 21:39:51 +08:00

1355 lines
57 KiB
Go

package controller
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"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())
gatewaySetting := `{"task_extend_plugin_keys":["lifecycle-only","other"]}`
gateway := model.Channel{Type: constant.ChannelTypeNewAPI, Status: common.ChannelStatusEnabled, Name: "gateway", Models: "doc,gpt", Group: "default", BaseURL: &baseURL, Setting: &gatewaySetting}
require.NoError(t, gateway.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`)
assert.Contains(t, recorder.Body.String(), `"disabled_channels":1`)
assert.Contains(t, recorder.Body.String(), `"unbound_channels":1`)
updated, err := model.GetChannelById(channel.Id, true)
require.NoError(t, err)
assert.Equal(t, common.ChannelStatusManuallyDisabled, updated.Status)
updatedGateway, err := model.GetChannelById(gateway.Id, true)
require.NoError(t, err)
assert.Equal(t, common.ChannelStatusEnabled, updatedGateway.Status, "a gateway channel keeps serving its other traffic")
assert.Equal(t, []string{"other"}, updatedGateway.GetSetting().TaskExtendPluginKeys, "only the disabled plugin is unbound")
}
// klingFactoryVersion returns the version declared in the embedded kling factory
// manifest so tests do not hardcode a value that moves with every plugin release.
func klingFactoryVersion(t *testing.T) string {
t.Helper()
factorySource, err := plugins.Source("kling")
require.NoError(t, err)
match := regexp.MustCompile(`version:\s*"([^"]+)"`).FindStringSubmatch(factorySource)
require.Len(t, match, 2, "kling factory manifest must declare a version")
return match[1]
}
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 TestDisableFactoryOverrideRowSuppressesBothLayers(t *testing.T) {
setupTaskPluginFactoryDisableTest(t)
factorySource, err := plugins.Source("kling")
require.NoError(t, err)
factoryVersion := klingFactoryVersion(t)
overrideSource := strings.Replace(factorySource, `version: "`+factoryVersion+`"`, `version: "`+factoryVersion+`-test-factory-status"`, 1)
require.NotEqual(t, factorySource, overrideSource, "factory version marker must be found in kling source")
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: model.LongText(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.Equal(t, []string{"kling"}, 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", item.RuntimeStatus)
assert.False(t, taskPluginOptionsHasKey(t, "kling"))
_, ok := jsplugin.DefaultRegistry.Get("kling")
assert.False(t, ok, "factory built-in must not keep serving after the plugin is switched off")
recorder = postTaskPluginStatus(t, "kling", "", `{"enabled":true}`)
assert.Contains(t, recorder.Body.String(), `"success":true`)
assert.Empty(t, setting.GetTaskPluginDisabledFactoryKeys())
row, err = model.GetTaskPluginVersion("kling", "")
require.NoError(t, err)
assert.True(t, row.Enabled)
got, ok := jsplugin.DefaultRegistry.Get("kling")
require.True(t, ok)
assert.Equal(t, loaded.Meta.Version, got.Meta.Version)
assert.Equal(t, "registered", listTaskPluginItem(t, "kling").RuntimeStatus)
// Regression: with every kling layer off, a plugin whose model differs from
// a built-in kling model only by case must upload without a routing conflict.
recorder = postTaskPluginStatus(t, "kling", "", `{"enabled":false}`)
require.Contains(t, recorder.Body.String(), `"success":true`)
cleanupTaskPluginControllerRuntime(t, "kling-shadow")
const shadowSource = `
export const meta = {apiVersion: 1, key: "kling-shadow", name: "Shadow", version: "1.0.0", author: {name: "Test"}, models: ["KLING-V1"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
body, err := common.Marshal(map[string]any{"source": shadowSource})
require.NoError(t, err)
uploadRecorder := httptest.NewRecorder()
uploadContext, _ := gin.CreateTestContext(uploadRecorder)
uploadContext.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", strings.NewReader(string(body)))
uploadContext.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(uploadContext)
assert.Contains(t, uploadRecorder.Body.String(), `"success":true`)
_, ok = jsplugin.DefaultRegistry.Get("kling-shadow")
assert.True(t, ok)
}
// The shipped alibaba plugin is already larger than a MySQL TEXT column
// (64 KiB), so storing it through the model path the upload endpoint uses and
// reading it back unchanged proves the source and icon columns are not
// truncated. Set TEST_TASK_DB_DIALECT plus TEST_MYSQL_DSN or TEST_POSTGRES_DSN
// to run the same contract against a real MySQL or PostgreSQL server.
func TestTaskPluginSourceAboveMySQLTextLimitRoundTrips(t *testing.T) {
database, _ := openTaskDialectDatabase(t, &model.TaskPlugin{})
originalDB := model.DB
model.DB = database
t.Cleanup(func() { model.DB = originalDB })
source, err := plugins.Source("alibaba")
require.NoError(t, err)
require.Greater(t, len(source), 65535, "fixture must exceed the MySQL TEXT limit")
icon := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(strings.Repeat(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`, 2048)))
require.Greater(t, len(icon), 65535, "icon fixture must exceed the MySQL TEXT limit")
plugin := model.TaskPlugin{Key: "alibaba", APIVersion: 1, Version: "roundtrip", Source: model.LongText(source), SourceHash: "hash", Enabled: true}
require.NoError(t, model.SaveTaskPlugin(&plugin))
// Re-saving the same version attaches the icon through the update path.
withIcon := model.TaskPlugin{Key: "alibaba", APIVersion: 1, Version: "roundtrip", Source: model.LongText(source), SourceHash: "hash", Icon: model.LongText(icon), Enabled: true}
require.NoError(t, model.SaveTaskPlugin(&withIcon))
stored, err := model.GetTaskPluginVersion("alibaba", "roundtrip")
require.NoError(t, err)
assert.Equal(t, source, string(stored.Source))
assert.Equal(t, icon, string(stored.Icon))
// The 30-second sync poll must not pull these payloads; it reads hashes
// and fetches source only for rows it has to recompile.
snapshot, err := model.GetTaskPluginSyncSnapshot()
require.NoError(t, err)
require.Len(t, snapshot.Plugins, 1)
assert.Equal(t, stored.Id, snapshot.Plugins[0].Id)
assert.Equal(t, "hash", snapshot.Plugins[0].SourceHash)
assert.Empty(t, snapshot.Plugins[0].Source)
assert.Empty(t, snapshot.Plugins[0].Icon)
loaded, err := model.GetTaskPluginSource(snapshot.Plugins[0].Id)
require.NoError(t, err)
assert.Equal(t, source, string(loaded))
}
func TestUploadTaskPluginAcceptsSourcesUpToEightMiB(t *testing.T) {
setupTaskPluginControllerTest(t)
cleanupTaskPluginControllerRuntime(t, "large-source")
upload := func(source string) *httptest.ResponseRecorder {
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", bytes.NewReader(body))
context.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(context)
return recorder
}
// A trailing comment pads a valid plugin past the former 1 MiB cap.
accepted := taskPluginControllerTestSource("large-source", "1.0.0") + "// " + strings.Repeat("x", 2<<20) + "\n"
recorder := upload(accepted)
require.Contains(t, recorder.Body.String(), `"success":true`)
stored, err := model.GetTaskPluginVersion("large-source", "1.0.0")
require.NoError(t, err)
assert.Equal(t, accepted, string(stored.Source))
rejected := upload(accepted + strings.Repeat("x", 6<<20))
assert.Contains(t, rejected.Body.String(), `"success":false`)
assert.Contains(t, rejected.Body.String(), "plugin source exceeds 8 MiB")
_, err = model.GetTaskPluginVersion("large-source", "2.0.0")
assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
}
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 TestGetTaskPluginOptionsIncludesDescriptionUsageSchemaIconBaseURLAndChannelTypes(t *testing.T) {
setupTaskPluginControllerTest(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"},
description: {en: "Video generation via the vendor API", zh: "通过厂商接口生成视频"},
icon: "text:UO", baseUrl: "http://localhost:9000/",
channelTypes: [1990, 1991],
upstreams: ["vendor", "new_api"],
models: ["usage-options-model"], fetchMode: "per_task",
usageSchema: {seconds: {type: "number", unit: "second", description: "Video generation unit price"}},
usageProfiles: [{models:["usage-options-model"],schema:{image_count:{type:"number",unit:"count"}}}]
};
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"`
Description jsplugin.LocalizedText `json:"description"`
Icon string `json:"icon"`
BaseURL string `json:"baseUrl"`
ChannelTypes []int `json:"channelTypes"`
Upstreams []string `json:"upstreams"`
UsageSchema map[string]jsplugin.UsageFieldSchema `json:"usageSchema"`
UsageProfiles []jsplugin.UsageProfile `json:"usageProfiles"`
} `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, jsplugin.LocalizedText{"en": "Video generation via the vendor API", "zh": "通过厂商接口生成视频"}, option.Description)
assert.Equal(t, "second", option.UsageSchema["seconds"].Unit)
assert.Equal(t, "Video generation unit price", option.UsageSchema["seconds"].Description["en"])
require.Len(t, option.UsageProfiles, 1)
assert.Equal(t, []string{"usage-options-model"}, option.UsageProfiles[0].Models)
assert.Equal(t, "count", option.UsageProfiles[0].Schema["image_count"].Unit)
assert.Equal(t, "text:UO", option.Icon)
assert.Equal(t, []int{1990, 1991}, option.ChannelTypes)
assert.Equal(t, []string{"vendor", "new_api"}, option.Upstreams, "the New API channel form lists only plugins declaring new_api")
assert.Equal(t, "http://localhost:9000", option.BaseURL, "the drawer prefills the normalized plugin default")
return
}
t.Fatal("task plugin option not found")
}
func TestDeleteActiveOverrideFallsBackToFactoryAndDeletesRecord(t *testing.T) {
setupTaskPluginControllerTest(t)
factorySource, err := plugins.Source("kling")
require.NoError(t, err)
factoryVersion := klingFactoryVersion(t)
overrideSource := strings.Replace(factorySource, `version: "`+factoryVersion+`"`, `version: "`+factoryVersion+`-test-override"`, 1)
require.NotEqual(t, factorySource, overrideSource, "factory version marker must be found in kling source")
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: model.LongText(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: model.LongText(v1Source), SourceHash: "hash-v1", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "2.0.0", Source: model.LongText(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: model.LongText(v1Source), SourceHash: "hash-v1", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "2.0.0", Source: model.LongText(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: model.LongText(firstSource), SourceHash: "first-hash", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: secondKey, APIVersion: 1, Version: "1.0.0", Source: model.LongText(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: model.LongText(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: model.LongText(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: model.LongText(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: model.LongText(v1Source), SourceHash: "retained-v1", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: ownerKey, APIVersion: 1, Version: "1.0.0", Source: model.LongText(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: model.LongText(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: model.LongText(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)
}
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 TestUploadTaskPluginReportsUnknownMetaField(t *testing.T) {
body, err := common.Marshal(map[string]any{
"source": `export const meta = { futureField: true };`,
})
require.NoError(t, err)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Request.Header.Set("Accept-Language", "zh-CN")
UploadTaskPlugin(c)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"`
Message string `json:"message"`
MessageKey string `json:"message_key"`
MessageParams map[string]any `json:"message_params"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.False(t, response.Success)
assert.Equal(t, `Plugin metadata contains an unknown field "futureField". If this plugin was downloaded from the official marketplace, it may require a newer version of new-api. Try updating new-api and installing the plugin again.`, response.Message)
assert.Equal(t, `Plugin metadata contains an unknown field "{{field}}". If this plugin was downloaded from the official marketplace, it may require a newer version of new-api. Try updating new-api and installing the plugin again.`, response.MessageKey)
assert.Equal(t, map[string]any{"field": "futureField"}, response.MessageParams)
}
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) {
for _, query := range []string{"", "?force=true"} {
t.Run(query, func(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"+query, 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)
})
}
}
func TestTaskPluginDisplayOrderAndMetadata(t *testing.T) {
setupTaskPluginControllerTest(t)
const website = "https://example.com/plugins"
keys := []string{"display-low", "display-zero", "display-beta", "display-alpha"}
priorities := []int{-10, 0, 50, 50}
for i, key := range keys {
source := strings.Replace(taskPluginControllerTestSource(key, "1.0.0"), "apiVersion: 1,", fmt.Sprintf("apiVersion: 1, sortPriority: %d, website: %q,", priorities[i], website), 1)
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
cleanupTaskPluginControllerRuntime(t, key)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: key, Version: "1.0.0", APIVersion: 1, Source: model.LongText(source), Enabled: true}))
}
for _, endpoint := range []string{"list", "options"} {
t.Run(endpoint, func(t *testing.T) {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/", nil)
var metas []jsplugin.Meta
if endpoint == "list" {
ListTaskPlugins(context)
var response struct {
Success bool
Data []taskPluginListItem
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
for _, item := range response.Data {
metas = append(metas, item.Meta)
}
} else {
GetTaskPluginOptions(context)
var response struct {
Success bool
Data []jsplugin.Meta
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
metas = response.Data
}
var ordered []string
for _, meta := range metas {
if strings.HasPrefix(meta.Key, "display-") {
ordered = append(ordered, meta.Key)
assert.Equal(t, website, meta.Website)
}
}
assert.Equal(t, []string{"display-alpha", "display-beta", "display-zero", "display-low"}, ordered)
})
}
}
func TestUploadTaskPluginStoresSidecarIconAndServesIt(t *testing.T) {
setupTaskPluginControllerTest(t)
const key = "icon-sidecar"
source := taskPluginControllerTestSource(key, "1.0.0")
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
svgIcon := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4"/></svg>`))
upload := func(icon string) *httptest.ResponseRecorder {
body, err := common.Marshal(map[string]any{"source": source, "icon": icon})
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", bytes.NewReader(body))
context.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(context)
return recorder
}
rejected := upload("data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>1</script></svg>`)))
assert.Contains(t, rejected.Body.String(), `"success":false`)
assert.Contains(t, rejected.Body.String(), "script")
_, err := model.GetTaskPluginVersion(key, "1.0.0")
require.ErrorIs(t, err, gorm.ErrRecordNotFound, "a rejected icon must not store the plugin")
accepted := upload(svgIcon)
require.Contains(t, accepted.Body.String(), `"success":true`)
assert.Contains(t, accepted.Body.String(), `"has_icon":true`)
assert.NotContains(t, accepted.Body.String(), "base64,", "icon bytes never travel inside detail JSON")
stored, err := model.GetTaskPluginVersion(key, "1.0.0")
require.NoError(t, err)
assert.Equal(t, svgIcon, string(stored.Icon))
item := listTaskPluginItem(t, key)
assert.True(t, item.HasIcon)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: key}}
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/"+key+"/icon", nil)
GetTaskPluginIcon(context)
require.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "image/svg+xml", recorder.Header().Get("Content-Type"))
assert.Equal(t, "nosniff", recorder.Header().Get("X-Content-Type-Options"))
assert.Contains(t, recorder.Body.String(), "<circle")
missing := httptest.NewRecorder()
context, _ = gin.CreateTestContext(missing)
context.Params = gin.Params{{Key: "key", Value: "no-such-plugin"}}
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/no-such-plugin/icon", nil)
GetTaskPluginIcon(context)
assert.Equal(t, http.StatusNotFound, missing.Code)
}