odac 1.3.0 → 1.4.1
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 +10 -1
- package/.github/workflows/release.yml +1 -5
- package/AGENTS.md +47 -0
- package/CHANGELOG.md +58 -0
- package/README.md +11 -1
- package/bin/odac.js +359 -6
- package/client/odac.js +15 -11
- package/docs/ai/README.md +49 -0
- package/docs/ai/skills/SKILL.md +40 -0
- package/docs/ai/skills/backend/authentication.md +74 -0
- package/docs/ai/skills/backend/config.md +39 -0
- package/docs/ai/skills/backend/controllers.md +69 -0
- package/docs/ai/skills/backend/cron.md +57 -0
- package/docs/ai/skills/backend/database.md +37 -0
- package/docs/ai/skills/backend/forms.md +26 -0
- package/docs/ai/skills/backend/ipc.md +62 -0
- package/docs/ai/skills/backend/mail.md +41 -0
- package/docs/ai/skills/backend/migrations.md +80 -0
- package/docs/ai/skills/backend/request_response.md +42 -0
- package/docs/ai/skills/backend/routing.md +58 -0
- package/docs/ai/skills/backend/storage.md +50 -0
- package/docs/ai/skills/backend/streaming.md +41 -0
- package/docs/ai/skills/backend/structure.md +64 -0
- package/docs/ai/skills/backend/translations.md +49 -0
- package/docs/ai/skills/backend/utilities.md +31 -0
- package/docs/ai/skills/backend/validation.md +60 -0
- package/docs/ai/skills/backend/views.md +68 -0
- package/docs/ai/skills/frontend/core.md +73 -0
- package/docs/ai/skills/frontend/forms.md +28 -0
- package/docs/ai/skills/frontend/navigation.md +27 -0
- package/docs/ai/skills/frontend/realtime.md +54 -0
- package/docs/backend/08-database/04-migrations.md +258 -37
- package/docs/backend/10-authentication/01-user-logins-with-authjs.md +2 -0
- package/docs/backend/10-authentication/05-session-management.md +25 -3
- package/package.json +1 -1
- package/src/Auth.js +128 -17
- package/src/Config.js +1 -1
- package/src/Database/ConnectionFactory.js +69 -0
- package/src/Database/Migration.js +1203 -0
- package/src/Database.js +35 -35
- package/src/Route/Internal.js +21 -18
- package/src/Route/MimeTypes.js +56 -0
- package/src/Route.js +40 -63
- package/src/View/Form.js +91 -51
- package/src/View.js +8 -3
- package/template/schema/users.js +23 -0
- package/test/Auth.test.js +310 -0
- package/test/Client.test.js +29 -0
- package/test/Config.test.js +7 -0
- package/test/Database/ConnectionFactory.test.js +80 -0
- package/test/Migration.test.js +943 -0
- package/test/View/Form.test.js +37 -0
package/src/View.js
CHANGED
|
@@ -58,6 +58,7 @@ class View {
|
|
|
58
58
|
function: '{ let _arr = $constructor; for(let $key in _arr){ let $value = _arr[$key];',
|
|
59
59
|
end: '}}',
|
|
60
60
|
arguments: {
|
|
61
|
+
in: null,
|
|
61
62
|
var: null,
|
|
62
63
|
get: null,
|
|
63
64
|
key: 'key',
|
|
@@ -83,6 +84,7 @@ class View {
|
|
|
83
84
|
},
|
|
84
85
|
list: {
|
|
85
86
|
arguments: {
|
|
87
|
+
in: null,
|
|
86
88
|
var: null,
|
|
87
89
|
get: null,
|
|
88
90
|
key: 'key',
|
|
@@ -453,12 +455,15 @@ class View {
|
|
|
453
455
|
let fun = func.function
|
|
454
456
|
|
|
455
457
|
if (key === 'for' || key === 'list') {
|
|
456
|
-
if (!vars.var && !vars.get) {
|
|
457
|
-
console.error(`"var" or "
|
|
458
|
+
if (!vars.var && !vars.get && !vars.in) {
|
|
459
|
+
console.error(`"var", "get" or "in" is required for "${match}"\n in "${file}"`)
|
|
458
460
|
continue
|
|
459
461
|
}
|
|
460
462
|
let constructor
|
|
461
|
-
if (vars.
|
|
463
|
+
if (vars.in) {
|
|
464
|
+
constructor = `await ${vars.in}`
|
|
465
|
+
delete vars.in
|
|
466
|
+
} else if (vars.var) {
|
|
462
467
|
constructor = `await ${vars.var}`
|
|
463
468
|
delete vars.var
|
|
464
469
|
} else if (vars.get) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Schema definition for 'users' — example schema file
|
|
2
|
+
// This is the single source of truth for the 'users' table.
|
|
3
|
+
// AI agents read this file to understand the final database state.
|
|
4
|
+
'use strict'
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
columns: {
|
|
8
|
+
id: {type: 'increments'},
|
|
9
|
+
name: {type: 'string', length: 255, nullable: false},
|
|
10
|
+
email: {type: 'string', length: 255, nullable: false},
|
|
11
|
+
password: {type: 'string', length: 255, nullable: false},
|
|
12
|
+
role: {type: 'enum', values: ['admin', 'user'], default: 'user'},
|
|
13
|
+
is_active: {type: 'boolean', default: true},
|
|
14
|
+
timestamps: {type: 'timestamps'}
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
indexes: [{columns: ['email'], unique: true}, {columns: ['role', 'is_active']}],
|
|
18
|
+
|
|
19
|
+
// Seed data — idempotent, runs on every migrate.
|
|
20
|
+
// seedKey determines the uniqueness check for upsert.
|
|
21
|
+
seed: [{name: 'Admin', email: 'admin@example.com', password: 'changeme', role: 'admin'}],
|
|
22
|
+
seedKey: 'email'
|
|
23
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
const Auth = require('../src/Auth.js')
|
|
2
|
+
|
|
3
|
+
describe('Auth - Refresh Token Rotation', () => {
|
|
4
|
+
let reqMock
|
|
5
|
+
let authInstance
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Why: Builds a chainable DB mock that resolves query results via .then() (thenable).
|
|
9
|
+
* This simulates Knex's chainable query builder pattern.
|
|
10
|
+
*
|
|
11
|
+
* @param {Array} rows - The rows the query should resolve to.
|
|
12
|
+
* @returns {object} Mock object with insert, update, delete, first, where tracking.
|
|
13
|
+
*/
|
|
14
|
+
const createDbMock = rows => {
|
|
15
|
+
const tracker = {
|
|
16
|
+
deleteCalls: [],
|
|
17
|
+
firstCalls: 0,
|
|
18
|
+
insertCalls: [],
|
|
19
|
+
updateCalls: []
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const chainable = () => ({
|
|
23
|
+
delete: jest.fn((...args) => {
|
|
24
|
+
tracker.deleteCalls.push(args)
|
|
25
|
+
return Promise.resolve(true)
|
|
26
|
+
}),
|
|
27
|
+
first: jest.fn(() => {
|
|
28
|
+
tracker.firstCalls++
|
|
29
|
+
return Promise.resolve(rows[0] ? {id: rows[0].user, name: 'TestUser'} : null)
|
|
30
|
+
}),
|
|
31
|
+
update: jest.fn(payload => {
|
|
32
|
+
tracker.updateCalls.push(payload)
|
|
33
|
+
return Promise.resolve(true)
|
|
34
|
+
}),
|
|
35
|
+
then: cb => cb(rows),
|
|
36
|
+
where: jest.fn(() => chainable())
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
chainable,
|
|
41
|
+
insert: jest.fn(payload => {
|
|
42
|
+
tracker.insertCalls.push(payload)
|
|
43
|
+
return Promise.resolve(true)
|
|
44
|
+
}),
|
|
45
|
+
tracker,
|
|
46
|
+
where: jest.fn(() => chainable())
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
// Cookie storage to separate get/set behavior
|
|
52
|
+
const cookieStore = {
|
|
53
|
+
odac_x: 'old_x',
|
|
54
|
+
odac_y: 'old_y'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
reqMock = {
|
|
58
|
+
cookie: jest.fn((name, value, options) => {
|
|
59
|
+
// Setter mode: 2+ arguments
|
|
60
|
+
if (value !== undefined) {
|
|
61
|
+
cookieStore[name] = value
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
// Getter mode: 1 argument
|
|
65
|
+
return cookieStore[name] || null
|
|
66
|
+
}),
|
|
67
|
+
header: jest.fn(() => 'TestBrowser'),
|
|
68
|
+
ip: '127.0.0.1'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
authInstance = new Auth(reqMock)
|
|
72
|
+
|
|
73
|
+
global.Odac = {
|
|
74
|
+
Config: {
|
|
75
|
+
auth: {
|
|
76
|
+
key: 'id',
|
|
77
|
+
rotationAge: 15 * 60 * 1000,
|
|
78
|
+
table: 'users',
|
|
79
|
+
token: 'user_tokens'
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
DB: {
|
|
83
|
+
fn: {now: () => new Date()},
|
|
84
|
+
nanoid: () => 'nano_' + Date.now()
|
|
85
|
+
},
|
|
86
|
+
Var: jest.fn(() => ({
|
|
87
|
+
hash: jest.fn(() => 'hashed_value'),
|
|
88
|
+
hashCheck: jest.fn(() => true)
|
|
89
|
+
}))
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
delete global.Odac
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should rotate token when tokenAge exceeds rotationAge and set Epoch Date marker', async () => {
|
|
98
|
+
const createdAt = Date.now() - 20 * 60 * 1000 // 20 mins ago -> exceeds 15 min rotationAge
|
|
99
|
+
|
|
100
|
+
const mockRecord = {
|
|
101
|
+
active: new Date(),
|
|
102
|
+
browser: 'TestBrowser',
|
|
103
|
+
date: new Date(createdAt),
|
|
104
|
+
id: 'token_1',
|
|
105
|
+
ip: '127.0.0.1',
|
|
106
|
+
token_x: 'old_x',
|
|
107
|
+
token_y: 'hashed_old_y',
|
|
108
|
+
user: 'user_10'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const dbMock = createDbMock([mockRecord])
|
|
112
|
+
global.Odac.DB.user_tokens = dbMock
|
|
113
|
+
global.Odac.DB.users = dbMock
|
|
114
|
+
|
|
115
|
+
const result = await authInstance.check()
|
|
116
|
+
|
|
117
|
+
expect(result).toBe(true)
|
|
118
|
+
|
|
119
|
+
// Verify new token was inserted
|
|
120
|
+
expect(dbMock.insert).toHaveBeenCalledTimes(1)
|
|
121
|
+
const inserted = dbMock.tracker.insertCalls[0]
|
|
122
|
+
expect(inserted.user).toBe('user_10')
|
|
123
|
+
expect(inserted.token_x).toBeDefined()
|
|
124
|
+
expect(inserted.token_y).toBe('hashed_value')
|
|
125
|
+
|
|
126
|
+
// Verify old token was marked with Epoch Date
|
|
127
|
+
expect(dbMock.tracker.updateCalls.length).toBe(1)
|
|
128
|
+
const updatePayload = dbMock.tracker.updateCalls[0]
|
|
129
|
+
expect(updatePayload.date.getTime()).toBe(0) // Epoch
|
|
130
|
+
|
|
131
|
+
// Verify new cookies were issued (setter calls)
|
|
132
|
+
const setCalls = reqMock.cookie.mock.calls.filter(c => c.length >= 2)
|
|
133
|
+
const xSet = setCalls.find(c => c[0] === 'odac_x' && c[2]?.httpOnly === true)
|
|
134
|
+
const ySet = setCalls.find(c => c[0] === 'odac_y' && c[2]?.httpOnly === true)
|
|
135
|
+
expect(xSet).toBeDefined()
|
|
136
|
+
expect(ySet).toBeDefined()
|
|
137
|
+
// New cookie values must differ from old ones
|
|
138
|
+
expect(xSet[1]).not.toBe('old_x')
|
|
139
|
+
expect(ySet[1]).not.toBe('old_y')
|
|
140
|
+
// Cookie max-age must use proper HTTP attribute name (hyphenated, not camelCase)
|
|
141
|
+
expect(xSet[2]['max-age']).toBeDefined()
|
|
142
|
+
expect(xSet[2].maxAge).toBeUndefined()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('should NOT rotate a recently-rotated token still within 5s threshold', async () => {
|
|
146
|
+
// Simulate a rotated token where active was set to give ~60s grace period
|
|
147
|
+
// and the rotation JUST happened (< 5 seconds ago)
|
|
148
|
+
const maxAge = 30 * 24 * 60 * 60 * 1000
|
|
149
|
+
const rotatedActiveDate = new Date(Date.now() - maxAge + 60000) // Grace period: ~60s left
|
|
150
|
+
|
|
151
|
+
const mockRecord = {
|
|
152
|
+
active: rotatedActiveDate,
|
|
153
|
+
browser: 'TestBrowser',
|
|
154
|
+
date: new Date(0), // Epoch marker = already rotated
|
|
155
|
+
id: 'token_2',
|
|
156
|
+
ip: '127.0.0.1',
|
|
157
|
+
token_x: 'old_x',
|
|
158
|
+
token_y: 'hashed_old_y',
|
|
159
|
+
user: 'user_10'
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const dbMock = createDbMock([mockRecord])
|
|
163
|
+
global.Odac.DB.user_tokens = dbMock
|
|
164
|
+
global.Odac.DB.users = dbMock
|
|
165
|
+
|
|
166
|
+
const result = await authInstance.check()
|
|
167
|
+
|
|
168
|
+
expect(result).toBe(true)
|
|
169
|
+
// No rotation should occur (timeSinceRotation < 5000)
|
|
170
|
+
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
171
|
+
expect(dbMock.tracker.updateCalls.length).toBe(0)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('should recovery-rotate and DELETE old token when client lost cookies (rotated token > 5s)', async () => {
|
|
175
|
+
// Simulate a rotated token where 10 seconds have passed since original rotation
|
|
176
|
+
// Client still has old cookies → recovery rotation should trigger
|
|
177
|
+
const maxAge = 30 * 24 * 60 * 60 * 1000
|
|
178
|
+
const timeSinceRotation = 10000 // 10 seconds since original rotation
|
|
179
|
+
// active was set to: rotationTime - maxAge + 60000
|
|
180
|
+
// So: inactiveAge = now - active = now - (rotationTime - maxAge + 60000) = timeSinceRotation + maxAge - 60000
|
|
181
|
+
// timeSinceRotation formula: inactiveAge - maxAge + 60000 = timeSinceRotation = 10000
|
|
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
|
+
|
|
227
|
+
it('should NOT rotate when tokenAge is within rotationAge threshold', async () => {
|
|
228
|
+
const recentDate = Date.now() - 5 * 60 * 1000 // 5 mins ago -> within 15 min rotationAge
|
|
229
|
+
|
|
230
|
+
const mockRecord = {
|
|
231
|
+
active: new Date(),
|
|
232
|
+
browser: 'TestBrowser',
|
|
233
|
+
date: new Date(recentDate),
|
|
234
|
+
id: 'token_3',
|
|
235
|
+
ip: '127.0.0.1',
|
|
236
|
+
token_x: 'old_x',
|
|
237
|
+
token_y: 'hashed_old_y',
|
|
238
|
+
user: 'user_10'
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const dbMock = createDbMock([mockRecord])
|
|
242
|
+
global.Odac.DB.user_tokens = dbMock
|
|
243
|
+
global.Odac.DB.users = dbMock
|
|
244
|
+
|
|
245
|
+
const result = await authInstance.check()
|
|
246
|
+
|
|
247
|
+
expect(result).toBe(true)
|
|
248
|
+
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
249
|
+
expect(dbMock.tracker.updateCalls.length).toBe(0)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('should delete token and return false when inactiveAge exceeds maxAge', async () => {
|
|
253
|
+
const staleActive = Date.now() - 31 * 24 * 60 * 60 * 1000 // 31 days ago -> exceeds 30 day maxAge
|
|
254
|
+
|
|
255
|
+
const mockRecord = {
|
|
256
|
+
active: new Date(staleActive),
|
|
257
|
+
browser: 'TestBrowser',
|
|
258
|
+
date: new Date(),
|
|
259
|
+
id: 'token_4',
|
|
260
|
+
ip: '127.0.0.1',
|
|
261
|
+
token_x: 'old_x',
|
|
262
|
+
token_y: 'hashed_old_y',
|
|
263
|
+
user: 'user_10'
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const dbMock = createDbMock([mockRecord])
|
|
267
|
+
global.Odac.DB.user_tokens = dbMock
|
|
268
|
+
global.Odac.DB.users = dbMock
|
|
269
|
+
|
|
270
|
+
const result = await authInstance.check()
|
|
271
|
+
|
|
272
|
+
expect(result).toBe(false)
|
|
273
|
+
// Token should be deleted
|
|
274
|
+
expect(dbMock.tracker.deleteCalls.length).toBe(1)
|
|
275
|
+
// No rotation should occur
|
|
276
|
+
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('should update active timestamp when inactiveAge exceeds updateAge but tokenAge is within rotationAge', async () => {
|
|
280
|
+
const staleActive = Date.now() - 25 * 60 * 60 * 1000 // 25 hours ago -> exceeds 24h updateAge
|
|
281
|
+
const recentDate = Date.now() - 5 * 60 * 1000 // 5 mins ago -> within rotationAge
|
|
282
|
+
|
|
283
|
+
const mockRecord = {
|
|
284
|
+
active: new Date(staleActive),
|
|
285
|
+
browser: 'TestBrowser',
|
|
286
|
+
date: new Date(recentDate),
|
|
287
|
+
id: 'token_5',
|
|
288
|
+
ip: '127.0.0.1',
|
|
289
|
+
token_x: 'old_x',
|
|
290
|
+
token_y: 'hashed_old_y',
|
|
291
|
+
user: 'user_10'
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const dbMock = createDbMock([mockRecord])
|
|
295
|
+
global.Odac.DB.user_tokens = dbMock
|
|
296
|
+
global.Odac.DB.users = dbMock
|
|
297
|
+
|
|
298
|
+
const result = await authInstance.check()
|
|
299
|
+
|
|
300
|
+
expect(result).toBe(true)
|
|
301
|
+
// Should NOT rotate (tokenAge within threshold)
|
|
302
|
+
expect(dbMock.insert).not.toHaveBeenCalled()
|
|
303
|
+
// Should update active timestamp (fallback path)
|
|
304
|
+
expect(dbMock.tracker.updateCalls.length).toBe(1)
|
|
305
|
+
const updatePayload = dbMock.tracker.updateCalls[0]
|
|
306
|
+
expect(updatePayload.active).toBeInstanceOf(Date)
|
|
307
|
+
// Should NOT have Epoch marker
|
|
308
|
+
expect(updatePayload.date).toBeUndefined()
|
|
309
|
+
})
|
|
310
|
+
})
|
package/test/Client.test.js
CHANGED
|
@@ -151,6 +151,35 @@ describe('Client (odac.js)', () => {
|
|
|
151
151
|
})
|
|
152
152
|
})
|
|
153
153
|
|
|
154
|
+
describe('get()', () => {
|
|
155
|
+
test('should automatically parse JSON if Content-Type is application/json', () => {
|
|
156
|
+
const mockCallback = jest.fn()
|
|
157
|
+
const mockData = {success: true}
|
|
158
|
+
|
|
159
|
+
mockXhr.open.mockClear()
|
|
160
|
+
mockXhr.send.mockClear()
|
|
161
|
+
|
|
162
|
+
// Mock token() to avoid side effects and XHR calls
|
|
163
|
+
jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
|
|
164
|
+
|
|
165
|
+
// Setup response for the get request
|
|
166
|
+
mockXhr.responseText = JSON.stringify(mockData)
|
|
167
|
+
mockXhr.status = 200
|
|
168
|
+
mockXhr.statusText = 'OK'
|
|
169
|
+
mockXhr.getResponseHeader.mockImplementation(header => {
|
|
170
|
+
if (header === 'Content-Type') return 'application/json'
|
|
171
|
+
return null
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
window.Odac.get('/api/test', mockCallback)
|
|
175
|
+
|
|
176
|
+
// Trigger the completion of the get request
|
|
177
|
+
if (mockXhr.onload) mockXhr.onload()
|
|
178
|
+
|
|
179
|
+
expect(mockCallback).toHaveBeenCalledWith(mockData, expect.anything(), expect.anything())
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
154
183
|
describe('OdacWebSocket', () => {
|
|
155
184
|
test('should connect to WebSocket and handle events', () => {
|
|
156
185
|
const ws = window.Odac.ws('/test-ws', {token: false})
|
package/test/Config.test.js
CHANGED
|
@@ -77,6 +77,13 @@ describe('Config', () => {
|
|
|
77
77
|
expect(result).toBe('hello-bar')
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
+
it('should replace ${VAR} when variable name includes hyphen', () => {
|
|
81
|
+
process.env['MY-VAR'] = 'hyphen-value'
|
|
82
|
+
const result = Config._interpolate('hello-${MY-VAR}')
|
|
83
|
+
expect(result).toBe('hello-hyphen-value')
|
|
84
|
+
delete process.env['MY-VAR']
|
|
85
|
+
})
|
|
86
|
+
|
|
80
87
|
it('should replace ${odac} with client path', () => {
|
|
81
88
|
// __dirname in Config.js is /.../src, so it replaces /src with /client
|
|
82
89
|
const result = Config._interpolate('path-${odac}')
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const mockKnex = jest.fn()
|
|
2
|
+
|
|
3
|
+
jest.mock(
|
|
4
|
+
'knex',
|
|
5
|
+
() =>
|
|
6
|
+
(...args) =>
|
|
7
|
+
mockKnex(...args)
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
const {buildConnections, buildConnectionConfig, resolveClient} = require('../../src/Database/ConnectionFactory')
|
|
11
|
+
|
|
12
|
+
describe('Database ConnectionFactory', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
mockKnex.mockReset()
|
|
15
|
+
mockKnex.mockImplementation(options => ({
|
|
16
|
+
options,
|
|
17
|
+
raw: jest.fn()
|
|
18
|
+
}))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('resolveClient should map known database aliases', () => {
|
|
22
|
+
expect(resolveClient('postgres')).toBe('pg')
|
|
23
|
+
expect(resolveClient('postgresql')).toBe('pg')
|
|
24
|
+
expect(resolveClient('pg')).toBe('pg')
|
|
25
|
+
expect(resolveClient('sqlite')).toBe('sqlite3')
|
|
26
|
+
expect(resolveClient('sqlite3')).toBe('sqlite3')
|
|
27
|
+
expect(resolveClient('mysql')).toBe('mysql2')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('buildConnectionConfig should create sqlite filename config', () => {
|
|
31
|
+
const config = buildConnectionConfig({database: 'db.sqlite3'}, 'sqlite3')
|
|
32
|
+
expect(config).toEqual({filename: 'db.sqlite3'})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('buildConnectionConfig should create host based config for non-sqlite', () => {
|
|
36
|
+
const config = buildConnectionConfig(
|
|
37
|
+
{
|
|
38
|
+
user: 'root',
|
|
39
|
+
password: 'secret',
|
|
40
|
+
database: 'app',
|
|
41
|
+
port: 3306
|
|
42
|
+
},
|
|
43
|
+
'mysql2'
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
expect(config).toEqual({
|
|
47
|
+
host: '127.0.0.1',
|
|
48
|
+
user: 'root',
|
|
49
|
+
password: 'secret',
|
|
50
|
+
database: 'app',
|
|
51
|
+
port: 3306
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('buildConnections should support single database config', () => {
|
|
56
|
+
const connections = buildConnections({
|
|
57
|
+
type: 'mysql',
|
|
58
|
+
user: 'root',
|
|
59
|
+
database: 'app'
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
expect(Object.keys(connections)).toEqual(['default'])
|
|
63
|
+
expect(mockKnex).toHaveBeenCalledTimes(1)
|
|
64
|
+
expect(mockKnex.mock.calls[0][0]).toMatchObject({
|
|
65
|
+
client: 'mysql2',
|
|
66
|
+
pool: {min: 0, max: 10},
|
|
67
|
+
useNullAsDefault: true
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('buildConnections should support multi database config', () => {
|
|
72
|
+
const connections = buildConnections({
|
|
73
|
+
analytics: {type: 'postgres', user: 'u', database: 'a'},
|
|
74
|
+
default: {type: 'sqlite', filename: './dev.sqlite3'}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(Object.keys(connections).sort()).toEqual(['analytics', 'default'])
|
|
78
|
+
expect(mockKnex).toHaveBeenCalledTimes(2)
|
|
79
|
+
})
|
|
80
|
+
})
|