@code2rich/jpage 1.5.0
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.
- package/.claude/settings.local.json +68 -0
- package/.dockerignore +8 -0
- package/.env.example +56 -0
- package/.github/workflows/ci.yml +43 -0
- package/CLAUDE.md +280 -0
- package/Dockerfile +44 -0
- package/LICENSE +21 -0
- package/README.md +433 -0
- package/README_EN.md +399 -0
- package/bin/args.js +64 -0
- package/bin/client.js +93 -0
- package/bin/commands/_shared.js +54 -0
- package/bin/commands/cat.js +23 -0
- package/bin/commands/ls.js +44 -0
- package/bin/commands/mv.js +20 -0
- package/bin/commands/rm.js +22 -0
- package/bin/commands/skills.js +70 -0
- package/bin/commands/star.js +23 -0
- package/bin/commands/tags.js +97 -0
- package/bin/commands/upload.js +84 -0
- package/bin/commands/url.js +25 -0
- package/bin/commands/whoami.js +29 -0
- package/bin/config.js +85 -0
- package/bin/jpage.js +168 -0
- package/build.js +112 -0
- package/docker-compose.yml +26 -0
- package/docs/api.md +438 -0
- package/docs/design/005-custom-modal.md +296 -0
- package/docs/design/013-file-version-history.md +324 -0
- package/docs/design/billing-system.md +600 -0
- package/docs/design/db-index-and-healthcheck.md +176 -0
- package/docs/design/loading-states.md +209 -0
- package/docs/virtual-hosting-feasibility.md +453 -0
- package/eslint.config.mjs +172 -0
- package/lib/auth-state.js +15 -0
- package/lib/categories.js +20 -0
- package/lib/crypto.js +85 -0
- package/lib/csp.js +66 -0
- package/lib/db.js +53 -0
- package/lib/dispatch.js +103 -0
- package/lib/fts.js +81 -0
- package/lib/middleware/auth.js +114 -0
- package/lib/middleware/files.js +42 -0
- package/lib/paths.js +9 -0
- package/lib/render-cache.js +48 -0
- package/lib/render.js +157 -0
- package/lib/templates.js +149 -0
- package/lib/util.js +66 -0
- package/lib/view-counts.js +59 -0
- package/lib/zip.js +192 -0
- package/logger.js +16 -0
- package/mailer.js +34 -0
- package/mcp/constants.js +16 -0
- package/mcp/resources.js +74 -0
- package/mcp/server.js +43 -0
- package/mcp/tools-categories.js +56 -0
- package/mcp/tools-content-templates.js +59 -0
- package/mcp/tools-files.js +245 -0
- package/mcp/tools-tags.js +41 -0
- package/mcp/tools-versions.js +57 -0
- package/mcp/transport.js +183 -0
- package/mcp/util.js +63 -0
- package/mcp-server.js +20 -0
- package/migrations/001_init_schema.js +25 -0
- package/migrations/002_add_share_key.js +33 -0
- package/migrations/003_add_roles_and_tokens.js +28 -0
- package/migrations/004_add_version_history.js +32 -0
- package/migrations/005_tags_starred_categories.js +49 -0
- package/migrations/006_zip_bundle.js +17 -0
- package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
- package/migrations/008_add_fts5.js +6 -0
- package/migrations/009_add_link_visits.js +20 -0
- package/migrations/010_add_templates_system.js +34 -0
- package/migrations/011_content_templates.js +233 -0
- package/migrations/012_add_email_and_verification.js +35 -0
- package/migrations/013_add_token_encrypted.js +14 -0
- package/migrations.js +65 -0
- package/package.json +63 -0
- package/public/css/style.css +2915 -0
- package/public/index.html +855 -0
- package/public/js/api.js +22 -0
- package/public/js/app.js +94 -0
- package/public/js/components/dialog.js +106 -0
- package/public/js/components/toast.js +13 -0
- package/public/js/pages/content-templates.js +330 -0
- package/public/js/pages/home.js +1903 -0
- package/public/js/pages/landing.js +158 -0
- package/public/js/pages/login.js +175 -0
- package/public/js/pages/preview.js +713 -0
- package/public/js/theme.js +44 -0
- package/public/js/utils.js +67 -0
- package/routes/admin.js +136 -0
- package/routes/auth.js +365 -0
- package/routes/categories.js +90 -0
- package/routes/content-templates.js +215 -0
- package/routes/files/_shared.js +112 -0
- package/routes/files/associations.js +94 -0
- package/routes/files/crud.js +139 -0
- package/routes/files/detail-serve.js +178 -0
- package/routes/files/index.js +38 -0
- package/routes/files/list.js +200 -0
- package/routes/files/overwrite.js +114 -0
- package/routes/files/upload.js +204 -0
- package/routes/files/versions.js +166 -0
- package/routes/files.js +16 -0
- package/routes/skills.js +93 -0
- package/routes/tags.js +65 -0
- package/routes/tokens.js +110 -0
- package/routes/users.js +120 -0
- package/server.js +372 -0
- package/skills/jpage-content-template/SKILL.md +98 -0
- package/skills/jpage-upload/SKILL.md +247 -0
- package/skills-registry.js +135 -0
- package/templates/academic.html +41 -0
- package/templates/dark-pro.html +41 -0
- package/templates/default.html +56 -0
- package/templates/github.html +67 -0
- package/test/browser-harness.js +125 -0
- package/test/dispatch-bench.js +74 -0
- package/test/helpers/setup.js +45 -0
- package/test/integration/admin.test.js +108 -0
- package/test/integration/auth.test.js +93 -0
- package/test/integration/categories.test.js +103 -0
- package/test/integration/cli.test.js +310 -0
- package/test/integration/content-templates.test.js +147 -0
- package/test/integration/files-security.test.js +248 -0
- package/test/integration/files.test.js +139 -0
- package/test/integration/share.test.js +79 -0
- package/test/integration/skills.test.js +104 -0
- package/test/integration/tags.test.js +84 -0
- package/test/integration/tokens.test.js +89 -0
- package/test/integration/users.test.js +138 -0
- package/test/mcp-harness.js +152 -0
- package/test/perf-bench.js +108 -0
- package/test/perf-harness.js +198 -0
- package/test/run-server.sh +15 -0
- package/test/unit/cli-args.test.js +88 -0
- package/test/unit/cli-config.test.js +89 -0
- package/test/unit/crypto.test.js +100 -0
- package/test/unit/fts.test.js +52 -0
- package/test/unit/render-cache.test.js +76 -0
- package/test/unit/util.test.js +81 -0
- package/test/unit/zip.test.js +164 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// CLI 集成测试:通过注入 fetch shim,把 bin/ 的 HTTP 客户端接到 in-process 的 Express app。
|
|
2
|
+
//
|
|
3
|
+
// 不 spawn 子进程(项目既有测试都是 in-process 模式)。
|
|
4
|
+
// 关键:bin/client.js 用 fetch(url, init),url 是完整 URL(http://host:port/api/...)。
|
|
5
|
+
// 这里写一个 fetchImpl,解析 url 的路径,转给 supertest 的 request(app)。
|
|
6
|
+
//
|
|
7
|
+
// 覆盖:upload(含 overwrite 分支)/ ls / cat / url / mv / rm / star+unstar / tags add+set+clear /
|
|
8
|
+
// skills ls+get+download / whoami(有效 + 401)。
|
|
9
|
+
|
|
10
|
+
const test = require('node:test');
|
|
11
|
+
const assert = require('node:assert');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const request = require('supertest');
|
|
16
|
+
const { createTestEnv } = require('../helpers/setup');
|
|
17
|
+
const { run } = require('../../bin/jpage');
|
|
18
|
+
const { resetIo } = require('../../bin/commands/_shared');
|
|
19
|
+
|
|
20
|
+
// fetch → supertest 桥。
|
|
21
|
+
// supertest 的 request(app) 返回可链式调用的 Test(支持 .send/.set/.buffer().parse()),
|
|
22
|
+
// 用 .then(res => makeResponse(res)) 包装成 Web Response(client.js 期望的形态)。
|
|
23
|
+
function makeFetchImpl(app, binaryPaths = []) {
|
|
24
|
+
return async function fetchImpl(url, init = {}) {
|
|
25
|
+
const u = new URL(url);
|
|
26
|
+
const pathname = u.pathname + (u.search || '');
|
|
27
|
+
const method = (init.method || 'GET').toUpperCase();
|
|
28
|
+
const headers = init.headers || {};
|
|
29
|
+
|
|
30
|
+
let req = request(app)[method.toLowerCase()](pathname);
|
|
31
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
32
|
+
req = req.set(k, v);
|
|
33
|
+
}
|
|
34
|
+
// body 处理:JSON 字符串 / FormData / undefined
|
|
35
|
+
if (init.body !== undefined && init.body !== null) {
|
|
36
|
+
if (typeof init.body === 'string') {
|
|
37
|
+
// JSON 字符串:supertest 用 .send(object) 才对;这里手动塞 body
|
|
38
|
+
// supertest 会按 content-type 解析,这里直接传字符串 + 设 content-type
|
|
39
|
+
req = req.set('content-type', 'application/json').send(init.body);
|
|
40
|
+
} else if (init.body instanceof FormData) {
|
|
41
|
+
// multipart:把 FormData 转成 supertest 的 .field / .attach
|
|
42
|
+
req = await formDataToSupertest(req, init.body);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const isBinary = binaryPaths.some((p) => pathname.startsWith(p));
|
|
46
|
+
let res;
|
|
47
|
+
if (isBinary) {
|
|
48
|
+
res = await req.buffer(true).parse(binaryParser);
|
|
49
|
+
} else {
|
|
50
|
+
res = await req;
|
|
51
|
+
}
|
|
52
|
+
return makeResponse(res);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// supertest 的 res({status, headers, body, text})→ Web Fetch Response 子集。
|
|
57
|
+
// client.js 用到:res.status、res.text()、res.arrayBuffer()。
|
|
58
|
+
function makeResponse(res) {
|
|
59
|
+
const bodyText = res.text !== undefined ? res.text
|
|
60
|
+
: (Buffer.isBuffer(res.body) ? res.body.toString('utf8') : JSON.stringify(res.body || ''));
|
|
61
|
+
const bodyBuf = Buffer.isBuffer(res.body) ? res.body
|
|
62
|
+
: (res.text !== undefined ? Buffer.from(res.text) : Buffer.from(bodyText));
|
|
63
|
+
return {
|
|
64
|
+
status: res.status,
|
|
65
|
+
ok: res.status >= 200 && res.status < 300,
|
|
66
|
+
headers: new Map(Object.entries(res.headers || {})),
|
|
67
|
+
async text() { return bodyText; },
|
|
68
|
+
async arrayBuffer() { return bodyBuf.buffer.slice(bodyBuf.byteOffset, bodyBuf.byteOffset + bodyBuf.byteLength); },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function binaryParser(res, cb) {
|
|
73
|
+
const data = [];
|
|
74
|
+
res.on('data', (chunk) => data.push(chunk));
|
|
75
|
+
res.on('end', () => cb(null, Buffer.concat(data)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// FormData → supertest:遍历 entries,文件(Blob)用 .attach,文本用 .field。
|
|
79
|
+
// supertest 的 .attach(field, buffer, filename) 和 .field(field, value)。
|
|
80
|
+
async function formDataToSupertest(req, formData) {
|
|
81
|
+
for (const [key, value] of formData.entries()) {
|
|
82
|
+
if (value instanceof Blob) {
|
|
83
|
+
const buf = Buffer.from(await value.arrayBuffer());
|
|
84
|
+
const filename = value.name || 'file';
|
|
85
|
+
req = req.attach(key, buf, filename);
|
|
86
|
+
} else {
|
|
87
|
+
req = req.field(key, value);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return req;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 捕获 stdout/stderr/exitCode:通过注入内存 sink 流,不 monkeypatch process.stdout
|
|
94
|
+
// (那会破坏 node:test 自身的 TAP 输出)。run() 接受 { stdout, stderr, exit } 注入。
|
|
95
|
+
function makeSinks() {
|
|
96
|
+
const outBuf = [];
|
|
97
|
+
const errBuf = [];
|
|
98
|
+
let exitCode = 0;
|
|
99
|
+
return {
|
|
100
|
+
stdout: { write: (chunk) => { outBuf.push(chunk); return true; } },
|
|
101
|
+
stderr: { write: (chunk) => { errBuf.push(chunk); return true; } },
|
|
102
|
+
exit: (code) => { exitCode = code; },
|
|
103
|
+
out: () => outBuf.join(''),
|
|
104
|
+
err: () => errBuf.join(''),
|
|
105
|
+
code: () => exitCode,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let env;
|
|
110
|
+
let agent;
|
|
111
|
+
let token;
|
|
112
|
+
|
|
113
|
+
test.before(async () => {
|
|
114
|
+
env = createTestEnv();
|
|
115
|
+
await env.ready();
|
|
116
|
+
agent = request.agent(env.app);
|
|
117
|
+
await agent.post('/api/auth/login').send({ username: 'admin', password: 'testpassword123' });
|
|
118
|
+
// 建一个 jp_ token 供 CLI 用
|
|
119
|
+
const created = await agent.post('/api/tokens').send({ name: 'CLI Test' });
|
|
120
|
+
token = created.body.token;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test.after(() => {
|
|
124
|
+
resetIo();
|
|
125
|
+
env.cleanup();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
function ctx(sinks, extra = {}) {
|
|
129
|
+
const fetchImpl = makeFetchImpl(env.app, ['/api/skills/', '/download']);
|
|
130
|
+
return {
|
|
131
|
+
fetchImpl,
|
|
132
|
+
env: {},
|
|
133
|
+
cwd: env.dataDir,
|
|
134
|
+
stdout: sinks.stdout,
|
|
135
|
+
stderr: sinks.stderr,
|
|
136
|
+
exit: sinks.exit,
|
|
137
|
+
...extra,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- upload ---
|
|
142
|
+
test('CLI upload: 上传 HTML 文件成功', async () => {
|
|
143
|
+
const tmp = path.join(env.dataDir, 'report.html');
|
|
144
|
+
fs.writeFileSync(tmp, '<h1>季度报告</h1>');
|
|
145
|
+
const s = makeSinks();
|
|
146
|
+
await run(['upload', tmp, '--public', '--token', token], ctx(s));
|
|
147
|
+
assert.strictEqual(s.code(), 0, '不应非零退出');
|
|
148
|
+
assert.match(s.out(), /上传成功/);
|
|
149
|
+
assert.match(s.out(), /\/s\//);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('CLI upload: 无文件参数 → UsageError 退出 2', async () => {
|
|
153
|
+
const s = makeSinks();
|
|
154
|
+
await run(['upload', '--token', token], ctx(s));
|
|
155
|
+
assert.strictEqual(s.code(), 2);
|
|
156
|
+
assert.match(s.err(), /用法/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('CLI upload: --overwrite 走覆盖端点', async () => {
|
|
160
|
+
// 先上传一个
|
|
161
|
+
const a = path.join(env.dataDir, 'ow.html');
|
|
162
|
+
fs.writeFileSync(a, '<p>v1</p>');
|
|
163
|
+
const s1 = makeSinks();
|
|
164
|
+
await run(['upload', a, '--public', '--token', token], ctx(s1));
|
|
165
|
+
const m = s1.out().match(/#(\d+)/);
|
|
166
|
+
const id = m && m[1];
|
|
167
|
+
assert.ok(id, '应拿到上传后的 id');
|
|
168
|
+
|
|
169
|
+
// 覆盖(同名 → 自动版本备份)
|
|
170
|
+
fs.writeFileSync(a, '<p>v2</p>');
|
|
171
|
+
const s2 = makeSinks();
|
|
172
|
+
await run(['upload', a, '--overwrite', id, '--token', token], ctx(s2));
|
|
173
|
+
assert.strictEqual(s2.code(), 0);
|
|
174
|
+
assert.match(s2.out(), /已更新|覆盖/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// --- ls ---
|
|
178
|
+
test('CLI ls: 列出文件', async () => {
|
|
179
|
+
const s = makeSinks();
|
|
180
|
+
await run(['ls', '--token', token], ctx(s));
|
|
181
|
+
assert.strictEqual(s.code(), 0);
|
|
182
|
+
assert.match(s.out(), /#\d+/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// 取一个文件 id 供后续命令用
|
|
186
|
+
let sharedId;
|
|
187
|
+
test('CLI ls: 取一个 id 备用', async () => {
|
|
188
|
+
const s = makeSinks();
|
|
189
|
+
await run(['ls', '--token', token], ctx(s));
|
|
190
|
+
const m = s.out().match(/#(\d+)/);
|
|
191
|
+
sharedId = m && m[1];
|
|
192
|
+
assert.ok(sharedId);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// --- cat ---
|
|
196
|
+
test('CLI cat: 输出内容', async () => {
|
|
197
|
+
const s = makeSinks();
|
|
198
|
+
await run(['cat', sharedId, '--token', token], ctx(s));
|
|
199
|
+
assert.strictEqual(s.code(), 0);
|
|
200
|
+
assert.ok(s.out().length > 0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// --- url ---
|
|
204
|
+
test('CLI url: 打印 /s/:key', async () => {
|
|
205
|
+
const s = makeSinks();
|
|
206
|
+
await run(['url', sharedId, '--token', token], ctx(s));
|
|
207
|
+
assert.strictEqual(s.code(), 0);
|
|
208
|
+
assert.match(s.out(), /\/s\//);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// --- star + unstar ---
|
|
212
|
+
test('CLI star/unstar', async () => {
|
|
213
|
+
const s1 = makeSinks();
|
|
214
|
+
await run(['star', sharedId, '--token', token], ctx(s1));
|
|
215
|
+
assert.strictEqual(s1.code(), 0);
|
|
216
|
+
assert.match(s1.out(), /收藏/);
|
|
217
|
+
|
|
218
|
+
const s2 = makeSinks();
|
|
219
|
+
await run(['unstar', sharedId, '--token', token], ctx(s2));
|
|
220
|
+
assert.strictEqual(s2.code(), 0);
|
|
221
|
+
assert.match(s2.out(), /取消收藏/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// --- tags add/set/clear ---
|
|
225
|
+
test('CLI tags: add → set → clear', async () => {
|
|
226
|
+
const add = makeSinks();
|
|
227
|
+
await run(['tags', sharedId, 'add', '季度,财报', '--token', token], ctx(add));
|
|
228
|
+
assert.strictEqual(add.code(), 0);
|
|
229
|
+
assert.match(add.out(), /追加/);
|
|
230
|
+
|
|
231
|
+
const set = makeSinks();
|
|
232
|
+
await run(['tags', sharedId, 'set', 'only-one', '--token', token], ctx(set));
|
|
233
|
+
assert.strictEqual(set.code(), 0);
|
|
234
|
+
assert.match(set.out(), /设置/);
|
|
235
|
+
|
|
236
|
+
// 验证现在只剩 only-one
|
|
237
|
+
const list = makeSinks();
|
|
238
|
+
await run(['tags', sharedId, '--token', token], ctx(list));
|
|
239
|
+
assert.strictEqual(list.code(), 0);
|
|
240
|
+
assert.match(list.out(), /only-one/);
|
|
241
|
+
assert.doesNotMatch(list.out(), /季度/); // set 后旧标签没了
|
|
242
|
+
|
|
243
|
+
const clr = makeSinks();
|
|
244
|
+
await run(['tags', sharedId, 'clear', '--token', token], ctx(clr));
|
|
245
|
+
assert.strictEqual(clr.code(), 0);
|
|
246
|
+
assert.match(clr.out(), /清空/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// --- skills ls/get/download ---
|
|
250
|
+
test('CLI skills: ls / get / download', async () => {
|
|
251
|
+
const lsS = makeSinks();
|
|
252
|
+
await run(['skills', 'ls', '--token', token], ctx(lsS));
|
|
253
|
+
assert.strictEqual(lsS.code(), 0);
|
|
254
|
+
assert.match(lsS.out(), /jpage-upload/);
|
|
255
|
+
|
|
256
|
+
const getS = makeSinks();
|
|
257
|
+
await run(['skills', 'get', 'jpage-upload', '--token', token], ctx(getS));
|
|
258
|
+
assert.strictEqual(getS.code(), 0);
|
|
259
|
+
assert.match(getS.out(), /jpage-upload/);
|
|
260
|
+
|
|
261
|
+
// download:写到 env.dataDir,避免污染仓库
|
|
262
|
+
const outFile = path.join(env.dataDir, 'skill.zip');
|
|
263
|
+
const dlS = makeSinks();
|
|
264
|
+
await run(['skills', 'download', 'jpage-upload', '--out', outFile, '--token', token], ctx(dlS));
|
|
265
|
+
assert.strictEqual(dlS.code(), 0);
|
|
266
|
+
assert.match(dlS.out(), /已下载/);
|
|
267
|
+
assert.ok(fs.existsSync(outFile));
|
|
268
|
+
const buf = fs.readFileSync(outFile);
|
|
269
|
+
assert.strictEqual(buf[0], 0x50); // 'P' zip 魔数
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// --- whoami ---
|
|
273
|
+
test('CLI whoami: 有效 token', async () => {
|
|
274
|
+
const s = makeSinks();
|
|
275
|
+
await run(['whoami', '--token', token], ctx(s));
|
|
276
|
+
assert.strictEqual(s.code(), 0);
|
|
277
|
+
assert.match(s.out(), /token 有效/);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('CLI whoami: 无效 token → 退出 1', async () => {
|
|
281
|
+
const s = makeSinks();
|
|
282
|
+
await run(['whoami', '--token', 'jp_invalid_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'], ctx(s));
|
|
283
|
+
assert.strictEqual(s.code(), 1);
|
|
284
|
+
assert.match(s.err(), /无效|未设置|token/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// --- 无 token ---
|
|
288
|
+
test('CLI: 未提供 token → 退出 2', async () => {
|
|
289
|
+
const s = makeSinks();
|
|
290
|
+
// cwd 用 /tmp,避免 loadEnvUp 向上遍历到项目根的 .env(含 MCP_TOKEN)
|
|
291
|
+
await run(['ls'], ctx(s, { env: {}, cwd: os.tmpdir() }));
|
|
292
|
+
assert.strictEqual(s.code(), 2);
|
|
293
|
+
assert.match(s.err(), /token/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// --- 未知命令 ---
|
|
297
|
+
test('CLI: 未知命令 → 退出 2', async () => {
|
|
298
|
+
const s = makeSinks();
|
|
299
|
+
await run(['bogus-cmd', '--token', token], ctx(s));
|
|
300
|
+
assert.strictEqual(s.code(), 2);
|
|
301
|
+
assert.match(s.err(), /未知命令/);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// --- help ---
|
|
305
|
+
test('CLI: --help 打印帮助', async () => {
|
|
306
|
+
const s = makeSinks();
|
|
307
|
+
await run(['--help'], ctx(s));
|
|
308
|
+
assert.match(s.out(), /jpage —— 即页命令行/);
|
|
309
|
+
assert.match(s.out(), /upload/);
|
|
310
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// 内容模板市场集成测试:公开匿名访问 / 创建校验 / owner-or-admin 权限 / use 计数。
|
|
2
|
+
// 挂载点 /api/content-templates。/public 与 /public/:id/preview 匿名可访问。
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert');
|
|
5
|
+
const request = require('supertest');
|
|
6
|
+
const { createTestEnv } = require('../helpers/setup');
|
|
7
|
+
|
|
8
|
+
let env;
|
|
9
|
+
let adminAgent;
|
|
10
|
+
let userAgent;
|
|
11
|
+
|
|
12
|
+
test.before(async () => {
|
|
13
|
+
env = createTestEnv();
|
|
14
|
+
await env.ready();
|
|
15
|
+
adminAgent = request.agent(env.app);
|
|
16
|
+
await adminAgent.post('/api/auth/login').send({ username: 'admin', password: 'testpassword123' });
|
|
17
|
+
await adminAgent.post('/api/users').send({ username: 'regular', password: 'regularpass123', role: 'user' });
|
|
18
|
+
userAgent = request.agent(env.app);
|
|
19
|
+
await userAgent.post('/api/auth/login').send({ username: 'regular', password: 'regularpass123' });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test.after(() => {
|
|
23
|
+
env.cleanup();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// --- 公开端点(匿名) ---
|
|
27
|
+
test('匿名 GET /api/content-templates/public → 200', async () => {
|
|
28
|
+
const res = await request(env.app).get('/api/content-templates/public');
|
|
29
|
+
assert.strictEqual(res.status, 200);
|
|
30
|
+
assert.ok(Array.isArray(res.body.templates));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('匿名 GET /api/content-templates/scenes → 401(需登录)', async () => {
|
|
34
|
+
const res = await request(env.app).get('/api/content-templates/scenes');
|
|
35
|
+
assert.strictEqual(res.status, 401);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('登录 GET /api/content-templates/scenes → 200,返回场景列表', async () => {
|
|
39
|
+
const res = await adminAgent.get('/api/content-templates/scenes');
|
|
40
|
+
assert.strictEqual(res.status, 200);
|
|
41
|
+
assert.ok(Array.isArray(res.body.scenes));
|
|
42
|
+
assert.ok(res.body.scenes.includes('dashboard'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// --- 列表(需登录) ---
|
|
46
|
+
test('未登录 GET /api/content-templates → 401', async () => {
|
|
47
|
+
const res = await request(env.app).get('/api/content-templates');
|
|
48
|
+
assert.strictEqual(res.status, 401);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('登录 GET /api/content-templates → 200', async () => {
|
|
52
|
+
const res = await adminAgent.get('/api/content-templates');
|
|
53
|
+
assert.strictEqual(res.status, 200);
|
|
54
|
+
assert.ok(Array.isArray(res.body.templates));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// --- 创建 + 校验 ---
|
|
58
|
+
test('创建模板:缺标题 → 400', async () => {
|
|
59
|
+
const res = await adminAgent.post('/api/content-templates').send({ content: '<p>x</p>' });
|
|
60
|
+
assert.strictEqual(res.status, 400);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('创建模板:缺内容 → 400', async () => {
|
|
64
|
+
const res = await adminAgent.post('/api/content-templates').send({ title: '无内容模板' });
|
|
65
|
+
assert.strictEqual(res.status, 400);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('创建模板:非法 fileType → 400', async () => {
|
|
69
|
+
const res = await adminAgent.post('/api/content-templates').send({ title: 't', content: 'x', fileType: 'pdf' });
|
|
70
|
+
assert.strictEqual(res.status, 400);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('创建模板:happy path → 200,返回 id', async () => {
|
|
74
|
+
const res = await adminAgent.post('/api/content-templates').send({
|
|
75
|
+
title: '仪表板模板',
|
|
76
|
+
description: '示例',
|
|
77
|
+
fileType: 'html',
|
|
78
|
+
scene: 'dashboard',
|
|
79
|
+
content: '<div class="dashboard">hello</div>',
|
|
80
|
+
isPublic: true,
|
|
81
|
+
});
|
|
82
|
+
assert.strictEqual(res.status, 200);
|
|
83
|
+
assert.ok(res.body.id);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// --- owner-or-admin 权限 ---
|
|
87
|
+
test('普通用户不能访问他人私有模板 → 403', async () => {
|
|
88
|
+
// admin 建私有模板
|
|
89
|
+
const create = await adminAgent.post('/api/content-templates').send({
|
|
90
|
+
title: '私密模板',
|
|
91
|
+
fileType: 'html',
|
|
92
|
+
content: '<p>private</p>',
|
|
93
|
+
isPublic: false,
|
|
94
|
+
});
|
|
95
|
+
// 普通用户读详情 → 403
|
|
96
|
+
const res = await userAgent.get(`/api/content-templates/${create.body.id}`);
|
|
97
|
+
assert.strictEqual(res.status, 403);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('所有者可读自己的私有模板 → 200', async () => {
|
|
101
|
+
const create = await adminAgent.post('/api/content-templates').send({
|
|
102
|
+
title: '我的私密',
|
|
103
|
+
fileType: 'html',
|
|
104
|
+
content: '<p>mine</p>',
|
|
105
|
+
isPublic: false,
|
|
106
|
+
});
|
|
107
|
+
const res = await adminAgent.get(`/api/content-templates/${create.body.id}`);
|
|
108
|
+
assert.strictEqual(res.status, 200);
|
|
109
|
+
assert.strictEqual(res.body.title, '我的私密');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('普通用户不能改他人模板 → 403', async () => {
|
|
113
|
+
const create = await adminAgent.post('/api/content-templates').send({
|
|
114
|
+
title: '他人模板',
|
|
115
|
+
fileType: 'html',
|
|
116
|
+
content: '<p>x</p>',
|
|
117
|
+
});
|
|
118
|
+
const res = await userAgent.put(`/api/content-templates/${create.body.id}`).send({ title: '篡改' });
|
|
119
|
+
assert.strictEqual(res.status, 403);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// --- use 计数 ---
|
|
123
|
+
test('POST /api/content-templates/:id/use → 200,use_count 递增', async () => {
|
|
124
|
+
const create = await adminAgent.post('/api/content-templates').send({
|
|
125
|
+
title: '使用计数模板',
|
|
126
|
+
fileType: 'html',
|
|
127
|
+
content: '<p>use me</p>',
|
|
128
|
+
});
|
|
129
|
+
const before = await adminAgent.get(`/api/content-templates/${create.body.id}`);
|
|
130
|
+
const useCountBefore = before.body.use_count || 0;
|
|
131
|
+
const use = await adminAgent.post(`/api/content-templates/${create.body.id}/use`);
|
|
132
|
+
assert.strictEqual(use.status, 200);
|
|
133
|
+
assert.ok(use.body.use_count > useCountBefore);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// --- 删除 ---
|
|
137
|
+
test('所有者删除模板 → 200,再读 404', async () => {
|
|
138
|
+
const create = await adminAgent.post('/api/content-templates').send({
|
|
139
|
+
title: '待删模板',
|
|
140
|
+
fileType: 'html',
|
|
141
|
+
content: '<p>bye</p>',
|
|
142
|
+
});
|
|
143
|
+
const del = await adminAgent.delete(`/api/content-templates/${create.body.id}`);
|
|
144
|
+
assert.strictEqual(del.status, 200);
|
|
145
|
+
const after = await adminAgent.get(`/api/content-templates/${create.body.id}`);
|
|
146
|
+
assert.strictEqual(after.status, 404);
|
|
147
|
+
});
|