@flancer32/teq-web 0.2.0 → 0.3.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/AGENTS.md +101 -0
- package/CHANGELOG.md +21 -3
- package/README.md +12 -6
- package/package.json +1 -1
- package/src/AGENTS.md +108 -0
- package/src/Back/Dto/Handler/Source.js +46 -0
- package/src/Back/Handler/Static/A/Config.js +58 -0
- package/src/Back/Handler/Static/A/Fallback.js +39 -0
- package/src/Back/Handler/Static/A/FileService.js +69 -0
- package/src/Back/Handler/Static/A/Registry.js +52 -0
- package/src/Back/Handler/Static/A/Resolver.js +83 -0
- package/src/Back/Handler/Static.js +25 -101
- package/src/Back/Helper/Cast.js +28 -0
- package/src/Back/Helper/Respond.js +77 -0
- package/teqfw.json +8 -0
- package/test/accept/Server.test.mjs +58 -5
- package/test/dev/app/Plugin/Start.js +5 -7
- package/test/unit/AGENTS.md +106 -0
- package/test/unit/Back/Dispatcher.test.mjs +150 -0
- package/test/unit/Back/Dto/Handler/Source.test.mjs +40 -0
- package/test/unit/Back/Handler/Pre/Log.test.mjs +22 -0
- package/test/unit/Back/Handler/Static/A/Config.test.mjs +52 -0
- package/test/unit/Back/Handler/Static/A/Fallback.test.mjs +60 -0
- package/test/unit/Back/Handler/Static/A/FileService.test.mjs +225 -0
- package/test/unit/Back/Handler/Static/A/Registry.test.mjs +83 -0
- package/test/unit/Back/Handler/Static/A/Resolver.test.mjs +73 -0
- package/test/unit/Back/Handler/Static/Static.test.mjs +235 -0
- package/test/unit/Back/Helper/Respond.test.mjs +83 -0
- package/src/Back/Handler/Npm.js +0 -161
- package/test/unit/Back/Handler/Npm.test.mjs +0 -69
|
@@ -1,137 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* Prevents directory traversal and streams file content to the response.
|
|
2
|
+
* Universal file handler serving files from multiple sources using helper modules.
|
|
4
3
|
*
|
|
5
4
|
* @implements Fl32_Web_Back_Api_Handler
|
|
6
5
|
*/
|
|
7
6
|
export default class Fl32_Web_Back_Handler_Static {
|
|
8
7
|
/* eslint-disable jsdoc/require-param-description,jsdoc/check-param-names */
|
|
9
8
|
/**
|
|
10
|
-
* @param {
|
|
11
|
-
* @param {
|
|
12
|
-
* @param {
|
|
13
|
-
* @param {Fl32_Web_Back_Logger} logger
|
|
14
|
-
* @param {
|
|
15
|
-
* @param {
|
|
16
|
-
* @param {Fl32_Web_Back_Dto_Handler_Info} dtoInfo - DTO factory for handler registration.
|
|
17
|
-
* @param {typeof Fl32_Web_Back_Enum_Stage} STAGE - Enum of handler stages.
|
|
9
|
+
* @param {Fl32_Web_Back_Handler_Static_A_Registry} registry
|
|
10
|
+
* @param {Fl32_Web_Back_Handler_Static_A_FileService} fileService
|
|
11
|
+
* @param {Fl32_Web_Back_Helper_Respond} respond
|
|
12
|
+
* @param {Fl32_Web_Back_Logger} logger
|
|
13
|
+
* @param {Fl32_Web_Back_Dto_Handler_Info} dtoInfo
|
|
14
|
+
* @param {typeof Fl32_Web_Back_Enum_Stage} STAGE
|
|
18
15
|
*/
|
|
19
16
|
constructor(
|
|
20
17
|
{
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
'node:path': path,
|
|
24
|
-
Fl32_Web_Back_Logger$: logger,
|
|
25
|
-
Fl32_Web_Back_Helper_Mime$: helpMime,
|
|
18
|
+
Fl32_Web_Back_Handler_Static_A_Registry$: registry,
|
|
19
|
+
Fl32_Web_Back_Handler_Static_A_FileService$: fileService,
|
|
26
20
|
Fl32_Web_Back_Helper_Respond$: respond,
|
|
21
|
+
Fl32_Web_Back_Logger$: logger,
|
|
27
22
|
Fl32_Web_Back_Dto_Handler_Info$: dtoInfo,
|
|
28
23
|
Fl32_Web_Back_Enum_Stage$: STAGE,
|
|
29
24
|
}
|
|
30
25
|
) {
|
|
31
26
|
/* eslint-enable jsdoc/check-param-names */
|
|
32
|
-
// VARS
|
|
33
|
-
const {promises: fsp} = fs;
|
|
34
|
-
const {constants: H2} = http2;
|
|
35
|
-
const {
|
|
36
|
-
HTTP2_HEADER_CONTENT_LENGTH,
|
|
37
|
-
HTTP2_HEADER_CONTENT_TYPE,
|
|
38
|
-
HTTP2_HEADER_LAST_MODIFIED,
|
|
39
|
-
HTTP_STATUS_OK,
|
|
40
|
-
} = H2;
|
|
41
27
|
|
|
42
|
-
/**
|
|
43
|
-
* Handler registration info.
|
|
44
|
-
* @type {Fl32_Web_Back_Dto_Handler_Info.Dto}
|
|
45
|
-
*/
|
|
46
28
|
const _info = dtoInfo.create();
|
|
47
29
|
_info.name = this.constructor.name;
|
|
48
30
|
_info.stage = STAGE.PROCESS;
|
|
49
31
|
Object.freeze(_info);
|
|
50
32
|
|
|
51
33
|
/**
|
|
52
|
-
*
|
|
53
|
-
* @type {string}
|
|
54
|
-
*/
|
|
55
|
-
let _root;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Default filenames to try when a path is a directory.
|
|
59
|
-
* @type {string[]}
|
|
60
|
-
*/
|
|
61
|
-
const _defaultFiles = ['index.html', 'index.htm', 'index.txt'];
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Handles the incoming request by attempting to serve a static file.
|
|
34
|
+
* Initialize registry with provided sources.
|
|
66
35
|
*
|
|
67
|
-
* @param {
|
|
68
|
-
* @
|
|
69
|
-
* @returns {Promise<boolean>} - True if the file was served, false otherwise.
|
|
36
|
+
* @param {{sources: Fl32_Web_Back_Dto_Handler_Source.Dto[]}} params
|
|
37
|
+
* @returns {Promise<void>}
|
|
70
38
|
*/
|
|
71
|
-
this.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
const urlPath = decodeURIComponent(req.url.split('?')[0]);
|
|
76
|
-
let fsPath = path.resolve(_root, '.' + urlPath);
|
|
77
|
-
|
|
78
|
-
if (!fsPath.startsWith(_root)) return false;
|
|
79
|
-
|
|
80
|
-
let stat;
|
|
81
|
-
try {
|
|
82
|
-
stat = await fsp.stat(fsPath);
|
|
83
|
-
} catch {
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// If a path is a directory — try default files
|
|
88
|
-
if (stat.isDirectory()) {
|
|
89
|
-
for (const file of _defaultFiles) {
|
|
90
|
-
const candidate = path.join(fsPath, file);
|
|
91
|
-
try {
|
|
92
|
-
const s = await fsp.stat(candidate);
|
|
93
|
-
if (s.isFile()) {
|
|
94
|
-
fsPath = candidate;
|
|
95
|
-
stat = s;
|
|
96
|
-
break;
|
|
97
|
-
}
|
|
98
|
-
} catch {
|
|
99
|
-
// ignore and continue
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
if (!stat.isFile()) return false;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const stream = fs.createReadStream(fsPath);
|
|
106
|
-
const ext = path.extname(fsPath).toLowerCase();
|
|
107
|
-
const headers = {
|
|
108
|
-
[HTTP2_HEADER_CONTENT_LENGTH]: stat.size,
|
|
109
|
-
[HTTP2_HEADER_CONTENT_TYPE]: helpMime.getByExt(ext),
|
|
110
|
-
[HTTP2_HEADER_LAST_MODIFIED]: stat.mtime.toUTCString(),
|
|
111
|
-
};
|
|
112
|
-
res.writeHead(HTTP_STATUS_OK, headers);
|
|
113
|
-
stream.pipe(res);
|
|
114
|
-
return true;
|
|
115
|
-
} catch (e) {
|
|
116
|
-
logger.exception(e);
|
|
117
|
-
return false;
|
|
118
|
-
}
|
|
39
|
+
this.init = async ({sources = []} = {}) => {
|
|
40
|
+
registry.addConfigs(sources);
|
|
119
41
|
};
|
|
120
42
|
|
|
121
43
|
/**
|
|
122
|
-
*
|
|
44
|
+
* Attempt to handle incoming request.
|
|
123
45
|
*
|
|
124
|
-
* @param {
|
|
125
|
-
* @param {
|
|
126
|
-
* @returns {Promise<
|
|
46
|
+
* @param {module:http.IncomingMessage|module:http2.Http2ServerRequest} req
|
|
47
|
+
* @param {module:http.ServerResponse|module:http2.Http2ServerResponse} res
|
|
48
|
+
* @returns {Promise<boolean>} True if file served
|
|
127
49
|
*/
|
|
128
|
-
this.
|
|
129
|
-
|
|
130
|
-
|
|
50
|
+
this.handle = async (req, res) => {
|
|
51
|
+
if (!respond.isWritable(res)) return false;
|
|
52
|
+
const urlPath = decodeURIComponent(req.url.split('?')[0]);
|
|
53
|
+
const match = registry.find(urlPath);
|
|
54
|
+
if (!match) return false;
|
|
55
|
+
return fileService.serve(match.config, match.rel, req, res);
|
|
131
56
|
};
|
|
132
57
|
|
|
133
58
|
/**
|
|
134
|
-
* Returns the handler registration info.
|
|
135
59
|
* @returns {Fl32_Web_Back_Dto_Handler_Info.Dto}
|
|
136
60
|
*/
|
|
137
61
|
this.getRegistrationInfo = () => _info;
|
package/src/Back/Helper/Cast.js
CHANGED
|
@@ -83,4 +83,32 @@ export default class Fl32_Web_Back_Helper_Cast {
|
|
|
83
83
|
}
|
|
84
84
|
return undefined;
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Cast an object to a map with string keys and array-of-string values.
|
|
89
|
+
* Throws error on invalid structure or values.
|
|
90
|
+
*
|
|
91
|
+
* @param {*} data - Raw input to cast.
|
|
92
|
+
* @returns {Record<string, string[]>}
|
|
93
|
+
*/
|
|
94
|
+
stringArrayMap(data) {
|
|
95
|
+
if (data === undefined) return {};
|
|
96
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
97
|
+
throw new Error('Invalid value for allow');
|
|
98
|
+
}
|
|
99
|
+
const res = {};
|
|
100
|
+
for (const [key, arr] of Object.entries(data)) {
|
|
101
|
+
if (!Array.isArray(arr)) throw new Error(`Invalid allow list for ${key}`);
|
|
102
|
+
const k = this.string(key);
|
|
103
|
+
if (!k) throw new Error('Invalid allow key');
|
|
104
|
+
const items = [];
|
|
105
|
+
for (const item of arr) {
|
|
106
|
+
const val = this.string(item);
|
|
107
|
+
if (!val) throw new Error(`Invalid allow list for ${k}`);
|
|
108
|
+
items.push(val);
|
|
109
|
+
}
|
|
110
|
+
res[k] = items;
|
|
111
|
+
}
|
|
112
|
+
return res;
|
|
113
|
+
}
|
|
86
114
|
}
|
|
@@ -56,16 +56,93 @@ export default class Fl32_Web_Back_Helper_Respond {
|
|
|
56
56
|
return send({res, headers, body}, HTTP_STATUS_OK);
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
/** @see send */
|
|
60
|
+
this.code201_Created = function ({res, headers = {}, body = ''}) {
|
|
61
|
+
return send({res, headers, body}, HTTP_STATUS_CREATED);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** @see send */
|
|
65
|
+
this.code204_NoContent = function ({res, headers = {}}) {
|
|
66
|
+
return send({res, headers}, HTTP_STATUS_NO_CONTENT);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** @see send */
|
|
70
|
+
this.code301_MovedPermanently = function ({res, headers = {}, body = ''}) {
|
|
71
|
+
return send({res, headers, body}, HTTP_STATUS_MOVED_PERMANENTLY);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** @see send */
|
|
75
|
+
this.code302_Found = function ({res, headers = {}, body = ''}) {
|
|
76
|
+
return send({res, headers, body}, HTTP_STATUS_FOUND);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** @see send */
|
|
80
|
+
this.code303_SeeOther = function ({res, headers = {}, body = ''}) {
|
|
81
|
+
return send({res, headers, body}, HTTP_STATUS_SEE_OTHER);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** @see send */
|
|
85
|
+
this.code304_NotModified = function ({res, headers = {}, body = ''}) {
|
|
86
|
+
return send({res, headers, body}, HTTP_STATUS_NOT_MODIFIED);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** @see send */
|
|
90
|
+
this.code400_BadRequest = function ({res, headers = {}, body = ''}) {
|
|
91
|
+
return send({res, headers, body}, HTTP_STATUS_BAD_REQUEST);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** @see send */
|
|
95
|
+
this.code401_Unauthorized = function ({res, headers = {}, body = ''}) {
|
|
96
|
+
return send({res, headers, body}, HTTP_STATUS_UNAUTHORIZED);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** @see send */
|
|
100
|
+
this.code402_PaymentRequired = function ({res, headers = {}, body = ''}) {
|
|
101
|
+
return send({res, headers, body}, HTTP_STATUS_PAYMENT_REQUIRED);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** @see send */
|
|
105
|
+
this.code403_Forbidden = function ({res, headers = {}, body = ''}) {
|
|
106
|
+
return send({res, headers, body}, HTTP_STATUS_FORBIDDEN);
|
|
107
|
+
};
|
|
108
|
+
|
|
59
109
|
/** @see send */
|
|
60
110
|
this.code404_NotFound = function ({res, headers = {}, body = ''}) {
|
|
61
111
|
return send({res, headers, body}, HTTP_STATUS_NOT_FOUND);
|
|
62
112
|
};
|
|
63
113
|
|
|
114
|
+
/** @see send */
|
|
115
|
+
this.code405_MethodNotAllowed = function ({res, headers = {}, body = '', allowed = 'HEAD, GET, POST'}) {
|
|
116
|
+
return send(
|
|
117
|
+
{
|
|
118
|
+
res,
|
|
119
|
+
headers: {...headers, [HTTP2_HEADER_ALLOW]: allowed},
|
|
120
|
+
body,
|
|
121
|
+
},
|
|
122
|
+
HTTP_STATUS_METHOD_NOT_ALLOWED
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** @see send */
|
|
127
|
+
this.code409_Conflict = function ({res, headers = {}, body = ''}) {
|
|
128
|
+
return send({res, headers, body}, HTTP_STATUS_CONFLICT);
|
|
129
|
+
};
|
|
130
|
+
|
|
64
131
|
/** @see send */
|
|
65
132
|
this.code500_InternalServerError = function ({res, headers = {}, body = 'Internal Server Error'}) {
|
|
66
133
|
return send({res, headers, body}, HTTP_STATUS_INTERNAL_SERVER_ERROR);
|
|
67
134
|
};
|
|
68
135
|
|
|
136
|
+
/** @see send */
|
|
137
|
+
this.code502_BadGateway = function ({res, headers = {}, body = ''}) {
|
|
138
|
+
return send({res, headers, body}, HTTP_STATUS_BAD_GATEWAY);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** @see send */
|
|
142
|
+
this.code503_ServiceUnavailable = function ({res, headers = {}, body = ''}) {
|
|
143
|
+
return send({res, headers, body}, HTTP_STATUS_SERVICE_UNAVAILABLE);
|
|
144
|
+
};
|
|
145
|
+
|
|
69
146
|
/**
|
|
70
147
|
* Checks if the response is writable and not yet sent.
|
|
71
148
|
* @param {module:http.ServerResponse|module:http2.Http2ServerResponse} res
|
package/teqfw.json
ADDED
|
@@ -108,11 +108,16 @@ describe('Fl32_Web_Back_Api_Handler', () => {
|
|
|
108
108
|
it('should serve allowed NPM file', async () => {
|
|
109
109
|
const container = buildTestContainer();
|
|
110
110
|
const dispatcher = await container.get('Fl32_Web_Back_Dispatcher$');
|
|
111
|
-
const handler = await container.get('
|
|
111
|
+
const handler = await container.get('Fl32_Web_Back_Handler_Static$');
|
|
112
|
+
const Cfg = await container.get('Fl32_Web_Back_Dto_Handler_Source$');
|
|
112
113
|
await handler.init({
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
sources: [Cfg.create({
|
|
115
|
+
root: 'node_modules',
|
|
116
|
+
prefix: '/npm/',
|
|
117
|
+
allow: {
|
|
118
|
+
'@teqfw/di/src': ['.'],
|
|
119
|
+
},
|
|
120
|
+
})],
|
|
116
121
|
});
|
|
117
122
|
dispatcher.addHandler(handler);
|
|
118
123
|
dispatcher.orderHandlers();
|
|
@@ -130,7 +135,7 @@ describe('Fl32_Web_Back_Api_Handler', () => {
|
|
|
130
135
|
const req = http.get({
|
|
131
136
|
hostname: 'localhost',
|
|
132
137
|
port: cfg.port,
|
|
133
|
-
path: '/
|
|
138
|
+
path: '/npm/@teqfw/di/src/Api/Container/Parser/Chunk.js',
|
|
134
139
|
}, res => {
|
|
135
140
|
const chunks = [];
|
|
136
141
|
res.on('data', ch => chunks.push(ch));
|
|
@@ -147,4 +152,52 @@ describe('Fl32_Web_Back_Api_Handler', () => {
|
|
|
147
152
|
await server.stop();
|
|
148
153
|
assert.strictEqual(server.getInstance(), undefined);
|
|
149
154
|
});
|
|
155
|
+
|
|
156
|
+
it('should serve allowed source file', async () => {
|
|
157
|
+
const container = buildTestContainer();
|
|
158
|
+
const dispatcher = await container.get('Fl32_Web_Back_Dispatcher$');
|
|
159
|
+
const handler = await container.get('Fl32_Web_Back_Handler_Static$');
|
|
160
|
+
const Cfg = await container.get('Fl32_Web_Back_Dto_Handler_Source$');
|
|
161
|
+
await handler.init({
|
|
162
|
+
sources: [Cfg.create({
|
|
163
|
+
root: 'src',
|
|
164
|
+
prefix: '/sources/',
|
|
165
|
+
allow: {
|
|
166
|
+
Back: ['Server.js'],
|
|
167
|
+
},
|
|
168
|
+
})],
|
|
169
|
+
});
|
|
170
|
+
dispatcher.addHandler(handler);
|
|
171
|
+
dispatcher.orderHandlers();
|
|
172
|
+
|
|
173
|
+
const server = await container.get('Fl32_Web_Back_Server$');
|
|
174
|
+
const SERVER_TYPE = await container.get('Fl32_Web_Back_Enum_Server_Type$');
|
|
175
|
+
const Config = await container.get('Fl32_Web_Back_Server_Config$');
|
|
176
|
+
const http = await container.get('node:http');
|
|
177
|
+
|
|
178
|
+
const cfg = Config.create({ port: 3057, type: SERVER_TYPE.HTTP });
|
|
179
|
+
await server.start(cfg);
|
|
180
|
+
await waitListening(server);
|
|
181
|
+
|
|
182
|
+
const result = await new Promise((resolve, reject) => {
|
|
183
|
+
const req = http.get({
|
|
184
|
+
hostname: 'localhost',
|
|
185
|
+
port: cfg.port,
|
|
186
|
+
path: '/sources/Back/Server.js',
|
|
187
|
+
}, res => {
|
|
188
|
+
const chunks = [];
|
|
189
|
+
res.on('data', ch => chunks.push(ch));
|
|
190
|
+
res.on('end', () => {
|
|
191
|
+
resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() });
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
req.on('error', reject);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
assert.strictEqual(result.status, 200);
|
|
198
|
+
assert.match(result.body, /class Fl32_Web_Back_Server/);
|
|
199
|
+
|
|
200
|
+
await server.stop();
|
|
201
|
+
assert.strictEqual(server.getInstance(), undefined);
|
|
202
|
+
});
|
|
150
203
|
});
|
|
@@ -4,8 +4,8 @@ export default class App_Plugin_Start {
|
|
|
4
4
|
* @param {typeof import('node:url')} url
|
|
5
5
|
* @param {Fl32_Web_Back_Dispatcher} dispatcher
|
|
6
6
|
* @param {Fl32_Web_Back_Handler_Pre_Log} hndlRequestLog
|
|
7
|
-
* @param {Fl32_Web_Back_Handler_Npm} hndlNpm
|
|
8
7
|
* @param {Fl32_Web_Back_Handler_Static} hndlStatic
|
|
8
|
+
* @param {Fl32_Web_Back_Dto_Handler_Source} dtoCfg
|
|
9
9
|
*/
|
|
10
10
|
constructor(
|
|
11
11
|
{
|
|
@@ -13,8 +13,8 @@ export default class App_Plugin_Start {
|
|
|
13
13
|
'node:url': url,
|
|
14
14
|
Fl32_Web_Back_Dispatcher$: dispatcher,
|
|
15
15
|
Fl32_Web_Back_Handler_Pre_Log$: hndlRequestLog,
|
|
16
|
-
Fl32_Web_Back_Handler_Npm$: hndlNpm,
|
|
17
16
|
Fl32_Web_Back_Handler_Static$: hndlStatic,
|
|
17
|
+
Fl32_Web_Back_Dto_Handler_Source$: dtoCfg,
|
|
18
18
|
}
|
|
19
19
|
) {
|
|
20
20
|
// VARS
|
|
@@ -30,12 +30,10 @@ export default class App_Plugin_Start {
|
|
|
30
30
|
const webRoot = join(root, 'web');
|
|
31
31
|
|
|
32
32
|
return async function () {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
await hndlStatic.init({
|
|
36
|
-
// Register handlers
|
|
33
|
+
const srcNpm = dtoCfg.create({root: 'node_modules', prefix: '/npm/', allow: {'@teqfw/di': ['src/Container.js']}});
|
|
34
|
+
const srcWeb = dtoCfg.create({root: webRoot, prefix: '/'});
|
|
35
|
+
await hndlStatic.init({sources: [srcNpm, srcWeb]});
|
|
37
36
|
dispatcher.addHandler(hndlRequestLog);
|
|
38
|
-
dispatcher.addHandler(hndlNpm);
|
|
39
37
|
dispatcher.addHandler(hndlStatic);
|
|
40
38
|
};
|
|
41
39
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# AI Agent Unit Testing Instructions for @flancer32/teq-web
|
|
2
|
+
|
|
3
|
+
This document outlines project-specific guidelines for writing and maintaining unit tests in the `@flancer32/teq-web` plugin. Follow these rules to ensure consistency and reliability.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Test File Location & Naming
|
|
8
|
+
|
|
9
|
+
* Mirror `src/` structure under `test/unit/`, using the same path and filenames.
|
|
10
|
+
* Use `.test.mjs` suffix for test files, e.g. `Config.test.mjs`, `Kahn.test.mjs`.
|
|
11
|
+
* Top-level `describe` must reference the full DI key:
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
describe('Fl32_Web_Back_Handler_Static_A_Config', () => { /* ... */ });
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 2. Dependency Injection via DI Container
|
|
18
|
+
|
|
19
|
+
* Always call `buildTestContainer()` from `test/unit/common.js` and then:
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_Config} */
|
|
23
|
+
const config = await container.get('Fl32_Web_Back_Handler_Static_A_Config$');
|
|
24
|
+
```
|
|
25
|
+
* **Do not** import production modules (`node:path`, etc.) directly—register or mock everything through the container.
|
|
26
|
+
* Use `container.register(depId, mock)` to override dependencies in test mode.
|
|
27
|
+
|
|
28
|
+
## 3. Asynchronous Subject Retrieval
|
|
29
|
+
|
|
30
|
+
* Declare your `it` or `beforeEach` callbacks as `async` if you call `await container.get(...)`.
|
|
31
|
+
* Always `await container.get('Your_Service_Key$')` to obtain the actual instance before invoking its methods.
|
|
32
|
+
|
|
33
|
+
## 4. Testing Patterns
|
|
34
|
+
|
|
35
|
+
* Use Node’s built-in test runner and assertion library:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
import { describe, it } from 'node:test';
|
|
39
|
+
import assert from 'node:assert/strict';
|
|
40
|
+
```
|
|
41
|
+
* **Success cases**:
|
|
42
|
+
|
|
43
|
+
* `assert.strictEqual(actual, expected)` for primitives.
|
|
44
|
+
* `assert.deepStrictEqual(actual, expected)` for objects/arrays.
|
|
45
|
+
* **Error cases**:
|
|
46
|
+
|
|
47
|
+
* `assert.throws(() => fn(), /message/)` matching a key fragment of the error.
|
|
48
|
+
|
|
49
|
+
## 5. DTO Shape & Defaults
|
|
50
|
+
|
|
51
|
+
* Pass a full DTO to `factory.create(dto)`.
|
|
52
|
+
* To test fallback/default logic, supply `[]` for optional arrays.
|
|
53
|
+
|
|
54
|
+
## 6. Comments & Documentation
|
|
55
|
+
|
|
56
|
+
* All inline comments must be in **English**.
|
|
57
|
+
* Comment only non-trivial logic; don’t restate obvious assertions.
|
|
58
|
+
|
|
59
|
+
## 7. Mocks & Helpers
|
|
60
|
+
|
|
61
|
+
* Use plain JS objects or small factory functions for mocks.
|
|
62
|
+
* No external mocking libraries—rely on `@teqfw/di` test mode.
|
|
63
|
+
|
|
64
|
+
## 8. Maintenance
|
|
65
|
+
|
|
66
|
+
* One behavior per `it` block—keep tests focused and concise.
|
|
67
|
+
* Update tests when API or default constants change.
|
|
68
|
+
* Ensure CI runs all tests automatically.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Test Mode Support in `@teqfw/di`
|
|
73
|
+
|
|
74
|
+
When you enable test mode, you can inject or override any dependency—built-in or custom—without touching production code:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
const container = buildTestContainer();
|
|
78
|
+
container.enableTestMode();
|
|
79
|
+
|
|
80
|
+
// override a service or Node builtin
|
|
81
|
+
container.register('Fl32_Web_Back_Logger$', mockLogger);
|
|
82
|
+
container.register('node:fs', { /* mock fs.promises.stat… */});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
* **register** vs. **registerInstance**
|
|
86
|
+
Use `container.register(depId, instanceOrFactory)` to bind mocks. Avoid `registerInstance`, which is not part of the public test-mode API.
|
|
87
|
+
|
|
88
|
+
### Mocking Node.js Built-ins
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
// simulate filesystem behavior
|
|
92
|
+
container.register('node:fs', {
|
|
93
|
+
promises: {
|
|
94
|
+
stat: async (p) => { /* … */ }
|
|
95
|
+
},
|
|
96
|
+
createReadStream: (p) => { /* … */ }
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// adjust path logic
|
|
100
|
+
container.register('node:path', {
|
|
101
|
+
join: (...parts) => parts.join('/'),
|
|
102
|
+
resolve: (p) => `/abs/${p}`
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
These overrides are injected into every module that asks for `node:fs` or `node:path`, enabling isolated, deterministic tests without side effects.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {describe, it, beforeEach} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {buildTestContainer} from '../common.js';
|
|
4
|
+
|
|
5
|
+
/** Collects execution order */
|
|
6
|
+
let log;
|
|
7
|
+
/** DI container for each test */
|
|
8
|
+
let container;
|
|
9
|
+
/** Dispatcher stages enum */
|
|
10
|
+
let STAGE;
|
|
11
|
+
/** Respond helper mock */
|
|
12
|
+
let respond;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
log = [];
|
|
16
|
+
container = buildTestContainer();
|
|
17
|
+
|
|
18
|
+
// Mock logger to keep tests quiet
|
|
19
|
+
container.register('Fl32_Web_Back_Logger$', {
|
|
20
|
+
info: () => {},
|
|
21
|
+
error: () => {},
|
|
22
|
+
exception: () => {},
|
|
23
|
+
});
|
|
24
|
+
// Keep handler order as provided
|
|
25
|
+
container.register('Fl32_Web_Back_Helper_Order_Kahn$', {sort: arr => arr});
|
|
26
|
+
|
|
27
|
+
// Respond helper stub
|
|
28
|
+
respond = {
|
|
29
|
+
isWritable: res => !res.headersSent && !res.writableEnded,
|
|
30
|
+
code404_NotFound: ({res}) => { res.code = 404; res.headersSent = true; },
|
|
31
|
+
code500_InternalServerError: ({res}) => { res.code = 500; res.headersSent = true; },
|
|
32
|
+
};
|
|
33
|
+
container.register('Fl32_Web_Back_Helper_Respond$', respond);
|
|
34
|
+
|
|
35
|
+
STAGE = await container.get('Fl32_Web_Back_Enum_Stage$');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function pre(name) {
|
|
39
|
+
return {
|
|
40
|
+
getRegistrationInfo: () => ({name, stage: STAGE.PRE}),
|
|
41
|
+
handle: async () => { log.push(name); },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function proc(name, opts = {}) {
|
|
46
|
+
const {handled = true, throwErr = false, send = true} = opts;
|
|
47
|
+
return {
|
|
48
|
+
getRegistrationInfo: () => ({name, stage: STAGE.PROCESS}),
|
|
49
|
+
handle: async (req, res) => {
|
|
50
|
+
log.push(name);
|
|
51
|
+
if (throwErr) throw new Error('boom');
|
|
52
|
+
if (send) res.headersSent = true;
|
|
53
|
+
return handled;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function post(name) {
|
|
59
|
+
return {
|
|
60
|
+
getRegistrationInfo: () => ({name, stage: STAGE.POST}),
|
|
61
|
+
handle: async () => { log.push(name); },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('Fl32_Web_Back_Dispatcher', () => {
|
|
66
|
+
it('calls pre-handlers even when a process handler fails', async () => {
|
|
67
|
+
const dispatcher = await container.get('Fl32_Web_Back_Dispatcher$');
|
|
68
|
+
dispatcher.addHandler(pre('pre'));
|
|
69
|
+
dispatcher.addHandler(proc('proc', {throwErr: true, send: false}));
|
|
70
|
+
dispatcher.orderHandlers();
|
|
71
|
+
|
|
72
|
+
const req = {url: '/'}; const res = {};
|
|
73
|
+
await dispatcher.onEventRequest(req, res);
|
|
74
|
+
|
|
75
|
+
assert.strictEqual(log[0], 'pre');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('executes post-handlers after a successful process handler', async () => {
|
|
79
|
+
const dispatcher = await container.get('Fl32_Web_Back_Dispatcher$');
|
|
80
|
+
dispatcher.addHandler(pre('pre'));
|
|
81
|
+
dispatcher.addHandler(proc('proc'));
|
|
82
|
+
dispatcher.addHandler(post('post'));
|
|
83
|
+
dispatcher.orderHandlers();
|
|
84
|
+
|
|
85
|
+
const req = {url: '/'}; const res = {};
|
|
86
|
+
await dispatcher.onEventRequest(req, res);
|
|
87
|
+
|
|
88
|
+
assert.deepStrictEqual(log, ['pre', 'proc', 'post']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns 500 if a process handler throws', async () => {
|
|
92
|
+
const dispatcher = await container.get('Fl32_Web_Back_Dispatcher$');
|
|
93
|
+
dispatcher.addHandler(proc('proc', {throwErr: true, send: false}));
|
|
94
|
+
dispatcher.orderHandlers();
|
|
95
|
+
|
|
96
|
+
const req = {url: '/'}; const res = {};
|
|
97
|
+
await dispatcher.onEventRequest(req, res);
|
|
98
|
+
|
|
99
|
+
assert.strictEqual(res.code, 500);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns 404 if no process handler handles the request', async () => {
|
|
103
|
+
const dispatcher = await container.get('Fl32_Web_Back_Dispatcher$');
|
|
104
|
+
dispatcher.addHandler(pre('pre'));
|
|
105
|
+
dispatcher.addHandler(proc('p1', {handled: false, send: false}));
|
|
106
|
+
dispatcher.addHandler(post('post'));
|
|
107
|
+
dispatcher.orderHandlers();
|
|
108
|
+
|
|
109
|
+
const req = {url: '/missing'}; const res = {};
|
|
110
|
+
await dispatcher.onEventRequest(req, res);
|
|
111
|
+
|
|
112
|
+
assert.strictEqual(res.code, 404);
|
|
113
|
+
assert.deepStrictEqual(log, ['pre', 'p1', 'post']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('orders handlers according to before/after dependencies', async () => {
|
|
117
|
+
const localLog = [];
|
|
118
|
+
const container2 = buildTestContainer();
|
|
119
|
+
|
|
120
|
+
container2.register('Fl32_Web_Back_Logger$', {
|
|
121
|
+
info: () => {},
|
|
122
|
+
error: () => {},
|
|
123
|
+
exception: () => {},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const respond2 = {
|
|
127
|
+
isWritable: res => !res.headersSent && !res.writableEnded,
|
|
128
|
+
code404_NotFound: ({res}) => { res.code = 404; res.headersSent = true; },
|
|
129
|
+
code500_InternalServerError: ({res}) => { res.code = 500; res.headersSent = true; },
|
|
130
|
+
};
|
|
131
|
+
container2.register('Fl32_Web_Back_Helper_Respond$', respond2);
|
|
132
|
+
|
|
133
|
+
const STAGE2 = await container2.get('Fl32_Web_Back_Enum_Stage$');
|
|
134
|
+
const mk = (name, after = []) => ({
|
|
135
|
+
getRegistrationInfo: () => ({name, stage: STAGE2.PRE, after}),
|
|
136
|
+
handle: async () => { localLog.push(name); },
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const dispatcher = await container2.get('Fl32_Web_Back_Dispatcher$');
|
|
140
|
+
dispatcher.addHandler(mk('a', ['c']));
|
|
141
|
+
dispatcher.addHandler(mk('b', ['a']));
|
|
142
|
+
dispatcher.addHandler(mk('c'));
|
|
143
|
+
dispatcher.orderHandlers();
|
|
144
|
+
|
|
145
|
+
await dispatcher.onEventRequest({}, {});
|
|
146
|
+
|
|
147
|
+
assert.deepStrictEqual(localLog, ['c', 'a', 'b']);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|