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.
Files changed (77) hide show
  1. package/.agent/rules/memory.md +5 -0
  2. package/.releaserc.js +9 -2
  3. package/CHANGELOG.md +64 -0
  4. package/README.md +1 -1
  5. package/bin/odac.js +3 -2
  6. package/client/odac.js +124 -28
  7. package/docs/ai/skills/backend/database.md +19 -0
  8. package/docs/ai/skills/backend/forms.md +107 -13
  9. package/docs/ai/skills/backend/migrations.md +8 -2
  10. package/docs/ai/skills/backend/validation.md +132 -32
  11. package/docs/ai/skills/frontend/forms.md +43 -15
  12. package/docs/backend/08-database/02-basics.md +49 -9
  13. package/docs/backend/08-database/04-migrations.md +1 -0
  14. package/package.json +1 -1
  15. package/src/Auth.js +15 -2
  16. package/src/Database/ConnectionFactory.js +1 -0
  17. package/src/Database/Migration.js +26 -1
  18. package/src/Database/nanoid.js +30 -0
  19. package/src/Database.js +122 -11
  20. package/src/Ipc.js +37 -0
  21. package/src/Odac.js +1 -1
  22. package/src/Route/Cron.js +11 -0
  23. package/src/Route.js +49 -30
  24. package/src/Server.js +77 -23
  25. package/src/Storage.js +15 -1
  26. package/src/Validator.js +22 -20
  27. package/test/{Auth.test.js → Auth/check.test.js} +91 -5
  28. package/test/Client/data.test.js +91 -0
  29. package/test/Client/get.test.js +90 -0
  30. package/test/Client/storage.test.js +87 -0
  31. package/test/Client/token.test.js +82 -0
  32. package/test/Client/ws.test.js +118 -0
  33. package/test/Config/deepMerge.test.js +14 -0
  34. package/test/Config/init.test.js +66 -0
  35. package/test/Config/interpolate.test.js +35 -0
  36. package/test/Database/ConnectionFactory/buildConnectionConfig.test.js +13 -0
  37. package/test/Database/ConnectionFactory/buildConnections.test.js +31 -0
  38. package/test/Database/ConnectionFactory/resolveClient.test.js +12 -0
  39. package/test/Database/Migration/migrate_column.test.js +52 -0
  40. package/test/Database/Migration/migrate_files.test.js +70 -0
  41. package/test/Database/Migration/migrate_index.test.js +89 -0
  42. package/test/Database/Migration/migrate_nanoid.test.js +160 -0
  43. package/test/Database/Migration/migrate_seed.test.js +77 -0
  44. package/test/Database/Migration/migrate_table.test.js +88 -0
  45. package/test/Database/Migration/rollback.test.js +61 -0
  46. package/test/Database/Migration/snapshot.test.js +38 -0
  47. package/test/Database/Migration/status.test.js +41 -0
  48. package/test/Database/autoNanoid.test.js +215 -0
  49. package/test/Database/nanoid.test.js +19 -0
  50. package/test/Lang/constructor.test.js +25 -0
  51. package/test/Lang/get.test.js +65 -0
  52. package/test/Lang/set.test.js +49 -0
  53. package/test/Odac/init.test.js +42 -0
  54. package/test/Odac/instance.test.js +58 -0
  55. package/test/Route/{Middleware.test.js → Middleware/chaining.test.js} +5 -29
  56. package/test/Route/Middleware/use.test.js +35 -0
  57. package/test/{Route.test.js → Route/check.test.js} +100 -50
  58. package/test/Route/set.test.js +52 -0
  59. package/test/Route/ws.test.js +23 -0
  60. package/test/View/EarlyHints/cache.test.js +32 -0
  61. package/test/View/EarlyHints/extractFromHtml.test.js +143 -0
  62. package/test/View/EarlyHints/formatLinkHeader.test.js +33 -0
  63. package/test/View/EarlyHints/send.test.js +99 -0
  64. package/test/View/{Form.test.js → Form/generateFieldHtml.test.js} +2 -2
  65. package/test/View/constructor.test.js +22 -0
  66. package/test/View/print.test.js +19 -0
  67. package/test/WebSocket/Client/limits.test.js +55 -0
  68. package/test/WebSocket/Server/broadcast.test.js +33 -0
  69. package/test/WebSocket/Server/route.test.js +37 -0
  70. package/test/Client.test.js +0 -197
  71. package/test/Config.test.js +0 -119
  72. package/test/Database/ConnectionFactory.test.js +0 -80
  73. package/test/Lang.test.js +0 -92
  74. package/test/Migration.test.js +0 -943
  75. package/test/Odac.test.js +0 -88
  76. package/test/View/EarlyHints.test.js +0 -282
  77. 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
+ })