odac 1.4.3 → 1.4.5
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/coding.md +2 -2
- package/.agent/rules/memory.md +5 -1
- package/.github/workflows/release.yml +2 -0
- package/.husky/pre-push +0 -1
- package/.kiro/steering/coding.md +27 -0
- package/.kiro/steering/memory.md +56 -0
- package/.kiro/steering/project.md +30 -0
- package/.kiro/steering/workflow.md +16 -0
- package/CHANGELOG.md +98 -0
- package/README.md +2 -1
- package/client/odac.js +121 -2
- package/docs/ai/skills/backend/authentication.md +7 -5
- package/docs/ai/skills/backend/controllers.md +24 -3
- package/docs/ai/skills/backend/forms.md +8 -6
- package/docs/ai/skills/backend/image-processing.md +93 -0
- package/docs/ai/skills/backend/request_response.md +2 -2
- package/docs/ai/skills/backend/routing.md +11 -0
- package/docs/ai/skills/backend/structure.md +1 -1
- package/docs/ai/skills/backend/views.md +34 -9
- package/docs/ai/skills/frontend/navigation.md +45 -1
- package/docs/ai/skills/frontend/realtime.md +18 -2
- package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +24 -0
- package/docs/backend/07-views/03-template-syntax.md +65 -15
- package/docs/backend/07-views/03-variables.md +22 -7
- package/docs/backend/07-views/11-image-optimization.md +197 -0
- package/docs/frontend/02-ajax-navigation/01-quick-start.md +22 -0
- package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +51 -0
- package/package.json +5 -2
- package/src/Auth.js +8 -4
- package/src/Config.js +5 -0
- package/src/Database/ConnectionFactory.js +16 -0
- package/src/Ipc.js +3 -2
- package/src/Lang.js +17 -10
- package/src/Odac.js +1 -0
- package/src/Request.js +20 -20
- package/src/Route.js +39 -3
- package/src/Validator.js +5 -5
- package/src/View/Image.js +495 -0
- package/src/View.js +4 -0
- package/template/controller/page/about.js +3 -3
- package/template/controller/page/index.js +2 -2
- package/template/public/assets/js/app.js +38 -54
- package/template/skeleton/main.html +4 -4
- package/template/view/content/about.html +64 -60
- package/template/view/content/home.html +148 -175
- package/template/view/css/app.css +46 -0
- package/template/view/footer/main.html +10 -9
- package/template/view/header/main.html +34 -11
- package/test/Auth/verifyMagicLink.test.js +281 -0
- package/test/Client/load.test.js +306 -0
- package/test/Lang/get.test.js +37 -11
- package/test/Odac/image.test.js +61 -0
- package/test/Route/set.test.js +102 -0
- package/test/View/Image/buildFilename.test.js +62 -0
- package/test/View/Image/hash.test.js +59 -0
- package/test/View/Image/isAvailable.test.js +15 -0
- package/test/View/Image/parse.test.js +83 -0
- package/test/View/Image/process.test.js +38 -0
- package/test/View/Image/render.test.js +117 -0
- package/test/View/Image/serve.test.js +56 -0
- package/test/View/Image/url.test.js +53 -0
- package/test/View/constructor.test.js +10 -0
- package/template/public/assets/css/style.css +0 -1835
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
describe('Auth.verifyMagicLink()', () => {
|
|
2
|
+
let Auth
|
|
3
|
+
let reqMock
|
|
4
|
+
let authInstance
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Why: Builds a chainable DB mock simulating Knex's query builder pattern.
|
|
8
|
+
* Tracks all insert/update/delete calls for assertion.
|
|
9
|
+
* Supports orWhere, columnInfo, and schema — required by check(), register(), and migration helpers.
|
|
10
|
+
*
|
|
11
|
+
* @param {Array} rows - The rows the query should resolve to.
|
|
12
|
+
* @returns {object} Mock with chainable query builder and call tracker.
|
|
13
|
+
*/
|
|
14
|
+
const createDbMock = (rows = []) => {
|
|
15
|
+
const tracker = {
|
|
16
|
+
deleteCalls: [],
|
|
17
|
+
firstResult: rows[0] || null,
|
|
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(() => Promise.resolve(tracker.firstResult)),
|
|
28
|
+
insert: jest.fn(payload => {
|
|
29
|
+
tracker.insertCalls.push(payload)
|
|
30
|
+
return Promise.resolve(true)
|
|
31
|
+
}),
|
|
32
|
+
orWhere: jest.fn(() => chainable()),
|
|
33
|
+
then: cb => cb(rows),
|
|
34
|
+
update: jest.fn(payload => {
|
|
35
|
+
tracker.updateCalls.push(payload)
|
|
36
|
+
return Promise.resolve(true)
|
|
37
|
+
}),
|
|
38
|
+
where: jest.fn(() => chainable())
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
// columnInfo: used by register() to detect PK column type
|
|
43
|
+
columnInfo: jest.fn(() => Promise.resolve({type: 'string'})),
|
|
44
|
+
insert: jest.fn(payload => {
|
|
45
|
+
tracker.insertCalls.push(payload)
|
|
46
|
+
return Promise.resolve(true)
|
|
47
|
+
}),
|
|
48
|
+
// orWhere: entry point used by check(where) before chaining
|
|
49
|
+
orWhere: jest.fn(() => chainable()),
|
|
50
|
+
// schema: used by migration helpers (#ensureUserTableV2, #ensureTokenTableV2)
|
|
51
|
+
schema: jest.fn(() => Promise.resolve()),
|
|
52
|
+
tracker,
|
|
53
|
+
where: jest.fn(() => chainable())
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Why: Builds a minimal magic link record for DB mock responses.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} overrides - Fields to override on the default record.
|
|
61
|
+
* @returns {object} Magic link record.
|
|
62
|
+
*/
|
|
63
|
+
const createMagicRecord = (overrides = {}) => ({
|
|
64
|
+
browser: 'TestBrowser',
|
|
65
|
+
email: 'test@example.com',
|
|
66
|
+
expires_at: new Date(Date.now() + 15 * 60 * 1000),
|
|
67
|
+
id: 1,
|
|
68
|
+
ip: '127.0.0.1',
|
|
69
|
+
token_hash: 'hashed_token',
|
|
70
|
+
...overrides
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
// Isolate module per test to reset the static #migrationCache Set on Auth class
|
|
75
|
+
jest.isolateModules(() => {
|
|
76
|
+
Auth = require('../../src/Auth.js')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
reqMock = {
|
|
80
|
+
cookie: jest.fn((name, value) => {
|
|
81
|
+
if (value !== undefined) return
|
|
82
|
+
return null
|
|
83
|
+
}),
|
|
84
|
+
header: jest.fn(() => 'TestBrowser'),
|
|
85
|
+
host: 'example.com',
|
|
86
|
+
ip: '127.0.0.1',
|
|
87
|
+
res: {},
|
|
88
|
+
session: jest.fn(() => null),
|
|
89
|
+
ssl: false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
authInstance = new Auth(reqMock)
|
|
93
|
+
|
|
94
|
+
global.Odac = {
|
|
95
|
+
Config: {
|
|
96
|
+
auth: {
|
|
97
|
+
key: 'id',
|
|
98
|
+
magicTable: 'odac_magic',
|
|
99
|
+
table: 'users',
|
|
100
|
+
token: 'odac_auth'
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
DB: {
|
|
104
|
+
fn: {now: () => new Date()},
|
|
105
|
+
nanoid: () => 'nano_' + Date.now()
|
|
106
|
+
},
|
|
107
|
+
Var: jest.fn(() => ({
|
|
108
|
+
hash: jest.fn(() => 'hashed_value'),
|
|
109
|
+
hashCheck: jest.fn(() => true),
|
|
110
|
+
is: jest.fn(() => false),
|
|
111
|
+
md5: jest.fn(() => 'md5_value')
|
|
112
|
+
}))
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
delete global.Odac
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ─── EXISTING USER SCENARIOS ──────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
it('should log in an existing user and return success', async () => {
|
|
123
|
+
const existingUser = {email: 'test@example.com', id: 'user_1', name: 'Test'}
|
|
124
|
+
const magicDbMock = createDbMock([createMagicRecord()])
|
|
125
|
+
// rows array is consumed by check(where) candidate query via .then()
|
|
126
|
+
const usersDbMock = createDbMock([existingUser])
|
|
127
|
+
usersDbMock.tracker.firstResult = existingUser
|
|
128
|
+
const authDbMock = createDbMock()
|
|
129
|
+
|
|
130
|
+
global.Odac.DB.odac_magic = magicDbMock
|
|
131
|
+
global.Odac.DB.users = usersDbMock
|
|
132
|
+
global.Odac.DB.odac_auth = authDbMock
|
|
133
|
+
|
|
134
|
+
const result = await authInstance.verifyMagicLink('raw_token', 'test@example.com')
|
|
135
|
+
|
|
136
|
+
expect(result.success).toBe(true)
|
|
137
|
+
expect(result.user).toEqual(existingUser)
|
|
138
|
+
// Exactly one login token must be inserted — no duplicate
|
|
139
|
+
expect(authDbMock.tracker.insertCalls.length).toBe(1)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should consume (delete) all magic tokens for the email after successful verification', async () => {
|
|
143
|
+
const existingUser = {email: 'test@example.com', id: 'user_1'}
|
|
144
|
+
const magicDbMock = createDbMock([createMagicRecord()])
|
|
145
|
+
const usersDbMock = createDbMock([existingUser])
|
|
146
|
+
usersDbMock.tracker.firstResult = existingUser
|
|
147
|
+
|
|
148
|
+
global.Odac.DB.odac_magic = magicDbMock
|
|
149
|
+
global.Odac.DB.users = usersDbMock
|
|
150
|
+
global.Odac.DB.odac_auth = createDbMock()
|
|
151
|
+
|
|
152
|
+
await authInstance.verifyMagicLink('raw_token', 'test@example.com')
|
|
153
|
+
|
|
154
|
+
// All magic tokens for this email must be deleted to prevent reuse
|
|
155
|
+
expect(magicDbMock.tracker.deleteCalls.length).toBe(1)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// ─── NEW USER (AUTO-REGISTER) SCENARIOS ───────────────────────────────────
|
|
159
|
+
|
|
160
|
+
it('should NOT create duplicate odac_auth tokens when auto-registering a new user', async () => {
|
|
161
|
+
// Regression test: verifyMagicLink must not call login() a second time
|
|
162
|
+
// when register() already performed auto-login internally.
|
|
163
|
+
const newUser = {email: 'new@example.com', id: 'nano_new'}
|
|
164
|
+
const magicDbMock = createDbMock([createMagicRecord({email: 'new@example.com'})])
|
|
165
|
+
|
|
166
|
+
// Query path breakdown:
|
|
167
|
+
// first() calls → (1) verifyMagicLink existence check: null
|
|
168
|
+
// (2) register unique field check: null
|
|
169
|
+
// (3) register post-insert retrieval: newUser
|
|
170
|
+
// then() calls → check(where) candidate query (used by login): always [newUser]
|
|
171
|
+
let usersFirstCallCount = 0
|
|
172
|
+
const usersDbMock = createDbMock()
|
|
173
|
+
const usersChainable = () => ({
|
|
174
|
+
delete: jest.fn(() => Promise.resolve(true)),
|
|
175
|
+
first: jest.fn(() => {
|
|
176
|
+
usersFirstCallCount++
|
|
177
|
+
return Promise.resolve(usersFirstCallCount < 3 ? null : newUser)
|
|
178
|
+
}),
|
|
179
|
+
orWhere: jest.fn(() => usersChainable()),
|
|
180
|
+
then: cb => cb([newUser]),
|
|
181
|
+
update: jest.fn(() => Promise.resolve(true)),
|
|
182
|
+
where: jest.fn(() => usersChainable())
|
|
183
|
+
})
|
|
184
|
+
usersDbMock.where = jest.fn(() => usersChainable())
|
|
185
|
+
usersDbMock.orWhere = jest.fn(() => usersChainable())
|
|
186
|
+
|
|
187
|
+
const authDbMock = createDbMock()
|
|
188
|
+
|
|
189
|
+
global.Odac.DB.odac_magic = magicDbMock
|
|
190
|
+
global.Odac.DB.users = usersDbMock
|
|
191
|
+
global.Odac.DB.odac_auth = authDbMock
|
|
192
|
+
|
|
193
|
+
const result = await authInstance.verifyMagicLink('raw_token', 'new@example.com')
|
|
194
|
+
|
|
195
|
+
expect(result.success).toBe(true)
|
|
196
|
+
// THE CRITICAL ASSERTION: register() auto-login + verifyMagicLink must produce exactly 1 token
|
|
197
|
+
expect(authDbMock.tracker.insertCalls.length).toBe(1)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should return the newly registered user on success', async () => {
|
|
201
|
+
const newUser = {email: 'new@example.com', id: 'nano_new'}
|
|
202
|
+
const magicDbMock = createDbMock([createMagicRecord({email: 'new@example.com'})])
|
|
203
|
+
|
|
204
|
+
let usersFirstCallCount = 0
|
|
205
|
+
const usersDbMock = createDbMock()
|
|
206
|
+
const usersChainable = () => ({
|
|
207
|
+
delete: jest.fn(() => Promise.resolve(true)),
|
|
208
|
+
first: jest.fn(() => {
|
|
209
|
+
usersFirstCallCount++
|
|
210
|
+
return Promise.resolve(usersFirstCallCount < 3 ? null : newUser)
|
|
211
|
+
}),
|
|
212
|
+
orWhere: jest.fn(() => usersChainable()),
|
|
213
|
+
then: cb => cb([newUser]),
|
|
214
|
+
update: jest.fn(() => Promise.resolve(true)),
|
|
215
|
+
where: jest.fn(() => usersChainable())
|
|
216
|
+
})
|
|
217
|
+
usersDbMock.where = jest.fn(() => usersChainable())
|
|
218
|
+
usersDbMock.orWhere = jest.fn(() => usersChainable())
|
|
219
|
+
|
|
220
|
+
global.Odac.DB.odac_magic = magicDbMock
|
|
221
|
+
global.Odac.DB.users = usersDbMock
|
|
222
|
+
global.Odac.DB.odac_auth = createDbMock()
|
|
223
|
+
|
|
224
|
+
const result = await authInstance.verifyMagicLink('raw_token', 'new@example.com')
|
|
225
|
+
|
|
226
|
+
expect(result.success).toBe(true)
|
|
227
|
+
expect(result.user).toBeDefined()
|
|
228
|
+
expect(result.user.email).toBe('new@example.com')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// ─── FAILURE SCENARIOS ────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
it('should return error when tokenRaw is missing', async () => {
|
|
234
|
+
const result = await authInstance.verifyMagicLink(null, 'test@example.com')
|
|
235
|
+
expect(result.success).toBe(false)
|
|
236
|
+
expect(result.error).toBe('Invalid link')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('should return error when email is missing', async () => {
|
|
240
|
+
const result = await authInstance.verifyMagicLink('raw_token', null)
|
|
241
|
+
expect(result.success).toBe(false)
|
|
242
|
+
expect(result.error).toBe('Invalid link')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('should return error when no valid (non-expired) magic records exist', async () => {
|
|
246
|
+
global.Odac.DB.odac_magic = createDbMock([])
|
|
247
|
+
|
|
248
|
+
const result = await authInstance.verifyMagicLink('raw_token', 'test@example.com')
|
|
249
|
+
|
|
250
|
+
expect(result.success).toBe(false)
|
|
251
|
+
expect(result.error).toBe('Link expired or invalid')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('should return error when token hash does not match', async () => {
|
|
255
|
+
global.Odac.Var = jest.fn(() => ({
|
|
256
|
+
hash: jest.fn(() => 'hashed_value'),
|
|
257
|
+
hashCheck: jest.fn(() => false),
|
|
258
|
+
is: jest.fn(() => false)
|
|
259
|
+
}))
|
|
260
|
+
global.Odac.DB.odac_magic = createDbMock([createMagicRecord()])
|
|
261
|
+
|
|
262
|
+
const result = await authInstance.verifyMagicLink('wrong_token', 'test@example.com')
|
|
263
|
+
|
|
264
|
+
expect(result.success).toBe(false)
|
|
265
|
+
expect(result.error).toBe('Invalid token')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('should NOT delete magic tokens when token hash does not match', async () => {
|
|
269
|
+
global.Odac.Var = jest.fn(() => ({
|
|
270
|
+
hash: jest.fn(() => 'hashed_value'),
|
|
271
|
+
hashCheck: jest.fn(() => false),
|
|
272
|
+
is: jest.fn(() => false)
|
|
273
|
+
}))
|
|
274
|
+
const magicDbMock = createDbMock([createMagicRecord()])
|
|
275
|
+
global.Odac.DB.odac_magic = magicDbMock
|
|
276
|
+
|
|
277
|
+
await authInstance.verifyMagicLink('wrong_token', 'test@example.com')
|
|
278
|
+
|
|
279
|
+
expect(magicDbMock.tracker.deleteCalls.length).toBe(0)
|
|
280
|
+
})
|
|
281
|
+
})
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
describe('Odac.load()', () => {
|
|
2
|
+
let mockXhr, mockDocument, mockWindow
|
|
3
|
+
|
|
4
|
+
const setupMocks = (options = {}) => {
|
|
5
|
+
jest.resetModules()
|
|
6
|
+
mockXhr = {
|
|
7
|
+
open: jest.fn(),
|
|
8
|
+
setRequestHeader: jest.fn(),
|
|
9
|
+
send: jest.fn(),
|
|
10
|
+
getResponseHeader: jest.fn(() => null),
|
|
11
|
+
responseURL: '',
|
|
12
|
+
status: 200,
|
|
13
|
+
responseText: '{}',
|
|
14
|
+
response: '{}',
|
|
15
|
+
onload: null,
|
|
16
|
+
onerror: null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const transitionElements = options.transitionElements || []
|
|
20
|
+
|
|
21
|
+
mockDocument = {
|
|
22
|
+
getElementById: jest.fn(),
|
|
23
|
+
querySelectorAll: jest.fn(selector => {
|
|
24
|
+
if (selector === '[odac-transition]') return transitionElements
|
|
25
|
+
return []
|
|
26
|
+
}),
|
|
27
|
+
querySelector: jest.fn(),
|
|
28
|
+
addEventListener: jest.fn(),
|
|
29
|
+
removeEventListener: jest.fn(),
|
|
30
|
+
dispatchEvent: jest.fn(),
|
|
31
|
+
documentElement: {dataset: {}},
|
|
32
|
+
cookie: '',
|
|
33
|
+
readyState: 'complete',
|
|
34
|
+
startViewTransition: options.hasViewTransition
|
|
35
|
+
? jest.fn(cb => {
|
|
36
|
+
cb()
|
|
37
|
+
return {
|
|
38
|
+
finished: Promise.resolve(),
|
|
39
|
+
ready: Promise.resolve(),
|
|
40
|
+
updateCallbackDone: Promise.resolve()
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
: undefined,
|
|
44
|
+
createElement: jest.fn(tag => {
|
|
45
|
+
const el = {
|
|
46
|
+
setAttribute: jest.fn(),
|
|
47
|
+
style: {},
|
|
48
|
+
appendChild: jest.fn(),
|
|
49
|
+
parentNode: {insertBefore: jest.fn()},
|
|
50
|
+
_innerHTML: '',
|
|
51
|
+
get value() {
|
|
52
|
+
if (tag === 'textarea') {
|
|
53
|
+
return this._innerHTML
|
|
54
|
+
.replace(/&/g, '&')
|
|
55
|
+
.replace(/</g, '<')
|
|
56
|
+
.replace(/>/g, '>')
|
|
57
|
+
.replace(/"/g, '"')
|
|
58
|
+
.replace(/'/g, "'")
|
|
59
|
+
}
|
|
60
|
+
return ''
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
Object.defineProperty(el, 'innerHTML', {
|
|
64
|
+
get() {
|
|
65
|
+
return el._innerHTML
|
|
66
|
+
},
|
|
67
|
+
set(v) {
|
|
68
|
+
el._innerHTML = v
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
return el
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
mockWindow = {
|
|
76
|
+
location: {protocol: 'http:', host: 'localhost', href: 'http://localhost/', replace: jest.fn()},
|
|
77
|
+
history: {pushState: jest.fn()},
|
|
78
|
+
scrollTo: jest.fn(),
|
|
79
|
+
addEventListener: jest.fn(),
|
|
80
|
+
XMLHttpRequest: jest.fn(() => mockXhr),
|
|
81
|
+
localStorage: {getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn()},
|
|
82
|
+
CustomEvent: jest.fn((name, detail) => ({name, detail})),
|
|
83
|
+
setTimeout: jest.fn(),
|
|
84
|
+
clearTimeout: jest.fn(),
|
|
85
|
+
requestAnimationFrame: jest.fn(cb => cb(Date.now())),
|
|
86
|
+
WebSocket: jest.fn(() => ({send: jest.fn(), close: jest.fn(), readyState: 1})),
|
|
87
|
+
FormData: jest.fn(),
|
|
88
|
+
URL: global.URL
|
|
89
|
+
}
|
|
90
|
+
mockWindow.window = mockWindow
|
|
91
|
+
mockWindow.document = mockDocument
|
|
92
|
+
mockWindow.WebSocket.OPEN = 1
|
|
93
|
+
mockWindow.WebSocket.CLOSED = 3
|
|
94
|
+
global.window = mockWindow
|
|
95
|
+
global.document = mockDocument
|
|
96
|
+
global.location = mockWindow.location
|
|
97
|
+
global.XMLHttpRequest = mockWindow.XMLHttpRequest
|
|
98
|
+
global.localStorage = mockWindow.localStorage
|
|
99
|
+
global.CustomEvent = mockWindow.CustomEvent
|
|
100
|
+
global.WebSocket = mockWindow.WebSocket
|
|
101
|
+
global.setTimeout = mockWindow.setTimeout
|
|
102
|
+
global.clearTimeout = mockWindow.clearTimeout
|
|
103
|
+
global.requestAnimationFrame = mockWindow.requestAnimationFrame
|
|
104
|
+
global.FormData = mockWindow.FormData
|
|
105
|
+
delete require.cache[require.resolve('../../client/odac.js')]
|
|
106
|
+
require('../../client/odac.js')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
delete global.window
|
|
111
|
+
delete global.document
|
|
112
|
+
delete global.location
|
|
113
|
+
delete global.XMLHttpRequest
|
|
114
|
+
delete global.localStorage
|
|
115
|
+
delete global.CustomEvent
|
|
116
|
+
delete global.WebSocket
|
|
117
|
+
delete global.setTimeout
|
|
118
|
+
delete global.clearTimeout
|
|
119
|
+
delete global.requestAnimationFrame
|
|
120
|
+
delete global.FormData
|
|
121
|
+
delete global.Odac
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('URL validation', () => {
|
|
125
|
+
beforeEach(() => setupMocks())
|
|
126
|
+
|
|
127
|
+
test('should resolve empty string to current URL and proceed with navigation', () => {
|
|
128
|
+
jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
|
|
129
|
+
window.Odac.load('', jest.fn())
|
|
130
|
+
// Empty string resolves to current URL via new URL('', base) — AJAX fires normally
|
|
131
|
+
expect(mockXhr.send).toHaveBeenCalled()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('should reject javascript: protocol URLs', () => {
|
|
135
|
+
const result = window.Odac.load('javascript:void(0)', jest.fn())
|
|
136
|
+
expect(result).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('should reject data: protocol URLs', () => {
|
|
140
|
+
const result = window.Odac.load('data:text/html,test', jest.fn())
|
|
141
|
+
expect(result).toBe(false)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('should reject vbscript: protocol URLs', () => {
|
|
145
|
+
const result = window.Odac.load('vbscript:test', jest.fn())
|
|
146
|
+
expect(result).toBe(false)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('should reject hash-only URLs', () => {
|
|
150
|
+
const result = window.Odac.load('#section', jest.fn())
|
|
151
|
+
expect(result).toBe(false)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('should prevent concurrent navigations', () => {
|
|
155
|
+
window.Odac.load('/page-1', jest.fn())
|
|
156
|
+
const result = window.Odac.load('/page-2', jest.fn())
|
|
157
|
+
expect(result).toBe(false)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('fade fallback path', () => {
|
|
162
|
+
beforeEach(() => setupMocks({hasViewTransition: false}))
|
|
163
|
+
|
|
164
|
+
test('should use fade when View Transition API is unavailable', () => {
|
|
165
|
+
jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
|
|
166
|
+
window.Odac.load('/test', jest.fn())
|
|
167
|
+
expect(mockXhr.open).toHaveBeenCalledWith('GET', 'http://localhost/test', true)
|
|
168
|
+
expect(mockXhr.send).toHaveBeenCalled()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('should fallback to window.location.replace on AJAX error', () => {
|
|
172
|
+
jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
|
|
173
|
+
window.Odac.load('/error-page', jest.fn())
|
|
174
|
+
mockXhr.onerror()
|
|
175
|
+
expect(mockWindow.location.replace).toHaveBeenCalledWith('http://localhost/error-page')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('View Transition API path', () => {
|
|
180
|
+
let transitionElements
|
|
181
|
+
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
transitionElements = [
|
|
184
|
+
{getAttribute: jest.fn(() => 'header'), style: {}, setAttribute: jest.fn()},
|
|
185
|
+
{getAttribute: jest.fn(() => 'hero'), style: {}, setAttribute: jest.fn()}
|
|
186
|
+
]
|
|
187
|
+
setupMocks({hasViewTransition: true, transitionElements})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('should use View Transition API when supported and odac-transition elements exist', () => {
|
|
191
|
+
jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
|
|
192
|
+
window.Odac.load('/new-page', jest.fn())
|
|
193
|
+
|
|
194
|
+
expect(transitionElements[0].style.viewTransitionName).toBe('header')
|
|
195
|
+
expect(transitionElements[1].style.viewTransitionName).toBe('hero')
|
|
196
|
+
expect(mockXhr.send).toHaveBeenCalled()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('should call startViewTransition on successful AJAX response', () => {
|
|
200
|
+
jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
|
|
201
|
+
mockXhr.responseURL = 'http://localhost/new-page'
|
|
202
|
+
mockXhr.getResponseHeader.mockImplementation(h => (h === 'Content-Type' ? 'application/json' : null))
|
|
203
|
+
|
|
204
|
+
window.Odac.load('/new-page', jest.fn())
|
|
205
|
+
|
|
206
|
+
mockXhr.responseText = JSON.stringify({title: 'New Page', output: {}})
|
|
207
|
+
mockXhr.status = 200
|
|
208
|
+
if (mockXhr.onload) mockXhr.onload()
|
|
209
|
+
|
|
210
|
+
expect(mockDocument.startViewTransition).toHaveBeenCalled()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('should apply transition names to elements before snapshot', () => {
|
|
214
|
+
jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
|
|
215
|
+
window.Odac.load('/page', jest.fn())
|
|
216
|
+
|
|
217
|
+
expect(transitionElements[0].getAttribute).toHaveBeenCalledWith('odac-transition')
|
|
218
|
+
expect(transitionElements[0].style.viewTransitionName).toBe('header')
|
|
219
|
+
expect(transitionElements[1].getAttribute).toHaveBeenCalledWith('odac-transition')
|
|
220
|
+
expect(transitionElements[1].style.viewTransitionName).toBe('hero')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('should fall back to full page load on skeleton change', () => {
|
|
224
|
+
jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
|
|
225
|
+
mockXhr.responseURL = 'http://localhost/new-skeleton'
|
|
226
|
+
mockXhr.getResponseHeader.mockImplementation(h => (h === 'Content-Type' ? 'application/json' : null))
|
|
227
|
+
|
|
228
|
+
window.Odac.load('/new-skeleton', jest.fn())
|
|
229
|
+
|
|
230
|
+
mockXhr.responseText = JSON.stringify({skeletonChanged: true})
|
|
231
|
+
mockXhr.status = 200
|
|
232
|
+
if (mockXhr.onload) mockXhr.onload()
|
|
233
|
+
|
|
234
|
+
expect(mockDocument.startViewTransition).not.toHaveBeenCalled()
|
|
235
|
+
expect(mockWindow.location.href).toBe('http://localhost/new-skeleton')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('should clean transition names on AJAX error', () => {
|
|
239
|
+
jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
|
|
240
|
+
window.Odac.load('/fail', jest.fn())
|
|
241
|
+
|
|
242
|
+
mockXhr.onerror()
|
|
243
|
+
|
|
244
|
+
expect(transitionElements[0].style.viewTransitionName).toBe('')
|
|
245
|
+
expect(transitionElements[1].style.viewTransitionName).toBe('')
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('fade fallback when no odac-transition elements', () => {
|
|
250
|
+
beforeEach(() => setupMocks({hasViewTransition: true, transitionElements: []}))
|
|
251
|
+
|
|
252
|
+
test('should use fade path when API exists but no odac-transition elements found', () => {
|
|
253
|
+
jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
|
|
254
|
+
window.Odac.load('/page', jest.fn())
|
|
255
|
+
|
|
256
|
+
expect(mockDocument.startViewTransition).not.toHaveBeenCalled()
|
|
257
|
+
expect(mockXhr.send).toHaveBeenCalled()
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe('title HTML entity decoding', () => {
|
|
262
|
+
beforeEach(() => setupMocks({hasViewTransition: false}))
|
|
263
|
+
|
|
264
|
+
test('should decode HTML entities in title during fade navigation', () => {
|
|
265
|
+
jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
|
|
266
|
+
mockXhr.responseURL = 'http://localhost/products'
|
|
267
|
+
mockXhr.getResponseHeader.mockImplementation(h => (h === 'Content-Type' ? 'application/json' : null))
|
|
268
|
+
|
|
269
|
+
window.Odac.load('/products', jest.fn())
|
|
270
|
+
|
|
271
|
+
mockXhr.responseText = JSON.stringify({title: 'Tom & Jerry', output: {}})
|
|
272
|
+
mockXhr.status = 200
|
|
273
|
+
mockXhr.onload()
|
|
274
|
+
|
|
275
|
+
expect(mockDocument.title).toBe('Tom & Jerry')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('should decode multiple HTML entities in title', () => {
|
|
279
|
+
jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
|
|
280
|
+
mockXhr.responseURL = 'http://localhost/page'
|
|
281
|
+
mockXhr.getResponseHeader.mockImplementation(h => (h === 'Content-Type' ? 'application/json' : null))
|
|
282
|
+
|
|
283
|
+
window.Odac.load('/page', jest.fn())
|
|
284
|
+
|
|
285
|
+
mockXhr.responseText = JSON.stringify({title: '<ODAC> & "Framework"', output: {}})
|
|
286
|
+
mockXhr.status = 200
|
|
287
|
+
mockXhr.onload()
|
|
288
|
+
|
|
289
|
+
expect(mockDocument.title).toBe('<ODAC> & "Framework"')
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test('should handle title without entities unchanged', () => {
|
|
293
|
+
jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
|
|
294
|
+
mockXhr.responseURL = 'http://localhost/simple'
|
|
295
|
+
mockXhr.getResponseHeader.mockImplementation(h => (h === 'Content-Type' ? 'application/json' : null))
|
|
296
|
+
|
|
297
|
+
window.Odac.load('/simple', jest.fn())
|
|
298
|
+
|
|
299
|
+
mockXhr.responseText = JSON.stringify({title: 'Simple Page Title', output: {}})
|
|
300
|
+
mockXhr.status = 200
|
|
301
|
+
mockXhr.onload()
|
|
302
|
+
|
|
303
|
+
expect(mockDocument.title).toBe('Simple Page Title')
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
})
|
package/test/Lang/get.test.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
const fs = require('fs')
|
|
1
|
+
const fs = require('node:fs')
|
|
2
2
|
const Lang = require('../../src/Lang')
|
|
3
3
|
|
|
4
|
-
jest.mock('fs')
|
|
4
|
+
jest.mock('node:fs', () => ({
|
|
5
|
+
promises: {
|
|
6
|
+
mkdir: jest.fn(),
|
|
7
|
+
writeFile: jest.fn(),
|
|
8
|
+
access: jest.fn()
|
|
9
|
+
},
|
|
10
|
+
existsSync: jest.fn(),
|
|
11
|
+
mkdirSync: jest.fn(),
|
|
12
|
+
writeFileSync: jest.fn(),
|
|
13
|
+
readFileSync: jest.fn()
|
|
14
|
+
}))
|
|
5
15
|
|
|
6
16
|
describe('Lang.get()', () => {
|
|
7
17
|
let mockOdac
|
|
@@ -22,8 +32,8 @@ describe('Lang.get()', () => {
|
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
fs.existsSync.mockReturnValue(false)
|
|
25
|
-
fs.
|
|
26
|
-
fs.
|
|
35
|
+
fs.promises.mkdir.mockResolvedValue()
|
|
36
|
+
fs.promises.writeFile.mockResolvedValue()
|
|
27
37
|
fs.readFileSync.mockImplementation(() => {
|
|
28
38
|
throw new Error('ENOENT')
|
|
29
39
|
})
|
|
@@ -31,7 +41,7 @@ describe('Lang.get()', () => {
|
|
|
31
41
|
lang = new Lang(mockOdac)
|
|
32
42
|
})
|
|
33
43
|
|
|
34
|
-
it('should return matching string and support placeholders', () => {
|
|
44
|
+
it('should return matching string and support placeholders', async () => {
|
|
35
45
|
fs.existsSync.mockImplementation(path => path.includes('/tr.json'))
|
|
36
46
|
fs.readFileSync.mockReturnValue(
|
|
37
47
|
JSON.stringify({
|
|
@@ -40,10 +50,10 @@ describe('Lang.get()', () => {
|
|
|
40
50
|
)
|
|
41
51
|
|
|
42
52
|
lang.set('tr')
|
|
43
|
-
expect(lang.get('welcome', 'Emre')).toBe('Merhaba Emre!')
|
|
53
|
+
expect(await lang.get('welcome', 'Emre')).toBe('Merhaba Emre!')
|
|
44
54
|
})
|
|
45
55
|
|
|
46
|
-
it('should support numbered placeholders', () => {
|
|
56
|
+
it('should support numbered placeholders', async () => {
|
|
47
57
|
fs.existsSync.mockImplementation(path => path.includes('/en.json'))
|
|
48
58
|
fs.readFileSync.mockReturnValue(
|
|
49
59
|
JSON.stringify({
|
|
@@ -52,14 +62,30 @@ describe('Lang.get()', () => {
|
|
|
52
62
|
)
|
|
53
63
|
|
|
54
64
|
lang.set('en')
|
|
55
|
-
expect(lang.get('order', 'A', 'B')).toBe('First: A, Second: B')
|
|
65
|
+
expect(await lang.get('order', 'A', 'B')).toBe('First: A, Second: B')
|
|
56
66
|
})
|
|
57
67
|
|
|
58
|
-
it('should auto-save new keys', () => {
|
|
68
|
+
it('should auto-save new keys', async () => {
|
|
59
69
|
lang.set('en')
|
|
60
|
-
const result = lang.get('new_key')
|
|
70
|
+
const result = await lang.get('new_key')
|
|
61
71
|
|
|
62
72
|
expect(result).toBe('new_key')
|
|
63
|
-
expect(fs.
|
|
73
|
+
expect(fs.promises.writeFile).toHaveBeenCalledWith(expect.stringContaining('/en.json'), expect.stringContaining('"new_key": "new_key"'))
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should set language from ACCEPT-LANGUAGE header', () => {
|
|
77
|
+
mockOdac.Request.header.mockImplementation(name => {
|
|
78
|
+
if (name === 'ACCEPT-LANGUAGE') return 'tr-TR,tr;q=0.9'
|
|
79
|
+
return null
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
lang.set()
|
|
83
|
+
// We check internal #lang via side effect or just trust the logic if we can't access private field easily.
|
|
84
|
+
// But we can check if it tries to read tr.json
|
|
85
|
+
fs.existsSync.mockImplementation(path => path.includes('/tr.json'))
|
|
86
|
+
fs.readFileSync.mockReturnValue('{}')
|
|
87
|
+
|
|
88
|
+
lang.set()
|
|
89
|
+
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('/tr.json'), 'utf8')
|
|
64
90
|
})
|
|
65
91
|
})
|