odac 1.1.0 → 1.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/.agent/rules/coding.md +27 -0
- package/.agent/rules/memory.md +42 -0
- package/.agent/rules/project.md +30 -0
- package/.agent/rules/workflow.md +16 -0
- package/.github/workflows/release.yml +68 -1
- package/.github/workflows/test-coverage.yml +6 -5
- package/.husky/pre-commit +10 -0
- package/.husky/pre-push +13 -0
- package/.releaserc.js +1 -1
- package/CHANGELOG.md +99 -0
- package/README.md +16 -0
- package/bin/odac.js +196 -38
- package/client/odac.js +238 -171
- package/docs/backend/01-overview/03-development-server.md +38 -45
- package/docs/backend/02-structure/01-typical-project-layout.md +59 -26
- package/docs/backend/03-config/00-configuration-overview.md +6 -6
- package/docs/backend/03-config/01-database-connection.md +2 -2
- package/docs/backend/03-config/02-static-route-mapping-optional.md +1 -1
- package/docs/backend/03-config/03-request-timeout.md +1 -1
- package/docs/backend/03-config/04-environment-variables.md +4 -4
- package/docs/backend/03-config/05-early-hints.md +2 -2
- package/docs/backend/04-routing/03-api-and-data-routes.md +18 -0
- package/docs/backend/04-routing/07-cron-jobs.md +17 -1
- package/docs/backend/04-routing/09-websocket.md +14 -1
- package/docs/backend/05-controllers/01-how-to-build-a-controller.md +48 -3
- package/docs/backend/05-controllers/03-controller-classes.md +40 -20
- package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +17 -0
- package/docs/backend/07-views/10-styling-and-tailwind.md +93 -0
- package/docs/backend/08-database/01-getting-started.md +2 -2
- package/docs/backend/10-authentication/03-register.md +1 -1
- package/docs/backend/10-authentication/04-odac-register-forms.md +2 -2
- package/docs/backend/10-authentication/05-session-management.md +15 -1
- package/docs/backend/10-authentication/06-odac-login-forms.md +2 -2
- package/docs/backend/10-authentication/07-magic-links.md +1 -1
- package/docs/index.json +5 -1
- package/jest.config.js +1 -1
- package/package.json +18 -14
- package/src/Auth.js +58 -23
- package/src/Config.js +7 -7
- package/src/Database.js +1 -1
- package/src/Env.js +3 -1
- package/src/Ipc.js +7 -0
- package/src/Lang.js +9 -2
- package/src/Mail.js +19 -9
- package/src/Odac.js +56 -44
- package/src/Request.js +6 -2
- package/src/Route/Cron.js +58 -17
- package/src/Route/Internal.js +1 -1
- package/src/Route.js +375 -125
- package/src/Server.js +40 -3
- package/src/Storage.js +4 -0
- package/src/Token.js +6 -4
- package/src/Validator.js +24 -15
- package/src/Var.js +22 -6
- package/src/View/EarlyHints.js +43 -33
- package/src/View/Form.js +17 -11
- package/src/View.js +68 -12
- package/src/WebSocket.js +45 -12
- package/template/package.json +3 -1
- package/template/view/content/home.html +3 -3
- package/template/view/head/main.html +2 -2
- package/test/Client.test.js +168 -0
- package/test/Config.test.js +112 -0
- package/test/Lang.test.js +92 -0
- package/test/Odac.test.js +88 -0
- package/test/{framework/middleware.test.js → Route/Middleware.test.js} +2 -2
- package/test/{framework/Route.test.js → Route.test.js} +105 -1
- package/test/{framework/View → View}/EarlyHints.test.js +1 -1
- package/test/WebSocket.test.js +238 -0
- package/test/scripts/check-coverage.js +4 -4
- package/test/cli/Cli.test.js +0 -36
- package/test/core/Commands.test.js +0 -538
- package/test/core/Config.test.js +0 -1432
- package/test/core/Lang.test.js +0 -250
- package/test/core/Odac.test.js +0 -234
- package/test/core/Process.test.js +0 -156
- package/test/framework/WebSocket.test.js +0 -100
- package/test/server/Api.test.js +0 -647
- package/test/server/DNS.test.js +0 -2050
- package/test/server/DNS.test.js.bak +0 -2084
- package/test/server/Hub.test.js +0 -497
- package/test/server/Log.test.js +0 -73
- package/test/server/Mail.account.test_.js +0 -460
- package/test/server/Mail.init.test_.js +0 -411
- package/test/server/Mail.test_.js +0 -1340
- package/test/server/SSL.test_.js +0 -1491
- package/test/server/Server.test.js +0 -765
- package/test/server/Service.test_.js +0 -1127
- package/test/server/Subdomain.test.js +0 -440
- package/test/server/Web/Firewall.test.js +0 -175
- package/test/server/Web/Proxy.test.js +0 -397
- package/test/server/Web.test.js +0 -1494
- package/test/server/__mocks__/acme-client.js +0 -17
- package/test/server/__mocks__/bcrypt.js +0 -50
- package/test/server/__mocks__/child_process.js +0 -389
- package/test/server/__mocks__/crypto.js +0 -432
- package/test/server/__mocks__/fs.js +0 -450
- package/test/server/__mocks__/globalOdac.js +0 -227
- package/test/server/__mocks__/http.js +0 -575
- package/test/server/__mocks__/https.js +0 -272
- package/test/server/__mocks__/index.js +0 -249
- package/test/server/__mocks__/mail/server.js +0 -100
- package/test/server/__mocks__/mail/smtp.js +0 -31
- package/test/server/__mocks__/mailparser.js +0 -81
- package/test/server/__mocks__/net.js +0 -369
- package/test/server/__mocks__/node-forge.js +0 -328
- package/test/server/__mocks__/os.js +0 -320
- package/test/server/__mocks__/path.js +0 -291
- package/test/server/__mocks__/selfsigned.js +0 -8
- package/test/server/__mocks__/server/src/mail/server.js +0 -100
- package/test/server/__mocks__/server/src/mail/smtp.js +0 -31
- package/test/server/__mocks__/smtp-server.js +0 -106
- package/test/server/__mocks__/sqlite3.js +0 -394
- package/test/server/__mocks__/testFactories.js +0 -299
- package/test/server/__mocks__/testHelpers.js +0 -363
- package/test/server/__mocks__/tls.js +0 -229
- /package/template/{config.json → odac.json} +0 -0
package/src/WebSocket.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const nodeCrypto = require('crypto')
|
|
2
2
|
|
|
3
3
|
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
4
|
-
const
|
|
4
|
+
const DEFAULT_MAX_PAYLOAD = 10 * 1024 * 1024
|
|
5
|
+
const DEFAULT_RATE_LIMIT_MAX = 50
|
|
6
|
+
const DEFAULT_RATE_LIMIT_WINDOW = 1000
|
|
7
|
+
|
|
5
8
|
const OPCODE = {
|
|
6
9
|
CONTINUATION: 0x0,
|
|
7
10
|
TEXT: 0x1,
|
|
@@ -18,13 +21,29 @@ class WebSocketClient {
|
|
|
18
21
|
#server
|
|
19
22
|
#id
|
|
20
23
|
#rooms = new Set()
|
|
24
|
+
#maxPayload
|
|
25
|
+
#rateLimitMax
|
|
26
|
+
#rateLimitWindow
|
|
27
|
+
#messageCount = 0
|
|
28
|
+
#rateLimitTimer
|
|
21
29
|
data = {}
|
|
22
30
|
|
|
23
|
-
constructor(socket, server, id) {
|
|
31
|
+
constructor(socket, server, id, options = {}) {
|
|
24
32
|
this.#socket = socket
|
|
25
33
|
this.#socket.pause()
|
|
26
34
|
this.#server = server
|
|
27
35
|
this.#id = id
|
|
36
|
+
this.#maxPayload = options.maxPayload || DEFAULT_MAX_PAYLOAD
|
|
37
|
+
|
|
38
|
+
this.#rateLimitMax = options.rateLimit?.max ?? DEFAULT_RATE_LIMIT_MAX
|
|
39
|
+
this.#rateLimitWindow = options.rateLimit?.window ?? DEFAULT_RATE_LIMIT_WINDOW
|
|
40
|
+
|
|
41
|
+
if (this.#rateLimitMax > 0) {
|
|
42
|
+
this.#rateLimitTimer = setInterval(() => {
|
|
43
|
+
this.#messageCount = 0
|
|
44
|
+
}, this.#rateLimitWindow)
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
this.#setupListeners()
|
|
29
48
|
}
|
|
30
49
|
|
|
@@ -93,7 +112,7 @@ class WebSocketClient {
|
|
|
93
112
|
offset = 10
|
|
94
113
|
}
|
|
95
114
|
|
|
96
|
-
if (payloadLength >
|
|
115
|
+
if (payloadLength > this.#maxPayload) {
|
|
97
116
|
this.close(1009, 'Payload too large')
|
|
98
117
|
return null
|
|
99
118
|
}
|
|
@@ -125,6 +144,14 @@ class WebSocketClient {
|
|
|
125
144
|
}
|
|
126
145
|
|
|
127
146
|
#handleFrame(frame) {
|
|
147
|
+
if (this.#rateLimitMax > 0) {
|
|
148
|
+
this.#messageCount++
|
|
149
|
+
if (this.#messageCount > this.#rateLimitMax) {
|
|
150
|
+
this.close(1008, 'Rate limit exceeded')
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
128
155
|
switch (frame.opcode) {
|
|
129
156
|
case OPCODE.TEXT:
|
|
130
157
|
this.#handleMessage(frame.payload.toString('utf8'))
|
|
@@ -158,6 +185,8 @@ class WebSocketClient {
|
|
|
158
185
|
if (this.#closed) return
|
|
159
186
|
this.#closed = true
|
|
160
187
|
|
|
188
|
+
if (this.#rateLimitTimer) clearInterval(this.#rateLimitTimer)
|
|
189
|
+
|
|
161
190
|
this.#socket.removeAllListeners()
|
|
162
191
|
|
|
163
192
|
for (const room of this.#rooms) {
|
|
@@ -169,8 +198,7 @@ class WebSocketClient {
|
|
|
169
198
|
}
|
|
170
199
|
|
|
171
200
|
#sendFrame(opcode, data) {
|
|
172
|
-
if (this.#closed) return
|
|
173
|
-
|
|
201
|
+
if (this.#closed && opcode !== OPCODE.CLOSE) return
|
|
174
202
|
const payload = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
|
175
203
|
const length = payload.length
|
|
176
204
|
|
|
@@ -240,6 +268,8 @@ class WebSocketClient {
|
|
|
240
268
|
if (this.#closed) return
|
|
241
269
|
this.#closed = true
|
|
242
270
|
|
|
271
|
+
if (this.#rateLimitTimer) clearInterval(this.#rateLimitTimer)
|
|
272
|
+
|
|
243
273
|
const reasonBuffer = Buffer.from(reason)
|
|
244
274
|
const payload = Buffer.alloc(2 + reasonBuffer.length)
|
|
245
275
|
payload.writeUInt16BE(code, 0)
|
|
@@ -287,14 +317,14 @@ class WebSocketServer {
|
|
|
287
317
|
#rooms = new Map()
|
|
288
318
|
#routes = new Map()
|
|
289
319
|
|
|
290
|
-
route(path, handler) {
|
|
291
|
-
this.#routes.set(path, handler)
|
|
320
|
+
route(path, handler, options = {}) {
|
|
321
|
+
this.#routes.set(path, {handler, options})
|
|
292
322
|
}
|
|
293
323
|
|
|
294
324
|
getRoute(path) {
|
|
295
325
|
if (this.#routes.has(path)) return this.#routes.get(path)
|
|
296
326
|
|
|
297
|
-
for (const [pattern,
|
|
327
|
+
for (const [pattern, config] of this.#routes) {
|
|
298
328
|
if (!pattern.includes('{')) continue
|
|
299
329
|
const regex = new RegExp('^' + pattern.replace(/\{[^}]+\}/g, '([^/]+)') + '$')
|
|
300
330
|
const match = path.match(regex)
|
|
@@ -304,7 +334,11 @@ class WebSocketServer {
|
|
|
304
334
|
paramNames.forEach((name, i) => {
|
|
305
335
|
params[name.slice(1, -1)] = match[i + 1]
|
|
306
336
|
})
|
|
307
|
-
return {
|
|
337
|
+
return {
|
|
338
|
+
handler: config.handler,
|
|
339
|
+
options: config.options,
|
|
340
|
+
params
|
|
341
|
+
}
|
|
308
342
|
}
|
|
309
343
|
}
|
|
310
344
|
return null
|
|
@@ -320,8 +354,7 @@ class WebSocketServer {
|
|
|
320
354
|
return
|
|
321
355
|
}
|
|
322
356
|
|
|
323
|
-
const handler =
|
|
324
|
-
const params = routeInfo.params || {}
|
|
357
|
+
const {handler, params = {}, options = {}} = routeInfo
|
|
325
358
|
|
|
326
359
|
const key = req.headers['sec-websocket-key']
|
|
327
360
|
if (!key) {
|
|
@@ -353,7 +386,7 @@ class WebSocketServer {
|
|
|
353
386
|
if (head && head.length > 0) socket.unshift(head)
|
|
354
387
|
|
|
355
388
|
const clientId = nodeCrypto.randomUUID()
|
|
356
|
-
const client = new WebSocketClient(socket, this, clientId)
|
|
389
|
+
const client = new WebSocketClient(socket, this, clientId, options)
|
|
357
390
|
this.#clients.set(clientId, client)
|
|
358
391
|
|
|
359
392
|
if (params) {
|
package/template/package.json
CHANGED
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
|
|
61
61
|
<div class="structure-item">
|
|
62
62
|
<div class="structure-icon">⚙️</div>
|
|
63
|
-
<h3 class="structure-title">
|
|
63
|
+
<h3 class="structure-title">odac.json</h3>
|
|
64
64
|
<p class="structure-description">Configuration file for database, routes, and other settings.</p>
|
|
65
|
-
<code class="structure-path">
|
|
65
|
+
<code class="structure-path">odac.json</code>
|
|
66
66
|
</div>
|
|
67
67
|
</div>
|
|
68
68
|
</div>
|
|
@@ -156,7 +156,7 @@
|
|
|
156
156
|
<div class="feature-icon">🗄️</div>
|
|
157
157
|
<div class="feature-content">
|
|
158
158
|
<h3>Database Ready</h3>
|
|
159
|
-
<p>MySQL and SQLite support built-in. Configure in <code>
|
|
159
|
+
<p>MySQL and SQLite support built-in. Configure in <code>odac.json</code></p>
|
|
160
160
|
</div>
|
|
161
161
|
</div>
|
|
162
162
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<meta charset="UTF-8" />
|
|
2
2
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
3
|
-
<title>
|
|
3
|
+
<title>ODAC</title>
|
|
4
4
|
<meta name="description" content="A next-generation server and framework toolkit for modern web development" />
|
|
5
|
-
<link rel="stylesheet" href="/assets/css/
|
|
5
|
+
<link rel="stylesheet" href="/assets/css/app.css" />
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
describe('Client (odac.js)', () => {
|
|
2
|
+
let mockXhr
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
jest.resetModules()
|
|
6
|
+
|
|
7
|
+
mockXhr = {
|
|
8
|
+
open: jest.fn(),
|
|
9
|
+
setRequestHeader: jest.fn(),
|
|
10
|
+
send: jest.fn(),
|
|
11
|
+
getResponseHeader: jest.fn(),
|
|
12
|
+
status: 200,
|
|
13
|
+
responseText: '{}',
|
|
14
|
+
response: '{}',
|
|
15
|
+
onload: null,
|
|
16
|
+
onerror: null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const mockDocument = {
|
|
20
|
+
getElementById: jest.fn(),
|
|
21
|
+
querySelectorAll: jest.fn(() => []),
|
|
22
|
+
querySelector: jest.fn(),
|
|
23
|
+
addEventListener: jest.fn(),
|
|
24
|
+
removeEventListener: jest.fn(),
|
|
25
|
+
dispatchEvent: jest.fn(),
|
|
26
|
+
documentElement: {dataset: {}},
|
|
27
|
+
cookie: '',
|
|
28
|
+
readyState: 'complete',
|
|
29
|
+
createElement: jest.fn(() => ({
|
|
30
|
+
setAttribute: jest.fn(),
|
|
31
|
+
style: {},
|
|
32
|
+
appendChild: jest.fn(),
|
|
33
|
+
parentNode: {insertBefore: jest.fn()}
|
|
34
|
+
}))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const mockWindow = {
|
|
38
|
+
location: {
|
|
39
|
+
protocol: 'http:',
|
|
40
|
+
host: 'localhost',
|
|
41
|
+
href: 'http://localhost/'
|
|
42
|
+
},
|
|
43
|
+
history: {
|
|
44
|
+
pushState: jest.fn()
|
|
45
|
+
},
|
|
46
|
+
scrollTo: jest.fn(),
|
|
47
|
+
addEventListener: jest.fn(),
|
|
48
|
+
XMLHttpRequest: jest.fn(() => mockXhr),
|
|
49
|
+
localStorage: {
|
|
50
|
+
getItem: jest.fn(),
|
|
51
|
+
setItem: jest.fn(),
|
|
52
|
+
removeItem: jest.fn()
|
|
53
|
+
},
|
|
54
|
+
CustomEvent: jest.fn((name, detail) => ({name, detail})),
|
|
55
|
+
setTimeout: jest.fn(),
|
|
56
|
+
clearTimeout: jest.fn(),
|
|
57
|
+
requestAnimationFrame: jest.fn(cb => cb(Date.now())),
|
|
58
|
+
WebSocket: jest.fn(() => ({
|
|
59
|
+
send: jest.fn(),
|
|
60
|
+
close: jest.fn(),
|
|
61
|
+
readyState: 1 // OPEN
|
|
62
|
+
})),
|
|
63
|
+
FormData: jest.fn()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
mockWindow.window = mockWindow
|
|
67
|
+
mockWindow.document = mockDocument
|
|
68
|
+
mockWindow.WebSocket.OPEN = 1
|
|
69
|
+
mockWindow.WebSocket.CLOSED = 3
|
|
70
|
+
|
|
71
|
+
global.window = mockWindow
|
|
72
|
+
global.document = mockDocument
|
|
73
|
+
global.location = mockWindow.location
|
|
74
|
+
global.XMLHttpRequest = mockWindow.XMLHttpRequest
|
|
75
|
+
global.localStorage = mockWindow.localStorage
|
|
76
|
+
global.CustomEvent = mockWindow.CustomEvent
|
|
77
|
+
global.WebSocket = mockWindow.WebSocket
|
|
78
|
+
global.setTimeout = mockWindow.setTimeout
|
|
79
|
+
global.clearTimeout = mockWindow.clearTimeout
|
|
80
|
+
global.requestAnimationFrame = mockWindow.requestAnimationFrame
|
|
81
|
+
global.FormData = mockWindow.FormData
|
|
82
|
+
|
|
83
|
+
delete require.cache[require.resolve('../client/odac.js')]
|
|
84
|
+
require('../client/odac.js')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
delete global.window
|
|
89
|
+
delete global.document
|
|
90
|
+
delete global.location
|
|
91
|
+
delete global.XMLHttpRequest
|
|
92
|
+
delete global.localStorage
|
|
93
|
+
delete global.CustomEvent
|
|
94
|
+
delete global.WebSocket
|
|
95
|
+
delete global.setTimeout
|
|
96
|
+
delete global.clearTimeout
|
|
97
|
+
delete global.requestAnimationFrame
|
|
98
|
+
delete global.FormData
|
|
99
|
+
delete global.Odac
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('Odac should be initialized on window', () => {
|
|
103
|
+
expect(window.Odac).toBeDefined()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('data()', () => {
|
|
107
|
+
test('should retrieve data from odac-data script tag', () => {
|
|
108
|
+
const mockData = {user: 'emre'}
|
|
109
|
+
document.getElementById.mockReturnValue({
|
|
110
|
+
textContent: JSON.stringify(mockData)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const result = window.Odac.data()
|
|
114
|
+
expect(result).toEqual(mockData)
|
|
115
|
+
expect(document.getElementById).toHaveBeenCalledWith('odac-data')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('should return specific key from data', () => {
|
|
119
|
+
const mockData = {user: 'emre', role: 'admin'}
|
|
120
|
+
document.getElementById.mockReturnValue({
|
|
121
|
+
textContent: JSON.stringify(mockData)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
expect(window.Odac.data('user')).toBe('emre')
|
|
125
|
+
expect(window.Odac.data('role')).toBe('admin')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('storage()', () => {
|
|
130
|
+
test('should get item from localStorage', () => {
|
|
131
|
+
localStorage.getItem.mockReturnValue('val')
|
|
132
|
+
expect(window.Odac.storage('key')).toBe('val')
|
|
133
|
+
expect(localStorage.getItem).toHaveBeenCalledWith('key')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('should set item in localStorage', () => {
|
|
137
|
+
window.Odac.storage('key', 'val')
|
|
138
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('key', 'val')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('token()', () => {
|
|
143
|
+
test('should fetch token via sync XHR if hash is empty', () => {
|
|
144
|
+
mockXhr.response = JSON.stringify({token: 'new-token'})
|
|
145
|
+
document.cookie = 'odac_client=abc'
|
|
146
|
+
|
|
147
|
+
const token = window.Odac.token()
|
|
148
|
+
|
|
149
|
+
expect(window.XMLHttpRequest).toHaveBeenCalled()
|
|
150
|
+
expect(token).toBe('new-token')
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('OdacWebSocket', () => {
|
|
155
|
+
test('should connect to WebSocket and handle events', () => {
|
|
156
|
+
const ws = window.Odac.ws('/test-ws', {token: false})
|
|
157
|
+
expect(window.WebSocket).toHaveBeenCalled()
|
|
158
|
+
|
|
159
|
+
const openHandler = jest.fn()
|
|
160
|
+
ws.on('open', openHandler)
|
|
161
|
+
|
|
162
|
+
const socketInstance = WebSocket.mock.results[0].value
|
|
163
|
+
socketInstance.onopen()
|
|
164
|
+
|
|
165
|
+
expect(openHandler).toHaveBeenCalled()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const os = require('os')
|
|
3
|
+
|
|
4
|
+
const Config = require('../src/Config')
|
|
5
|
+
|
|
6
|
+
jest.mock('fs')
|
|
7
|
+
jest.mock('os')
|
|
8
|
+
|
|
9
|
+
describe('Config', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
jest.clearAllMocks()
|
|
12
|
+
// Reset global.__dir which is used in Config.js
|
|
13
|
+
global.__dir = '/mock/project'
|
|
14
|
+
|
|
15
|
+
// Reset Config properties to defaults before each test
|
|
16
|
+
Config.system = undefined
|
|
17
|
+
Config.encrypt.key = 'odac'
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('init', () => {
|
|
21
|
+
it('should load system config from home directory', () => {
|
|
22
|
+
os.homedir.mockReturnValue('/home/user')
|
|
23
|
+
fs.readFileSync.mockImplementation(path => {
|
|
24
|
+
if (path === '/home/user/.odac/config.json') {
|
|
25
|
+
return JSON.stringify({deviceId: '123'})
|
|
26
|
+
}
|
|
27
|
+
return '{}'
|
|
28
|
+
})
|
|
29
|
+
fs.existsSync.mockReturnValue(false)
|
|
30
|
+
|
|
31
|
+
Config.init()
|
|
32
|
+
|
|
33
|
+
expect(Config.system).toEqual({deviceId: '123'})
|
|
34
|
+
expect(fs.readFileSync).toHaveBeenCalledWith('/home/user/.odac/config.json')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should load project config and merge it', () => {
|
|
38
|
+
os.homedir.mockReturnValue('/home/user')
|
|
39
|
+
fs.existsSync.mockImplementation(path => {
|
|
40
|
+
if (path === '/mock/project/odac.json') return true
|
|
41
|
+
return false
|
|
42
|
+
})
|
|
43
|
+
fs.readFileSync.mockImplementation(path => {
|
|
44
|
+
if (path === '/mock/project/odac.json') {
|
|
45
|
+
return JSON.stringify({encrypt: {key: 'secret'}})
|
|
46
|
+
}
|
|
47
|
+
return '{}'
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
Config.init()
|
|
51
|
+
|
|
52
|
+
// The key gets hashed in init(), so it won't be 'secret' anymore
|
|
53
|
+
expect(Config.encrypt.key).not.toBe('secret')
|
|
54
|
+
expect(Config.encrypt.key).toBeInstanceOf(Buffer)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should interpolate variables in config', () => {
|
|
58
|
+
process.env.TEST_VAR = 'env_value'
|
|
59
|
+
os.homedir.mockReturnValue('/home/user')
|
|
60
|
+
fs.existsSync.mockReturnValue(true)
|
|
61
|
+
fs.readFileSync.mockReturnValue(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
custom: 'value-${TEST_VAR}'
|
|
64
|
+
})
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
Config.init()
|
|
68
|
+
|
|
69
|
+
expect(Config.custom).toBe('value-env_value')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('_interpolate', () => {
|
|
74
|
+
it('should replace ${VAR} with environment variables', () => {
|
|
75
|
+
process.env.FOO = 'bar'
|
|
76
|
+
const result = Config._interpolate('hello-${FOO}')
|
|
77
|
+
expect(result).toBe('hello-bar')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should replace ${odac} with client path', () => {
|
|
81
|
+
// __dirname in Config.js is /.../src, so it replaces /src with /client
|
|
82
|
+
const result = Config._interpolate('path-${odac}')
|
|
83
|
+
expect(result).toMatch(/\/client$/)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should handle nested objects and arrays', () => {
|
|
87
|
+
process.env.VAR = 'x'
|
|
88
|
+
const obj = {
|
|
89
|
+
a: ['${VAR}'],
|
|
90
|
+
b: {c: '${VAR}'}
|
|
91
|
+
}
|
|
92
|
+
const result = Config._interpolate(obj)
|
|
93
|
+
expect(result).toEqual({
|
|
94
|
+
a: ['x'],
|
|
95
|
+
b: {c: 'x'}
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('_deepMerge', () => {
|
|
101
|
+
it('should merge objects deeply', () => {
|
|
102
|
+
const target = {a: {b: 1}, c: 2}
|
|
103
|
+
const source = {a: {d: 3}, e: 4}
|
|
104
|
+
Config._deepMerge(target, source)
|
|
105
|
+
expect(target).toEqual({
|
|
106
|
+
a: {b: 1, d: 3},
|
|
107
|
+
c: 2,
|
|
108
|
+
e: 4
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const Lang = require('../src/Lang')
|
|
3
|
+
|
|
4
|
+
jest.mock('fs')
|
|
5
|
+
|
|
6
|
+
describe('Lang', () => {
|
|
7
|
+
let mockOdac
|
|
8
|
+
let lang
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
jest.clearAllMocks()
|
|
12
|
+
global.__dir = '/mock/project'
|
|
13
|
+
|
|
14
|
+
mockOdac = {
|
|
15
|
+
Config: {lang: {default: 'en'}},
|
|
16
|
+
Var: jest.fn(val => ({
|
|
17
|
+
is: jest.fn(type => type === 'alpha' && /^[a-zA-Z]+$/.test(val))
|
|
18
|
+
})),
|
|
19
|
+
Request: {
|
|
20
|
+
header: jest.fn()
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Default fs mock behaviors
|
|
25
|
+
fs.existsSync.mockReturnValue(false)
|
|
26
|
+
fs.mkdirSync.mockImplementation(() => {})
|
|
27
|
+
fs.writeFileSync.mockImplementation(() => {})
|
|
28
|
+
fs.readFileSync.mockImplementation(() => {
|
|
29
|
+
throw new Error('ENOENT')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
lang = new Lang(mockOdac)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('constructor and set', () => {
|
|
36
|
+
it('should default to en when no config or header', () => {
|
|
37
|
+
lang = new Lang(mockOdac)
|
|
38
|
+
// Private #lang is not accessible, but we can check where it tries to save
|
|
39
|
+
lang.get('test')
|
|
40
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(expect.stringContaining('/en.json'), expect.any(String))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should use lang from header if available', () => {
|
|
44
|
+
mockOdac.Request.header.mockReturnValue('tr-TR')
|
|
45
|
+
lang = new Lang(mockOdac)
|
|
46
|
+
lang.get('test')
|
|
47
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(expect.stringContaining('/tr.json'), expect.any(String))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should use explicit lang in set()', () => {
|
|
51
|
+
lang = new Lang(mockOdac)
|
|
52
|
+
lang.set('fr')
|
|
53
|
+
lang.get('test')
|
|
54
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(expect.stringContaining('/fr.json'), expect.any(String))
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('get', () => {
|
|
59
|
+
it('should return matching string and support placeholders', () => {
|
|
60
|
+
// Mock loading tr.json
|
|
61
|
+
fs.existsSync.mockImplementation(path => path.includes('/tr.json'))
|
|
62
|
+
fs.readFileSync.mockReturnValue(
|
|
63
|
+
JSON.stringify({
|
|
64
|
+
welcome: 'Merhaba %s!'
|
|
65
|
+
})
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
lang.set('tr')
|
|
69
|
+
expect(lang.get('welcome', 'Emre')).toBe('Merhaba Emre!')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should support numbered placeholders', () => {
|
|
73
|
+
fs.existsSync.mockImplementation(path => path.includes('/en.json'))
|
|
74
|
+
fs.readFileSync.mockReturnValue(
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
order: 'First: %s1, Second: %s2'
|
|
77
|
+
})
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
lang.set('en')
|
|
81
|
+
expect(lang.get('order', 'A', 'B')).toBe('First: A, Second: B')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should auto-save new keys', () => {
|
|
85
|
+
lang.set('en')
|
|
86
|
+
const result = lang.get('new_key')
|
|
87
|
+
|
|
88
|
+
expect(result).toBe('new_key')
|
|
89
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(expect.stringContaining('/en.json'), expect.stringContaining('"new_key": "new_key"'))
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const Odac = require('../src/Odac')
|
|
2
|
+
|
|
3
|
+
// Mock all dependencies
|
|
4
|
+
jest.mock('../src/Storage', () => ({init: jest.fn()}))
|
|
5
|
+
jest.mock('../src/Env', () => ({init: jest.fn(), get: jest.fn()}))
|
|
6
|
+
jest.mock('../src/Config', () => ({
|
|
7
|
+
init: jest.fn(),
|
|
8
|
+
request: {timeout: 10000},
|
|
9
|
+
lang: {default: 'en'}
|
|
10
|
+
}))
|
|
11
|
+
jest.mock('../src/Database', () => ({init: jest.fn()}))
|
|
12
|
+
jest.mock('../src/Ipc', () => ({init: jest.fn(), subscribe: jest.fn(), unsubscribe: jest.fn()}))
|
|
13
|
+
jest.mock('../src/Route', () => {
|
|
14
|
+
return jest.fn().mockImplementation(() => ({
|
|
15
|
+
init: jest.fn(),
|
|
16
|
+
routes: {
|
|
17
|
+
www: {}
|
|
18
|
+
}
|
|
19
|
+
}))
|
|
20
|
+
})
|
|
21
|
+
jest.mock('../src/Server', () => ({init: jest.fn()}))
|
|
22
|
+
|
|
23
|
+
describe('Odac', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.clearAllMocks()
|
|
26
|
+
global.__dir = '/mock/project'
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('init', () => {
|
|
30
|
+
it('should initialize all components and set global.Odac', async () => {
|
|
31
|
+
await Odac.init()
|
|
32
|
+
expect(global.Odac).toBeDefined()
|
|
33
|
+
expect(global.Odac.Storage).toBeDefined()
|
|
34
|
+
expect(global.Odac.Config).toBeDefined()
|
|
35
|
+
expect(global.Odac.Env).toBeDefined()
|
|
36
|
+
expect(global.Odac.Database).toBeDefined()
|
|
37
|
+
expect(global.Odac.Ipc).toBeDefined()
|
|
38
|
+
expect(global.Odac.Route).toBeDefined()
|
|
39
|
+
expect(global.Odac.Server).toBeDefined()
|
|
40
|
+
expect(typeof global.__).toBe('function')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('instance', () => {
|
|
45
|
+
it('should create a context object without req/res', () => {
|
|
46
|
+
const ctx = Odac.instance('id-123')
|
|
47
|
+
expect(ctx.Config).toBeDefined()
|
|
48
|
+
expect(ctx.Database).toBeDefined()
|
|
49
|
+
expect(ctx.Ipc).toBeDefined()
|
|
50
|
+
expect(ctx.Request).toBeUndefined()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should create a context object with req/res', () => {
|
|
54
|
+
const mockReq = {url: '/', method: 'GET', headers: {host: 'example.com'}, connection: {remoteAddress: '127.0.0.1'}, on: jest.fn()}
|
|
55
|
+
const mockRes = {writeHead: jest.fn(), end: jest.fn(), finished: false}
|
|
56
|
+
const ctx = Odac.instance('id-123', mockReq, mockRes)
|
|
57
|
+
expect(ctx.Request).toBeDefined()
|
|
58
|
+
expect(ctx.Auth).toBeDefined()
|
|
59
|
+
expect(ctx.Token).toBeDefined()
|
|
60
|
+
expect(ctx.Lang).toBeDefined()
|
|
61
|
+
expect(ctx.View).toBeDefined()
|
|
62
|
+
ctx.cleanup()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should provide helper methods on the context', () => {
|
|
66
|
+
const mockReq = {url: '/', method: 'GET', headers: {host: 'example.com'}, connection: {remoteAddress: '127.0.0.1'}, on: jest.fn()}
|
|
67
|
+
const mockRes = {writeHead: jest.fn(), end: jest.fn(), write: jest.fn(), finished: false}
|
|
68
|
+
const ctx = Odac.instance('id-123', mockReq, mockRes)
|
|
69
|
+
|
|
70
|
+
expect(typeof ctx.env).toBe('function')
|
|
71
|
+
expect(typeof ctx.return).toBe('function')
|
|
72
|
+
expect(typeof ctx.write).toBe('function')
|
|
73
|
+
ctx.cleanup()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should handle Ipc subscription through Proxy', async () => {
|
|
77
|
+
const ctx = Odac.instance('id-123')
|
|
78
|
+
const callback = jest.fn()
|
|
79
|
+
const IpcSingleton = require('../src/Ipc')
|
|
80
|
+
IpcSingleton.subscribe.mockResolvedValue('sub-id')
|
|
81
|
+
|
|
82
|
+
await ctx.Ipc.subscribe('test-channel', callback)
|
|
83
|
+
|
|
84
|
+
expect(IpcSingleton.subscribe).toHaveBeenCalledWith('test-channel', callback)
|
|
85
|
+
expect(ctx._ipcSubs).toHaveLength(1)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const Route = require('../../
|
|
1
|
+
const Route = require('../../src/Route.js')
|
|
2
2
|
|
|
3
3
|
describe('Middleware System', () => {
|
|
4
4
|
let route
|
|
@@ -52,7 +52,7 @@ describe('Middleware System', () => {
|
|
|
52
52
|
|
|
53
53
|
test('chaining should work: auth.use().page()', () => {
|
|
54
54
|
route.auth.use('admin').page('/admin', () => {})
|
|
55
|
-
expect(route.routes.test
|
|
55
|
+
expect(route.routes.test['#page']['/admin'].middlewares).toEqual(['admin'])
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
test('middlewares should be attached to routes', () => {
|