@vue-skuilder/express 0.1.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.
- package/.env.development +9 -0
- package/.prettierignore +4 -0
- package/.prettierrc +8 -0
- package/.vscode/launch.json +20 -0
- package/assets/classroomDesignDoc.js +24 -0
- package/assets/courseValidateDocUpdate.js +56 -0
- package/assets/get-tagsDesignDoc.json +9 -0
- package/babel.config.js +6 -0
- package/dist/app.d.ts +6 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +194 -0
- package/dist/app.js.map +1 -0
- package/dist/attachment-preprocessing/index.d.ts +11 -0
- package/dist/attachment-preprocessing/index.d.ts.map +1 -0
- package/dist/attachment-preprocessing/index.js +146 -0
- package/dist/attachment-preprocessing/index.js.map +1 -0
- package/dist/attachment-preprocessing/normalize.d.ts +7 -0
- package/dist/attachment-preprocessing/normalize.d.ts.map +1 -0
- package/dist/attachment-preprocessing/normalize.js +90 -0
- package/dist/attachment-preprocessing/normalize.js.map +1 -0
- package/dist/client-requests/classroom-requests.d.ts +26 -0
- package/dist/client-requests/classroom-requests.d.ts.map +1 -0
- package/dist/client-requests/classroom-requests.js +171 -0
- package/dist/client-requests/classroom-requests.js.map +1 -0
- package/dist/client-requests/course-requests.d.ts +10 -0
- package/dist/client-requests/course-requests.d.ts.map +1 -0
- package/dist/client-requests/course-requests.js +135 -0
- package/dist/client-requests/course-requests.js.map +1 -0
- package/dist/client.d.ts +31 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +70 -0
- package/dist/client.js.map +1 -0
- package/dist/couchdb/authentication.d.ts +4 -0
- package/dist/couchdb/authentication.d.ts.map +1 -0
- package/dist/couchdb/authentication.js +64 -0
- package/dist/couchdb/authentication.js.map +1 -0
- package/dist/couchdb/index.d.ts +18 -0
- package/dist/couchdb/index.d.ts.map +1 -0
- package/dist/couchdb/index.js +52 -0
- package/dist/couchdb/index.js.map +1 -0
- package/dist/design-docs.d.ts +63 -0
- package/dist/design-docs.d.ts.map +1 -0
- package/dist/design-docs.js +90 -0
- package/dist/design-docs.js.map +1 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +62 -0
- package/dist/logger.js.map +1 -0
- package/dist/routes/logs.d.ts +3 -0
- package/dist/routes/logs.d.ts.map +1 -0
- package/dist/routes/logs.js +274 -0
- package/dist/routes/logs.js.map +1 -0
- package/dist/utils/env.d.ts +10 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/env.js +38 -0
- package/dist/utils/env.js.map +1 -0
- package/dist/utils/processQueue.d.ts +39 -0
- package/dist/utils/processQueue.d.ts.map +1 -0
- package/dist/utils/processQueue.js +175 -0
- package/dist/utils/processQueue.js.map +1 -0
- package/eslint.config.js +19 -0
- package/jest.config.ts +24 -0
- package/package.json +74 -0
- package/src/app.ts +246 -0
- package/src/attachment-preprocessing/index.ts +204 -0
- package/src/attachment-preprocessing/normalize.ts +123 -0
- package/src/client-requests/classroom-requests.ts +234 -0
- package/src/client-requests/course-requests.ts +188 -0
- package/src/client.ts +97 -0
- package/src/couchdb/authentication.ts +85 -0
- package/src/couchdb/index.ts +76 -0
- package/src/design-docs.ts +107 -0
- package/src/logger.ts +75 -0
- package/src/routes/logs.ts +289 -0
- package/src/utils/env.ts +51 -0
- package/src/utils/processQueue.ts +218 -0
- package/test/client.test.ts +144 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import express, { Request, Response } from 'express';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import rateLimit from 'express-rate-limit';
|
|
5
|
+
|
|
6
|
+
import logger from '../logger.js';
|
|
7
|
+
import process from 'process';
|
|
8
|
+
|
|
9
|
+
const router = express.Router();
|
|
10
|
+
|
|
11
|
+
// Rate limiting middleware for logs routes
|
|
12
|
+
const logsRateLimit = rateLimit({
|
|
13
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
14
|
+
max: 20, // Limit each IP to 20 requests per windowMs
|
|
15
|
+
message: {
|
|
16
|
+
error: 'Too many log requests from this IP, please try again later.'
|
|
17
|
+
},
|
|
18
|
+
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
|
19
|
+
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Apply rate limiting to all routes in this router
|
|
23
|
+
router.use(logsRateLimit);
|
|
24
|
+
|
|
25
|
+
// Get list of available log files
|
|
26
|
+
router.get('/', (_req: Request, res: Response) => {
|
|
27
|
+
void (async () => {
|
|
28
|
+
// [ ] add an auth mechanism. Below fcn is based on
|
|
29
|
+
// the CouchDB auth mechanism, forwarded from the web-app (not direct-access of server).
|
|
30
|
+
//
|
|
31
|
+
// const auth = await requestIsAdminAuthenticated(req);
|
|
32
|
+
// if (!auth) {
|
|
33
|
+
// res.status(401).json({ error: 'Unauthorized' });
|
|
34
|
+
// return;
|
|
35
|
+
// }
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const logsDir = path.join(process.cwd(), 'logs');
|
|
39
|
+
const files = await fs.readdir(logsDir);
|
|
40
|
+
|
|
41
|
+
// Create HTML content
|
|
42
|
+
const html = `
|
|
43
|
+
<!DOCTYPE html>
|
|
44
|
+
<html>
|
|
45
|
+
<head>
|
|
46
|
+
<title>Log Files</title>
|
|
47
|
+
<style>
|
|
48
|
+
body {
|
|
49
|
+
font-family: Arial, sans-serif;
|
|
50
|
+
margin: 20px;
|
|
51
|
+
}
|
|
52
|
+
h1 {
|
|
53
|
+
color: #333;
|
|
54
|
+
}
|
|
55
|
+
.log-list {
|
|
56
|
+
list-style: none;
|
|
57
|
+
padding: 0;
|
|
58
|
+
}
|
|
59
|
+
.log-list li {
|
|
60
|
+
margin: 10px 0;
|
|
61
|
+
}
|
|
62
|
+
.log-list a {
|
|
63
|
+
color: #0066cc;
|
|
64
|
+
text-decoration: none;
|
|
65
|
+
padding: 5px;
|
|
66
|
+
}
|
|
67
|
+
.log-list a:hover {
|
|
68
|
+
text-decoration: underline;
|
|
69
|
+
}
|
|
70
|
+
</style>
|
|
71
|
+
</head>
|
|
72
|
+
<body>
|
|
73
|
+
<h1>Available Log Files</h1>
|
|
74
|
+
<ul class="log-list">
|
|
75
|
+
${files
|
|
76
|
+
.map(
|
|
77
|
+
(file) => `
|
|
78
|
+
<li>
|
|
79
|
+
<a href="/logs/${file}">${file}</a>
|
|
80
|
+
</li>
|
|
81
|
+
`
|
|
82
|
+
)
|
|
83
|
+
.join('')}
|
|
84
|
+
</ul>
|
|
85
|
+
</body>
|
|
86
|
+
</html>
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
res.type('html').send(html);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
logger.error('Error reading logs directory:', error);
|
|
92
|
+
res.status(500).json({ error: 'Failed to retrieve log files' });
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
router.get('/:filename', (req: Request, res: Response) => {
|
|
98
|
+
void (async () => {
|
|
99
|
+
try {
|
|
100
|
+
const filename = req.params.filename;
|
|
101
|
+
// Sanitize filename to prevent directory traversal
|
|
102
|
+
if (filename.includes('..') || !filename.match(/^[a-zA-Z0-9-_.]+$/)) {
|
|
103
|
+
res.status(400).send('<h1>Error</h1><p>Invalid filename</p>');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const logPath = path.join(process.cwd(), 'logs', filename);
|
|
108
|
+
const content = (await fs.readFile(logPath, 'utf-8')).toString();
|
|
109
|
+
|
|
110
|
+
// Parse log lines
|
|
111
|
+
const lines = content
|
|
112
|
+
.split('\n')
|
|
113
|
+
.filter((line) => line.trim())
|
|
114
|
+
.map((line) => {
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(line);
|
|
117
|
+
} catch {
|
|
118
|
+
return line;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const html = `
|
|
123
|
+
<!DOCTYPE html>
|
|
124
|
+
<html>
|
|
125
|
+
<head>
|
|
126
|
+
<title>Log Viewer - ${filename}</title>
|
|
127
|
+
<style>
|
|
128
|
+
body {
|
|
129
|
+
font-family: Arial, sans-serif;
|
|
130
|
+
margin: 20px;
|
|
131
|
+
max-width: 1200px;
|
|
132
|
+
margin: 0 auto;
|
|
133
|
+
padding: 20px;
|
|
134
|
+
}
|
|
135
|
+
.header {
|
|
136
|
+
display: flex;
|
|
137
|
+
justify-content: space-between;
|
|
138
|
+
align-items: center;
|
|
139
|
+
margin-bottom: 20px;
|
|
140
|
+
}
|
|
141
|
+
.nav-links {
|
|
142
|
+
margin-bottom: 20px;
|
|
143
|
+
}
|
|
144
|
+
.nav-links a {
|
|
145
|
+
color: #0066cc;
|
|
146
|
+
text-decoration: none;
|
|
147
|
+
margin-right: 15px;
|
|
148
|
+
}
|
|
149
|
+
.nav-links a:hover {
|
|
150
|
+
text-decoration: underline;
|
|
151
|
+
}
|
|
152
|
+
.filters {
|
|
153
|
+
margin-bottom: 20px;
|
|
154
|
+
padding: 10px;
|
|
155
|
+
background-color: #f5f5f5;
|
|
156
|
+
border-radius: 4px;
|
|
157
|
+
}
|
|
158
|
+
.filters input, .filters select {
|
|
159
|
+
margin: 5px;
|
|
160
|
+
padding: 5px;
|
|
161
|
+
}
|
|
162
|
+
.log-entry {
|
|
163
|
+
padding: 8px;
|
|
164
|
+
border-bottom: 1px solid #eee;
|
|
165
|
+
font-family: monospace;
|
|
166
|
+
white-space: pre-wrap;
|
|
167
|
+
}
|
|
168
|
+
.log-entry:nth-child(odd) {
|
|
169
|
+
background-color: #f9f9f9;
|
|
170
|
+
}
|
|
171
|
+
.error { color: #d32f2f; }
|
|
172
|
+
.warn { color: #ff9800; }
|
|
173
|
+
.info { color: #2196f3; }
|
|
174
|
+
.debug { color: #4caf50; }
|
|
175
|
+
</style>
|
|
176
|
+
<script>
|
|
177
|
+
function filterLogs() {
|
|
178
|
+
const search = document.getElementById('search').value.toLowerCase();
|
|
179
|
+
const level = document.getElementById('level').value;
|
|
180
|
+
const entries = document.getElementsByClassName('log-entry');
|
|
181
|
+
|
|
182
|
+
Array.from(entries).forEach(entry => {
|
|
183
|
+
const text = entry.textContent.toLowerCase();
|
|
184
|
+
const matchesSearch = !search || text.includes(search);
|
|
185
|
+
const matchesLevel = !level || text.includes('"level":"' + level + '"');
|
|
186
|
+
entry.style.display = (matchesSearch && matchesLevel) ? 'block' : 'none';
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
</script>
|
|
190
|
+
</head>
|
|
191
|
+
<body>
|
|
192
|
+
<div class="header">
|
|
193
|
+
<h1>Log Viewer - ${filename}</h1>
|
|
194
|
+
<div class="nav-links">
|
|
195
|
+
<a href="/logs">← Back to Log List</a>
|
|
196
|
+
<a href="/logs/${filename}/raw">View Raw Log →</a>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div class="filters">
|
|
201
|
+
<input
|
|
202
|
+
type="text"
|
|
203
|
+
id="search"
|
|
204
|
+
placeholder="Search logs..."
|
|
205
|
+
oninput="filterLogs()"
|
|
206
|
+
>
|
|
207
|
+
<select id="level" onchange="filterLogs()">
|
|
208
|
+
<option value="">All Levels</option>
|
|
209
|
+
<option value="error">Error</option>
|
|
210
|
+
<option value="warn">Warn</option>
|
|
211
|
+
<option value="info">Info</option>
|
|
212
|
+
<option value="debug">Debug</option>
|
|
213
|
+
</select>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div class="log-content">
|
|
217
|
+
${lines
|
|
218
|
+
.map((line) => {
|
|
219
|
+
let levelClass = '';
|
|
220
|
+
if (typeof line === 'object' && line.level) {
|
|
221
|
+
levelClass = line.level.toLowerCase();
|
|
222
|
+
}
|
|
223
|
+
return `<div class="log-entry ${levelClass}">
|
|
224
|
+
${
|
|
225
|
+
typeof line === 'object'
|
|
226
|
+
? JSON.stringify(line, null, 2)
|
|
227
|
+
: line
|
|
228
|
+
}
|
|
229
|
+
</div>`;
|
|
230
|
+
})
|
|
231
|
+
.join('')}
|
|
232
|
+
</div>
|
|
233
|
+
</body>
|
|
234
|
+
</html>
|
|
235
|
+
`;
|
|
236
|
+
|
|
237
|
+
res.type('html').send(html);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
logger.error(`Error reading log file ${req.params.filename}:`, error);
|
|
240
|
+
res.status(500).send('<h1>Error</h1><p>Failed to retrieve log file</p>');
|
|
241
|
+
}
|
|
242
|
+
})();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Get contents of specific log file
|
|
246
|
+
router.get('/:filename/raw', (req: Request, res: Response) => {
|
|
247
|
+
void (async () => {
|
|
248
|
+
// const auth = await requestIsAdminAuthenticated(req);
|
|
249
|
+
// if (!auth) {
|
|
250
|
+
// res.status(401).json({ error: 'Unauthorized' });
|
|
251
|
+
// return;
|
|
252
|
+
// }
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const filename = req.params.filename;
|
|
256
|
+
// Sanitize filename to prevent directory traversal
|
|
257
|
+
if (filename.includes('..') || !filename.match(/^[a-zA-Z0-9-_.]+$/)) {
|
|
258
|
+
res.status(400).json({ error: 'Invalid filename' });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const logPath = path.join(process.cwd(), 'logs', filename);
|
|
263
|
+
const content = (await fs.readFile(logPath, 'utf-8')).toString();
|
|
264
|
+
|
|
265
|
+
// If the client requests JSON format
|
|
266
|
+
if (req.headers.accept === 'application/json') {
|
|
267
|
+
const lines = content
|
|
268
|
+
.split('\n')
|
|
269
|
+
.filter((line) => line.trim())
|
|
270
|
+
.map((line) => {
|
|
271
|
+
try {
|
|
272
|
+
return JSON.parse(line);
|
|
273
|
+
} catch {
|
|
274
|
+
return line;
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
res.json(lines);
|
|
278
|
+
} else {
|
|
279
|
+
// Return plain text by default
|
|
280
|
+
res.type('text/plain').send(content);
|
|
281
|
+
}
|
|
282
|
+
} catch (error) {
|
|
283
|
+
logger.error(`Error reading log file ${req.params.filename}:`, error);
|
|
284
|
+
res.status(500).json({ error: 'Failed to retrieve log file' });
|
|
285
|
+
}
|
|
286
|
+
})();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
export default router;
|
package/src/utils/env.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
import process from 'process';
|
|
3
|
+
|
|
4
|
+
import { initializeDataLayer } from '@vue-skuilder/db';
|
|
5
|
+
import logger from '../logger.js';
|
|
6
|
+
|
|
7
|
+
dotenv.config({
|
|
8
|
+
path:
|
|
9
|
+
process.argv && process.argv.length == 3
|
|
10
|
+
? process.argv[2]
|
|
11
|
+
: '.env.development',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
type Env = {
|
|
15
|
+
COUCHDB_SERVER: string;
|
|
16
|
+
COUCHDB_PROTOCOL: string;
|
|
17
|
+
COUCHDB_ADMIN: string;
|
|
18
|
+
COUCHDB_PASSWORD: string;
|
|
19
|
+
VERSION: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function getVar(name: string): string {
|
|
23
|
+
if (process.env[name]) {
|
|
24
|
+
return process.env[name];
|
|
25
|
+
} else {
|
|
26
|
+
throw new Error(`${name} not defined in environment`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const env: Env = {
|
|
31
|
+
COUCHDB_SERVER: getVar('COUCHDB_SERVER'),
|
|
32
|
+
COUCHDB_PROTOCOL: getVar('COUCHDB_PROTOCOL'),
|
|
33
|
+
COUCHDB_ADMIN: getVar('COUCHDB_ADMIN'),
|
|
34
|
+
COUCHDB_PASSWORD: getVar('COUCHDB_PASSWORD'),
|
|
35
|
+
VERSION: getVar('VERSION'),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
initializeDataLayer({
|
|
39
|
+
type: 'pouch',
|
|
40
|
+
options: {
|
|
41
|
+
COUCHDB_PASSWORD: env.COUCHDB_PASSWORD,
|
|
42
|
+
COUCHDB_USERNAME: env.COUCHDB_ADMIN,
|
|
43
|
+
COUCHDB_SERVER_PROTOCOL: env.COUCHDB_PROTOCOL,
|
|
44
|
+
COUCHDB_SERVER_URL: env.COUCHDB_SERVER,
|
|
45
|
+
},
|
|
46
|
+
}).catch((e) => {
|
|
47
|
+
logger.error('Error initializing data layer:', e);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export default env;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { IServerRequest } from '@vue-skuilder/common';
|
|
2
|
+
import logger from '../logger.js';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
export interface Result {
|
|
6
|
+
status: 'ok' | 'awaiting' | 'warning' | 'error';
|
|
7
|
+
ok: boolean;
|
|
8
|
+
error?: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ProcessingFunction<T> {
|
|
12
|
+
(data: T): Promise<Result>;
|
|
13
|
+
}
|
|
14
|
+
interface LabelledRequest<R> {
|
|
15
|
+
id: number;
|
|
16
|
+
request: R;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface FailedRequest<R> extends LabelledRequest<R> {
|
|
20
|
+
result: Result | null;
|
|
21
|
+
error: unknown;
|
|
22
|
+
}
|
|
23
|
+
interface CompletedRequest<R> extends LabelledRequest<R> {
|
|
24
|
+
result: Result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* This queue executes async prcesses sequentially, waiting
|
|
29
|
+
* for each to complete before launching the next.
|
|
30
|
+
*/
|
|
31
|
+
export default class AsyncProcessQueue<
|
|
32
|
+
T extends IServerRequest,
|
|
33
|
+
R extends Result
|
|
34
|
+
> {
|
|
35
|
+
private processRequest: ProcessingFunction<T>;
|
|
36
|
+
|
|
37
|
+
private queue: LabelledRequest<T>[] = [];
|
|
38
|
+
private errors: FailedRequest<T>[] = [];
|
|
39
|
+
private completed: CompletedRequest<T>[] = [];
|
|
40
|
+
|
|
41
|
+
private processing = false;
|
|
42
|
+
private nextID = 0;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns 'complete' if the job is complete, 'error' if the
|
|
46
|
+
* job failed, and the job's position in queue if not yet
|
|
47
|
+
* completed.
|
|
48
|
+
*
|
|
49
|
+
* @param jobID The jobID returned by addRequest
|
|
50
|
+
*/
|
|
51
|
+
public jobStatus(jobID: number): 'complete' | 'error' | number {
|
|
52
|
+
let ret: 'complete' | 'error' | number = -1; // Default to -1 if job not found
|
|
53
|
+
this.queue.forEach((req) => {
|
|
54
|
+
if (req.id === jobID) {
|
|
55
|
+
ret = this.queue.indexOf(req);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
this.completed.forEach((req) => {
|
|
60
|
+
if (req.id === jobID) {
|
|
61
|
+
ret = 'complete';
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
this.errors.forEach((req) => {
|
|
66
|
+
if (req.id === jobID) {
|
|
67
|
+
ret = 'error';
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return ret;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async recurseGetResult(jobID: number, depth: number): Promise<R> {
|
|
75
|
+
// polling intervals in milliseconds
|
|
76
|
+
logger.info(`Checking job status of job ${jobID}...`);
|
|
77
|
+
const intervals = [100, 200, 400, 800, 1000, 2000, 3000, 5000];
|
|
78
|
+
depth = Math.min(depth, intervals.length - 1);
|
|
79
|
+
|
|
80
|
+
let status: 'complete' | 'error' | number;
|
|
81
|
+
|
|
82
|
+
const p = new Promise((resolve, reject) => {
|
|
83
|
+
setTimeout(
|
|
84
|
+
() => {
|
|
85
|
+
status = this.jobStatus(jobID);
|
|
86
|
+
if (status === 'complete' || status === 'error') {
|
|
87
|
+
resolve(null);
|
|
88
|
+
} else {
|
|
89
|
+
reject();
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
intervals[depth]
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return p
|
|
97
|
+
.then(() => {
|
|
98
|
+
return this.getResult(jobID);
|
|
99
|
+
})
|
|
100
|
+
.catch(() => {
|
|
101
|
+
return this.recurseGetResult(jobID, depth + 1);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public async getResult(jobID: number, _depth = 0): Promise<R> {
|
|
106
|
+
const status = this.jobStatus(jobID);
|
|
107
|
+
|
|
108
|
+
if (status === 'complete') {
|
|
109
|
+
const res = this.completed.find((val) => {
|
|
110
|
+
return val.id === jobID;
|
|
111
|
+
});
|
|
112
|
+
if (res) {
|
|
113
|
+
return res.result as R;
|
|
114
|
+
} else {
|
|
115
|
+
return {
|
|
116
|
+
error: 'No result found',
|
|
117
|
+
ok: false,
|
|
118
|
+
status: 'error',
|
|
119
|
+
} as R;
|
|
120
|
+
}
|
|
121
|
+
} else if (status === 'error') {
|
|
122
|
+
const res = this.errors.find((val) => {
|
|
123
|
+
return val.id === jobID;
|
|
124
|
+
});
|
|
125
|
+
if (!res) {
|
|
126
|
+
return {
|
|
127
|
+
error: 'Job failed - no error log found',
|
|
128
|
+
ok: false,
|
|
129
|
+
status: 'error',
|
|
130
|
+
} as R;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (res.result) {
|
|
134
|
+
return res.result as R;
|
|
135
|
+
} else {
|
|
136
|
+
// result is null b/c of an uncaugth exception
|
|
137
|
+
return {
|
|
138
|
+
error: res.error,
|
|
139
|
+
ok: false,
|
|
140
|
+
status: 'error',
|
|
141
|
+
} as R;
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
return this.recurseGetResult(jobID, 0);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
public addRequest(req: T): number {
|
|
149
|
+
const id: number = this.nextID++;
|
|
150
|
+
|
|
151
|
+
this.queue.push({
|
|
152
|
+
id: id,
|
|
153
|
+
request: req,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (!this.processing) {
|
|
157
|
+
void this.process();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return id;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
*
|
|
165
|
+
*/
|
|
166
|
+
constructor(processingFcn: ProcessingFunction<T>) {
|
|
167
|
+
this.processRequest = processingFcn;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async process() {
|
|
171
|
+
this.processing = true;
|
|
172
|
+
|
|
173
|
+
while (this.queue.length > 0) {
|
|
174
|
+
const req = this.queue[0];
|
|
175
|
+
logger.info(`Processing ${req.id}`);
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const result = await this.processRequest(req.request);
|
|
179
|
+
if (result.ok) {
|
|
180
|
+
this.completed.push({
|
|
181
|
+
id: req.id,
|
|
182
|
+
request: req.request,
|
|
183
|
+
result: result,
|
|
184
|
+
});
|
|
185
|
+
} else {
|
|
186
|
+
if (result) {
|
|
187
|
+
this.errors.push({
|
|
188
|
+
id: req.id,
|
|
189
|
+
request: req.request,
|
|
190
|
+
result: result,
|
|
191
|
+
error: result.error,
|
|
192
|
+
});
|
|
193
|
+
} else {
|
|
194
|
+
this.errors.push({
|
|
195
|
+
id: req.id,
|
|
196
|
+
request: req.request,
|
|
197
|
+
error: 'error',
|
|
198
|
+
result: null,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch (e) {
|
|
203
|
+
this.errors.push({
|
|
204
|
+
id: req.id,
|
|
205
|
+
request: req.request,
|
|
206
|
+
result: null,
|
|
207
|
+
error: e,
|
|
208
|
+
});
|
|
209
|
+
} finally {
|
|
210
|
+
// remove the completed (or errored)
|
|
211
|
+
// request from the queue
|
|
212
|
+
this.queue.shift();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.processing = false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { ChildProcess, exec } from 'child_process';
|
|
2
|
+
import { CourseConfig } from '../../vue/src/server/types';
|
|
3
|
+
import Client from '../src/client';
|
|
4
|
+
import env from '../src/utils/env';
|
|
5
|
+
import { DataShapeName } from '../../vue/src/enums/DataShapeNames';
|
|
6
|
+
import { abort } from 'process';
|
|
7
|
+
|
|
8
|
+
let serverProcess: ChildProcess;
|
|
9
|
+
const client: Client = new Client('http://localhost:3000');
|
|
10
|
+
const credentials = {
|
|
11
|
+
username: env.COUCHDB_ADMIN,
|
|
12
|
+
password: env.COUCHDB_PASSWORD,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
serverProcess = exec('yarn serve');
|
|
17
|
+
if (!serverProcess || !serverProcess.stdout) {
|
|
18
|
+
abort();
|
|
19
|
+
}
|
|
20
|
+
serverProcess.stdout.on('data', (data) => {
|
|
21
|
+
console.log(data);
|
|
22
|
+
});
|
|
23
|
+
console.log('Server starting...');
|
|
24
|
+
|
|
25
|
+
const checkServerReady = async () => {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const timeout = setTimeout(() => {
|
|
28
|
+
reject(new Error('Server did not start within 30 seconds'));
|
|
29
|
+
}, 10000);
|
|
30
|
+
|
|
31
|
+
const interval = setInterval(async () => {
|
|
32
|
+
try {
|
|
33
|
+
const response = await client.getVersion();
|
|
34
|
+
if (response) {
|
|
35
|
+
clearTimeout(timeout);
|
|
36
|
+
clearInterval(interval);
|
|
37
|
+
resolve(true);
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// Server not ready yet
|
|
41
|
+
console.log('Server not ready yet');
|
|
42
|
+
}
|
|
43
|
+
}, 1000);
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return await checkServerReady();
|
|
48
|
+
}, 10_000);
|
|
49
|
+
|
|
50
|
+
test('getVersion', async () => {
|
|
51
|
+
const version = await client.getVersion();
|
|
52
|
+
expect(version).toBe(env.VERSION);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('Course Methods', async () => {
|
|
56
|
+
const testCourse: CourseConfig = createTestCourse();
|
|
57
|
+
|
|
58
|
+
// assert non-existence of test course
|
|
59
|
+
const courses = await client.getCourses();
|
|
60
|
+
expect(courses.find((c) => c.split(' - ')[1] === testCourse.name)).toBeUndefined();
|
|
61
|
+
console.log(`test course not found`);
|
|
62
|
+
|
|
63
|
+
// create test course
|
|
64
|
+
const createResp = await client.createCourse(testCourse, credentials);
|
|
65
|
+
expect(createResp.data?.ok).toBe(true);
|
|
66
|
+
expect(createResp.data?.courseID).not.toBe('');
|
|
67
|
+
console.log(`created test course`);
|
|
68
|
+
const courseID = createResp.data!.courseID;
|
|
69
|
+
|
|
70
|
+
// assert existence of test course
|
|
71
|
+
const courses2 = await client.getCourses();
|
|
72
|
+
expect(courses2.find((c) => c.split(' - ')[1] === testCourse.name)).toBeDefined();
|
|
73
|
+
expect(courses2.find((c) => c.split(' - ')[0] === courseID)).toBeDefined();
|
|
74
|
+
console.log(`found test course`);
|
|
75
|
+
|
|
76
|
+
// delete test course
|
|
77
|
+
const crsClient = client.getCourseClient(courseID);
|
|
78
|
+
await crsClient.deleteCourse(credentials);
|
|
79
|
+
console.log(`requested test course deletion`);
|
|
80
|
+
|
|
81
|
+
// assert non-existence of test course
|
|
82
|
+
const courses3 = await client.getCourses();
|
|
83
|
+
expect(courses3.find((c) => c.split(' - ')[0] === courseID)).toBeUndefined();
|
|
84
|
+
console.log(`test course deleted`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// test('createNote', async () => {
|
|
88
|
+
// const crs = createTestCourse();
|
|
89
|
+
// const createResp = await client.createCourse(crs, credentials);
|
|
90
|
+
|
|
91
|
+
// const crsClient = client.getCourseClient(createResp.data!.courseID);
|
|
92
|
+
// const cfg = await crsClient.getConfig();
|
|
93
|
+
|
|
94
|
+
// for (const k in crs) {
|
|
95
|
+
// expect(cfg[k]).toEqual(crs[k]);
|
|
96
|
+
// }
|
|
97
|
+
// // expect(cfg).toEqual(crs);
|
|
98
|
+
// await crsClient.addData({
|
|
99
|
+
// author: 'test-author',
|
|
100
|
+
// data: 'this is a test',
|
|
101
|
+
// tags: ['test-tag'],
|
|
102
|
+
// courseID: crsClient.id,
|
|
103
|
+
// codeCourse: 'test-code-course-id',
|
|
104
|
+
// shape: {
|
|
105
|
+
// fields: [],
|
|
106
|
+
// name: DataShapeName.Blanks,
|
|
107
|
+
// },
|
|
108
|
+
// });
|
|
109
|
+
// });
|
|
110
|
+
|
|
111
|
+
afterAll(async () => {
|
|
112
|
+
if (!serverProcess || !serverProcess.stdin || !serverProcess.stdout || !serverProcess.stderr) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// see https://stackoverflow.com/questions/54562879/jest-open-handle-when-using-node-exec/75830272#75830272
|
|
117
|
+
serverProcess.stdin.destroy();
|
|
118
|
+
serverProcess.stdout.destroy();
|
|
119
|
+
serverProcess.stderr.destroy();
|
|
120
|
+
|
|
121
|
+
serverProcess.kill();
|
|
122
|
+
serverProcess.unref();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @returns a course config with a randomized name
|
|
127
|
+
*/
|
|
128
|
+
function createTestCourse(): CourseConfig {
|
|
129
|
+
const salt = Math.random();
|
|
130
|
+
const name = 'test-course-' + salt;
|
|
131
|
+
|
|
132
|
+
const testCourse: CourseConfig = {
|
|
133
|
+
name,
|
|
134
|
+
description: 'test-course',
|
|
135
|
+
admins: ['test-admin'],
|
|
136
|
+
creator: 'test-creator',
|
|
137
|
+
dataShapes: [],
|
|
138
|
+
questionTypes: [],
|
|
139
|
+
deleted: false,
|
|
140
|
+
moderators: [],
|
|
141
|
+
public: true,
|
|
142
|
+
};
|
|
143
|
+
return testCourse;
|
|
144
|
+
}
|