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.
Files changed (96) hide show
  1. package/.agent/rules/memory.md +8 -0
  2. package/.github/workflows/release.yml +1 -1
  3. package/.releaserc.js +9 -2
  4. package/CHANGELOG.md +61 -0
  5. package/README.md +10 -0
  6. package/bin/odac.js +193 -2
  7. package/client/odac.js +32 -13
  8. package/docs/ai/skills/SKILL.md +4 -3
  9. package/docs/ai/skills/backend/authentication.md +7 -0
  10. package/docs/ai/skills/backend/config.md +7 -0
  11. package/docs/ai/skills/backend/controllers.md +7 -0
  12. package/docs/ai/skills/backend/cron.md +9 -2
  13. package/docs/ai/skills/backend/database.md +37 -2
  14. package/docs/ai/skills/backend/forms.md +112 -11
  15. package/docs/ai/skills/backend/ipc.md +7 -0
  16. package/docs/ai/skills/backend/mail.md +7 -0
  17. package/docs/ai/skills/backend/migrations.md +86 -0
  18. package/docs/ai/skills/backend/request_response.md +7 -0
  19. package/docs/ai/skills/backend/routing.md +7 -0
  20. package/docs/ai/skills/backend/storage.md +7 -0
  21. package/docs/ai/skills/backend/streaming.md +7 -0
  22. package/docs/ai/skills/backend/structure.md +8 -1
  23. package/docs/ai/skills/backend/translations.md +7 -0
  24. package/docs/ai/skills/backend/utilities.md +7 -0
  25. package/docs/ai/skills/backend/validation.md +138 -31
  26. package/docs/ai/skills/backend/views.md +7 -0
  27. package/docs/ai/skills/frontend/core.md +7 -0
  28. package/docs/ai/skills/frontend/forms.md +48 -13
  29. package/docs/ai/skills/frontend/navigation.md +7 -0
  30. package/docs/ai/skills/frontend/realtime.md +7 -0
  31. package/docs/backend/08-database/02-basics.md +49 -9
  32. package/docs/backend/08-database/04-migrations.md +259 -37
  33. package/package.json +1 -1
  34. package/src/Auth.js +82 -43
  35. package/src/Config.js +1 -1
  36. package/src/Database/ConnectionFactory.js +70 -0
  37. package/src/Database/Migration.js +1228 -0
  38. package/src/Database/nanoid.js +30 -0
  39. package/src/Database.js +157 -46
  40. package/src/Ipc.js +37 -0
  41. package/src/Odac.js +1 -1
  42. package/src/Route/Cron.js +11 -0
  43. package/src/Route.js +8 -0
  44. package/src/Server.js +77 -23
  45. package/src/Storage.js +15 -1
  46. package/src/Validator.js +22 -20
  47. package/template/schema/users.js +23 -0
  48. package/test/{Auth.test.js → Auth/check.test.js} +153 -6
  49. package/test/Client/data.test.js +91 -0
  50. package/test/Client/get.test.js +90 -0
  51. package/test/Client/storage.test.js +87 -0
  52. package/test/Client/token.test.js +82 -0
  53. package/test/Client/ws.test.js +86 -0
  54. package/test/Config/deepMerge.test.js +14 -0
  55. package/test/Config/init.test.js +66 -0
  56. package/test/Config/interpolate.test.js +35 -0
  57. package/test/Database/ConnectionFactory/buildConnectionConfig.test.js +13 -0
  58. package/test/Database/ConnectionFactory/buildConnections.test.js +31 -0
  59. package/test/Database/ConnectionFactory/resolveClient.test.js +12 -0
  60. package/test/Database/Migration/migrate_column.test.js +52 -0
  61. package/test/Database/Migration/migrate_files.test.js +70 -0
  62. package/test/Database/Migration/migrate_index.test.js +89 -0
  63. package/test/Database/Migration/migrate_nanoid.test.js +160 -0
  64. package/test/Database/Migration/migrate_seed.test.js +77 -0
  65. package/test/Database/Migration/migrate_table.test.js +88 -0
  66. package/test/Database/Migration/rollback.test.js +61 -0
  67. package/test/Database/Migration/snapshot.test.js +38 -0
  68. package/test/Database/Migration/status.test.js +41 -0
  69. package/test/Database/autoNanoid.test.js +215 -0
  70. package/test/Database/nanoid.test.js +19 -0
  71. package/test/Lang/constructor.test.js +25 -0
  72. package/test/Lang/get.test.js +65 -0
  73. package/test/Lang/set.test.js +49 -0
  74. package/test/Odac/init.test.js +42 -0
  75. package/test/Odac/instance.test.js +58 -0
  76. package/test/Route/{Middleware.test.js → Middleware/chaining.test.js} +5 -29
  77. package/test/Route/Middleware/use.test.js +35 -0
  78. package/test/{Route.test.js → Route/check.test.js} +4 -55
  79. package/test/Route/set.test.js +52 -0
  80. package/test/Route/ws.test.js +23 -0
  81. package/test/View/EarlyHints/cache.test.js +32 -0
  82. package/test/View/EarlyHints/extractFromHtml.test.js +143 -0
  83. package/test/View/EarlyHints/formatLinkHeader.test.js +33 -0
  84. package/test/View/EarlyHints/send.test.js +99 -0
  85. package/test/View/{Form.test.js → Form/generateFieldHtml.test.js} +2 -2
  86. package/test/View/constructor.test.js +22 -0
  87. package/test/View/print.test.js +19 -0
  88. package/test/WebSocket/Client/limits.test.js +55 -0
  89. package/test/WebSocket/Server/broadcast.test.js +33 -0
  90. package/test/WebSocket/Server/route.test.js +37 -0
  91. package/test/Client.test.js +0 -197
  92. package/test/Config.test.js +0 -112
  93. package/test/Lang.test.js +0 -92
  94. package/test/Odac.test.js +0 -88
  95. package/test/View/EarlyHints.test.js +0 -282
  96. package/test/WebSocket.test.js +0 -238
@@ -1,6 +1,6 @@
1
- const Auth = require('../src/Auth.js')
1
+ const Auth = require('../../src/Auth.js')
2
2
 
3
- describe('Auth - Refresh Token Rotation', () => {
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 already marked as rotated (Epoch Date marker)', async () => {
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: new Date(), // Still within maxAge
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
+ })