befly 3.9.40 → 3.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +47 -19
  2. package/befly.config.ts +19 -2
  3. package/checks/checkApi.ts +79 -77
  4. package/checks/checkHook.ts +48 -0
  5. package/checks/checkMenu.ts +168 -0
  6. package/checks/checkPlugin.ts +48 -0
  7. package/checks/checkTable.ts +137 -183
  8. package/docs/README.md +17 -11
  9. package/docs/api/api.md +16 -2
  10. package/docs/guide/quickstart.md +31 -10
  11. package/docs/hooks/hook.md +2 -2
  12. package/docs/hooks/rateLimit.md +1 -1
  13. package/docs/infra/redis.md +26 -14
  14. package/docs/plugins/plugin.md +23 -21
  15. package/docs/quickstart.md +5 -328
  16. package/docs/reference/addon.md +0 -4
  17. package/docs/reference/config.md +14 -31
  18. package/docs/reference/logger.md +3 -3
  19. package/docs/reference/sync.md +132 -237
  20. package/docs/reference/table.md +28 -30
  21. package/hooks/auth.ts +3 -4
  22. package/hooks/cors.ts +4 -6
  23. package/hooks/parser.ts +3 -4
  24. package/hooks/permission.ts +3 -4
  25. package/hooks/validator.ts +3 -4
  26. package/lib/cacheHelper.ts +89 -153
  27. package/lib/cacheKeys.ts +1 -1
  28. package/lib/connect.ts +9 -13
  29. package/lib/dbDialect.ts +285 -0
  30. package/lib/dbHelper.ts +179 -507
  31. package/lib/dbUtils.ts +450 -0
  32. package/lib/logger.ts +41 -5
  33. package/lib/redisHelper.ts +1 -0
  34. package/lib/sqlBuilder.ts +358 -58
  35. package/lib/sqlCheck.ts +136 -0
  36. package/lib/validator.ts +1 -1
  37. package/loader/loadApis.ts +23 -126
  38. package/loader/loadHooks.ts +31 -46
  39. package/loader/loadPlugins.ts +37 -52
  40. package/main.ts +58 -19
  41. package/package.json +24 -25
  42. package/paths.ts +14 -14
  43. package/plugins/cache.ts +12 -6
  44. package/plugins/cipher.ts +2 -2
  45. package/plugins/config.ts +6 -8
  46. package/plugins/db.ts +14 -19
  47. package/plugins/jwt.ts +6 -7
  48. package/plugins/logger.ts +7 -9
  49. package/plugins/redis.ts +8 -10
  50. package/plugins/tool.ts +3 -4
  51. package/router/api.ts +3 -2
  52. package/router/static.ts +7 -5
  53. package/sync/syncApi.ts +80 -235
  54. package/sync/syncCache.ts +16 -0
  55. package/sync/syncDev.ts +167 -202
  56. package/sync/syncMenu.ts +230 -444
  57. package/sync/syncTable.ts +1247 -0
  58. package/tests/_mocks/mockSqliteDb.ts +204 -0
  59. package/tests/addonHelper-cache.test.ts +32 -0
  60. package/tests/apiHandler-routePath-only.test.ts +32 -0
  61. package/tests/cacheHelper.test.ts +16 -51
  62. package/tests/checkApi-routePath-strict.test.ts +166 -0
  63. package/tests/checkMenu.test.ts +346 -0
  64. package/tests/checkTable-smoke.test.ts +157 -0
  65. package/tests/dbDialect-cache.test.ts +23 -0
  66. package/tests/dbDialect.test.ts +46 -0
  67. package/tests/dbHelper-advanced.test.ts +1 -1
  68. package/tests/dbHelper-all-array-types.test.ts +15 -15
  69. package/tests/dbHelper-batch-write.test.ts +90 -0
  70. package/tests/dbHelper-columns.test.ts +36 -54
  71. package/tests/dbHelper-execute.test.ts +26 -26
  72. package/tests/dbHelper-joins.test.ts +85 -176
  73. package/tests/fixtures/scanFilesAddon/node_modules/@befly-addon/demo/apis/sub/b.ts +3 -0
  74. package/tests/fixtures/scanFilesApis/a.ts +3 -0
  75. package/tests/fixtures/scanFilesApis/sub/b.ts +3 -0
  76. package/tests/loadPlugins-order-smoke.test.ts +75 -0
  77. package/tests/logger.test.ts +6 -6
  78. package/tests/redisHelper.test.ts +6 -1
  79. package/tests/scanFiles-routePath.test.ts +46 -0
  80. package/tests/smoke-sql.test.ts +24 -0
  81. package/tests/sqlBuilder-advanced.test.ts +18 -5
  82. package/tests/sqlBuilder.test.ts +24 -0
  83. package/tests/sync-init-guard.test.ts +105 -0
  84. package/tests/syncApi-insBatch-fields-consistent.test.ts +61 -0
  85. package/tests/syncApi-obsolete-records.test.ts +69 -0
  86. package/tests/syncApi-type-compat.test.ts +72 -0
  87. package/tests/syncDev-permissions.test.ts +81 -0
  88. package/tests/syncMenu-disableMenus-hard-delete.test.ts +88 -0
  89. package/tests/syncMenu-duplicate-path.test.ts +122 -0
  90. package/tests/syncMenu-obsolete-records.test.ts +161 -0
  91. package/tests/syncMenu-parentPath-from-tree.test.ts +75 -0
  92. package/tests/syncMenu-paths.test.ts +0 -9
  93. package/tests/{syncDb-apply.test.ts → syncTable-apply.test.ts} +14 -24
  94. package/tests/{syncDb-array-number.test.ts → syncTable-array-number.test.ts} +31 -31
  95. package/tests/syncTable-constants.test.ts +101 -0
  96. package/tests/syncTable-db-integration.test.ts +237 -0
  97. package/tests/{syncDb-ddl.test.ts → syncTable-ddl.test.ts} +67 -53
  98. package/tests/{syncDb-helpers.test.ts → syncTable-helpers.test.ts} +12 -26
  99. package/tests/syncTable-schema.test.ts +99 -0
  100. package/tests/syncTable-testkit.test.ts +25 -0
  101. package/tests/syncTable-types.test.ts +122 -0
  102. package/tests/tableRef-and-deserialize.test.ts +67 -0
  103. package/tsconfig.json +1 -1
  104. package/types/api.d.ts +1 -1
  105. package/types/befly.d.ts +13 -12
  106. package/types/cache.d.ts +2 -2
  107. package/types/context.d.ts +1 -1
  108. package/types/database.d.ts +0 -5
  109. package/types/hook.d.ts +1 -10
  110. package/types/plugin.d.ts +2 -96
  111. package/types/sync.d.ts +19 -25
  112. package/utils/convertBigIntFields.ts +38 -0
  113. package/utils/disableMenusGlob.ts +85 -0
  114. package/utils/importDefault.ts +21 -0
  115. package/utils/isDirentDirectory.ts +23 -0
  116. package/utils/loadMenuConfigs.ts +145 -0
  117. package/utils/processFields.ts +25 -0
  118. package/utils/scanAddons.ts +72 -0
  119. package/utils/scanFiles.ts +129 -21
  120. package/utils/scanSources.ts +64 -0
  121. package/utils/sortModules.ts +137 -0
  122. package/checks/checkApp.ts +0 -55
  123. package/docs/cipher.md +0 -582
  124. package/docs/database.md +0 -1176
  125. package/hooks/rateLimit.ts +0 -276
  126. package/sync/syncAll.ts +0 -35
  127. package/sync/syncDb/apply.ts +0 -192
  128. package/sync/syncDb/constants.ts +0 -119
  129. package/sync/syncDb/ddl.ts +0 -251
  130. package/sync/syncDb/helpers.ts +0 -84
  131. package/sync/syncDb/schema.ts +0 -202
  132. package/sync/syncDb/sqlite.ts +0 -48
  133. package/sync/syncDb/table.ts +0 -207
  134. package/sync/syncDb/tableCreate.ts +0 -163
  135. package/sync/syncDb/types.ts +0 -132
  136. package/sync/syncDb/version.ts +0 -69
  137. package/sync/syncDb.ts +0 -168
  138. package/tests/rateLimit-hook.test.ts +0 -477
  139. package/tests/syncDb-constants.test.ts +0 -130
  140. package/tests/syncDb-schema.test.ts +0 -179
  141. package/tests/syncDb-types.test.ts +0 -139
  142. package/utils/addonHelper.ts +0 -90
  143. package/utils/modules.ts +0 -98
  144. package/utils/route.ts +0 -23
@@ -0,0 +1,204 @@
1
+ export type MockColumn = { name: string; type: string; notnull: 0 | 1; dflt_value: any };
2
+
3
+ export type MockSqliteState = {
4
+ executedSql: string[];
5
+ tables: Record<
6
+ string,
7
+ {
8
+ columns: Record<string, MockColumn>;
9
+ indexes: Record<string, string[]>;
10
+ }
11
+ >;
12
+ };
13
+
14
+ function normalizeQuotedIdent(input: string): string {
15
+ return String(input).trim().replace(/^`/, "").replace(/`$/, "").replace(/^"/, "").replace(/"$/, "");
16
+ }
17
+
18
+ function parseCreateTable(sql: string): { tableName: string; columnDefs: string } | null {
19
+ const m = /^CREATE\s+TABLE\s+(.+?)\s*\((.*)\)\s*$/is.exec(sql.trim());
20
+ if (!m) return null;
21
+ return {
22
+ tableName: normalizeQuotedIdent(m[1].trim()),
23
+ columnDefs: m[2]
24
+ };
25
+ }
26
+
27
+ function parseAlterAddColumn(sql: string): { tableName: string; colName: string; colType: string; notnull: 0 | 1; dflt: any } | null {
28
+ const m = /^ALTER\s+TABLE\s+(.+?)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?(.+?)\s+(.*)$/i.exec(sql.trim());
29
+ if (!m) return null;
30
+
31
+ const tableName = normalizeQuotedIdent(m[1].trim());
32
+ const colName = normalizeQuotedIdent(m[2].trim());
33
+ const rest = m[3];
34
+
35
+ const upper = rest.toUpperCase();
36
+ const colType = upper.includes("INTEGER") ? "INTEGER" : "TEXT";
37
+ const notnull: 0 | 1 = upper.includes("NOT NULL") ? 1 : 0;
38
+
39
+ let dflt: any = null;
40
+ const dfltMatch = /\bDEFAULT\s+(.+?)(\s|$)/i.exec(rest);
41
+ if (dfltMatch) {
42
+ const raw = dfltMatch[1].trim();
43
+ if (raw === "''") dflt = "";
44
+ else if (raw.startsWith("'") && raw.endsWith("'")) dflt = raw.slice(1, -1).replace(/''/g, "'");
45
+ else if (/^\d+$/.test(raw)) dflt = Number(raw);
46
+ else dflt = raw;
47
+ }
48
+
49
+ return { tableName: tableName, colName: colName, colType: colType, notnull: notnull, dflt: dflt };
50
+ }
51
+
52
+ function parseCreateIndex(sql: string): { indexName: string; tableName: string; columns: string[] } | null {
53
+ const m = /^CREATE\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(.+?)\s+ON\s+(.+?)\s*\((.+)\)\s*$/is.exec(sql.trim());
54
+ if (!m) return null;
55
+
56
+ const indexName = normalizeQuotedIdent(m[1].trim());
57
+ const tableName = normalizeQuotedIdent(m[2].trim());
58
+ const columns = m[3]
59
+ .split(",")
60
+ .map((s) => normalizeQuotedIdent(s.trim()))
61
+ .filter((s) => s.length > 0);
62
+
63
+ return { indexName: indexName, tableName: tableName, columns: columns };
64
+ }
65
+
66
+ function parseDropIndex(sql: string): { indexName: string } | null {
67
+ const m = /^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?(.+?)\s*$/i.exec(sql.trim());
68
+ if (!m) return null;
69
+ return { indexName: normalizeQuotedIdent(m[1].trim()) };
70
+ }
71
+
72
+ function extractPragmaIdent(sqlStr: string): string {
73
+ const m = /\((.+)\)\s*$/i.exec(String(sqlStr).trim());
74
+ return normalizeQuotedIdent(m ? m[1] : "");
75
+ }
76
+
77
+ export function createMockSqliteDb(state: MockSqliteState) {
78
+ return {
79
+ unsafe: async (sqlStr: string, params?: unknown[]) => {
80
+ const sql = String(sqlStr);
81
+ state.executedSql.push(sql);
82
+
83
+ if (sql.includes("sqlite_version()")) {
84
+ return [{ version: "3.50.1" }];
85
+ }
86
+
87
+ if (sql.includes("sqlite_master")) {
88
+ const tableName = String(params?.[0] || "");
89
+ return [{ count: state.tables[tableName] ? 1 : 0 }];
90
+ }
91
+
92
+ if (/^PRAGMA\s+table_info\s*\(/i.test(sql)) {
93
+ const tableName = extractPragmaIdent(sql);
94
+ const t = state.tables[tableName];
95
+ if (!t) return [];
96
+ return Object.values(t.columns).map((c) => {
97
+ return {
98
+ name: c.name,
99
+ type: c.type,
100
+ notnull: c.notnull,
101
+ dflt_value: c.dflt_value
102
+ };
103
+ });
104
+ }
105
+
106
+ if (/^PRAGMA\s+index_list\s*\(/i.test(sql)) {
107
+ const tableName = extractPragmaIdent(sql);
108
+ const t = state.tables[tableName];
109
+ if (!t) return [];
110
+ return Object.keys(t.indexes).map((name) => {
111
+ return { name: name };
112
+ });
113
+ }
114
+
115
+ if (/^PRAGMA\s+index_info\s*\(/i.test(sql)) {
116
+ const indexName = extractPragmaIdent(sql);
117
+ for (const table of Object.values(state.tables)) {
118
+ if (table.indexes[indexName]) {
119
+ return table.indexes[indexName].map((col) => {
120
+ return { name: col };
121
+ });
122
+ }
123
+ }
124
+ return [];
125
+ }
126
+
127
+ const createTable = parseCreateTable(sql);
128
+ if (createTable) {
129
+ const colMap: Record<string, MockColumn> = {};
130
+ const parts = createTable.columnDefs
131
+ .split(",")
132
+ .map((s) => s.trim())
133
+ .filter((s) => s.length > 0);
134
+
135
+ for (const p of parts) {
136
+ const colMatch = /^(.+?)\s+(.+)$/s.exec(p);
137
+ if (!colMatch) continue;
138
+ const colName = normalizeQuotedIdent(colMatch[1].trim());
139
+ const rest = colMatch[2];
140
+
141
+ const upper = rest.toUpperCase();
142
+ const colType = upper.includes("INTEGER") ? "INTEGER" : upper.includes("BIGINT") ? "INTEGER" : "TEXT";
143
+ const notnull: 0 | 1 = upper.includes("NOT NULL") || upper.includes("PRIMARY KEY") ? 1 : 0;
144
+
145
+ let dflt: any = null;
146
+ const dfltMatch = /\bDEFAULT\s+(.+?)(\s|$)/i.exec(rest);
147
+ if (dfltMatch) {
148
+ const raw = dfltMatch[1].trim();
149
+ if (raw === "''") dflt = "";
150
+ else if (raw.startsWith("'") && raw.endsWith("'")) dflt = raw.slice(1, -1).replace(/''/g, "'");
151
+ else if (/^\d+$/.test(raw)) dflt = Number(raw);
152
+ else dflt = raw;
153
+ }
154
+
155
+ colMap[colName] = { name: colName, type: colType, notnull: notnull, dflt_value: dflt };
156
+ }
157
+
158
+ state.tables[createTable.tableName] = {
159
+ columns: colMap,
160
+ indexes: {}
161
+ };
162
+
163
+ return [];
164
+ }
165
+
166
+ const addColumn = parseAlterAddColumn(sql);
167
+ if (addColumn) {
168
+ const t = state.tables[addColumn.tableName];
169
+ if (!t) throw new Error(`mock sqlite db: 表不存在,无法 ADD COLUMN: ${addColumn.tableName}`);
170
+ if (!t.columns[addColumn.colName]) {
171
+ t.columns[addColumn.colName] = {
172
+ name: addColumn.colName,
173
+ type: addColumn.colType,
174
+ notnull: addColumn.notnull,
175
+ dflt_value: addColumn.dflt
176
+ };
177
+ }
178
+ return [];
179
+ }
180
+
181
+ const createIndex = parseCreateIndex(sql);
182
+ if (createIndex) {
183
+ const t = state.tables[createIndex.tableName];
184
+ if (!t) throw new Error(`mock sqlite db: 表不存在,无法 CREATE INDEX: ${createIndex.tableName}`);
185
+ t.indexes[createIndex.indexName] = createIndex.columns;
186
+ return [];
187
+ }
188
+
189
+ const dropIndex = parseDropIndex(sql);
190
+ if (dropIndex) {
191
+ for (const t of Object.values(state.tables)) {
192
+ delete t.indexes[dropIndex.indexName];
193
+ }
194
+ return [];
195
+ }
196
+
197
+ if (/^DROP\s+TABLE/i.test(sql)) {
198
+ return [];
199
+ }
200
+
201
+ throw new Error(`mock sqlite db: 未处理的 SQL: ${sql}`);
202
+ }
203
+ };
204
+ }
@@ -0,0 +1,32 @@
1
+ import { test, expect } from "bun:test";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+
4
+ import { join } from "pathe";
5
+
6
+ test("scanAddons - should scan node_modules @befly-addon", async () => {
7
+ const originalCwd = process.cwd();
8
+ const projectDir = join(originalCwd, "temp", `addonHelper-cache-test-${Date.now()}`);
9
+
10
+ try {
11
+ // 构造一个最小可扫描的项目结构
12
+ mkdirSync(join(projectDir, "node_modules", "@befly-addon", "demo"), { recursive: true });
13
+ mkdirSync(join(projectDir, "node_modules", "@befly-addon", "_ignore"), { recursive: true });
14
+
15
+ // 放一个非目录项,确保扫描逻辑不会误判
16
+ writeFileSync(join(projectDir, "node_modules", "@befly-addon", "README.md"), "x", { encoding: "utf8" });
17
+
18
+ // scanAddons 依赖 appDir=process.cwd()(模块初始化时取值),所以先切 cwd 再动态 import
19
+ process.chdir(projectDir);
20
+
21
+ const mod = await import(`../utils/scanAddons.js?cacheTest=${Date.now()}`);
22
+ const scanAddons = mod.scanAddons as () => any[];
23
+
24
+ const addons = scanAddons();
25
+ expect(addons.map((a) => a.name)).toEqual(["demo"]);
26
+ expect(addons[0].fullPath.endsWith("node_modules/@befly-addon/demo")).toBe(true);
27
+ expect(addons[0].camelName).toBe("demo");
28
+ } finally {
29
+ process.chdir(originalCwd);
30
+ rmSync(projectDir, { recursive: true, force: true });
31
+ }
32
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { apiHandler } from "../router/api.js";
4
+
5
+ describe("apiHandler - route matching", () => {
6
+ test("接口存在性判断应仅使用 url.pathname(不附加 method)", async () => {
7
+ const apis = new Map<string, any>();
8
+
9
+ apis.set("/api/hello", {
10
+ name: "hello",
11
+ method: "POST",
12
+ handler: async () => {
13
+ return {
14
+ msg: "ok"
15
+ };
16
+ }
17
+ });
18
+
19
+ const hooks: any[] = [];
20
+ const context: any = {};
21
+
22
+ const handler = apiHandler(apis as any, hooks as any, context as any);
23
+
24
+ // 如果 key 里拼了 method,这里会变成 "POST /api/hello" 之类,从而导致接口不存在
25
+ const res = await handler(new Request("http://localhost/api/hello", { method: "POST" }));
26
+ expect(res.status).toBe(200);
27
+
28
+ const body = await res.json();
29
+ expect(body.code).toBe(0);
30
+ expect(body.msg).toBe("ok");
31
+ });
32
+ });
@@ -2,8 +2,6 @@
2
2
  * CacheHelper 单元测试
3
3
  */
4
4
 
5
- import type { BeflyContext } from "../types/befly.js";
6
-
7
5
  import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
8
6
 
9
7
  import { CacheHelper } from "../lib/cacheHelper.js";
@@ -25,7 +23,6 @@ const mockPino = {
25
23
 
26
24
  describe("CacheHelper", () => {
27
25
  let cacheHelper: CacheHelper;
28
- let mockBefly: BeflyContext;
29
26
  let mockDb: any;
30
27
  let mockRedis: any;
31
28
 
@@ -52,13 +49,7 @@ describe("CacheHelper", () => {
52
49
  exists: mock(() => Promise.resolve(true))
53
50
  };
54
51
 
55
- // 创建 mock befly context
56
- mockBefly = {
57
- db: mockDb,
58
- redis: mockRedis
59
- } as unknown as BeflyContext;
60
-
61
- cacheHelper = new CacheHelper(mockBefly);
52
+ cacheHelper = new CacheHelper({ db: mockDb, redis: mockRedis });
62
53
  });
63
54
 
64
55
  afterEach(() => {
@@ -78,8 +69,8 @@ describe("CacheHelper", () => {
78
69
 
79
70
  it("正常缓存接口列表", async () => {
80
71
  const apis = [
81
- { id: 1, name: "登录", path: "/api/login", method: "POST" },
82
- { id: 2, name: "用户列表", path: "/api/user/list", method: "GET" }
72
+ { id: 1, name: "登录", routePath: "/api/login" },
73
+ { id: 2, name: "用户列表", routePath: "/api/user/list" }
83
74
  ];
84
75
  mockDb.getAll = mock(() => Promise.resolve({ lists: apis, total: apis.length }));
85
76
 
@@ -131,7 +122,6 @@ describe("CacheHelper", () => {
131
122
  describe("rebuildRoleApiPermissions", () => {
132
123
  it("表不存在时跳过缓存", async () => {
133
124
  mockDb.tableExists = mock((table: string) => {
134
- if (table === "addon_admin_api") return Promise.resolve(true);
135
125
  if (table === "addon_admin_role") return Promise.resolve(false);
136
126
  return Promise.resolve(false);
137
127
  });
@@ -143,21 +133,12 @@ describe("CacheHelper", () => {
143
133
 
144
134
  it("正常重建角色权限(覆盖更新)", async () => {
145
135
  const roles = [
146
- { id: 1, code: "admin", apis: [1, "2", 3] },
147
- { id: 2, code: "user", apis: [1] }
148
- ];
149
- const apis = [
150
- { id: 1, path: "/api/login", method: "POST" },
151
- { id: 2, path: "/api/user/list", method: "GET" },
152
- { id: 3, path: "/api/user/del", method: "POST" }
136
+ { id: 1, code: "admin", apis: ["/api/login", "/api/user/list", "/api/user/del"] },
137
+ { id: 2, code: "user", apis: ["/api/login"] }
153
138
  ];
154
139
 
155
140
  mockDb.getAll = mock((opts: any) => {
156
141
  if (opts.table === "addon_admin_role") return Promise.resolve({ lists: roles, total: roles.length });
157
- if (opts.table === "addon_admin_api") {
158
- // rebuildRoleApiPermissions 会通过 where: { id$in: [...] } 分块查询
159
- return Promise.resolve({ lists: apis, total: apis.length });
160
- }
161
142
  return Promise.resolve({ lists: [], total: 0 });
162
143
  });
163
144
 
@@ -177,19 +158,17 @@ describe("CacheHelper", () => {
177
158
  expect(saddBatchArgs).toEqual([
178
159
  {
179
160
  key: CacheKeys.roleApis("admin"),
180
- members: ["GET/api/user/list", "POST/api/login", "POST/api/user/del"]
161
+ members: ["/api/login", "/api/user/del", "/api/user/list"]
181
162
  },
182
- { key: CacheKeys.roleApis("user"), members: ["POST/api/login"] }
163
+ { key: CacheKeys.roleApis("user"), members: ["/api/login"] }
183
164
  ]);
184
165
  });
185
166
 
186
167
  it("无权限时仍会清理旧缓存,但不写入成员", async () => {
187
168
  const roles = [{ id: 1, code: "empty", apis: [] }];
188
- const apis = [{ id: 1, path: "/api/login", method: "POST" }];
189
169
 
190
170
  mockDb.getAll = mock((opts: any) => {
191
171
  if (opts.table === "addon_admin_role") return Promise.resolve({ lists: roles, total: roles.length });
192
- if (opts.table === "addon_admin_api") return Promise.resolve({ lists: apis, total: apis.length });
193
172
  return Promise.resolve({ lists: [], total: 0 });
194
173
  });
195
174
 
@@ -201,34 +180,20 @@ describe("CacheHelper", () => {
201
180
  });
202
181
 
203
182
  describe("refreshRoleApiPermissions", () => {
204
- it("apiIds 为空数组时只清理缓存,不查询 API 表", async () => {
183
+ it("apiPaths 为空数组时只清理缓存,不查询 DB", async () => {
205
184
  mockDb.getAll = mock(() => Promise.resolve({ lists: [], total: 0 }));
206
185
 
207
186
  await cacheHelper.refreshRoleApiPermissions("admin", []);
208
187
 
209
188
  expect(mockRedis.del).toHaveBeenCalledWith(CacheKeys.roleApis("admin"));
210
- expect(mockDb.getAll).not.toHaveBeenCalledWith(
211
- expect.objectContaining({
212
- table: "addon_admin_api"
213
- })
214
- );
189
+ expect(mockDb.getAll).not.toHaveBeenCalled();
215
190
  });
216
191
 
217
- it("apiIds 非空时使用 $in 查询并重建该角色缓存(覆盖更新)", async () => {
218
- const apis = [
219
- { id: 1, path: "/api/login", method: "POST" },
220
- { id: 2, path: "/api/user/list", method: "GET" }
221
- ];
222
-
223
- mockDb.getAll = mock((opts: any) => {
224
- if (opts.table === "addon_admin_api") return Promise.resolve({ lists: apis, total: apis.length });
225
- return Promise.resolve({ lists: [], total: 0 });
226
- });
227
-
228
- await cacheHelper.refreshRoleApiPermissions("admin", [1, 2]);
192
+ it("apiPaths 非空时直接覆盖更新该角色缓存(DEL + SADD)", async () => {
193
+ await cacheHelper.refreshRoleApiPermissions("admin", ["/api/login", "/api/user/list"]);
229
194
 
230
195
  expect(mockRedis.del).toHaveBeenCalledWith(CacheKeys.roleApis("admin"));
231
- expect(mockRedis.sadd).toHaveBeenCalledWith(CacheKeys.roleApis("admin"), ["POST/api/login", "GET/api/user/list"]);
196
+ expect(mockRedis.sadd).toHaveBeenCalledWith(CacheKeys.roleApis("admin"), ["/api/login", "/api/user/list"]);
232
197
  });
233
198
  });
234
199
 
@@ -272,7 +237,7 @@ describe("CacheHelper", () => {
272
237
 
273
238
  describe("getRolePermissions", () => {
274
239
  it("返回角色的权限列表", async () => {
275
- const permissions = ["POST/api/login", "GET/api/user/list"];
240
+ const permissions = ["/api/login", "/api/user/list"];
276
241
  mockRedis.smembers = mock(() => Promise.resolve(permissions));
277
242
 
278
243
  const result = await cacheHelper.getRolePermissions("admin");
@@ -294,16 +259,16 @@ describe("CacheHelper", () => {
294
259
  it("有权限时返回 true", async () => {
295
260
  mockRedis.sismember = mock(() => Promise.resolve(true));
296
261
 
297
- const result = await cacheHelper.checkRolePermission("admin", "POST/api/login");
262
+ const result = await cacheHelper.checkRolePermission("admin", "/api/login");
298
263
 
299
- expect(mockRedis.sismember).toHaveBeenCalledWith(CacheKeys.roleApis("admin"), "POST/api/login");
264
+ expect(mockRedis.sismember).toHaveBeenCalledWith(CacheKeys.roleApis("admin"), "/api/login");
300
265
  expect(result).toBe(true);
301
266
  });
302
267
 
303
268
  it("无权限时返回 false", async () => {
304
269
  mockRedis.sismember = mock(() => Promise.resolve(false));
305
270
 
306
- const result = await cacheHelper.checkRolePermission("user", "POST/api/admin/del");
271
+ const result = await cacheHelper.checkRolePermission("user", "/api/admin/del");
307
272
 
308
273
  expect(result).toBe(false);
309
274
  });
@@ -0,0 +1,166 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { checkApi } from "../checks/checkApi.js";
4
+
5
+ describe("checkApi - routePath strict", () => {
6
+ test("合法 routePath 应通过", async () => {
7
+ let thrown: any = null;
8
+
9
+ try {
10
+ await checkApi([
11
+ {
12
+ name: "hello",
13
+ handler: () => {
14
+ return null;
15
+ },
16
+ routePath: "/api/hello",
17
+ routePrefix: "/app"
18
+ }
19
+ ]);
20
+ } catch (error: any) {
21
+ thrown = error;
22
+ }
23
+
24
+ expect(thrown).toBeNull();
25
+ });
26
+
27
+ test("routePath 为空字符串应阻断启动", async () => {
28
+ let thrown: any = null;
29
+
30
+ try {
31
+ await checkApi([
32
+ {
33
+ name: "hello",
34
+ handler: () => null,
35
+ routePath: "",
36
+ routePrefix: "/app"
37
+ }
38
+ ]);
39
+ } catch (error: any) {
40
+ thrown = error;
41
+ }
42
+
43
+ expect(thrown).toBeTruthy();
44
+ expect(thrown.message).toBe("接口结构检查失败");
45
+ });
46
+
47
+ test("routePath 非字符串应阻断启动", async () => {
48
+ let thrown: any = null;
49
+
50
+ try {
51
+ await checkApi([
52
+ {
53
+ name: "hello",
54
+ handler: () => null,
55
+ routePath: 123,
56
+ routePrefix: "/app"
57
+ } as any
58
+ ]);
59
+ } catch (error: any) {
60
+ thrown = error;
61
+ }
62
+
63
+ expect(thrown).toBeTruthy();
64
+ expect(thrown.message).toBe("接口结构检查失败");
65
+ });
66
+
67
+ test("routePath 不允许 method 前缀(POST/api/...)", async () => {
68
+ let thrown: any = null;
69
+
70
+ try {
71
+ await checkApi([
72
+ {
73
+ name: "hello",
74
+ handler: () => null,
75
+ routePath: "POST/api/hello",
76
+ routePrefix: "/app"
77
+ }
78
+ ]);
79
+ } catch (error: any) {
80
+ thrown = error;
81
+ }
82
+
83
+ expect(thrown).toBeTruthy();
84
+ expect(thrown.message).toBe("接口结构检查失败");
85
+ });
86
+
87
+ test("routePath 不允许 method + 空格(POST /api/...)", async () => {
88
+ let thrown: any = null;
89
+
90
+ try {
91
+ await checkApi([
92
+ {
93
+ name: "hello",
94
+ handler: () => null,
95
+ routePath: "POST /api/hello",
96
+ routePrefix: "/app"
97
+ }
98
+ ]);
99
+ } catch (error: any) {
100
+ thrown = error;
101
+ }
102
+
103
+ expect(thrown).toBeTruthy();
104
+ expect(thrown.message).toBe("接口结构检查失败");
105
+ });
106
+
107
+ test("routePath 必须以 /api/ 开头", async () => {
108
+ let thrown: any = null;
109
+
110
+ try {
111
+ await checkApi([
112
+ {
113
+ name: "hello",
114
+ handler: () => null,
115
+ routePath: "/app/hello",
116
+ routePrefix: "/app"
117
+ }
118
+ ]);
119
+ } catch (error: any) {
120
+ thrown = error;
121
+ }
122
+
123
+ expect(thrown).toBeTruthy();
124
+ expect(thrown.message).toBe("接口结构检查失败");
125
+ });
126
+
127
+ test("routePath 不允许包含空格", async () => {
128
+ let thrown: any = null;
129
+
130
+ try {
131
+ await checkApi([
132
+ {
133
+ name: "hello",
134
+ handler: () => null,
135
+ routePath: "/api/hello world",
136
+ routePrefix: "/app"
137
+ }
138
+ ]);
139
+ } catch (error: any) {
140
+ thrown = error;
141
+ }
142
+
143
+ expect(thrown).toBeTruthy();
144
+ expect(thrown.message).toBe("接口结构检查失败");
145
+ });
146
+
147
+ test("routePath 不允许出现 /api//(重复斜杠)", async () => {
148
+ let thrown: any = null;
149
+
150
+ try {
151
+ await checkApi([
152
+ {
153
+ name: "hello",
154
+ handler: () => null,
155
+ routePath: "/api//hello",
156
+ routePrefix: "/app"
157
+ }
158
+ ]);
159
+ } catch (error: any) {
160
+ thrown = error;
161
+ }
162
+
163
+ expect(thrown).toBeTruthy();
164
+ expect(thrown.message).toBe("接口结构检查失败");
165
+ });
166
+ });