odac 1.4.2 → 1.4.4

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 (48) hide show
  1. package/.agent/rules/coding.md +2 -2
  2. package/.github/workflows/release.yml +2 -0
  3. package/.husky/pre-push +0 -1
  4. package/.kiro/steering/coding.md +27 -0
  5. package/.kiro/steering/memory.md +56 -0
  6. package/.kiro/steering/project.md +30 -0
  7. package/.kiro/steering/workflow.md +16 -0
  8. package/CHANGELOG.md +99 -0
  9. package/README.md +1 -1
  10. package/client/odac.js +92 -15
  11. package/docs/ai/skills/backend/authentication.md +7 -5
  12. package/docs/ai/skills/backend/controllers.md +24 -3
  13. package/docs/ai/skills/backend/forms.md +8 -6
  14. package/docs/ai/skills/backend/image-processing.md +93 -0
  15. package/docs/ai/skills/backend/request_response.md +2 -2
  16. package/docs/ai/skills/backend/routing.md +11 -0
  17. package/docs/ai/skills/backend/structure.md +1 -1
  18. package/docs/ai/skills/frontend/realtime.md +18 -2
  19. package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +24 -0
  20. package/docs/backend/07-views/03-template-syntax.md +18 -2
  21. package/docs/backend/07-views/11-image-optimization.md +197 -0
  22. package/package.json +5 -2
  23. package/src/Auth.js +8 -4
  24. package/src/Config.js +5 -0
  25. package/src/Database/ConnectionFactory.js +16 -0
  26. package/src/Ipc.js +3 -2
  27. package/src/Lang.js +17 -10
  28. package/src/Odac.js +1 -0
  29. package/src/Request.js +20 -20
  30. package/src/Route.js +80 -33
  31. package/src/Validator.js +5 -5
  32. package/src/View/Image.js +495 -0
  33. package/src/View.js +4 -0
  34. package/test/Auth/verifyMagicLink.test.js +281 -0
  35. package/test/Client/ws.test.js +32 -0
  36. package/test/Lang/get.test.js +37 -11
  37. package/test/Odac/image.test.js +61 -0
  38. package/test/Route/check.test.js +101 -0
  39. package/test/Route/set.test.js +102 -0
  40. package/test/View/Image/buildFilename.test.js +62 -0
  41. package/test/View/Image/hash.test.js +59 -0
  42. package/test/View/Image/isAvailable.test.js +15 -0
  43. package/test/View/Image/parse.test.js +83 -0
  44. package/test/View/Image/process.test.js +38 -0
  45. package/test/View/Image/render.test.js +117 -0
  46. package/test/View/Image/serve.test.js +56 -0
  47. package/test/View/Image/url.test.js +53 -0
  48. package/test/View/constructor.test.js +10 -0
package/src/View.js CHANGED
@@ -3,6 +3,7 @@ const fs = require('fs')
3
3
  const fsPromises = fs.promises
4
4
  const Form = require('./View/Form')
5
5
  const EarlyHints = require('./View/EarlyHints')
6
+ const Image = require('./View/Image')
6
7
 
7
8
  const TITLE_REGEX = /<title[^>]*>([^<]*)<\/title>/i
8
9
 
@@ -119,7 +120,9 @@ class View {
119
120
  this.#earlyHints = global.Odac.View.EarlyHints
120
121
  }
121
122
  global.Odac.View.Form = Form
123
+ global.Odac.View.Image = Image
122
124
  this.Form = Form
125
+ this.Image = Image
123
126
  }
124
127
 
125
128
  all(name) {
@@ -404,6 +407,7 @@ class View {
404
407
 
405
408
  if (content !== null) {
406
409
  content = Form.parse(content, this.#odac)
410
+ content = Image.parse(content)
407
411
 
408
412
  const jsBlocks = []
409
413
  content = content.replace(/<script:odac([^>]*)>([\s\S]*?)<\/script:odac>/g, (match, attrs, jsContent) => {
@@ -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
+ })
@@ -83,4 +83,36 @@ describe('Odac.ws()', () => {
83
83
  socketInstance.onopen()
84
84
  expect(openHandler).toHaveBeenCalled()
85
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
+ })
86
118
  })
@@ -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.mkdirSync.mockImplementation(() => {})
26
- fs.writeFileSync.mockImplementation(() => {})
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.writeFileSync).toHaveBeenCalledWith(expect.stringContaining('/en.json'), expect.stringContaining('"new_key": "new_key"'))
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
  })
@@ -0,0 +1,61 @@
1
+ const Odac = require('../../src/Odac')
2
+
3
+ describe('Odac.image()', () => {
4
+ let mockOdac
5
+
6
+ beforeEach(() => {
7
+ mockOdac = {
8
+ Config: {request: {timeout: 1000}},
9
+ DB: {init: jest.fn(), close: jest.fn()},
10
+ Route: {
11
+ init: jest.fn(),
12
+ routes: {www: {}}
13
+ },
14
+ Storage: {get: jest.fn(), put: jest.fn()},
15
+ Env: {get: jest.fn()}
16
+ }
17
+ global.Odac = mockOdac
18
+ global.__dir = '/mock'
19
+ })
20
+
21
+ afterEach(() => {
22
+ delete global.Odac
23
+ delete global.__dir
24
+ })
25
+
26
+ test('should be available on instance without req/res (cron context)', () => {
27
+ const ctx = Odac.instance(null, 'cron')
28
+ expect(typeof ctx.image).toBe('function')
29
+ })
30
+
31
+ test('should be available on instance with req/res (controller context)', () => {
32
+ const mockReq = {
33
+ method: 'GET',
34
+ url: '/',
35
+ headers: {host: 'www.example.com'},
36
+ connection: {remoteAddress: '127.0.0.1'},
37
+ on: jest.fn()
38
+ }
39
+ const mockRes = {on: jest.fn()}
40
+ const ctx = Odac.instance('id', mockReq, mockRes)
41
+ expect(typeof ctx.image).toBe('function')
42
+ })
43
+
44
+ test('should return a promise', () => {
45
+ const ctx = Odac.instance(null, 'cron')
46
+ const result = ctx.image('/images/test.jpg', {width: 300})
47
+ expect(result).toBeInstanceOf(Promise)
48
+ })
49
+
50
+ test('should return original src when sharp is unavailable', async () => {
51
+ const ctx = Odac.instance(null, 'cron')
52
+ const result = await ctx.image('/images/test.jpg')
53
+ // sharp not installed in test env → returns original src
54
+ expect(result).toBe('/images/test.jpg')
55
+ })
56
+
57
+ test('should return empty string for empty src', async () => {
58
+ const ctx = Odac.instance(null, 'cron')
59
+ expect(await ctx.image('')).toBe('')
60
+ })
61
+ })
@@ -308,4 +308,105 @@ describe('Route.check()', () => {
308
308
  expect(paramHandler).not.toHaveBeenCalled()
309
309
  })
310
310
  })
311
+
312
+ describe('public static file serving', () => {
313
+ const fs = require('fs')
314
+ const path = require('path')
315
+
316
+ const createMockOdac = url => ({
317
+ Auth: {check: jest.fn().mockResolvedValue(true)},
318
+ Config: {debug: true},
319
+ Request: {
320
+ url,
321
+ method: 'get',
322
+ route: 'test_route',
323
+ header: jest.fn(),
324
+ cookie: jest.fn(() => null),
325
+ abort: jest.fn(),
326
+ setSession: jest.fn(),
327
+ data: {url: {}}
328
+ },
329
+ request: jest.fn().mockResolvedValue(null)
330
+ })
331
+
332
+ beforeEach(() => {
333
+ global.__dir = '/app'
334
+ })
335
+
336
+ afterEach(() => {
337
+ jest.restoreAllMocks()
338
+ })
339
+
340
+ it('should serve a public static file successfully', async () => {
341
+ const mockStat = jest.spyOn(fs.promises, 'stat').mockResolvedValue({isFile: () => true, size: 1024})
342
+ const mockStream = {pipe: jest.fn()}
343
+ const mockCreateReadStream = jest.spyOn(fs, 'createReadStream').mockReturnValue(mockStream)
344
+ const expectedPath = path.normalize('/app/public/style.css')
345
+
346
+ const mockOdac = createMockOdac('/style.css')
347
+ const result = await route.check(mockOdac)
348
+
349
+ expect(mockStat).toHaveBeenCalledWith(expectedPath)
350
+ expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Type', 'text/css')
351
+ expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Length', 1024)
352
+ expect(mockCreateReadStream).toHaveBeenCalledWith(expectedPath)
353
+ expect(result).toBe(mockStream)
354
+ })
355
+
356
+ it('should prevent path traversal attacks', async () => {
357
+ const mockStat = jest.spyOn(fs.promises, 'stat')
358
+
359
+ const mockOdac = createMockOdac('/../secrets.txt')
360
+ const result = await route.check(mockOdac)
361
+
362
+ expect(mockStat).not.toHaveBeenCalled()
363
+ expect(result).toBeUndefined() // Falls through if blocked
364
+ })
365
+
366
+ it('should prevent null byte injection attacks', async () => {
367
+ const mockStat = jest.spyOn(fs.promises, 'stat')
368
+
369
+ const mockOdac = createMockOdac('/style%00.css')
370
+ const result = await route.check(mockOdac)
371
+
372
+ expect(mockStat).not.toHaveBeenCalled()
373
+ expect(result).toBeUndefined()
374
+ })
375
+
376
+ it('should handle invalid URI encoding gracefully', async () => {
377
+ const mockStat = jest.spyOn(fs.promises, 'stat')
378
+
379
+ const mockOdac = createMockOdac('/%E0%A4%A') // Invalid URL encoded string
380
+ const result = await route.check(mockOdac)
381
+
382
+ expect(mockStat).not.toHaveBeenCalled()
383
+ expect(result).toBeUndefined()
384
+ })
385
+
386
+ it('should cache metadata in production mode', async () => {
387
+ jest.spyOn(fs.promises, 'stat').mockResolvedValue({isFile: () => true, size: 2048})
388
+ const mockCreateReadStream = jest.spyOn(fs, 'createReadStream').mockReturnValue('mock_stream')
389
+
390
+ const mockOdac = createMockOdac('/script.js')
391
+ mockOdac.Config.debug = false // prod mode
392
+
393
+ // first call (cache miss -> set cache)
394
+ await route.check(mockOdac)
395
+ expect(fs.promises.stat).toHaveBeenCalledTimes(1)
396
+ expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Type', 'text/javascript')
397
+
398
+ // reset mock tracking (cache hit -> no stat)
399
+ jest.clearAllMocks()
400
+ jest.spyOn(fs, 'createReadStream').mockReturnValue('mock_stream_2')
401
+
402
+ // second call
403
+ const result2 = await route.check(mockOdac)
404
+
405
+ expect(fs.promises.stat).not.toHaveBeenCalled() // Not called because metadata is cached
406
+ expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Type', 'text/javascript')
407
+ expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Length', 2048)
408
+ expect(mockCreateReadStream).toHaveBeenCalledWith(path.normalize('/app/public/script.js'))
409
+ expect(result2).toBe('mock_stream_2')
410
+ })
411
+ })
311
412
  })