@fuzionx/framework 0.1.29 → 0.1.31
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/cli/index.js +230 -114
- package/cli/templates/{app/fuzionx → make/app}/controllers/HomeController.js +1 -0
- package/cli/templates/{app/tester → make/app}/views/default/errors/404.html +0 -4
- package/cli/templates/{app/fuzionx/views/default/errors/404.html → make/app/views/default/errors/500.html} +1 -2
- package/cli/templates/make/app/views/default/pages/home.html +11 -0
- package/index.js +3 -0
- package/lib/core/Application.js +31 -6
- package/lib/core/Context.js +30 -1
- package/lib/helpers/I18nHelper.js +10 -6
- package/lib/middleware/apiAuth.js +79 -0
- package/lib/middleware/auth.js +42 -0
- package/lib/middleware/bodyParser.js +19 -0
- package/lib/middleware/cors.js +47 -0
- package/lib/middleware/csrf.js +32 -0
- package/lib/middleware/index.js +8 -277
- package/lib/middleware/session.js +27 -0
- package/lib/middleware/theme.js +20 -0
- package/lib/schedule/Job.js +4 -0
- package/lib/schedule/Queue.js +20 -8
- package/lib/schedule/Scheduler.js +84 -75
- package/lib/utilities/ArrUtil.js +112 -0
- package/lib/utilities/DateUtil.js +98 -0
- package/lib/utilities/FunctionUtil.js +119 -0
- package/lib/utilities/NumUtil.js +75 -0
- package/lib/utilities/ObjectUtil.js +170 -0
- package/lib/utilities/PaginationUtil.js +81 -0
- package/lib/utilities/StrUtil.js +105 -0
- package/lib/utilities/index.js +18 -0
- package/package.json +2 -2
- package/cli/templates/app/.env.example.tpl +0 -14
- package/cli/templates/app/.gitignore.tpl +0 -4
- package/cli/templates/app/app.js.tpl +0 -6
- package/cli/templates/app/database/models/User.js +0 -9
- package/cli/templates/app/fuzionx/views/default/errors/500.html +0 -14
- package/cli/templates/app/fuzionx/views/default/pages/home.html +0 -188
- package/cli/templates/app/fuzionx.yaml.tpl +0 -202
- package/cli/templates/app/locales/en.json +0 -52
- package/cli/templates/app/locales/ko.json +0 -52
- package/cli/templates/app/package.json.tpl +0 -16
- package/cli/templates/app/shared/events/userEvents.js +0 -10
- package/cli/templates/app/shared/jobs/CleanupJob.js +0 -18
- package/cli/templates/app/shared/jobs/EmailTask.js +0 -17
- package/cli/templates/app/shared/jobs/VideoPreviewTask.js +0 -47
- package/cli/templates/app/shared/workers/heavy.js +0 -18
- package/cli/templates/app/tester/controllers/FileController.js +0 -288
- package/cli/templates/app/tester/controllers/HomeController.js +0 -36
- package/cli/templates/app/tester/controllers/UserController.js +0 -43
- package/cli/templates/app/tester/middleware/RequestLogger.js +0 -13
- package/cli/templates/app/tester/routes/api.js +0 -397
- package/cli/templates/app/tester/routes/web.js +0 -8
- package/cli/templates/app/tester/services/UserService.js +0 -52
- package/cli/templates/app/tester/views/default/errors/500.html +0 -14
- package/cli/templates/app/tester/views/default/layouts/main.html +0 -82
- package/cli/templates/app/tester/views/default/pages/home.html +0 -56
- package/cli/templates/app/tester/views/default/pages/i18n.html +0 -104
- package/cli/templates/app/tester/views/default/pages/upload.html +0 -149
- package/cli/templates/app/tester/views/default/pages/websocket.html +0 -239
- package/cli/templates/app/tester/views/default/partials/footer.html +0 -8
- package/cli/templates/app/tester/views/default/partials/header.html +0 -20
- package/cli/templates/app/tester/ws/ChatHandler.js +0 -98
- /package/cli/templates/{app/fuzionx/routes/api.js.tpl → make/app/routes/api.js} +0 -0
- /package/cli/templates/{app/fuzionx/routes/web.js.tpl → make/app/routes/web.js} +0 -0
- /package/cli/templates/{app/fuzionx → make/app}/views/default/layouts/main.html +0 -0
|
@@ -1,397 +0,0 @@
|
|
|
1
|
-
import UserController from '../controllers/UserController.js';
|
|
2
|
-
import FileController from '../controllers/FileController.js';
|
|
3
|
-
import Joi from 'joi';
|
|
4
|
-
|
|
5
|
-
const userCreateSchema = Joi.object({
|
|
6
|
-
name: Joi.string().min(2).max(50).required().description('사용자 이름'),
|
|
7
|
-
email: Joi.string().email().required().description('이메일 주소'),
|
|
8
|
-
role: Joi.string().valid('user', 'admin').default('user').description('역할'),
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
const idParam = Joi.object({
|
|
12
|
-
id: Joi.number().integer().required().description('사용자 ID'),
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
export default (r) => {
|
|
16
|
-
|
|
17
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
18
|
-
// System
|
|
19
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
20
|
-
r.get('/api/health', (ctx) => {
|
|
21
|
-
ctx.json({ status: 'ok', timestamp: Date.now() });
|
|
22
|
-
}, { docs: { summary: 'Health Check', tags: ['System'] } });
|
|
23
|
-
|
|
24
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
25
|
-
// Controller — User CRUD (AutoLoader __handler__)
|
|
26
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
27
|
-
r.group('/api/users', (r) => {
|
|
28
|
-
r.get('/', UserController.index, { docs: { summary: '사용자 목록', tags: ['Controller'] } });
|
|
29
|
-
r.post('/', UserController.store, {
|
|
30
|
-
validate: { body: userCreateSchema },
|
|
31
|
-
docs: { summary: '사용자 생성', tags: ['Controller'] },
|
|
32
|
-
});
|
|
33
|
-
r.get('/:id', UserController.show, {
|
|
34
|
-
validate: { params: idParam },
|
|
35
|
-
docs: { summary: '사용자 상세', tags: ['Controller'] },
|
|
36
|
-
});
|
|
37
|
-
r.put('/:id', UserController.update, { docs: { summary: '사용자 수정', tags: ['Controller'] } });
|
|
38
|
-
r.delete('/:id', UserController.destroy, { docs: { summary: '사용자 삭제', tags: ['Controller'] } });
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
42
|
-
// EventBus — on / emit / off / clear
|
|
43
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
44
|
-
r.group('/api/events', (r) => {
|
|
45
|
-
r.post('/emit', async (ctx) => {
|
|
46
|
-
const { event, data } = ctx.body || {};
|
|
47
|
-
if (!event) return ctx.status(400).json({ error: 'event 필요' });
|
|
48
|
-
const before = ctx.app._eventHandlers.has(event) ? ctx.app._eventHandlers.get(event).length : 0;
|
|
49
|
-
await ctx.app.emit(event, data);
|
|
50
|
-
ctx.json({ emitted: true, event, data, handlerCount: before });
|
|
51
|
-
}, { docs: { summary: '이벤트 발행', tags: ['EventBus'] } });
|
|
52
|
-
|
|
53
|
-
r.post('/on', (ctx) => {
|
|
54
|
-
const { event } = ctx.body || {};
|
|
55
|
-
if (!event) return ctx.status(400).json({ error: 'event 필요' });
|
|
56
|
-
ctx.app.on(event, (data, meta) => {
|
|
57
|
-
console.log(`[EventBus] ${event} → ${JSON.stringify(data)}, meta=${JSON.stringify(meta)}`);
|
|
58
|
-
});
|
|
59
|
-
const count = ctx.app._eventHandlers.get(event)?.length || 0;
|
|
60
|
-
ctx.json({ subscribed: true, event, handlerCount: count });
|
|
61
|
-
}, { docs: { summary: '이벤트 구독', tags: ['EventBus'] } });
|
|
62
|
-
|
|
63
|
-
r.get('/list', (ctx) => {
|
|
64
|
-
const events = {};
|
|
65
|
-
for (const [key, handlers] of ctx.app._eventHandlers) {
|
|
66
|
-
events[key] = handlers.length;
|
|
67
|
-
}
|
|
68
|
-
ctx.json({ events, totalEvents: Object.keys(events).length });
|
|
69
|
-
}, { docs: { summary: '등록된 이벤트 목록', tags: ['EventBus'] } });
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
73
|
-
// Scheduler — register / _jobs / _parseCron
|
|
74
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
75
|
-
r.group('/api/scheduler', (r) => {
|
|
76
|
-
r.get('/status', (ctx) => {
|
|
77
|
-
const scheduler = ctx.app._scheduler;
|
|
78
|
-
const jobs = scheduler?._jobs || [];
|
|
79
|
-
ctx.json({
|
|
80
|
-
running: scheduler?._running || false,
|
|
81
|
-
registeredJobs: jobs.map(j => ({
|
|
82
|
-
name: j.name || j.constructor?.name,
|
|
83
|
-
schedule: j.schedule || j.constructor?.schedule,
|
|
84
|
-
timeout: j.timeout || j.constructor?.timeout || 30000,
|
|
85
|
-
enabled: j.enabled !== undefined ? j.enabled : (j.constructor?.enabled ?? true),
|
|
86
|
-
})),
|
|
87
|
-
activeTimers: scheduler?._timers?.length || 0,
|
|
88
|
-
count: jobs.length,
|
|
89
|
-
});
|
|
90
|
-
}, { docs: { summary: '스케줄러 상태', tags: ['Scheduler'] } });
|
|
91
|
-
|
|
92
|
-
r.post('/trigger', async (ctx) => {
|
|
93
|
-
const { jobName } = ctx.body || {};
|
|
94
|
-
const scheduler = ctx.app._scheduler;
|
|
95
|
-
const JobClass = scheduler?._jobs?.find(j => (j.name || j.constructor?.name) === jobName);
|
|
96
|
-
if (!JobClass) return ctx.status(404).json({ error: `Job '${jobName}' 없음` });
|
|
97
|
-
const job = new JobClass(ctx.app);
|
|
98
|
-
try {
|
|
99
|
-
const result = await job.handle();
|
|
100
|
-
ctx.json({ triggered: true, job: jobName, result });
|
|
101
|
-
} catch (err) {
|
|
102
|
-
await job.onError(err);
|
|
103
|
-
ctx.status(500).json({ error: err.message });
|
|
104
|
-
}
|
|
105
|
-
}, { docs: { summary: 'Job 수동 트리거', tags: ['Scheduler'] } });
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
109
|
-
// Queue — dispatch / pending / _tasks
|
|
110
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
111
|
-
r.group('/api/queue', (r) => {
|
|
112
|
-
r.post('/dispatch', async (ctx) => {
|
|
113
|
-
const { task, data } = ctx.body || {};
|
|
114
|
-
if (!task) return ctx.status(400).json({ error: 'task 이름 필요' });
|
|
115
|
-
const queue = ctx.app._queue;
|
|
116
|
-
if (!queue) return ctx.status(500).json({ error: 'Queue 미초기화' });
|
|
117
|
-
queue.dispatch(task, data || {});
|
|
118
|
-
ctx.json({ dispatched: true, task, data, pending: queue.pending });
|
|
119
|
-
}, { docs: { summary: 'Task 디스패치', tags: ['Queue'] } });
|
|
120
|
-
|
|
121
|
-
r.get('/status', (ctx) => {
|
|
122
|
-
const queue = ctx.app._queue;
|
|
123
|
-
ctx.json({
|
|
124
|
-
driver: queue?.driver || 'unknown',
|
|
125
|
-
registeredTasks: queue ? [...queue._tasks.keys()] : [],
|
|
126
|
-
pending: queue?.pending || 0,
|
|
127
|
-
processing: queue?._processing || false,
|
|
128
|
-
});
|
|
129
|
-
}, { docs: { summary: '큐 상태', tags: ['Queue'] } });
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
133
|
-
// Worker — run(name) / exec(fn) / active
|
|
134
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
135
|
-
r.group('/api/worker', (r) => {
|
|
136
|
-
// WorkerPool.run(name, data) — workers/ 폴더 스크립트 실행
|
|
137
|
-
r.post('/run', async (ctx) => {
|
|
138
|
-
const { name, data } = ctx.body || {};
|
|
139
|
-
if (!name) return ctx.status(400).json({ error: 'worker name 필요' });
|
|
140
|
-
try {
|
|
141
|
-
const result = await ctx.app.worker.run(name, data || {}, { timeout: 5000 });
|
|
142
|
-
ctx.json({ success: true, name, result, active: ctx.app.worker.active });
|
|
143
|
-
} catch (err) {
|
|
144
|
-
ctx.status(500).json({ error: err.message, active: ctx.app.worker.active });
|
|
145
|
-
}
|
|
146
|
-
}, { docs: { summary: '워커 스크립트 실행 (run)', tags: ['Worker'] } });
|
|
147
|
-
|
|
148
|
-
// WorkerPool.exec(fn, data) — 인라인 함수 실행
|
|
149
|
-
r.post('/exec', async (ctx) => {
|
|
150
|
-
try {
|
|
151
|
-
const iterations = ctx.body?.iterations || 100000;
|
|
152
|
-
const result = await ctx.app.worker.exec(
|
|
153
|
-
(data) => {
|
|
154
|
-
let r = 0;
|
|
155
|
-
for (let i = 0; i < data.n; i++) r += Math.sqrt(i);
|
|
156
|
-
return { result: r, n: data.n, pid: process.pid };
|
|
157
|
-
},
|
|
158
|
-
{ n: iterations },
|
|
159
|
-
{ timeout: 5000 }
|
|
160
|
-
);
|
|
161
|
-
ctx.json({ success: true, ...result, active: ctx.app.worker.active });
|
|
162
|
-
} catch (err) {
|
|
163
|
-
ctx.status(500).json({ error: err.message });
|
|
164
|
-
}
|
|
165
|
-
}, { docs: { summary: '인라인 함수 워커 실행 (exec)', tags: ['Worker'] } });
|
|
166
|
-
|
|
167
|
-
r.get('/status', (ctx) => {
|
|
168
|
-
ctx.json({
|
|
169
|
-
active: ctx.app.worker?.active || 0,
|
|
170
|
-
timeout: ctx.app.worker?._defaultTimeout || 30000,
|
|
171
|
-
});
|
|
172
|
-
}, { docs: { summary: '워커풀 상태', tags: ['Worker'] } });
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
176
|
-
// Middleware — 체인 테스트
|
|
177
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
178
|
-
r.group('/api/middleware', (r) => {
|
|
179
|
-
r.get('/cors-test', (ctx) => {
|
|
180
|
-
// CORS 헤더 확인
|
|
181
|
-
ctx.json({
|
|
182
|
-
origin: ctx.get('origin') || 'none',
|
|
183
|
-
responseHeaders: {
|
|
184
|
-
'access-control-allow-origin': ctx._res?._headers?.['Access-Control-Allow-Origin'] || '(설정 필요)',
|
|
185
|
-
},
|
|
186
|
-
});
|
|
187
|
-
}, { docs: { summary: 'CORS 미들웨어 테스트', tags: ['Middleware'] } });
|
|
188
|
-
|
|
189
|
-
r.get('/auth-test', (ctx) => {
|
|
190
|
-
// auth 미들웨어가 설정한 ctx.user 확인
|
|
191
|
-
ctx.json({
|
|
192
|
-
authenticated: !!ctx.user,
|
|
193
|
-
user: ctx.user || null,
|
|
194
|
-
sessionId: ctx._sessionId || null,
|
|
195
|
-
});
|
|
196
|
-
}, { docs: { summary: 'Auth 미들웨어 테스트', tags: ['Middleware'] } });
|
|
197
|
-
|
|
198
|
-
r.get('/theme-test', (ctx) => {
|
|
199
|
-
ctx.json({
|
|
200
|
-
theme: ctx.theme || 'default',
|
|
201
|
-
host: ctx.host || 'unknown',
|
|
202
|
-
});
|
|
203
|
-
}, { docs: { summary: 'Theme 미들웨어 테스트', tags: ['Middleware'] } });
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
207
|
-
// WebSocket — 핸들러 정보 + 이벤트 맵
|
|
208
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
209
|
-
r.get('/api/ws/info', (ctx) => {
|
|
210
|
-
const handlers = ctx.app._wsHandlers;
|
|
211
|
-
const info = [];
|
|
212
|
-
if (handlers) {
|
|
213
|
-
for (const [ns, HandlerClass] of handlers) {
|
|
214
|
-
const eventMap = HandlerClass.buildEventMap ? HandlerClass.buildEventMap() : new Map();
|
|
215
|
-
info.push({
|
|
216
|
-
namespace: ns,
|
|
217
|
-
handlerName: HandlerClass.name,
|
|
218
|
-
middleware: HandlerClass.middleware || [],
|
|
219
|
-
events: [...eventMap.keys()],
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
ctx.json({ websocket: true, handlers: info, count: info.length });
|
|
224
|
-
}, { docs: { summary: 'WebSocket 핸들러 상세', tags: ['WebSocket'] } });
|
|
225
|
-
|
|
226
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
227
|
-
// Upload — 단일/다중/목록
|
|
228
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
229
|
-
r.group('/api/files', (r) => {
|
|
230
|
-
r.post('/upload', FileController.upload, {
|
|
231
|
-
upload: { maxSize: '10MB', allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], maxFiles: 1 },
|
|
232
|
-
docs: { summary: '단일 파일 업로드', tags: ['Upload'] },
|
|
233
|
-
});
|
|
234
|
-
r.post('/upload-multiple', FileController.uploadMultiple, {
|
|
235
|
-
upload: { maxSize: '50MB', maxFiles: 10 },
|
|
236
|
-
docs: { summary: '다중 파일 업로드', tags: ['Upload'] },
|
|
237
|
-
});
|
|
238
|
-
r.get('/list', FileController.listFiles, {
|
|
239
|
-
docs: { summary: '업로드 파일 목록', tags: ['Upload'] },
|
|
240
|
-
});
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
244
|
-
// Media — resize / toWebp / imageInfo / videoThumbnail
|
|
245
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
246
|
-
r.group('/api/media', (r) => {
|
|
247
|
-
r.post('/resize', FileController.resize, {
|
|
248
|
-
upload: { maxSize: '20MB', allowedTypes: ['image/jpeg', 'image/png', 'image/webp'], maxFiles: 1 },
|
|
249
|
-
docs: { summary: '이미지 리사이즈', tags: ['Media'] },
|
|
250
|
-
});
|
|
251
|
-
r.post('/resize-multiple', FileController.resizeMultiple, {
|
|
252
|
-
upload: { maxSize: '20MB', allowedTypes: ['image/jpeg', 'image/png', 'image/webp'], maxFiles: 1 },
|
|
253
|
-
docs: { summary: '다중 리사이즈 (L/M/S)', tags: ['Media'] },
|
|
254
|
-
});
|
|
255
|
-
r.post('/to-webp', FileController.toWebp, {
|
|
256
|
-
upload: { maxSize: '20MB', allowedTypes: ['image/jpeg', 'image/png'], maxFiles: 1 },
|
|
257
|
-
docs: { summary: 'WebP 변환', tags: ['Media'] },
|
|
258
|
-
});
|
|
259
|
-
r.post('/image-info', FileController.imageInfo, {
|
|
260
|
-
upload: { maxSize: '20MB', allowedTypes: ['image/jpeg', 'image/png', 'image/webp'], maxFiles: 1 },
|
|
261
|
-
docs: { summary: '이미지 정보', tags: ['Media'] },
|
|
262
|
-
});
|
|
263
|
-
r.post('/video-thumbnail', FileController.videoThumbnail, {
|
|
264
|
-
upload: { maxSize: '100MB', allowedTypes: ['video/mp4', 'video/webm'], maxFiles: 1 },
|
|
265
|
-
docs: { summary: '비디오 썸네일', tags: ['Media'] },
|
|
266
|
-
});
|
|
267
|
-
r.post('/video-preview', FileController.videoPreview, {
|
|
268
|
-
upload: { maxSize: '2GB', allowedTypes: ['video/mp4', 'video/webm', 'video/matroska'], maxFiles: 1 },
|
|
269
|
-
docs: { summary: '비디오 미리보기 (간격 기반 다중 썸네일 + 스프라이트 시트)', tags: ['Media'] },
|
|
270
|
-
});
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
274
|
-
// Utility — Crypto / Hash
|
|
275
|
-
// CryptoHelper: encrypt(key, plaintext), decrypt(key, ciphertext)
|
|
276
|
-
// HashHelper: bcrypt(password), bcryptVerify(password, hash)
|
|
277
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
278
|
-
r.group('/api/utils', (r) => {
|
|
279
|
-
r.post('/encrypt', (ctx) => {
|
|
280
|
-
const { text, key } = ctx.body || {};
|
|
281
|
-
if (!text) return ctx.status(400).json({ error: 'text 필요' });
|
|
282
|
-
try {
|
|
283
|
-
const secretKey = key || 'test-secret-key-32bytes!';
|
|
284
|
-
const encrypted = ctx.app.crypto.encrypt(secretKey, text);
|
|
285
|
-
ctx.json({ original: text, encrypted, key: secretKey });
|
|
286
|
-
} catch (err) { ctx.status(500).json({ error: err.message }); }
|
|
287
|
-
}, { docs: { summary: '텍스트 암호화 (AES-256-GCM)', tags: ['Utility'] } });
|
|
288
|
-
|
|
289
|
-
r.post('/decrypt', (ctx) => {
|
|
290
|
-
const { encrypted, key } = ctx.body || {};
|
|
291
|
-
if (!encrypted) return ctx.status(400).json({ error: 'encrypted 필요' });
|
|
292
|
-
try {
|
|
293
|
-
const secretKey = key || 'test-secret-key-32bytes!';
|
|
294
|
-
const decrypted = ctx.app.crypto.decrypt(secretKey, encrypted);
|
|
295
|
-
ctx.json({ decrypted, encrypted, key: secretKey });
|
|
296
|
-
} catch (err) { ctx.status(500).json({ error: err.message }); }
|
|
297
|
-
}, { docs: { summary: '텍스트 복호화', tags: ['Utility'] } });
|
|
298
|
-
|
|
299
|
-
r.post('/hash', (ctx) => {
|
|
300
|
-
const { password, algorithm } = ctx.body || {};
|
|
301
|
-
if (!password) return ctx.status(400).json({ error: 'password 필요' });
|
|
302
|
-
try {
|
|
303
|
-
if (algorithm === 'argon2') {
|
|
304
|
-
const hashed = ctx.app.hash.argon2(password);
|
|
305
|
-
const verified = ctx.app.hash.argon2Verify(password, hashed);
|
|
306
|
-
ctx.json({ password, algorithm: 'argon2', hashed, verified });
|
|
307
|
-
} else {
|
|
308
|
-
const hashed = ctx.app.hash.bcrypt(password);
|
|
309
|
-
const verified = ctx.app.hash.bcryptVerify(password, hashed);
|
|
310
|
-
ctx.json({ password, algorithm: 'bcrypt', hashed, verified });
|
|
311
|
-
}
|
|
312
|
-
} catch (err) { ctx.status(500).json({ error: err.message }); }
|
|
313
|
-
}, { docs: { summary: '패스워드 해싱 + 검증', tags: ['Utility'] } });
|
|
314
|
-
|
|
315
|
-
r.get('/uuid', (ctx) => {
|
|
316
|
-
ctx.json({ uuid: ctx.app.crypto.uuid() });
|
|
317
|
-
}, { docs: { summary: 'UUID 생성', tags: ['Utility'] } });
|
|
318
|
-
|
|
319
|
-
r.post('/md5', (ctx) => {
|
|
320
|
-
const { text } = ctx.body || {};
|
|
321
|
-
if (!text) return ctx.status(400).json({ error: 'text 필요' });
|
|
322
|
-
ctx.json({ text, md5: ctx.app.crypto.md5(text), sha256: ctx.app.crypto.sha256(text) });
|
|
323
|
-
}, { docs: { summary: 'MD5 + SHA256 해시', tags: ['Utility'] } });
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
327
|
-
// Service — cache / mutex / retryable / runParallel
|
|
328
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
329
|
-
r.get('/api/service/dashboard', async (ctx) => {
|
|
330
|
-
const svc = ctx.app.make('UserService');
|
|
331
|
-
const result = await svc.getDashboard();
|
|
332
|
-
ctx.json(result);
|
|
333
|
-
}, { docs: { summary: 'Service 병렬 실행', tags: ['Service'] } });
|
|
334
|
-
|
|
335
|
-
r.get('/api/service/cache-test', async (ctx) => {
|
|
336
|
-
const svc = ctx.app.make('UserService');
|
|
337
|
-
const t1 = Date.now();
|
|
338
|
-
await svc.getUsers();
|
|
339
|
-
const first = Date.now() - t1;
|
|
340
|
-
const t2 = Date.now();
|
|
341
|
-
await svc.getUsers();
|
|
342
|
-
const second = Date.now() - t2;
|
|
343
|
-
ctx.json({ firstCallMs: first, secondCallMs: second, cached: second <= first });
|
|
344
|
-
}, { docs: { summary: 'Service 캐시 테스트', tags: ['Service'] } });
|
|
345
|
-
|
|
346
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
347
|
-
// i18n — translate / all / locales (Bridge 우선)
|
|
348
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
349
|
-
r.group('/api/lang', (r) => {
|
|
350
|
-
r.get('/translate', (ctx) => {
|
|
351
|
-
const locale = ctx.query?.locale || ctx.get('Accept-Language')?.split(',')[0]?.trim() || 'ko';
|
|
352
|
-
const key = ctx.query?.key;
|
|
353
|
-
if (!key) return ctx.status(400).json({ error: 'key 파라미터 필요' });
|
|
354
|
-
const vars = {};
|
|
355
|
-
if (ctx.query?.name) vars.name = ctx.query.name;
|
|
356
|
-
if (ctx.query?.count) vars.count = ctx.query.count;
|
|
357
|
-
const value = ctx.app.i18n.translate(locale, key, vars);
|
|
358
|
-
ctx.json({ locale, key, value, vars, source: ctx.app.i18n._bridge ? 'bridge' : 'js-fallback' });
|
|
359
|
-
}, { docs: { summary: '번역 조회', tags: ['i18n'] } });
|
|
360
|
-
|
|
361
|
-
r.get('/all', (ctx) => {
|
|
362
|
-
const locale = ctx.query?.locale || 'ko';
|
|
363
|
-
const messages = ctx.app.i18n.all(locale);
|
|
364
|
-
ctx.json({ locale, messages, count: Object.keys(messages).length });
|
|
365
|
-
}, { docs: { summary: '전체 번역 키', tags: ['i18n'] } });
|
|
366
|
-
|
|
367
|
-
r.get('/locales', (ctx) => {
|
|
368
|
-
const locales = ctx.app.i18n.locales();
|
|
369
|
-
ctx.json({
|
|
370
|
-
locales,
|
|
371
|
-
default: ctx.app.i18n.defaultLocale,
|
|
372
|
-
fallback: ctx.app.i18n.fallback,
|
|
373
|
-
source: ctx.app.i18n._bridge ? 'bridge' : 'js-fallback',
|
|
374
|
-
});
|
|
375
|
-
}, { docs: { summary: '로케일 목록', tags: ['i18n'] } });
|
|
376
|
-
|
|
377
|
-
r.post('/render', (ctx) => {
|
|
378
|
-
const { template, context, locale } = ctx.body || {};
|
|
379
|
-
if (!template) return ctx.status(400).json({ error: 'template 필요' });
|
|
380
|
-
try {
|
|
381
|
-
// Bridge ssrRenderString(template, context, locale) 사용
|
|
382
|
-
const rendered = ctx.app.i18n._bridge
|
|
383
|
-
? ctx.app._bridge.ssrRenderString(template, JSON.stringify(context || {}), locale || 'ko')
|
|
384
|
-
: template.replace(/\{\{(\w+)\}\}/g, (_, k) => (context || {})[k] || `{{${k}}}`);
|
|
385
|
-
ctx.json({ rendered, template, context, locale: locale || 'ko', source: ctx.app.i18n._bridge ? 'bridge' : 'js-fallback' });
|
|
386
|
-
} catch (err) { ctx.status(500).json({ error: err.message }); }
|
|
387
|
-
}, { docs: { summary: 'SSR 렌더링 (Bridge)', tags: ['i18n'] } });
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
391
|
-
// Error Handler
|
|
392
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
393
|
-
r.get('/api/error-test', (ctx) => {
|
|
394
|
-
console.error(new Error('test'));
|
|
395
|
-
throw new Error('Intentional test error');
|
|
396
|
-
}, { docs: { summary: '에러 핸들러 테스트 (500)', tags: ['System'] } });
|
|
397
|
-
};
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import HomeController from '../controllers/HomeController.js';
|
|
2
|
-
|
|
3
|
-
export default (r) => {
|
|
4
|
-
r.get('/', HomeController.index);
|
|
5
|
-
r.get('/test/websocket', HomeController.websocket);
|
|
6
|
-
r.get('/test/upload', HomeController.upload);
|
|
7
|
-
r.get('/test/i18n', HomeController.i18n);
|
|
8
|
-
};
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { Service } from '@fuzionx/framework';
|
|
2
|
-
|
|
3
|
-
export default class UserService extends Service {
|
|
4
|
-
|
|
5
|
-
/** 사용자 목록 (캐시 적용) */
|
|
6
|
-
async getUsers() {
|
|
7
|
-
return this.withCache('users:all', 60, async () => {
|
|
8
|
-
// 실제 DB연동 시: return this.db.User.findAll();
|
|
9
|
-
return [
|
|
10
|
-
{ id: 1, name: 'Alice', email: 'alice@test.com', role: 'admin' },
|
|
11
|
-
{ id: 2, name: 'Bob', email: 'bob@test.com', role: 'user' },
|
|
12
|
-
];
|
|
13
|
-
});
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** 사용자 등록 (뮤텍스로 중복 방지) */
|
|
17
|
-
async register(data) {
|
|
18
|
-
return this.mutex(`user:register:${data.email}`, async () => {
|
|
19
|
-
const user = {
|
|
20
|
-
id: Date.now(),
|
|
21
|
-
...data,
|
|
22
|
-
createdAt: new Date().toISOString(),
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
// 이벤트 발행
|
|
26
|
-
this.emit('user:created', user);
|
|
27
|
-
this.invalidateCache('users:all');
|
|
28
|
-
|
|
29
|
-
return user;
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** 외부 API 호출 (재시도 적용) */
|
|
34
|
-
async fetchExternalData() {
|
|
35
|
-
return this.retryable(
|
|
36
|
-
async () => {
|
|
37
|
-
// 시뮬레이션: 성공
|
|
38
|
-
return { source: 'external', data: 'fetched', timestamp: Date.now() };
|
|
39
|
-
},
|
|
40
|
-
{ retries: 2, delay: 100 }
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** 병렬 처리 테스트 */
|
|
45
|
-
async getDashboard() {
|
|
46
|
-
const [users, external] = await this.runParallel([
|
|
47
|
-
() => this.getUsers(),
|
|
48
|
-
() => this.fetchExternalData(),
|
|
49
|
-
]);
|
|
50
|
-
return { users, external };
|
|
51
|
-
}
|
|
52
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
{% extends "layouts/main.html" %}
|
|
2
|
-
|
|
3
|
-
{% block title %}500 — 서버 오류{% endblock %}
|
|
4
|
-
|
|
5
|
-
{% block content %}
|
|
6
|
-
<div style="text-align:center;padding:80px 20px;">
|
|
7
|
-
<h1 style="font-size:72px;color:#e74c3c;">500</h1>
|
|
8
|
-
<p style="font-size:20px;margin:16px 0;">{{ error.message | default(value='Internal Server Error') }}</p>
|
|
9
|
-
{% if config.debug and error.stack %}
|
|
10
|
-
<pre style="text-align:left;max-width:600px;margin:24px auto;background:#f5f5f5;padding:16px;border-radius:8px;overflow:auto;">{{ error.stack }}</pre>
|
|
11
|
-
{% endif %}
|
|
12
|
-
<a href="/" style="display:inline-block;margin-top:24px;padding:12px 24px;background:#e74c3c;color:#fff;text-decoration:none;border-radius:6px;">홈으로 돌아가기</a>
|
|
13
|
-
</div>
|
|
14
|
-
{% endblock %}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="{{ locale | default(value='ko') }}" data-bs-theme="dark">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<title>{% block title %}{{ config.app.name | default(value='FuzionX') }}{% endblock %}</title>
|
|
7
|
-
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
8
|
-
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
|
9
|
-
<style>
|
|
10
|
-
.nav-brand { font-weight: 700; color: #0dcaf0 !important; }
|
|
11
|
-
pre { font-size: 12px; max-height: 200px; overflow-y: auto; }
|
|
12
|
-
</style>
|
|
13
|
-
{% block head %}{% endblock %}
|
|
14
|
-
</head>
|
|
15
|
-
<body>
|
|
16
|
-
{% include "partials/header.html" ignore missing %}
|
|
17
|
-
|
|
18
|
-
<main>
|
|
19
|
-
{% block content %}{% endblock %}
|
|
20
|
-
</main>
|
|
21
|
-
|
|
22
|
-
{% include "partials/footer.html" ignore missing %}
|
|
23
|
-
|
|
24
|
-
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
25
|
-
|
|
26
|
-
{# ── ASP: WASM 클라이언트 초기화 ── #}
|
|
27
|
-
{% if config.bridge.asp.enabled is defined and config.bridge.asp.enabled or config.app.asp.enabled is defined and config.app.asp.enabled %}
|
|
28
|
-
|
|
29
|
-
<script>
|
|
30
|
-
// aspFetch를 동기적으로 정의 — WASM 로딩은 내부에서 lazy await
|
|
31
|
-
window.aspEnabled = true;
|
|
32
|
-
window._aspReady = (async () => {
|
|
33
|
-
const _v = Date.now();
|
|
34
|
-
const mod = await import(`/pkg/fuzionx_client_wasm.js?v=${_v}`);
|
|
35
|
-
await mod.default({ module_or_path: `/pkg/fuzionx_client_wasm_bg.wasm?v=${_v}` });
|
|
36
|
-
const { FuzionXClient, FuzionXSocket } = mod;
|
|
37
|
-
window.FuzionXSocket = FuzionXSocket;
|
|
38
|
-
window._aspClient = FuzionXClient.new_with_options(
|
|
39
|
-
'{{ config.bridge.asp.master_secret }}',
|
|
40
|
-
'{{ config.bridge.asp.header_signal | default(value="Ruxy-Enc-Mode") }}'
|
|
41
|
-
);
|
|
42
|
-
console.log('[ASP] WASM client initialized');
|
|
43
|
-
})();
|
|
44
|
-
|
|
45
|
-
window.aspFetch = async function(url, opts = {}) {
|
|
46
|
-
await window._aspReady;
|
|
47
|
-
const method = (opts.method || 'GET').toUpperCase();
|
|
48
|
-
if (!url.startsWith('/api')) return fetch(url, opts);
|
|
49
|
-
|
|
50
|
-
// FormData (파일 업로드) — WASM upload() 메서드로 위임
|
|
51
|
-
// body는 암호화 불가 (바이너리), 응답 복호화는 WASM 내부에서 처리
|
|
52
|
-
if (opts.body instanceof FormData) {
|
|
53
|
-
return window._aspClient.upload(url, opts.body);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// JSON body 파싱
|
|
57
|
-
let bodyObj = undefined;
|
|
58
|
-
if (opts.body) {
|
|
59
|
-
bodyObj = typeof opts.body === 'string' ? JSON.parse(opts.body) : opts.body;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// WASM client 메서드 사용 — 자동 ASP 암/복호화
|
|
63
|
-
switch (method) {
|
|
64
|
-
case 'GET': return window._aspClient.get(url);
|
|
65
|
-
case 'POST': return window._aspClient.post(url, bodyObj);
|
|
66
|
-
case 'PUT': return window._aspClient.put(url, bodyObj);
|
|
67
|
-
case 'PATCH': return window._aspClient.patch(url, bodyObj);
|
|
68
|
-
case 'DELETE': return window._aspClient.delete(url);
|
|
69
|
-
default: return window._aspClient.get(url);
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
</script>
|
|
73
|
-
{% else %}
|
|
74
|
-
<script>
|
|
75
|
-
window.aspFetch = fetch.bind(window);
|
|
76
|
-
window.aspEnabled = false;
|
|
77
|
-
</script>
|
|
78
|
-
{% endif %}
|
|
79
|
-
|
|
80
|
-
{% block scripts %}{% endblock %}
|
|
81
|
-
</body>
|
|
82
|
-
</html>
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
{% extends "layouts/main.html" %}
|
|
2
|
-
|
|
3
|
-
{% block title %}Dashboard — {{ config.app.name | default(value='FuzionX') }}{% endblock %}
|
|
4
|
-
|
|
5
|
-
{% block head %}
|
|
6
|
-
<style>
|
|
7
|
-
.test-card pre { max-height: 180px; }
|
|
8
|
-
</style>
|
|
9
|
-
{% endblock %}
|
|
10
|
-
|
|
11
|
-
{% block content %}
|
|
12
|
-
<div class="container-fluid py-3">
|
|
13
|
-
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
14
|
-
<h5 class="mb-0"><i class="bi bi-speedometer2"></i> API 테스트 대시보드</h5>
|
|
15
|
-
<button class="btn btn-info btn-sm" onclick="runAll()"><i class="bi bi-play-circle-fill"></i> 전체 테스트</button>
|
|
16
|
-
</div>
|
|
17
|
-
<div class="row g-3" id="cards"></div>
|
|
18
|
-
<div class="alert alert-secondary mt-3 mb-0 py-2 small" id="statusBar"><i class="bi bi-info-circle"></i> Ready</div>
|
|
19
|
-
</div>
|
|
20
|
-
{% endblock %}
|
|
21
|
-
|
|
22
|
-
{% block scripts %}
|
|
23
|
-
<script>
|
|
24
|
-
const tests = [
|
|
25
|
-
{ id:'health',name:'Health Check',icon:'bi-heart-pulse',method:'GET',url:'/api/health',cat:'System' },
|
|
26
|
-
{ id:'users',name:'User List',icon:'bi-people',method:'GET',url:'/api/users',cat:'Controller' },
|
|
27
|
-
{ id:'create',name:'User Create',icon:'bi-person-plus',method:'POST',url:'/api/users',body:{name:'TestUser',email:'test@fx.com'},cat:'Controller' },
|
|
28
|
-
{ id:'show',name:'User Show',icon:'bi-person',method:'GET',url:'/api/users/1',cat:'Controller' },
|
|
29
|
-
{ id:'dashboard',name:'Service Dashboard',icon:'bi-columns-gap',method:'GET',url:'/api/service/dashboard',cat:'Service' },
|
|
30
|
-
{ id:'cache',name:'Cache Test',icon:'bi-database',method:'GET',url:'/api/service/cache-test',cat:'Service' },
|
|
31
|
-
{ id:'events',name:'Events List',icon:'bi-broadcast',method:'GET',url:'/api/events/list',cat:'EventBus' },
|
|
32
|
-
{ id:'emit',name:'Event Emit',icon:'bi-send',method:'POST',url:'/api/events/emit',body:{event:'test:ping',data:{ts:Date.now()}},cat:'EventBus' },
|
|
33
|
-
{ id:'scheduler',name:'Scheduler Status',icon:'bi-clock-history',method:'GET',url:'/api/scheduler/status',cat:'Scheduler' },
|
|
34
|
-
{ id:'trigger',name:'Job Trigger',icon:'bi-play-btn',method:'POST',url:'/api/scheduler/trigger',body:{jobName:'CleanupJob'},cat:'Scheduler' },
|
|
35
|
-
{ id:'qstatus',name:'Queue Status',icon:'bi-list-task',method:'GET',url:'/api/queue/status',cat:'Queue' },
|
|
36
|
-
{ id:'dispatch',name:'Queue Dispatch',icon:'bi-envelope-paper',method:'POST',url:'/api/queue/dispatch',body:{task:'EmailTask',data:{to:'t@t.com',subject:'Hi'}},cat:'Queue' },
|
|
37
|
-
{ id:'wstatus',name:'Worker Status',icon:'bi-cpu',method:'GET',url:'/api/worker/status',cat:'Worker' },
|
|
38
|
-
{ id:'wexec',name:'Worker Exec',icon:'bi-gear-wide-connected',method:'POST',url:'/api/worker/exec',body:{iterations:50000},cat:'Worker' },
|
|
39
|
-
{ id:'wrun',name:'Worker Run',icon:'bi-terminal',method:'POST',url:'/api/worker/run',body:{name:'heavy',data:{iterations:50000}},cat:'Worker' },
|
|
40
|
-
{ id:'ws',name:'WebSocket Info',icon:'bi-plug',method:'GET',url:'/api/ws/info',cat:'WebSocket' },
|
|
41
|
-
{ id:'i18nko',name:'i18n 한국어',icon:'bi-translate',method:'GET',url:'/api/lang/translate?locale=ko&key=common.greeting&name=Admin',cat:'i18n' },
|
|
42
|
-
{ id:'i18nen',name:'i18n English',icon:'bi-globe',method:'GET',url:'/api/lang/translate?locale=en&key=common.greeting&name=Admin',cat:'i18n' },
|
|
43
|
-
{ id:'encrypt',name:'Encrypt',icon:'bi-lock',method:'POST',url:'/api/utils/encrypt',body:{text:'Hello FuzionX'},cat:'Utility' },
|
|
44
|
-
{ id:'hash',name:'Hash',icon:'bi-key',method:'POST',url:'/api/utils/hash',body:{password:'secret123'},cat:'Utility' },
|
|
45
|
-
{ id:'uuid',name:'UUID',icon:'bi-fingerprint',method:'GET',url:'/api/utils/uuid',cat:'Utility' },
|
|
46
|
-
{ id:'files',name:'File List',icon:'bi-folder2-open',method:'GET',url:'/api/files/list',cat:'Upload' },
|
|
47
|
-
{ id:'mw',name:'Middleware Theme',icon:'bi-palette',method:'GET',url:'/api/middleware/theme-test',cat:'Middleware' },
|
|
48
|
-
{ id:'openapi',name:'OpenAPI Spec',icon:'bi-file-earmark-code',method:'GET',url:'/docs/openapi.json',cat:'Swagger' },
|
|
49
|
-
{ id:'error',name:'Error 500',icon:'bi-exclamation-triangle',method:'GET',url:'/api/error-test',cat:'System' },
|
|
50
|
-
];
|
|
51
|
-
const C=document.getElementById('cards');
|
|
52
|
-
tests.forEach(t=>{C.innerHTML+=`<div class="col-xl-3 col-lg-4 col-md-6"><div class="card test-card h-100"><div class="card-header d-flex justify-content-between align-items-center py-2"><small><i class="bi ${t.icon}"></i> ${t.name}</small><span class="badge text-bg-secondary" id="b-${t.id}">대기</span></div><div class="card-body p-2"><code class="small text-muted">${t.method} ${t.url.split('?')[0]}</code><pre class="bg-black text-success rounded p-2 mt-2 mb-2" id="r-${t.id}">—</pre><button class="btn btn-outline-info btn-sm w-100" onclick="run('${t.id}')"><i class="bi bi-play-fill"></i> 실행</button></div></div></div>`;});
|
|
53
|
-
async function run(id){const t=tests.find(x=>x.id===id),b=document.getElementById('b-'+id),r=document.getElementById('r-'+id);b.className='badge text-bg-warning';b.textContent='실행중';r.textContent='...';try{const s=Date.now();let d,status;if(window.aspEnabled&&t.url.startsWith('/api')){const result=await aspFetch(t.url,{method:t.method,headers:{'Content-Type':'application/json'},body:t.body?JSON.stringify(t.body):undefined});if(result instanceof Response){status=result.status;const txt=await result.text();try{d=JSON.stringify(JSON.parse(txt),null,2)}catch{d=txt.slice(0,300)}}else{status=200;d=JSON.stringify(result,null,2)}}else{const o={method:t.method,headers:{'Content-Type':'application/json'}};if(t.body)o.body=JSON.stringify(t.body);const res=await fetch(t.url,o);status=res.status;const txt=await res.text();try{d=JSON.stringify(JSON.parse(txt),null,2)}catch{d=txt.slice(0,300)}}const ms=Date.now()-s;r.textContent=`HTTP ${status} (${ms}ms)\n${d}`;const ok=status<400||id==='error';b.className=`badge text-bg-${ok?'success':'danger'}`;b.textContent=ok?'성공':'실패';document.getElementById('statusBar').innerHTML=`<i class="bi bi-${ok?'check-circle':'x-circle'}"></i> ${t.name} — HTTP ${status} (${ms}ms)`}catch(e){r.textContent=e.message;b.className='badge text-bg-danger';b.textContent='실패'}}
|
|
54
|
-
async function runAll(){let p=0,f=0;document.getElementById('statusBar').innerHTML='<i class="bi bi-hourglass-split"></i> 전체 테스트 실행중...';for(const t of tests){await run(t.id);p+=document.getElementById('b-'+t.id).textContent==='성공'?1:0;f+=document.getElementById('b-'+t.id).textContent==='실패'?1:0}document.getElementById('statusBar').innerHTML=`<i class="bi bi-check-circle-fill"></i> 완료: <strong>${p}</strong> 성공 / <strong>${f}</strong> 실패 (총 ${tests.length}개)`}
|
|
55
|
-
</script>
|
|
56
|
-
{% endblock %}
|