odac 1.4.1 → 1.4.3
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 +5 -0
- package/.releaserc.js +9 -2
- package/CHANGELOG.md +64 -0
- package/README.md +1 -1
- package/bin/odac.js +3 -2
- package/client/odac.js +124 -28
- package/docs/ai/skills/backend/database.md +19 -0
- package/docs/ai/skills/backend/forms.md +107 -13
- package/docs/ai/skills/backend/migrations.md +8 -2
- package/docs/ai/skills/backend/validation.md +132 -32
- package/docs/ai/skills/frontend/forms.md +43 -15
- package/docs/backend/08-database/02-basics.md +49 -9
- package/docs/backend/08-database/04-migrations.md +1 -0
- package/package.json +1 -1
- package/src/Auth.js +15 -2
- package/src/Database/ConnectionFactory.js +1 -0
- package/src/Database/Migration.js +26 -1
- package/src/Database/nanoid.js +30 -0
- package/src/Database.js +122 -11
- package/src/Ipc.js +37 -0
- package/src/Odac.js +1 -1
- package/src/Route/Cron.js +11 -0
- package/src/Route.js +49 -30
- package/src/Server.js +77 -23
- package/src/Storage.js +15 -1
- package/src/Validator.js +22 -20
- package/test/{Auth.test.js → Auth/check.test.js} +91 -5
- 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 +118 -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} +100 -50
- 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 -119
- package/test/Database/ConnectionFactory.test.js +0 -80
- package/test/Lang.test.js +0 -92
- package/test/Migration.test.js +0 -943
- package/test/Odac.test.js +0 -88
- package/test/View/EarlyHints.test.js +0 -282
- package/test/WebSocket.test.js +0 -238
|
@@ -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,118 @@
|
|
|
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
|
+
|
|
87
|
+
test('should use fresh token on reconnect', () => {
|
|
88
|
+
let tokenCounter = 0
|
|
89
|
+
mockXhr.response = JSON.stringify({token: 'initial-token'})
|
|
90
|
+
mockXhr.responseText = JSON.stringify({token: 'initial-token'})
|
|
91
|
+
mockXhr.onload = null
|
|
92
|
+
mockXhr.send = jest.fn(function () {
|
|
93
|
+
tokenCounter++
|
|
94
|
+
this.response = JSON.stringify({token: `token-${tokenCounter}`})
|
|
95
|
+
this.responseText = this.response
|
|
96
|
+
if (this.onload) this.onload()
|
|
97
|
+
})
|
|
98
|
+
mockDocument.cookie = 'odac_client=test-client'
|
|
99
|
+
|
|
100
|
+
window.Odac.ws('/test-ws', {token: true, autoReconnect: true, reconnectDelay: 100})
|
|
101
|
+
const firstCall = WebSocket.mock.calls[0]
|
|
102
|
+
expect(firstCall[1]).toEqual(expect.arrayContaining([expect.stringMatching(/^odac-token-/)]))
|
|
103
|
+
const firstToken = firstCall[1][0]
|
|
104
|
+
|
|
105
|
+
const socketInstance = WebSocket.mock.results[0].value
|
|
106
|
+
socketInstance.onopen()
|
|
107
|
+
|
|
108
|
+
global.setTimeout = jest.fn(fn => fn())
|
|
109
|
+
socketInstance.readyState = 3
|
|
110
|
+
socketInstance.onclose({code: 1006})
|
|
111
|
+
|
|
112
|
+
expect(WebSocket.mock.calls.length).toBeGreaterThan(1)
|
|
113
|
+
const secondCall = WebSocket.mock.calls[1]
|
|
114
|
+
expect(secondCall[1]).toEqual(expect.arrayContaining([expect.stringMatching(/^odac-token-/)]))
|
|
115
|
+
const secondToken = secondCall[1][0]
|
|
116
|
+
expect(secondToken).not.toBe(firstToken)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const os = require('os')
|
|
3
|
+
const Config = require('../../src/Config')
|
|
4
|
+
|
|
5
|
+
jest.mock('fs')
|
|
6
|
+
jest.mock('os')
|
|
7
|
+
|
|
8
|
+
describe('Config.init()', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
jest.clearAllMocks()
|
|
11
|
+
global.__dir = '/mock/project'
|
|
12
|
+
Config.system = undefined
|
|
13
|
+
Config.encrypt.key = 'odac'
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should load system config from home directory', () => {
|
|
17
|
+
os.homedir.mockReturnValue('/home/user')
|
|
18
|
+
fs.readFileSync.mockImplementation(path => {
|
|
19
|
+
if (path === '/home/user/.odac/config.json') {
|
|
20
|
+
return JSON.stringify({deviceId: '123'})
|
|
21
|
+
}
|
|
22
|
+
return '{}'
|
|
23
|
+
})
|
|
24
|
+
fs.existsSync.mockReturnValue(false)
|
|
25
|
+
|
|
26
|
+
Config.init()
|
|
27
|
+
|
|
28
|
+
expect(Config.system).toEqual({deviceId: '123'})
|
|
29
|
+
expect(fs.readFileSync).toHaveBeenCalledWith('/home/user/.odac/config.json')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should load project config and merge it', () => {
|
|
33
|
+
os.homedir.mockReturnValue('/home/user')
|
|
34
|
+
fs.existsSync.mockImplementation(path => {
|
|
35
|
+
if (path === '/mock/project/odac.json') return true
|
|
36
|
+
return false
|
|
37
|
+
})
|
|
38
|
+
fs.readFileSync.mockImplementation(path => {
|
|
39
|
+
if (path === '/mock/project/odac.json') {
|
|
40
|
+
return JSON.stringify({encrypt: {key: 'secret'}})
|
|
41
|
+
}
|
|
42
|
+
return '{}'
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
Config.init()
|
|
46
|
+
|
|
47
|
+
// The key gets hashed in init(), so it won't be 'secret' anymore
|
|
48
|
+
expect(Config.encrypt.key).not.toBe('secret')
|
|
49
|
+
expect(Config.encrypt.key).toBeInstanceOf(Buffer)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should interpolate variables in config', () => {
|
|
53
|
+
process.env.TEST_VAR = 'env_value'
|
|
54
|
+
os.homedir.mockReturnValue('/home/user')
|
|
55
|
+
fs.existsSync.mockReturnValue(true)
|
|
56
|
+
fs.readFileSync.mockReturnValue(
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
custom: 'value-${TEST_VAR}'
|
|
59
|
+
})
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
Config.init()
|
|
63
|
+
|
|
64
|
+
expect(Config.custom).toBe('value-env_value')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const Config = require('../../src/Config')
|
|
2
|
+
|
|
3
|
+
describe('Config._interpolate()', () => {
|
|
4
|
+
it('should replace ${VAR} with environment variables', () => {
|
|
5
|
+
process.env.FOO = 'bar'
|
|
6
|
+
const result = Config._interpolate('hello-${FOO}')
|
|
7
|
+
expect(result).toBe('hello-bar')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('should replace ${VAR} when variable name includes hyphen', () => {
|
|
11
|
+
process.env['MY-VAR'] = 'hyphen-value'
|
|
12
|
+
const result = Config._interpolate('hello-${MY-VAR}')
|
|
13
|
+
expect(result).toBe('hello-hyphen-value')
|
|
14
|
+
delete process.env['MY-VAR']
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should replace ${odac} with client path', () => {
|
|
18
|
+
// __dirname in Config.js is /.../src, so it replaces /src with /client
|
|
19
|
+
const result = Config._interpolate('path-${odac}')
|
|
20
|
+
expect(result).toMatch(/\/client$/)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should handle nested objects and arrays', () => {
|
|
24
|
+
process.env.VAR = 'x'
|
|
25
|
+
const obj = {
|
|
26
|
+
a: ['${VAR}'],
|
|
27
|
+
b: {c: '${VAR}'}
|
|
28
|
+
}
|
|
29
|
+
const result = Config._interpolate(obj)
|
|
30
|
+
expect(result).toEqual({
|
|
31
|
+
a: ['x'],
|
|
32
|
+
b: {c: 'x'}
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const {buildConnectionConfig} = require('../../../src/Database/ConnectionFactory')
|
|
2
|
+
|
|
3
|
+
describe('ConnectionFactory.buildConnectionConfig()', () => {
|
|
4
|
+
it('should create sqlite filename config', () => {
|
|
5
|
+
const config = buildConnectionConfig({database: 'db.sqlite3'}, 'sqlite3')
|
|
6
|
+
expect(config).toEqual({filename: 'db.sqlite3'})
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('should create host based config for non-sqlite', () => {
|
|
10
|
+
const config = buildConnectionConfig({user: 'root', password: 'secret', database: 'app', port: 3306}, 'mysql2')
|
|
11
|
+
expect(config).toEqual({host: '127.0.0.1', user: 'root', password: 'secret', database: 'app', port: 3306})
|
|
12
|
+
})
|
|
13
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const mockKnex = jest.fn()
|
|
2
|
+
jest.mock(
|
|
3
|
+
'knex',
|
|
4
|
+
() =>
|
|
5
|
+
(...args) =>
|
|
6
|
+
mockKnex(...args)
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
const {buildConnections} = require('../../../src/Database/ConnectionFactory')
|
|
10
|
+
|
|
11
|
+
describe('ConnectionFactory.buildConnections()', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
mockKnex.mockReset()
|
|
14
|
+
mockKnex.mockImplementation(options => ({options, raw: jest.fn()}))
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should support single database config', () => {
|
|
18
|
+
const connections = buildConnections({type: 'mysql', user: 'root', database: 'app'})
|
|
19
|
+
expect(Object.keys(connections)).toEqual(['default'])
|
|
20
|
+
expect(mockKnex).toHaveBeenCalledTimes(1)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should support multi database config', () => {
|
|
24
|
+
const connections = buildConnections({
|
|
25
|
+
analytics: {type: 'postgres', user: 'u', database: 'a'},
|
|
26
|
+
default: {type: 'sqlite', filename: './dev.sqlite3'}
|
|
27
|
+
})
|
|
28
|
+
expect(Object.keys(connections).sort()).toEqual(['analytics', 'default'])
|
|
29
|
+
expect(mockKnex).toHaveBeenCalledTimes(2)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const {resolveClient} = require('../../../src/Database/ConnectionFactory')
|
|
2
|
+
|
|
3
|
+
describe('ConnectionFactory.resolveClient()', () => {
|
|
4
|
+
it('should map known database aliases', () => {
|
|
5
|
+
expect(resolveClient('postgres')).toBe('pg')
|
|
6
|
+
expect(resolveClient('postgresql')).toBe('pg')
|
|
7
|
+
expect(resolveClient('pg')).toBe('pg')
|
|
8
|
+
expect(resolveClient('sqlite')).toBe('sqlite3')
|
|
9
|
+
expect(resolveClient('sqlite3')).toBe('sqlite3')
|
|
10
|
+
expect(resolveClient('mysql')).toBe('mysql2')
|
|
11
|
+
})
|
|
12
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
const fs = require('node:fs')
|
|
5
|
+
const os = require('node:os')
|
|
6
|
+
const knex = require('knex')
|
|
7
|
+
const Migration = require('../../../src/Database/Migration')
|
|
8
|
+
|
|
9
|
+
let db, tmpDir
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odac-migration-column-'))
|
|
13
|
+
db = knex({client: 'sqlite3', connection: {filename: ':memory:'}, useNullAsDefault: true})
|
|
14
|
+
Migration.init(tmpDir, {default: db})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await db.destroy()
|
|
19
|
+
fs.rmSync(tmpDir, {recursive: true, force: true})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
function writeSchema(name, content) {
|
|
23
|
+
const dir = path.join(tmpDir, 'schema')
|
|
24
|
+
fs.mkdirSync(dir, {recursive: true})
|
|
25
|
+
fs.writeFileSync(path.join(dir, `${name}.js`), `module.exports = ${JSON.stringify(content, null, 2)}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('Migration.migrate() - Column Diff', () => {
|
|
29
|
+
it('should add a new column to an existing table', async () => {
|
|
30
|
+
writeSchema('posts', {columns: {id: {type: 'increments'}, title: {type: 'string'}}})
|
|
31
|
+
await Migration.migrate()
|
|
32
|
+
|
|
33
|
+
writeSchema('posts', {columns: {id: {type: 'increments'}, title: {type: 'string'}, body: {type: 'text', nullable: true}}})
|
|
34
|
+
const result = await Migration.migrate()
|
|
35
|
+
const addOps = result.default.schema.filter(op => op.type === 'add_column')
|
|
36
|
+
|
|
37
|
+
expect(addOps).toEqual(expect.arrayContaining([expect.objectContaining({type: 'add_column', column: 'body', table: 'posts'})]))
|
|
38
|
+
const info = await db('posts').columnInfo()
|
|
39
|
+
expect(info).toHaveProperty('body')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should drop a column removed from schema', async () => {
|
|
43
|
+
writeSchema('items', {columns: {id: {type: 'increments'}, name: {type: 'string'}, obsolete: {type: 'string'}}})
|
|
44
|
+
await Migration.migrate()
|
|
45
|
+
|
|
46
|
+
writeSchema('items', {columns: {id: {type: 'increments'}, name: {type: 'string'}}})
|
|
47
|
+
const result = await Migration.migrate()
|
|
48
|
+
const dropOps = result.default.schema.filter(op => op.type === 'drop_column')
|
|
49
|
+
|
|
50
|
+
expect(dropOps).toEqual(expect.arrayContaining([expect.objectContaining({type: 'drop_column', column: 'obsolete', table: 'items'})]))
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
const fs = require('node:fs')
|
|
5
|
+
const os = require('node:os')
|
|
6
|
+
const knex = require('knex')
|
|
7
|
+
const Migration = require('../../../src/Database/Migration')
|
|
8
|
+
|
|
9
|
+
let db, tmpDir
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odac-migration-files-'))
|
|
13
|
+
db = knex({client: 'sqlite3', connection: {filename: ':memory:'}, useNullAsDefault: true})
|
|
14
|
+
Migration.init(tmpDir, {default: db})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await db.destroy()
|
|
19
|
+
fs.rmSync(tmpDir, {recursive: true, force: true})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
function writeMigrationFile(name, upFn, downFn) {
|
|
23
|
+
const dir = path.join(tmpDir, 'migration')
|
|
24
|
+
fs.mkdirSync(dir, {recursive: true})
|
|
25
|
+
|
|
26
|
+
const content = `
|
|
27
|
+
module.exports = {
|
|
28
|
+
up: ${upFn.toString()},
|
|
29
|
+
down: ${downFn ? downFn.toString() : 'undefined'}
|
|
30
|
+
}
|
|
31
|
+
`
|
|
32
|
+
fs.writeFileSync(path.join(dir, name), content)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('Migration.migrate() - Migration Files', () => {
|
|
36
|
+
it('should run pending migration files in order', async () => {
|
|
37
|
+
await db.schema.createTable('counters', t => {
|
|
38
|
+
t.increments('id')
|
|
39
|
+
t.string('name')
|
|
40
|
+
t.integer('value').defaultTo(0)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
writeMigrationFile('20260225_001_init.js', async db => {
|
|
44
|
+
await db('counters').insert({name: 'visits', value: 0})
|
|
45
|
+
})
|
|
46
|
+
writeMigrationFile('20260225_002_add.js', async db => {
|
|
47
|
+
await db('counters').insert({name: 'signups', value: 0})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const result = await Migration.migrate()
|
|
51
|
+
expect(result.default.files).toHaveLength(2)
|
|
52
|
+
|
|
53
|
+
const rows = await db('counters').select()
|
|
54
|
+
expect(rows).toHaveLength(2)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should not re-run already applied migration files', async () => {
|
|
58
|
+
await db.schema.createTable('data', t => {
|
|
59
|
+
t.increments('id')
|
|
60
|
+
t.string('value')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
writeMigrationFile('20260225_001_insert.js', async db => {
|
|
64
|
+
await db('data').insert({value: 'test'})
|
|
65
|
+
})
|
|
66
|
+
await Migration.migrate()
|
|
67
|
+
const result2 = await Migration.migrate()
|
|
68
|
+
expect(result2.default.files).toHaveLength(0)
|
|
69
|
+
})
|
|
70
|
+
})
|