clearlove2.1
1package plugin
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "clearlove/internal/config"
13 "clearlove/internal/database"
14 "clearlove/internal/models"
15)
16
17// setupAppEnv 准备带数据库的应用型插件测试环境
18func setupAppEnv(t *testing.T) string {
19 t.Helper()
20 root := t.TempDir()
21 dataDir := filepath.Join(root, "data")
22 if err := os.MkdirAll(dataDir, 0o755); err != nil {
23 t.Fatal(err)
24 }
25 config.Cfg = config.Config{
26 DataDir: dataDir,
27 UploadDir: filepath.Join(root, "uploads"),
28 DBType: "sqlite",
29 SQLitePath: filepath.Join(dataDir, "clearlove.db"),
30 Installed: true,
31 Secret: "test-secret",
32 }
33 if err := database.Connect(); err != nil {
34 t.Fatalf("数据库连接失败: %v", err)
35 }
36 t.Cleanup(func() {
37 if database.DB != nil {
38 _ = database.DB.Close()
39 database.DB = nil
40 }
41 })
42 if err := database.Migrate(); err != nil {
43 t.Fatalf("数据库迁移失败: %v", err)
44 }
45 return dataDir
46}
47
48func writeAppPlugin(t *testing.T, dataDir, dir, manifest, script string) {
49 t.Helper()
50 d := filepath.Join(dataDir, "plugins", dir)
51 if err := os.MkdirAll(d, 0o755); err != nil {
52 t.Fatal(err)
53 }
54 if err := os.WriteFile(filepath.Join(d, "plugin.json"), []byte(manifest), 0o644); err != nil {
55 t.Fatal(err)
56 }
57 if err := os.WriteFile(filepath.Join(d, "main.js"), []byte(script), 0o644); err != nil {
58 t.Fatal(err)
59 }
60}
61
62const demoManifest = `{
63 "kind": "app",
64 "name": "demo",
65 "slug": "demo",
66 "version": "1.0.0",
67 "runtime": { "engine": "js", "entry": "main.js", "timeout_ms": 800 },
68 "permissions": ["db", "cache", "settings.read"]
69}`
70
71const demoScript = `
72var T = clv.db.table("log");
73function setup() {
74 clv.table("log", { user_id: "int", day: "string" }, { unique: [["user_id", "day"]] });
75 clv.route("GET", "/", "pageHome", { auth: "none" });
76 clv.route("POST", "/do", "doPost", { auth: "user" });
77 clv.adminPage("/", "adminHome", { perm: "plugins" });
78 clv.adminMenu({ label: "演示管理", path: "/", perm: "plugins", order: 10 });
79 clv.slot("footer_html", "footSlot");
80 clv.on("post_created", "onPost");
81 clv.filter("filter.content", 10, "censor");
82 clv.job("tick", "1h", "onTick");
83}
84function pageHome(ctx) { return { body: "<h1>hi</h1>" }; }
85function doPost(ctx) { return { json: { ok: true } }; }
86function adminHome(ctx) { return { body: "admin" }; }
87function footSlot(data) { return "<span class='demo-slot'>slot</span>"; }
88function onPost(e) { clv.cache.set("last_post", e.post_id, 60); }
89function censor(s) { return s.replace(/广告/g, "***"); }
90function onTick(ctx) { clv.log.info("tick"); }
91function __check() { return clv.cache.get("last_post") || 0; }
92function __badPerm() {
93 try { clv.setting.set("hack", "1"); return "allowed"; } catch (e) { return "denied"; }
94}
95function __badSQL() {
96 try { clv.db.exec("DELETE FROM posts WHERE id=0"); return "allowed"; } catch (e) { return "denied"; }
97}
98function __loop() { while (true) {} }
99`
100
101// TestAppPluginLifecycle 覆盖应用型插件的完整能力:注册、建表、slot、过滤器、事件、任务
102func TestAppPluginLifecycle(t *testing.T) {
103 dataDir := setupAppEnv(t)
104 writeAppPlugin(t, dataDir, "demo", demoManifest, demoScript)
105
106 SetEnabled("demo", true)
107 if err := LoadApp("demo"); err != nil {
108 t.Fatalf("加载应用型插件失败: %v", err)
109 }
110 defer func() {
111 UnloadApp("demo")
112 SetEnabled("demo", false)
113 }()
114
115 app := GetApp("demo")
116 if app == nil {
117 t.Fatal("插件未登记到注册表")
118 }
119 if app.Plugin.SlugOf() != "demo" {
120 t.Fatalf("slug 解析异常: %q", app.Plugin.SlugOf())
121 }
122
123 // 1) 各类扩展点均已注册
124 if len(app.Routes) != 2 {
125 t.Fatalf("路由数 = %d,期望 2", len(app.Routes))
126 }
127 if len(app.Pages) != 1 || len(AdminMenus()) != 1 {
128 t.Fatalf("后台页面/菜单注册异常: pages=%d menus=%d", len(app.Pages), len(AdminMenus()))
129 }
130 if len(EventHooks("post_created")) != 1 {
131 t.Fatal("事件未注册")
132 }
133 if len(FilterHooks("filter.content")) != 1 {
134 t.Fatal("过滤器未注册")
135 }
136 if len(app.Jobs) != 1 {
137 t.Fatal("定时任务未注册")
138 }
139
140 // 2) 数据表已创建(pl_<slug>_log)
141 if _, err := database.DB.Exec("INSERT INTO pl_demo_log(user_id,day) VALUES(1,'2026-01-01')"); err != nil {
142 t.Fatalf("插件数据表不可写: %v", err)
143 }
144
145 // 3) Slot 输出包含插件内容
146 if got := SlotHTML("footer_html", map[string]any{}); !strings.Contains(got, "demo-slot") {
147 t.Fatalf("Slot 未生效: %q", got)
148 }
149
150 // 4) 过滤器生效
151 if got := ApplyFilterStr("filter.content", "这是一条广告"); got != "这是一条***" {
152 t.Fatalf("过滤器未生效: %q", got)
153 }
154
155 // 5) 事件回调(异步)确实执行
156 Emit("post_created", map[string]any{"post_id": 42})
157 deadline := time.Now().Add(2 * time.Second)
158 var last any
159 for time.Now().Before(deadline) {
160 v, err := app.Worker.Do(context.Background(), func(rt *jsRuntime) (any, error) {
161 return rt.callFn("__check")
162 })
163 if err != nil {
164 t.Fatalf("脚本调用失败: %v", err)
165 }
166 last = v
167 if fmt.Sprint(v) == "42" {
168 break
169 }
170 time.Sleep(50 * time.Millisecond)
171 }
172 if fmt.Sprint(last) != "42" {
173 t.Fatalf("事件回调未执行,cache = %v", last)
174 }
175}
176
177// TestAppPluginPermission 未声明的权限必须被拒绝
178func TestAppPluginPermission(t *testing.T) {
179 dataDir := setupAppEnv(t)
180 writeAppPlugin(t, dataDir, "demo", demoManifest, demoScript)
181
182 SetEnabled("demo", true)
183 if err := LoadApp("demo"); err != nil {
184 t.Fatalf("加载失败: %v", err)
185 }
186 defer func() {
187 UnloadApp("demo")
188 SetEnabled("demo", false)
189 }()
190
191 app := GetApp("demo")
192
193 // settings.write 未在 permissions 中声明
194 v, err := app.Worker.Do(context.Background(), func(rt *jsRuntime) (any, error) {
195 return rt.callFn("__badPerm")
196 })
197 if err != nil {
198 t.Fatalf("调用失败: %v", err)
199 }
200 if fmt.Sprint(v) != "denied" {
201 t.Fatalf("未声明的权限未被拒绝: %v", v)
202 }
203
204 // 写内核表必须被拒绝(只允许 pl_<slug>_ 前缀)
205 v2, err := app.Worker.Do(context.Background(), func(rt *jsRuntime) (any, error) {
206 return rt.callFn("__badSQL")
207 })
208 if err != nil {
209 t.Fatalf("调用失败: %v", err)
210 }
211 if fmt.Sprint(v2) != "denied" {
212 t.Fatalf("内核表写操作未被拒绝: %v", v2)
213 }
214}
215
216// TestAppPluginTimeoutInterrupt 死循环必须被超时中断,且中断后插件仍可继续服务
217func TestAppPluginTimeoutInterrupt(t *testing.T) {
218 dataDir := setupAppEnv(t)
219 writeAppPlugin(t, dataDir, "demo", demoManifest, demoScript)
220
221 SetEnabled("demo", true)
222 if err := LoadApp("demo"); err != nil {
223 t.Fatalf("加载失败: %v", err)
224 }
225 defer func() {
226 UnloadApp("demo")
227 SetEnabled("demo", false)
228 }()
229
230 app := GetApp("demo")
231 ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
232 defer cancel()
233 _, err := app.Worker.Do(ctx, func(rt *jsRuntime) (any, error) {
234 return rt.callFn("__loop")
235 })
236 if err == nil {
237 t.Fatal("死循环未被超时中断")
238 }
239
240 // 中断后虚拟机应恢复可用
241 ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
242 defer cancel2()
243 if _, err := app.Worker.Do(ctx2, func(rt *jsRuntime) (any, error) {
244 return rt.callFn("__check")
245 }); err != nil {
246 t.Fatalf("超时中断后插件不可用: %v", err)
247 }
248}
249
250// TestDeclarativeUnaffected 声明式插件仍然按原逻辑工作(回归)
251func TestDeclarativeUnaffected(t *testing.T) {
252 dataDir := setupAppEnv(t)
253 writePlugin(t, dataDir, "lite", `{
254 "name":"lite","version":"1.0.0",
255 "hooks":[{"hook":"footer_html","type":"html","html":"<b>lite</b>"},
256 {"hook":"content_filter","type":"filter","match":"坏","replace":"好"}]
257 }`, "")
258
259 SetEnabled("lite", true)
260 defer SetEnabled("lite", false)
261
262 if got := CallHTML("footer_html"); !strings.Contains(got, "<b>lite</b>") {
263 t.Fatalf("声明式 HTML 钩子失效: %q", got)
264 }
265 if got := ApplyFilterStr("filter.content", "坏人"); got != "好人" {
266 t.Fatalf("声明式过滤器失效: %q", got)
267 }
268 if models.QueryInt("SELECT 1") != 1 {
269 t.Fatal("数据库不可用")
270 }
271}