odac 1.4.0 → 1.4.2
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/memory.md +8 -0
- package/.github/workflows/release.yml +1 -1
- package/.releaserc.js +9 -2
- package/CHANGELOG.md +61 -0
- package/README.md +10 -0
- package/bin/odac.js +193 -2
- package/client/odac.js +32 -13
- package/docs/ai/skills/SKILL.md +4 -3
- package/docs/ai/skills/backend/authentication.md +7 -0
- package/docs/ai/skills/backend/config.md +7 -0
- package/docs/ai/skills/backend/controllers.md +7 -0
- package/docs/ai/skills/backend/cron.md +9 -2
- package/docs/ai/skills/backend/database.md +37 -2
- package/docs/ai/skills/backend/forms.md +112 -11
- package/docs/ai/skills/backend/ipc.md +7 -0
- package/docs/ai/skills/backend/mail.md +7 -0
- package/docs/ai/skills/backend/migrations.md +86 -0
- package/docs/ai/skills/backend/request_response.md +7 -0
- package/docs/ai/skills/backend/routing.md +7 -0
- package/docs/ai/skills/backend/storage.md +7 -0
- package/docs/ai/skills/backend/streaming.md +7 -0
- package/docs/ai/skills/backend/structure.md +8 -1
- package/docs/ai/skills/backend/translations.md +7 -0
- package/docs/ai/skills/backend/utilities.md +7 -0
- package/docs/ai/skills/backend/validation.md +138 -31
- package/docs/ai/skills/backend/views.md +7 -0
- package/docs/ai/skills/frontend/core.md +7 -0
- package/docs/ai/skills/frontend/forms.md +48 -13
- package/docs/ai/skills/frontend/navigation.md +7 -0
- package/docs/ai/skills/frontend/realtime.md +7 -0
- package/docs/backend/08-database/02-basics.md +49 -9
- package/docs/backend/08-database/04-migrations.md +259 -37
- package/package.json +1 -1
- package/src/Auth.js +82 -43
- package/src/Config.js +1 -1
- package/src/Database/ConnectionFactory.js +70 -0
- package/src/Database/Migration.js +1228 -0
- package/src/Database/nanoid.js +30 -0
- package/src/Database.js +157 -46
- package/src/Ipc.js +37 -0
- package/src/Odac.js +1 -1
- package/src/Route/Cron.js +11 -0
- package/src/Route.js +8 -0
- package/src/Server.js +77 -23
- package/src/Storage.js +15 -1
- package/src/Validator.js +22 -20
- package/template/schema/users.js +23 -0
- package/test/{Auth.test.js → Auth/check.test.js} +153 -6
- package/test/Client/data.test.js +91 -0
- package/test/Client/get.test.js +90 -0
- package/test/Client/storage.test.js +87 -0
- package/test/Client/token.test.js +82 -0
- package/test/Client/ws.test.js +86 -0
- package/test/Config/deepMerge.test.js +14 -0
- package/test/Config/init.test.js +66 -0
- package/test/Config/interpolate.test.js +35 -0
- package/test/Database/ConnectionFactory/buildConnectionConfig.test.js +13 -0
- package/test/Database/ConnectionFactory/buildConnections.test.js +31 -0
- package/test/Database/ConnectionFactory/resolveClient.test.js +12 -0
- package/test/Database/Migration/migrate_column.test.js +52 -0
- package/test/Database/Migration/migrate_files.test.js +70 -0
- package/test/Database/Migration/migrate_index.test.js +89 -0
- package/test/Database/Migration/migrate_nanoid.test.js +160 -0
- package/test/Database/Migration/migrate_seed.test.js +77 -0
- package/test/Database/Migration/migrate_table.test.js +88 -0
- package/test/Database/Migration/rollback.test.js +61 -0
- package/test/Database/Migration/snapshot.test.js +38 -0
- package/test/Database/Migration/status.test.js +41 -0
- package/test/Database/autoNanoid.test.js +215 -0
- package/test/Database/nanoid.test.js +19 -0
- package/test/Lang/constructor.test.js +25 -0
- package/test/Lang/get.test.js +65 -0
- package/test/Lang/set.test.js +49 -0
- package/test/Odac/init.test.js +42 -0
- package/test/Odac/instance.test.js +58 -0
- package/test/Route/{Middleware.test.js → Middleware/chaining.test.js} +5 -29
- package/test/Route/Middleware/use.test.js +35 -0
- package/test/{Route.test.js → Route/check.test.js} +4 -55
- package/test/Route/set.test.js +52 -0
- package/test/Route/ws.test.js +23 -0
- package/test/View/EarlyHints/cache.test.js +32 -0
- package/test/View/EarlyHints/extractFromHtml.test.js +143 -0
- package/test/View/EarlyHints/formatLinkHeader.test.js +33 -0
- package/test/View/EarlyHints/send.test.js +99 -0
- package/test/View/{Form.test.js → Form/generateFieldHtml.test.js} +2 -2
- package/test/View/constructor.test.js +22 -0
- package/test/View/print.test.js +19 -0
- package/test/WebSocket/Client/limits.test.js +55 -0
- package/test/WebSocket/Server/broadcast.test.js +33 -0
- package/test/WebSocket/Server/route.test.js +37 -0
- package/test/Client.test.js +0 -197
- package/test/Config.test.js +0 -112
- package/test/Lang.test.js +0 -92
- package/test/Odac.test.js +0 -88
- package/test/View/EarlyHints.test.js +0 -282
- package/test/WebSocket.test.js +0 -238
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const Auth = require('
|
|
1
|
+
const Auth = require('../../src/Auth.js')
|
|
2
2
|
|
|
3
|
-
describe('Auth
|
|
3
|
+
describe('Auth.check()', () => {
|
|
4
4
|
let reqMock
|
|
5
5
|
let authInstance
|
|
6
6
|
|
|
@@ -65,7 +65,8 @@ describe('Auth - Refresh Token Rotation', () => {
|
|
|
65
65
|
return cookieStore[name] || null
|
|
66
66
|
}),
|
|
67
67
|
header: jest.fn(() => 'TestBrowser'),
|
|
68
|
-
ip: '127.0.0.1'
|
|
68
|
+
ip: '127.0.0.1',
|
|
69
|
+
res: {} // HTTP context (non-null res indicates Set-Cookie can be delivered)
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
authInstance = new Auth(reqMock)
|
|
@@ -137,11 +138,19 @@ describe('Auth - Refresh Token Rotation', () => {
|
|
|
137
138
|
// New cookie values must differ from old ones
|
|
138
139
|
expect(xSet[1]).not.toBe('old_x')
|
|
139
140
|
expect(ySet[1]).not.toBe('old_y')
|
|
141
|
+
// Cookie max-age must use proper HTTP attribute name (hyphenated, not camelCase)
|
|
142
|
+
expect(xSet[2]['max-age']).toBeDefined()
|
|
143
|
+
expect(xSet[2].maxAge).toBeUndefined()
|
|
140
144
|
})
|
|
141
145
|
|
|
142
|
-
it('should NOT rotate a token
|
|
146
|
+
it('should NOT rotate a recently-rotated token still within 5s threshold', async () => {
|
|
147
|
+
// Simulate a rotated token where active was set to give ~60s grace period
|
|
148
|
+
// and the rotation JUST happened (< 5 seconds ago)
|
|
149
|
+
const maxAge = 30 * 24 * 60 * 60 * 1000
|
|
150
|
+
const rotatedActiveDate = new Date(Date.now() - maxAge + 60000) // Grace period: ~60s left
|
|
151
|
+
|
|
143
152
|
const mockRecord = {
|
|
144
|
-
active:
|
|
153
|
+
active: rotatedActiveDate,
|
|
145
154
|
browser: 'TestBrowser',
|
|
146
155
|
date: new Date(0), // Epoch marker = already rotated
|
|
147
156
|
id: 'token_2',
|
|
@@ -158,11 +167,63 @@ describe('Auth - Refresh Token Rotation', () => {
|
|
|
158
167
|
const result = await authInstance.check()
|
|
159
168
|
|
|
160
169
|
expect(result).toBe(true)
|
|
161
|
-
// No rotation should occur
|
|
170
|
+
// No rotation should occur (timeSinceRotation < 5000)
|
|
162
171
|
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
163
172
|
expect(dbMock.tracker.updateCalls.length).toBe(0)
|
|
164
173
|
})
|
|
165
174
|
|
|
175
|
+
it('should recovery-rotate and DELETE old token when client lost cookies (rotated token > 5s)', async () => {
|
|
176
|
+
// Simulate a rotated token where 10 seconds have passed since original rotation
|
|
177
|
+
// Client still has old cookies -> recovery rotation should trigger
|
|
178
|
+
const maxAge = 30 * 24 * 60 * 60 * 1000
|
|
179
|
+
const timeSinceRotation = 10000 // 10 seconds since original rotation
|
|
180
|
+
// active was set to: rotationTime - maxAge + 60000
|
|
181
|
+
// So: inactiveAge = now - active = now - (rotationTime - maxAge + 60000) = timeSinceRotation + maxAge - 60000
|
|
182
|
+
const rotatedActiveDate = new Date(Date.now() - maxAge + 60000 - timeSinceRotation)
|
|
183
|
+
|
|
184
|
+
const mockRecord = {
|
|
185
|
+
active: rotatedActiveDate,
|
|
186
|
+
browser: 'TestBrowser',
|
|
187
|
+
date: new Date(0), // Epoch marker = rotated
|
|
188
|
+
id: 'token_recovery',
|
|
189
|
+
ip: '127.0.0.1',
|
|
190
|
+
token_x: 'old_x',
|
|
191
|
+
token_y: 'hashed_old_y',
|
|
192
|
+
user: 'user_10'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const dbMock = createDbMock([mockRecord])
|
|
196
|
+
global.Odac.DB.user_tokens = dbMock
|
|
197
|
+
global.Odac.DB.users = dbMock
|
|
198
|
+
|
|
199
|
+
const result = await authInstance.check()
|
|
200
|
+
|
|
201
|
+
expect(result).toBe(true)
|
|
202
|
+
|
|
203
|
+
// Should insert a new token (recovery rotation)
|
|
204
|
+
expect(dbMock.insert).toHaveBeenCalledTimes(1)
|
|
205
|
+
const inserted = dbMock.tracker.insertCalls[0]
|
|
206
|
+
expect(inserted.user).toBe('user_10')
|
|
207
|
+
expect(inserted.token_x).toBeDefined()
|
|
208
|
+
|
|
209
|
+
// Old token should be DELETED, not updated (prevents token multiplication)
|
|
210
|
+
expect(dbMock.tracker.deleteCalls.length).toBe(1)
|
|
211
|
+
expect(dbMock.tracker.updateCalls.length).toBe(0)
|
|
212
|
+
|
|
213
|
+
// New cookies should be issued
|
|
214
|
+
const setCalls = reqMock.cookie.mock.calls.filter(c => c.length >= 2)
|
|
215
|
+
const xSet = setCalls.find(c => c[0] === 'odac_x' && c[2]?.httpOnly === true)
|
|
216
|
+
const ySet = setCalls.find(c => c[0] === 'odac_y' && c[2]?.httpOnly === true)
|
|
217
|
+
expect(xSet).toBeDefined()
|
|
218
|
+
expect(ySet).toBeDefined()
|
|
219
|
+
expect(xSet[1]).not.toBe('old_x')
|
|
220
|
+
expect(ySet[1]).not.toBe('old_y')
|
|
221
|
+
|
|
222
|
+
// Cookie max-age attribute should use proper HTTP naming (hyphenated)
|
|
223
|
+
expect(xSet[2]['max-age']).toBeDefined()
|
|
224
|
+
expect(xSet[2].maxAge).toBeUndefined()
|
|
225
|
+
})
|
|
226
|
+
|
|
166
227
|
it('should NOT rotate when tokenAge is within rotationAge threshold', async () => {
|
|
167
228
|
const recentDate = Date.now() - 5 * 60 * 1000 // 5 mins ago -> within 15 min rotationAge
|
|
168
229
|
|
|
@@ -215,6 +276,92 @@ describe('Auth - Refresh Token Rotation', () => {
|
|
|
215
276
|
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
216
277
|
})
|
|
217
278
|
|
|
279
|
+
it('should skip rotation for WebSocket connections (res === null) and update active instead', async () => {
|
|
280
|
+
const createdAt = Date.now() - 20 * 60 * 1000 // 20 mins ago -> exceeds 15 min rotationAge
|
|
281
|
+
|
|
282
|
+
const wsReqMock = {
|
|
283
|
+
cookie: jest.fn((name, value) => {
|
|
284
|
+
if (value !== undefined) return
|
|
285
|
+
return {odac_x: 'old_x', odac_y: 'old_y'}[name] || null
|
|
286
|
+
}),
|
|
287
|
+
header: jest.fn(() => 'TestBrowser'),
|
|
288
|
+
ip: '127.0.0.1',
|
|
289
|
+
res: null // WebSocket context: no HTTP response available
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const wsAuth = new Auth(wsReqMock)
|
|
293
|
+
|
|
294
|
+
const mockRecord = {
|
|
295
|
+
active: new Date(),
|
|
296
|
+
browser: 'TestBrowser',
|
|
297
|
+
date: new Date(createdAt),
|
|
298
|
+
id: 'token_ws',
|
|
299
|
+
ip: '127.0.0.1',
|
|
300
|
+
token_x: 'old_x',
|
|
301
|
+
token_y: 'hashed_old_y',
|
|
302
|
+
user: 'user_10'
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const dbMock = createDbMock([mockRecord])
|
|
306
|
+
global.Odac.DB.user_tokens = dbMock
|
|
307
|
+
global.Odac.DB.users = dbMock
|
|
308
|
+
|
|
309
|
+
const result = await wsAuth.check()
|
|
310
|
+
|
|
311
|
+
expect(result).toBe(true)
|
|
312
|
+
// No rotation: no new token inserted
|
|
313
|
+
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
314
|
+
// Active timestamp should be refreshed instead
|
|
315
|
+
expect(dbMock.tracker.updateCalls.length).toBe(1)
|
|
316
|
+
expect(dbMock.tracker.updateCalls[0].active).toBeInstanceOf(Date)
|
|
317
|
+
// No new cookies set (nothing to deliver over WS)
|
|
318
|
+
const setCalls = wsReqMock.cookie.mock.calls.filter(c => c.length >= 2)
|
|
319
|
+
expect(setCalls.length).toBe(0)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('should skip recovery rotation for WebSocket connections (res === null)', async () => {
|
|
323
|
+
const maxAge = 30 * 24 * 60 * 60 * 1000
|
|
324
|
+
const timeSinceRotation = 10000 // 10 seconds since original rotation
|
|
325
|
+
const rotatedActiveDate = new Date(Date.now() - maxAge + 60000 - timeSinceRotation)
|
|
326
|
+
|
|
327
|
+
const wsReqMock = {
|
|
328
|
+
cookie: jest.fn((name, value) => {
|
|
329
|
+
if (value !== undefined) return
|
|
330
|
+
return {odac_x: 'old_x', odac_y: 'old_y'}[name] || null
|
|
331
|
+
}),
|
|
332
|
+
header: jest.fn(() => 'TestBrowser'),
|
|
333
|
+
ip: '127.0.0.1',
|
|
334
|
+
res: null // WebSocket context
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const wsAuth = new Auth(wsReqMock)
|
|
338
|
+
|
|
339
|
+
const mockRecord = {
|
|
340
|
+
active: rotatedActiveDate,
|
|
341
|
+
browser: 'TestBrowser',
|
|
342
|
+
date: new Date(0), // Epoch marker = rotated
|
|
343
|
+
id: 'token_ws_recovery',
|
|
344
|
+
ip: '127.0.0.1',
|
|
345
|
+
token_x: 'old_x',
|
|
346
|
+
token_y: 'hashed_old_y',
|
|
347
|
+
user: 'user_10'
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const dbMock = createDbMock([mockRecord])
|
|
351
|
+
global.Odac.DB.user_tokens = dbMock
|
|
352
|
+
global.Odac.DB.users = dbMock
|
|
353
|
+
|
|
354
|
+
const result = await wsAuth.check()
|
|
355
|
+
|
|
356
|
+
expect(result).toBe(true)
|
|
357
|
+
// No recovery rotation: no insert, no delete
|
|
358
|
+
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
359
|
+
expect(dbMock.tracker.deleteCalls.length).toBe(0)
|
|
360
|
+
// No cookies set
|
|
361
|
+
const setCalls = wsReqMock.cookie.mock.calls.filter(c => c.length >= 2)
|
|
362
|
+
expect(setCalls.length).toBe(0)
|
|
363
|
+
})
|
|
364
|
+
|
|
218
365
|
it('should update active timestamp when inactiveAge exceeds updateAge but tokenAge is within rotationAge', async () => {
|
|
219
366
|
const staleActive = Date.now() - 25 * 60 * 60 * 1000 // 25 hours ago -> exceeds 24h updateAge
|
|
220
367
|
const recentDate = Date.now() - 5 * 60 * 1000 // 5 mins ago -> within rotationAge
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
describe('Odac.data()', () => {
|
|
2
|
+
let mockXhr, mockDocument, mockWindow
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
jest.resetModules()
|
|
6
|
+
mockXhr = {
|
|
7
|
+
open: jest.fn(),
|
|
8
|
+
setRequestHeader: jest.fn(),
|
|
9
|
+
send: jest.fn(),
|
|
10
|
+
getResponseHeader: jest.fn(),
|
|
11
|
+
status: 200,
|
|
12
|
+
responseText: '{}',
|
|
13
|
+
response: '{}',
|
|
14
|
+
onload: null,
|
|
15
|
+
onerror: null
|
|
16
|
+
}
|
|
17
|
+
mockDocument = {
|
|
18
|
+
getElementById: jest.fn(),
|
|
19
|
+
querySelectorAll: jest.fn(() => []),
|
|
20
|
+
querySelector: jest.fn(),
|
|
21
|
+
addEventListener: jest.fn(),
|
|
22
|
+
removeEventListener: jest.fn(),
|
|
23
|
+
dispatchEvent: jest.fn(),
|
|
24
|
+
documentElement: {dataset: {}},
|
|
25
|
+
cookie: '',
|
|
26
|
+
readyState: 'complete',
|
|
27
|
+
createElement: jest.fn(() => ({setAttribute: jest.fn(), style: {}, appendChild: jest.fn(), parentNode: {insertBefore: jest.fn()}}))
|
|
28
|
+
}
|
|
29
|
+
mockWindow = {
|
|
30
|
+
location: {protocol: 'http:', host: 'localhost', href: 'http://localhost/'},
|
|
31
|
+
history: {pushState: jest.fn()},
|
|
32
|
+
scrollTo: jest.fn(),
|
|
33
|
+
addEventListener: jest.fn(),
|
|
34
|
+
XMLHttpRequest: jest.fn(() => mockXhr),
|
|
35
|
+
localStorage: {getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn()},
|
|
36
|
+
CustomEvent: jest.fn((name, detail) => ({name, detail})),
|
|
37
|
+
setTimeout: jest.fn(),
|
|
38
|
+
clearTimeout: jest.fn(),
|
|
39
|
+
requestAnimationFrame: jest.fn(cb => cb(Date.now())),
|
|
40
|
+
WebSocket: jest.fn(() => ({send: jest.fn(), close: jest.fn(), readyState: 1})),
|
|
41
|
+
FormData: jest.fn()
|
|
42
|
+
}
|
|
43
|
+
mockWindow.window = mockWindow
|
|
44
|
+
mockWindow.document = mockDocument
|
|
45
|
+
mockWindow.WebSocket.OPEN = 1
|
|
46
|
+
mockWindow.WebSocket.CLOSED = 3
|
|
47
|
+
global.window = mockWindow
|
|
48
|
+
global.document = mockDocument
|
|
49
|
+
global.location = mockWindow.location
|
|
50
|
+
global.XMLHttpRequest = mockWindow.XMLHttpRequest
|
|
51
|
+
global.localStorage = mockWindow.localStorage
|
|
52
|
+
global.CustomEvent = mockWindow.CustomEvent
|
|
53
|
+
global.WebSocket = mockWindow.WebSocket
|
|
54
|
+
global.setTimeout = mockWindow.setTimeout
|
|
55
|
+
global.clearTimeout = mockWindow.clearTimeout
|
|
56
|
+
global.requestAnimationFrame = mockWindow.requestAnimationFrame
|
|
57
|
+
global.FormData = mockWindow.FormData
|
|
58
|
+
delete require.cache[require.resolve('../../client/odac.js')]
|
|
59
|
+
require('../../client/odac.js')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
delete global.window
|
|
64
|
+
delete global.document
|
|
65
|
+
delete global.location
|
|
66
|
+
delete global.XMLHttpRequest
|
|
67
|
+
delete global.localStorage
|
|
68
|
+
delete global.CustomEvent
|
|
69
|
+
delete global.WebSocket
|
|
70
|
+
delete global.setTimeout
|
|
71
|
+
delete global.clearTimeout
|
|
72
|
+
delete global.requestAnimationFrame
|
|
73
|
+
delete global.FormData
|
|
74
|
+
delete global.Odac
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('should retrieve data from odac-data script tag', () => {
|
|
78
|
+
const mockData = {user: 'emre'}
|
|
79
|
+
document.getElementById.mockReturnValue({textContent: JSON.stringify(mockData)})
|
|
80
|
+
const result = window.Odac.data()
|
|
81
|
+
expect(result).toEqual(mockData)
|
|
82
|
+
expect(document.getElementById).toHaveBeenCalledWith('odac-data')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('should return specific key from data', () => {
|
|
86
|
+
const mockData = {user: 'emre', role: 'admin'}
|
|
87
|
+
document.getElementById.mockReturnValue({textContent: JSON.stringify(mockData)})
|
|
88
|
+
expect(window.Odac.data('user')).toBe('emre')
|
|
89
|
+
expect(window.Odac.data('role')).toBe('admin')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
describe('Odac.get()', () => {
|
|
2
|
+
let mockXhr, mockDocument, mockWindow
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
jest.resetModules()
|
|
6
|
+
mockXhr = {
|
|
7
|
+
open: jest.fn(),
|
|
8
|
+
setRequestHeader: jest.fn(),
|
|
9
|
+
send: jest.fn(),
|
|
10
|
+
getResponseHeader: jest.fn(),
|
|
11
|
+
status: 200,
|
|
12
|
+
responseText: '{}',
|
|
13
|
+
response: '{}',
|
|
14
|
+
onload: null,
|
|
15
|
+
onerror: null
|
|
16
|
+
}
|
|
17
|
+
mockDocument = {
|
|
18
|
+
getElementById: jest.fn(),
|
|
19
|
+
querySelectorAll: jest.fn(() => []),
|
|
20
|
+
querySelector: jest.fn(),
|
|
21
|
+
addEventListener: jest.fn(),
|
|
22
|
+
removeEventListener: jest.fn(),
|
|
23
|
+
dispatchEvent: jest.fn(),
|
|
24
|
+
documentElement: {dataset: {}},
|
|
25
|
+
cookie: '',
|
|
26
|
+
readyState: 'complete',
|
|
27
|
+
createElement: jest.fn(() => ({setAttribute: jest.fn(), style: {}, appendChild: jest.fn(), parentNode: {insertBefore: jest.fn()}}))
|
|
28
|
+
}
|
|
29
|
+
mockWindow = {
|
|
30
|
+
location: {protocol: 'http:', host: 'localhost', href: 'http://localhost/'},
|
|
31
|
+
history: {pushState: jest.fn()},
|
|
32
|
+
scrollTo: jest.fn(),
|
|
33
|
+
addEventListener: jest.fn(),
|
|
34
|
+
XMLHttpRequest: jest.fn(() => mockXhr),
|
|
35
|
+
localStorage: {getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn()},
|
|
36
|
+
CustomEvent: jest.fn((name, detail) => ({name, detail})),
|
|
37
|
+
setTimeout: jest.fn(),
|
|
38
|
+
clearTimeout: jest.fn(),
|
|
39
|
+
requestAnimationFrame: jest.fn(cb => cb(Date.now())),
|
|
40
|
+
WebSocket: jest.fn(() => ({send: jest.fn(), close: jest.fn(), readyState: 1})),
|
|
41
|
+
FormData: jest.fn()
|
|
42
|
+
}
|
|
43
|
+
mockWindow.window = mockWindow
|
|
44
|
+
mockWindow.document = mockDocument
|
|
45
|
+
mockWindow.WebSocket.OPEN = 1
|
|
46
|
+
mockWindow.WebSocket.CLOSED = 3
|
|
47
|
+
global.window = mockWindow
|
|
48
|
+
global.document = mockDocument
|
|
49
|
+
global.location = mockWindow.location
|
|
50
|
+
global.XMLHttpRequest = mockWindow.XMLHttpRequest
|
|
51
|
+
global.localStorage = mockWindow.localStorage
|
|
52
|
+
global.CustomEvent = mockWindow.CustomEvent
|
|
53
|
+
global.WebSocket = mockWindow.WebSocket
|
|
54
|
+
global.setTimeout = mockWindow.setTimeout
|
|
55
|
+
global.clearTimeout = mockWindow.clearTimeout
|
|
56
|
+
global.requestAnimationFrame = mockWindow.requestAnimationFrame
|
|
57
|
+
global.FormData = mockWindow.FormData
|
|
58
|
+
delete require.cache[require.resolve('../../client/odac.js')]
|
|
59
|
+
require('../../client/odac.js')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
delete global.window
|
|
64
|
+
delete global.document
|
|
65
|
+
delete global.location
|
|
66
|
+
delete global.XMLHttpRequest
|
|
67
|
+
delete global.localStorage
|
|
68
|
+
delete global.CustomEvent
|
|
69
|
+
delete global.WebSocket
|
|
70
|
+
delete global.setTimeout
|
|
71
|
+
delete global.clearTimeout
|
|
72
|
+
delete global.requestAnimationFrame
|
|
73
|
+
delete global.FormData
|
|
74
|
+
delete global.Odac
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('should automatically parse JSON if Content-Type is application/json', () => {
|
|
78
|
+
const mockCallback = jest.fn()
|
|
79
|
+
const mockData = {success: true}
|
|
80
|
+
jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
|
|
81
|
+
mockXhr.responseText = JSON.stringify(mockData)
|
|
82
|
+
mockXhr.status = 200
|
|
83
|
+
mockXhr.statusText = 'OK'
|
|
84
|
+
mockXhr.getResponseHeader.mockImplementation(h => (h === 'Content-Type' ? 'application/json' : null))
|
|
85
|
+
|
|
86
|
+
window.Odac.get('/api/test', mockCallback)
|
|
87
|
+
if (mockXhr.onload) mockXhr.onload()
|
|
88
|
+
expect(mockCallback).toHaveBeenCalledWith(mockData, 'OK', expect.anything())
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
describe('Odac.storage()', () => {
|
|
2
|
+
let mockXhr, mockDocument, mockWindow
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
jest.resetModules()
|
|
6
|
+
mockXhr = {
|
|
7
|
+
open: jest.fn(),
|
|
8
|
+
setRequestHeader: jest.fn(),
|
|
9
|
+
send: jest.fn(),
|
|
10
|
+
getResponseHeader: jest.fn(),
|
|
11
|
+
status: 200,
|
|
12
|
+
responseText: '{}',
|
|
13
|
+
response: '{}',
|
|
14
|
+
onload: null,
|
|
15
|
+
onerror: null
|
|
16
|
+
}
|
|
17
|
+
mockDocument = {
|
|
18
|
+
getElementById: jest.fn(),
|
|
19
|
+
querySelectorAll: jest.fn(() => []),
|
|
20
|
+
querySelector: jest.fn(),
|
|
21
|
+
addEventListener: jest.fn(),
|
|
22
|
+
removeEventListener: jest.fn(),
|
|
23
|
+
dispatchEvent: jest.fn(),
|
|
24
|
+
documentElement: {dataset: {}},
|
|
25
|
+
cookie: '',
|
|
26
|
+
readyState: 'complete',
|
|
27
|
+
createElement: jest.fn(() => ({setAttribute: jest.fn(), style: {}, appendChild: jest.fn(), parentNode: {insertBefore: jest.fn()}}))
|
|
28
|
+
}
|
|
29
|
+
mockWindow = {
|
|
30
|
+
location: {protocol: 'http:', host: 'localhost', href: 'http://localhost/'},
|
|
31
|
+
history: {pushState: jest.fn()},
|
|
32
|
+
scrollTo: jest.fn(),
|
|
33
|
+
addEventListener: jest.fn(),
|
|
34
|
+
XMLHttpRequest: jest.fn(() => mockXhr),
|
|
35
|
+
localStorage: {getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn()},
|
|
36
|
+
CustomEvent: jest.fn((name, detail) => ({name, detail})),
|
|
37
|
+
setTimeout: jest.fn(),
|
|
38
|
+
clearTimeout: jest.fn(),
|
|
39
|
+
requestAnimationFrame: jest.fn(cb => cb(Date.now())),
|
|
40
|
+
WebSocket: jest.fn(() => ({send: jest.fn(), close: jest.fn(), readyState: 1})),
|
|
41
|
+
FormData: jest.fn()
|
|
42
|
+
}
|
|
43
|
+
mockWindow.window = mockWindow
|
|
44
|
+
mockWindow.document = mockDocument
|
|
45
|
+
mockWindow.WebSocket.OPEN = 1
|
|
46
|
+
mockWindow.WebSocket.CLOSED = 3
|
|
47
|
+
global.window = mockWindow
|
|
48
|
+
global.document = mockDocument
|
|
49
|
+
global.location = mockWindow.location
|
|
50
|
+
global.XMLHttpRequest = mockWindow.XMLHttpRequest
|
|
51
|
+
global.localStorage = mockWindow.localStorage
|
|
52
|
+
global.CustomEvent = mockWindow.CustomEvent
|
|
53
|
+
global.WebSocket = mockWindow.WebSocket
|
|
54
|
+
global.setTimeout = mockWindow.setTimeout
|
|
55
|
+
global.clearTimeout = mockWindow.clearTimeout
|
|
56
|
+
global.requestAnimationFrame = mockWindow.requestAnimationFrame
|
|
57
|
+
global.FormData = mockWindow.FormData
|
|
58
|
+
delete require.cache[require.resolve('../../client/odac.js')]
|
|
59
|
+
require('../../client/odac.js')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
delete global.window
|
|
64
|
+
delete global.document
|
|
65
|
+
delete global.location
|
|
66
|
+
delete global.XMLHttpRequest
|
|
67
|
+
delete global.localStorage
|
|
68
|
+
delete global.CustomEvent
|
|
69
|
+
delete global.WebSocket
|
|
70
|
+
delete global.setTimeout
|
|
71
|
+
delete global.clearTimeout
|
|
72
|
+
delete global.requestAnimationFrame
|
|
73
|
+
delete global.FormData
|
|
74
|
+
delete global.Odac
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('should get item from localStorage', () => {
|
|
78
|
+
localStorage.getItem.mockReturnValue('val')
|
|
79
|
+
expect(window.Odac.storage('key')).toBe('val')
|
|
80
|
+
expect(localStorage.getItem).toHaveBeenCalledWith('key')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('should set item in localStorage', () => {
|
|
84
|
+
window.Odac.storage('key', 'val')
|
|
85
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('key', 'val')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
describe('Odac.token()', () => {
|
|
2
|
+
let mockXhr, mockDocument, mockWindow
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
jest.resetModules()
|
|
6
|
+
mockXhr = {
|
|
7
|
+
open: jest.fn(),
|
|
8
|
+
setRequestHeader: jest.fn(),
|
|
9
|
+
send: jest.fn(),
|
|
10
|
+
getResponseHeader: jest.fn(),
|
|
11
|
+
status: 200,
|
|
12
|
+
responseText: '{}',
|
|
13
|
+
response: JSON.stringify({token: 'new-token'}),
|
|
14
|
+
onload: null,
|
|
15
|
+
onerror: null
|
|
16
|
+
}
|
|
17
|
+
mockDocument = {
|
|
18
|
+
getElementById: jest.fn(),
|
|
19
|
+
querySelectorAll: jest.fn(() => []),
|
|
20
|
+
querySelector: jest.fn(),
|
|
21
|
+
addEventListener: jest.fn(),
|
|
22
|
+
removeEventListener: jest.fn(),
|
|
23
|
+
dispatchEvent: jest.fn(),
|
|
24
|
+
documentElement: {dataset: {}},
|
|
25
|
+
cookie: 'odac_client=abc',
|
|
26
|
+
readyState: 'complete',
|
|
27
|
+
createElement: jest.fn(() => ({setAttribute: jest.fn(), style: {}, appendChild: jest.fn(), parentNode: {insertBefore: jest.fn()}}))
|
|
28
|
+
}
|
|
29
|
+
mockWindow = {
|
|
30
|
+
location: {protocol: 'http:', host: 'localhost', href: 'http://localhost/'},
|
|
31
|
+
history: {pushState: jest.fn()},
|
|
32
|
+
scrollTo: jest.fn(),
|
|
33
|
+
addEventListener: jest.fn(),
|
|
34
|
+
XMLHttpRequest: jest.fn(() => mockXhr),
|
|
35
|
+
localStorage: {getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn()},
|
|
36
|
+
CustomEvent: jest.fn((name, detail) => ({name, detail})),
|
|
37
|
+
setTimeout: jest.fn(),
|
|
38
|
+
clearTimeout: jest.fn(),
|
|
39
|
+
requestAnimationFrame: jest.fn(cb => cb(Date.now())),
|
|
40
|
+
WebSocket: jest.fn(() => ({send: jest.fn(), close: jest.fn(), readyState: 1})),
|
|
41
|
+
FormData: jest.fn()
|
|
42
|
+
}
|
|
43
|
+
mockWindow.window = mockWindow
|
|
44
|
+
mockWindow.document = mockDocument
|
|
45
|
+
mockWindow.WebSocket.OPEN = 1
|
|
46
|
+
mockWindow.WebSocket.CLOSED = 3
|
|
47
|
+
global.window = mockWindow
|
|
48
|
+
global.document = mockDocument
|
|
49
|
+
global.location = mockWindow.location
|
|
50
|
+
global.XMLHttpRequest = mockWindow.XMLHttpRequest
|
|
51
|
+
global.localStorage = mockWindow.localStorage
|
|
52
|
+
global.CustomEvent = mockWindow.CustomEvent
|
|
53
|
+
global.WebSocket = mockWindow.WebSocket
|
|
54
|
+
global.setTimeout = mockWindow.setTimeout
|
|
55
|
+
global.clearTimeout = mockWindow.clearTimeout
|
|
56
|
+
global.requestAnimationFrame = mockWindow.requestAnimationFrame
|
|
57
|
+
global.FormData = mockWindow.FormData
|
|
58
|
+
delete require.cache[require.resolve('../../client/odac.js')]
|
|
59
|
+
require('../../client/odac.js')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
delete global.window
|
|
64
|
+
delete global.document
|
|
65
|
+
delete global.location
|
|
66
|
+
delete global.XMLHttpRequest
|
|
67
|
+
delete global.localStorage
|
|
68
|
+
delete global.CustomEvent
|
|
69
|
+
delete global.WebSocket
|
|
70
|
+
delete global.setTimeout
|
|
71
|
+
delete global.clearTimeout
|
|
72
|
+
delete global.requestAnimationFrame
|
|
73
|
+
delete global.FormData
|
|
74
|
+
delete global.Odac
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('should fetch token via sync XHR if hash is empty', () => {
|
|
78
|
+
const token = window.Odac.token()
|
|
79
|
+
expect(window.XMLHttpRequest).toHaveBeenCalled()
|
|
80
|
+
expect(token).toBe('new-token')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
describe('Odac.ws()', () => {
|
|
2
|
+
let mockXhr, mockDocument, mockWindow
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
jest.resetModules()
|
|
6
|
+
mockXhr = {
|
|
7
|
+
open: jest.fn(),
|
|
8
|
+
setRequestHeader: jest.fn(),
|
|
9
|
+
send: jest.fn(),
|
|
10
|
+
getResponseHeader: jest.fn(),
|
|
11
|
+
status: 200,
|
|
12
|
+
responseText: '{}',
|
|
13
|
+
response: '{}',
|
|
14
|
+
onload: null,
|
|
15
|
+
onerror: null
|
|
16
|
+
}
|
|
17
|
+
mockDocument = {
|
|
18
|
+
getElementById: jest.fn(),
|
|
19
|
+
querySelectorAll: jest.fn(() => []),
|
|
20
|
+
querySelector: jest.fn(),
|
|
21
|
+
addEventListener: jest.fn(),
|
|
22
|
+
removeEventListener: jest.fn(),
|
|
23
|
+
dispatchEvent: jest.fn(),
|
|
24
|
+
documentElement: {dataset: {}},
|
|
25
|
+
cookie: '',
|
|
26
|
+
readyState: 'complete',
|
|
27
|
+
createElement: jest.fn(() => ({setAttribute: jest.fn(), style: {}, appendChild: jest.fn(), parentNode: {insertBefore: jest.fn()}}))
|
|
28
|
+
}
|
|
29
|
+
mockWindow = {
|
|
30
|
+
location: {protocol: 'http:', host: 'localhost', href: 'http://localhost/'},
|
|
31
|
+
history: {pushState: jest.fn()},
|
|
32
|
+
scrollTo: jest.fn(),
|
|
33
|
+
addEventListener: jest.fn(),
|
|
34
|
+
XMLHttpRequest: jest.fn(() => mockXhr),
|
|
35
|
+
localStorage: {getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn()},
|
|
36
|
+
CustomEvent: jest.fn((name, detail) => ({name, detail})),
|
|
37
|
+
setTimeout: jest.fn(),
|
|
38
|
+
clearTimeout: jest.fn(),
|
|
39
|
+
requestAnimationFrame: jest.fn(cb => cb(Date.now())),
|
|
40
|
+
WebSocket: jest.fn(() => ({send: jest.fn(), close: jest.fn(), readyState: 1})),
|
|
41
|
+
FormData: jest.fn()
|
|
42
|
+
}
|
|
43
|
+
mockWindow.window = mockWindow
|
|
44
|
+
mockWindow.document = mockDocument
|
|
45
|
+
mockWindow.WebSocket.OPEN = 1
|
|
46
|
+
mockWindow.WebSocket.CLOSED = 3
|
|
47
|
+
global.window = mockWindow
|
|
48
|
+
global.document = mockDocument
|
|
49
|
+
global.location = mockWindow.location
|
|
50
|
+
global.XMLHttpRequest = mockWindow.XMLHttpRequest
|
|
51
|
+
global.localStorage = mockWindow.localStorage
|
|
52
|
+
global.CustomEvent = mockWindow.CustomEvent
|
|
53
|
+
global.WebSocket = mockWindow.WebSocket
|
|
54
|
+
global.setTimeout = mockWindow.setTimeout
|
|
55
|
+
global.clearTimeout = mockWindow.clearTimeout
|
|
56
|
+
global.requestAnimationFrame = mockWindow.requestAnimationFrame
|
|
57
|
+
global.FormData = mockWindow.FormData
|
|
58
|
+
delete require.cache[require.resolve('../../client/odac.js')]
|
|
59
|
+
require('../../client/odac.js')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
delete global.window
|
|
64
|
+
delete global.document
|
|
65
|
+
delete global.location
|
|
66
|
+
delete global.XMLHttpRequest
|
|
67
|
+
delete global.localStorage
|
|
68
|
+
delete global.CustomEvent
|
|
69
|
+
delete global.WebSocket
|
|
70
|
+
delete global.setTimeout
|
|
71
|
+
delete global.clearTimeout
|
|
72
|
+
delete global.requestAnimationFrame
|
|
73
|
+
delete global.FormData
|
|
74
|
+
delete global.Odac
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('should connect to WebSocket and handle events', () => {
|
|
78
|
+
const ws = window.Odac.ws('/test-ws', {token: false})
|
|
79
|
+
expect(window.WebSocket).toHaveBeenCalled()
|
|
80
|
+
const openHandler = jest.fn()
|
|
81
|
+
ws.on('open', openHandler)
|
|
82
|
+
const socketInstance = WebSocket.mock.results[0].value
|
|
83
|
+
socketInstance.onopen()
|
|
84
|
+
expect(openHandler).toHaveBeenCalled()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const Config = require('../../src/Config')
|
|
2
|
+
|
|
3
|
+
describe('Config._deepMerge()', () => {
|
|
4
|
+
it('should merge objects deeply', () => {
|
|
5
|
+
const target = {a: {b: 1}, c: 2}
|
|
6
|
+
const source = {a: {d: 3}, e: 4}
|
|
7
|
+
Config._deepMerge(target, source)
|
|
8
|
+
expect(target).toEqual({
|
|
9
|
+
a: {b: 1, d: 3},
|
|
10
|
+
c: 2,
|
|
11
|
+
e: 4
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
})
|