package plugin
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"clearlove/internal/config"
"clearlove/internal/database"
"clearlove/internal/models"
)
// setupAppEnv 准备带数据库的应用型插件测试环境
func setupAppEnv(t *testing.T) string {
t.Helper()
root := t.TempDir()
dataDir := filepath.Join(root, "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
config.Cfg = config.Config{
DataDir: dataDir,
UploadDir: filepath.Join(root, "uploads"),
DBType: "sqlite",
SQLitePath: filepath.Join(dataDir, "clearlove.db"),
Installed: true,
Secret: "test-secret",
}
if err := database.Connect(); err != nil {
t.Fatalf("数据库连接失败: %v", err)
}
t.Cleanup(func() {
if database.DB != nil {
_ = database.DB.Close()
database.DB = nil
}
})
if err := database.Migrate(); err != nil {
t.Fatalf("数据库迁移失败: %v", err)
}
return dataDir
}
func writeAppPlugin(t *testing.T, dataDir, dir, manifest, script string) {
t.Helper()
d := filepath.Join(dataDir, "plugins", dir)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "main.js"), []byte(script), 0o644); err != nil {
t.Fatal(err)
}
}
const demoManifest = `{
"kind": "app",
"name": "demo",
"slug": "demo",
"version": "1.0.0",
"runtime": { "engine": "js", "entry": "main.js", "timeout_ms": 800 },
"permissions": ["db", "cache", "settings.read"]
}`
const demoScript = `
var T = clv.db.table("log");
function setup() {
clv.table("log", { user_id: "int", day: "string" }, { unique: [["user_id", "day"]] });
clv.route("GET", "/", "pageHome", { auth: "none" });
clv.route("POST", "/do", "doPost", { auth: "user" });
clv.adminPage("/", "adminHome", { perm: "plugins" });
clv.adminMenu({ label: "演示管理", path: "/", perm: "plugins", order: 10 });
clv.slot("footer_html", "footSlot");
clv.on("post_created", "onPost");
clv.filter("filter.content", 10, "censor");
clv.job("tick", "1h", "onTick");
}
function pageHome(ctx) { return { body: "
hi
" }; }
function doPost(ctx) { return { json: { ok: true } }; }
function adminHome(ctx) { return { body: "admin" }; }
function footSlot(data) { return "slot"; }
function onPost(e) { clv.cache.set("last_post", e.post_id, 60); }
function censor(s) { return s.replace(/广告/g, "***"); }
function onTick(ctx) { clv.log.info("tick"); }
function __check() { return clv.cache.get("last_post") || 0; }
function __badPerm() {
try { clv.setting.set("hack", "1"); return "allowed"; } catch (e) { return "denied"; }
}
function __badSQL() {
try { clv.db.exec("DELETE FROM posts WHERE id=0"); return "allowed"; } catch (e) { return "denied"; }
}
function __loop() { while (true) {} }
`
// TestAppPluginLifecycle 覆盖应用型插件的完整能力:注册、建表、slot、过滤器、事件、任务
func TestAppPluginLifecycle(t *testing.T) {
dataDir := setupAppEnv(t)
writeAppPlugin(t, dataDir, "demo", demoManifest, demoScript)
SetEnabled("demo", true)
if err := LoadApp("demo"); err != nil {
t.Fatalf("加载应用型插件失败: %v", err)
}
defer func() {
UnloadApp("demo")
SetEnabled("demo", false)
}()
app := GetApp("demo")
if app == nil {
t.Fatal("插件未登记到注册表")
}
if app.Plugin.SlugOf() != "demo" {
t.Fatalf("slug 解析异常: %q", app.Plugin.SlugOf())
}
// 1) 各类扩展点均已注册
if len(app.Routes) != 2 {
t.Fatalf("路由数 = %d,期望 2", len(app.Routes))
}
if len(app.Pages) != 1 || len(AdminMenus()) != 1 {
t.Fatalf("后台页面/菜单注册异常: pages=%d menus=%d", len(app.Pages), len(AdminMenus()))
}
if len(EventHooks("post_created")) != 1 {
t.Fatal("事件未注册")
}
if len(FilterHooks("filter.content")) != 1 {
t.Fatal("过滤器未注册")
}
if len(app.Jobs) != 1 {
t.Fatal("定时任务未注册")
}
// 2) 数据表已创建(pl__log)
if _, err := database.DB.Exec("INSERT INTO pl_demo_log(user_id,day) VALUES(1,'2026-01-01')"); err != nil {
t.Fatalf("插件数据表不可写: %v", err)
}
// 3) Slot 输出包含插件内容
if got := SlotHTML("footer_html", map[string]any{}); !strings.Contains(got, "demo-slot") {
t.Fatalf("Slot 未生效: %q", got)
}
// 4) 过滤器生效
if got := ApplyFilterStr("filter.content", "这是一条广告"); got != "这是一条***" {
t.Fatalf("过滤器未生效: %q", got)
}
// 5) 事件回调(异步)确实执行
Emit("post_created", map[string]any{"post_id": 42})
deadline := time.Now().Add(2 * time.Second)
var last any
for time.Now().Before(deadline) {
v, err := app.Worker.Do(context.Background(), func(rt *jsRuntime) (any, error) {
return rt.callFn("__check")
})
if err != nil {
t.Fatalf("脚本调用失败: %v", err)
}
last = v
if fmt.Sprint(v) == "42" {
break
}
time.Sleep(50 * time.Millisecond)
}
if fmt.Sprint(last) != "42" {
t.Fatalf("事件回调未执行,cache = %v", last)
}
}
// TestAppPluginPermission 未声明的权限必须被拒绝
func TestAppPluginPermission(t *testing.T) {
dataDir := setupAppEnv(t)
writeAppPlugin(t, dataDir, "demo", demoManifest, demoScript)
SetEnabled("demo", true)
if err := LoadApp("demo"); err != nil {
t.Fatalf("加载失败: %v", err)
}
defer func() {
UnloadApp("demo")
SetEnabled("demo", false)
}()
app := GetApp("demo")
// settings.write 未在 permissions 中声明
v, err := app.Worker.Do(context.Background(), func(rt *jsRuntime) (any, error) {
return rt.callFn("__badPerm")
})
if err != nil {
t.Fatalf("调用失败: %v", err)
}
if fmt.Sprint(v) != "denied" {
t.Fatalf("未声明的权限未被拒绝: %v", v)
}
// 写内核表必须被拒绝(只允许 pl__ 前缀)
v2, err := app.Worker.Do(context.Background(), func(rt *jsRuntime) (any, error) {
return rt.callFn("__badSQL")
})
if err != nil {
t.Fatalf("调用失败: %v", err)
}
if fmt.Sprint(v2) != "denied" {
t.Fatalf("内核表写操作未被拒绝: %v", v2)
}
}
// TestAppPluginTimeoutInterrupt 死循环必须被超时中断,且中断后插件仍可继续服务
func TestAppPluginTimeoutInterrupt(t *testing.T) {
dataDir := setupAppEnv(t)
writeAppPlugin(t, dataDir, "demo", demoManifest, demoScript)
SetEnabled("demo", true)
if err := LoadApp("demo"); err != nil {
t.Fatalf("加载失败: %v", err)
}
defer func() {
UnloadApp("demo")
SetEnabled("demo", false)
}()
app := GetApp("demo")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
_, err := app.Worker.Do(ctx, func(rt *jsRuntime) (any, error) {
return rt.callFn("__loop")
})
if err == nil {
t.Fatal("死循环未被超时中断")
}
// 中断后虚拟机应恢复可用
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel2()
if _, err := app.Worker.Do(ctx2, func(rt *jsRuntime) (any, error) {
return rt.callFn("__check")
}); err != nil {
t.Fatalf("超时中断后插件不可用: %v", err)
}
}
// TestDeclarativeUnaffected 声明式插件仍然按原逻辑工作(回归)
func TestDeclarativeUnaffected(t *testing.T) {
dataDir := setupAppEnv(t)
writePlugin(t, dataDir, "lite", `{
"name":"lite","version":"1.0.0",
"hooks":[{"hook":"footer_html","type":"html","html":"lite"},
{"hook":"content_filter","type":"filter","match":"坏","replace":"好"}]
}`, "")
SetEnabled("lite", true)
defer SetEnabled("lite", false)
if got := CallHTML("footer_html"); !strings.Contains(got, "lite") {
t.Fatalf("声明式 HTML 钩子失效: %q", got)
}
if got := ApplyFilterStr("filter.content", "坏人"); got != "好人" {
t.Fatalf("声明式过滤器失效: %q", got)
}
if models.QueryInt("SELECT 1") != 1 {
t.Fatal("数据库不可用")
}
}