odac 1.4.4 → 1.4.6

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.
@@ -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(/&lt;/g, '<')
56
+ .replace(/&gt;/g, '>')
57
+ .replace(/&quot;/g, '"')
58
+ .replace(/&#039;/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 &amp; 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: '&lt;ODAC&gt; &amp; &quot;Framework&quot;', 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
+ })
@@ -6,6 +6,10 @@ jest.mock(
6
6
  mockKnex(...args)
7
7
  )
8
8
 
9
+ jest.mock('mysql2', () => ({}), {virtual: true})
10
+ jest.mock('pg', () => ({}), {virtual: true})
11
+ jest.mock('sqlite3', () => ({}), {virtual: true})
12
+
9
13
  const {buildConnections} = require('../../../src/Database/ConnectionFactory')
10
14
 
11
15
  describe('ConnectionFactory.buildConnections()', () => {
@@ -0,0 +1,180 @@
1
+ const fs = require('fs')
2
+ const fsPromises = fs.promises
3
+ const path = require('path')
4
+ const View = require('../../src/View')
5
+
6
+ const FIXTURE_DIR = path.resolve(__dirname, '_fixtures')
7
+
8
+ /**
9
+ * Integration tests for the #parseOdacTag private method.
10
+ * Since #parseOdacTag is private, we test it indirectly through the
11
+ * full render pipeline by creating temporary .html view files,
12
+ * invoking View.print(), and asserting the rendered output.
13
+ */
14
+ describe('View.#parseOdacTag()', () => {
15
+ let originalDir
16
+ let originalCwd
17
+
18
+ beforeAll(async () => {
19
+ await fsPromises.mkdir(path.join(FIXTURE_DIR, 'skeleton'), {recursive: true})
20
+ await fsPromises.mkdir(path.join(FIXTURE_DIR, 'view', 'content'), {recursive: true})
21
+ })
22
+
23
+ afterAll(async () => {
24
+ await fsPromises.rm(FIXTURE_DIR, {recursive: true, force: true})
25
+ })
26
+
27
+ beforeEach(() => {
28
+ originalDir = global.__dir
29
+ originalCwd = process.cwd()
30
+ global.__dir = FIXTURE_DIR
31
+ process.chdir(FIXTURE_DIR)
32
+
33
+ if (global.Odac?.View?.cache) {
34
+ global.Odac.View.cache = {}
35
+ }
36
+ if (global.Odac?.View?.skeletons) {
37
+ global.Odac.View.skeletons = {}
38
+ }
39
+
40
+ // Clear require cache for compiled templates
41
+ for (const key of Object.keys(require.cache)) {
42
+ if (key.includes('.cache')) {
43
+ delete require.cache[key]
44
+ }
45
+ }
46
+ })
47
+
48
+ afterEach(() => {
49
+ global.__dir = originalDir
50
+ process.chdir(originalCwd)
51
+ })
52
+
53
+ let testCounter = 0
54
+
55
+ /**
56
+ * Writes a view file, triggers render via print(), and captures output.
57
+ * Uses a unique filename per call to avoid cache collisions between tests.
58
+ * Returns the rendered HTML string.
59
+ */
60
+ async function renderTemplate(templateContent) {
61
+ const uniqueId = `test_${++testCounter}`
62
+ const viewFile = path.join(FIXTURE_DIR, 'view', 'content', `${uniqueId}.html`)
63
+ await fsPromises.writeFile(viewFile, templateContent, 'utf8')
64
+
65
+ const skeletonFile = path.join(FIXTURE_DIR, 'skeleton', 'main.html')
66
+ await fsPromises.writeFile(skeletonFile, '{{ CONTENT }}', 'utf8')
67
+
68
+ let capturedOutput = ''
69
+ const errors = []
70
+ const originalError = console.error
71
+ console.error = (...args) => errors.push(args.join(' '))
72
+
73
+ const mockOdac = {
74
+ Config: {debug: true},
75
+ Var: value => {
76
+ const str = value === null || value === undefined ? '' : String(value)
77
+ return {
78
+ html: () => str.replace(/[&<>"']/g, m => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'})[m])
79
+ }
80
+ },
81
+ Request: {
82
+ req: {url: '/test'},
83
+ res: {finished: false, headersSent: false},
84
+ isAjaxLoad: false,
85
+ ajaxLoad: [],
86
+ variables: {},
87
+ sharedData: {},
88
+ page: '',
89
+ get: () => '',
90
+ header: () => {},
91
+ end: output => {
92
+ capturedOutput = output
93
+ },
94
+ hasEarlyHints: () => false,
95
+ setEarlyHints: () => {}
96
+ },
97
+ Lang: {
98
+ get: (...args) => args[0]
99
+ },
100
+ View: {}
101
+ }
102
+
103
+ try {
104
+ const view = new View(mockOdac)
105
+ view.skeleton('main')
106
+ view.set('content', uniqueId)
107
+ await view.print()
108
+ } finally {
109
+ console.error = originalError
110
+ }
111
+
112
+ if (errors.length > 0) {
113
+ throw new Error(`Template render error: ${errors.join('\n')}`)
114
+ }
115
+
116
+ return capturedOutput
117
+ }
118
+
119
+ describe('single quote escaping in <odac> tags', () => {
120
+ it('should render a single quote inside <odac> without syntax error', async () => {
121
+ const result = await renderTemplate("<odac>'</odac>")
122
+ // Single quotes are HTML-escaped by Odac.Var().html()
123
+ expect(result).toContain('&#39;')
124
+ })
125
+
126
+ it('should render text with embedded single quotes', async () => {
127
+ const result = await renderTemplate("<odac>it's working</odac>")
128
+ expect(result).toContain('it&#39;s working')
129
+ })
130
+
131
+ it('should render multiple single quotes', async () => {
132
+ const result = await renderTemplate("<odac>it's a developer's life</odac>")
133
+ expect(result).toContain('it&#39;s a developer&#39;s life')
134
+ })
135
+
136
+ it('should render escaped apostrophe in translation tags', async () => {
137
+ const result = await renderTemplate("<odac t>it's translated</odac>")
138
+ expect(result).toContain('it&#39;s translated')
139
+ })
140
+ })
141
+
142
+ describe('basic <odac> tag rendering', () => {
143
+ it('should render plain text inside <odac> tags', async () => {
144
+ const result = await renderTemplate('<odac>hello world</odac>')
145
+ expect(result).toContain('hello world')
146
+ })
147
+
148
+ it('should strip backend comments (multi-line)', async () => {
149
+ const result = await renderTemplate('visible<!--odac hidden odac-->visible2')
150
+ expect(result).toContain('visible')
151
+ expect(result).toContain('visible2')
152
+ expect(result).not.toContain('hidden')
153
+ })
154
+
155
+ it('should strip backend comments (single-line)', async () => {
156
+ const result = await renderTemplate('visible<!--odac hidden -->visible2')
157
+ expect(result).toContain('visible')
158
+ expect(result).toContain('visible2')
159
+ expect(result).not.toContain('hidden')
160
+ })
161
+
162
+ it('should handle empty <odac> tags gracefully', async () => {
163
+ const result = await renderTemplate('<odac></odac>')
164
+ expect(typeof result).toBe('string')
165
+ })
166
+ })
167
+
168
+ describe('special characters in <odac> tags', () => {
169
+ it('should handle double quotes inside <odac> tags', async () => {
170
+ const result = await renderTemplate('<odac>say "hello"</odac>')
171
+ expect(result).toContain('say')
172
+ })
173
+
174
+ it('should handle angle brackets in text (HTML entities)', async () => {
175
+ const result = await renderTemplate('<odac>&lt;div&gt;</odac>')
176
+ // Already-escaped entities get double-escaped by Odac.Var().html()
177
+ expect(result).toContain('&amp;lt;div&amp;gt;')
178
+ })
179
+ })
180
+ })