@flancer32/teq-web 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/npm-publish.yml +1 -1
- package/AGENTS.md +101 -0
- package/CHANGELOG.md +33 -0
- package/README.md +41 -2
- package/package.json +4 -2
- 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/test/accept/ExternalServer.test.mjs +66 -0
- package/test/accept/Server.test.mjs +99 -1
- package/test/dev/app/Plugin/Start.js +5 -3
- 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
|
@@ -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
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {describe, it} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import {buildTestContainer} from '../unit/common.js';
|
|
5
|
+
import Express from 'express';
|
|
6
|
+
import Fastify from 'fastify';
|
|
7
|
+
|
|
8
|
+
async function startExpress(port, dispatcher) {
|
|
9
|
+
const app = Express();
|
|
10
|
+
app.use(async (req, res) => {
|
|
11
|
+
await dispatcher.onEventRequest(req, res);
|
|
12
|
+
});
|
|
13
|
+
const server = await new Promise((resolve, reject) => {
|
|
14
|
+
const srv = app.listen(port, err => err ? reject(err) : resolve(srv));
|
|
15
|
+
});
|
|
16
|
+
return server;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function startFastify(port, dispatcher) {
|
|
20
|
+
const fastify = Fastify();
|
|
21
|
+
fastify.all('*', async (request, reply) => {
|
|
22
|
+
const req = request.raw;
|
|
23
|
+
const res = reply.raw;
|
|
24
|
+
await dispatcher.onEventRequest(req, res);
|
|
25
|
+
});
|
|
26
|
+
await fastify.listen({port});
|
|
27
|
+
return fastify;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('Dispatcher integration with external servers', () => {
|
|
31
|
+
it('should respond via express', async () => {
|
|
32
|
+
const container = buildTestContainer();
|
|
33
|
+
const dispatcher = await container.get('Fl32_Web_Back_Dispatcher$');
|
|
34
|
+
const port = 3054;
|
|
35
|
+
const server = await startExpress(port, dispatcher);
|
|
36
|
+
|
|
37
|
+
const status = await new Promise((resolve, reject) => {
|
|
38
|
+
http.get(`http://localhost:${port}`, res => {
|
|
39
|
+
const {statusCode} = res;
|
|
40
|
+
res.resume();
|
|
41
|
+
res.on('end', () => resolve(statusCode));
|
|
42
|
+
}).on('error', reject);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
assert.strictEqual(status, 404);
|
|
46
|
+
await new Promise(resolve => server.close(resolve));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should respond via fastify', async () => {
|
|
50
|
+
const container = buildTestContainer();
|
|
51
|
+
const dispatcher = await container.get('Fl32_Web_Back_Dispatcher$');
|
|
52
|
+
const port = 3055;
|
|
53
|
+
const fastify = await startFastify(port, dispatcher);
|
|
54
|
+
|
|
55
|
+
const status = await new Promise((resolve, reject) => {
|
|
56
|
+
http.get(`http://localhost:${port}`, res => {
|
|
57
|
+
const {statusCode} = res;
|
|
58
|
+
res.resume();
|
|
59
|
+
res.on('end', () => resolve(statusCode));
|
|
60
|
+
}).on('error', reject);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.strictEqual(status, 404);
|
|
64
|
+
await fastify.close();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -11,7 +11,7 @@ async function waitListening(server) {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
describe('Fl32_Web_Back_Server
|
|
14
|
+
describe('Fl32_Web_Back_Server', () => {
|
|
15
15
|
it('should start and respond in HTTP/1 mode', async () => {
|
|
16
16
|
const container = buildTestContainer();
|
|
17
17
|
const server = await container.get('Fl32_Web_Back_Server$');
|
|
@@ -103,3 +103,101 @@ describe('Fl32_Web_Back_Server (real)', () => {
|
|
|
103
103
|
assert.strictEqual(server.getInstance(), undefined);
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
describe('Fl32_Web_Back_Api_Handler', () => {
|
|
108
|
+
it('should serve allowed NPM file', async () => {
|
|
109
|
+
const container = buildTestContainer();
|
|
110
|
+
const dispatcher = await container.get('Fl32_Web_Back_Dispatcher$');
|
|
111
|
+
const handler = await container.get('Fl32_Web_Back_Handler_Static$');
|
|
112
|
+
const Cfg = await container.get('Fl32_Web_Back_Dto_Handler_Source$');
|
|
113
|
+
await handler.init({
|
|
114
|
+
sources: [Cfg.create({
|
|
115
|
+
root: 'node_modules',
|
|
116
|
+
prefix: '/npm/',
|
|
117
|
+
allow: {
|
|
118
|
+
'@teqfw/di/src': ['.'],
|
|
119
|
+
},
|
|
120
|
+
})],
|
|
121
|
+
});
|
|
122
|
+
dispatcher.addHandler(handler);
|
|
123
|
+
dispatcher.orderHandlers();
|
|
124
|
+
|
|
125
|
+
const server = await container.get('Fl32_Web_Back_Server$');
|
|
126
|
+
const SERVER_TYPE = await container.get('Fl32_Web_Back_Enum_Server_Type$');
|
|
127
|
+
const Config = await container.get('Fl32_Web_Back_Server_Config$');
|
|
128
|
+
const http = await container.get('node:http');
|
|
129
|
+
|
|
130
|
+
const cfg = Config.create({ port: 3056, type: SERVER_TYPE.HTTP });
|
|
131
|
+
await server.start(cfg);
|
|
132
|
+
await waitListening(server);
|
|
133
|
+
|
|
134
|
+
const result = await new Promise((resolve, reject) => {
|
|
135
|
+
const req = http.get({
|
|
136
|
+
hostname: 'localhost',
|
|
137
|
+
port: cfg.port,
|
|
138
|
+
path: '/npm/@teqfw/di/src/Api/Container/Parser/Chunk.js',
|
|
139
|
+
}, res => {
|
|
140
|
+
const chunks = [];
|
|
141
|
+
res.on('data', ch => chunks.push(ch));
|
|
142
|
+
res.on('end', () => {
|
|
143
|
+
resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
req.on('error', reject);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
assert.strictEqual(result.status, 200);
|
|
150
|
+
assert.match(result.body, /class TeqFw_Di_Api_Container_Parser_Chunk/);
|
|
151
|
+
|
|
152
|
+
await server.stop();
|
|
153
|
+
assert.strictEqual(server.getInstance(), undefined);
|
|
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
|
+
});
|
|
203
|
+
});
|
|
@@ -5,6 +5,7 @@ export default class App_Plugin_Start {
|
|
|
5
5
|
* @param {Fl32_Web_Back_Dispatcher} dispatcher
|
|
6
6
|
* @param {Fl32_Web_Back_Handler_Pre_Log} hndlRequestLog
|
|
7
7
|
* @param {Fl32_Web_Back_Handler_Static} hndlStatic
|
|
8
|
+
* @param {Fl32_Web_Back_Dto_Handler_Source} dtoCfg
|
|
8
9
|
*/
|
|
9
10
|
constructor(
|
|
10
11
|
{
|
|
@@ -13,6 +14,7 @@ export default class App_Plugin_Start {
|
|
|
13
14
|
Fl32_Web_Back_Dispatcher$: dispatcher,
|
|
14
15
|
Fl32_Web_Back_Handler_Pre_Log$: hndlRequestLog,
|
|
15
16
|
Fl32_Web_Back_Handler_Static$: hndlStatic,
|
|
17
|
+
Fl32_Web_Back_Dto_Handler_Source$: dtoCfg,
|
|
16
18
|
}
|
|
17
19
|
) {
|
|
18
20
|
// VARS
|
|
@@ -28,9 +30,9 @@ export default class App_Plugin_Start {
|
|
|
28
30
|
const webRoot = join(root, 'web');
|
|
29
31
|
|
|
30
32
|
return async function () {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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]});
|
|
34
36
|
dispatcher.addHandler(hndlRequestLog);
|
|
35
37
|
dispatcher.addHandler(hndlStatic);
|
|
36
38
|
};
|
|
@@ -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.
|