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
@@ -1,7 +1,11 @@
1
1
  const Route = require('../../src/Route')
2
+ const path = require('path')
3
+ const fs = require('fs')
4
+ const os = require('os')
2
5
 
3
6
  describe('Route.set()', () => {
4
7
  let route
8
+ let consoleSpy
5
9
 
6
10
  beforeEach(() => {
7
11
  route = new Route()
@@ -10,9 +14,11 @@ describe('Route.set()', () => {
10
14
  Config: {}
11
15
  }
12
16
  global.__dir = process.cwd()
17
+ consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
13
18
  })
14
19
 
15
20
  afterEach(() => {
21
+ consoleSpy.mockRestore()
16
22
  delete global.Odac
17
23
  delete global.__dir
18
24
  })
@@ -49,4 +55,100 @@ describe('Route.set()', () => {
49
55
  expect(route.routes.test_route.get['/test']).toBeDefined()
50
56
  expect(route.routes.test_route.get['/test/']).toBeUndefined()
51
57
  })
58
+
59
+ it('should log "Controller not found" when file does not exist', async () => {
60
+ global.Odac.Route.buff = 'test_route'
61
+
62
+ route.set('get', '/missing', 'nonexistent_controller')
63
+
64
+ await Promise.all(route._pendingRouteLoads)
65
+
66
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Controller not found'))
67
+ })
68
+
69
+ it('should log the actual error message when controller has a load error', async () => {
70
+ global.Odac.Route.buff = 'test_route'
71
+
72
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odac-test-'))
73
+ const controllerDir = path.join(tmpDir, 'controller', 'get')
74
+ fs.mkdirSync(controllerDir, {recursive: true})
75
+ fs.writeFileSync(path.join(controllerDir, 'broken.js'), "const x = require('nonexistent_module_xyz_12345');")
76
+
77
+ global.__dir = tmpDir
78
+
79
+ route.set('get', '/broken', 'broken')
80
+
81
+ await Promise.all(route._pendingRouteLoads)
82
+
83
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to load controller'))
84
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('nonexistent_module_xyz_12345'))
85
+
86
+ fs.rmSync(tmpDir, {recursive: true, force: true})
87
+ })
88
+
89
+ describe('hot reload', () => {
90
+ it('should update inline function reference on hot reload', async () => {
91
+ global.Odac.Route.buff = 'app'
92
+
93
+ const originalHandler = jest.fn(() => 'original')
94
+ route.set('get', '/api/test', originalHandler)
95
+ await Promise.all(route._pendingRouteLoads)
96
+ route._pendingRouteLoads = []
97
+
98
+ expect(route.routes.app.get['/api/test'].cache).toBe(originalHandler)
99
+ expect(route.routes.app.get['/api/test'].type).toBe('function')
100
+
101
+ const updatedHandler = jest.fn(() => 'updated')
102
+ route.set('get', '/api/test', updatedHandler)
103
+ await Promise.all(route._pendingRouteLoads)
104
+ route._pendingRouteLoads = []
105
+
106
+ expect(route.routes.app.get['/api/test'].cache).toBe(updatedHandler)
107
+ expect(route.routes.app.get['/api/test'].file).toBe(updatedHandler)
108
+ })
109
+
110
+ it('should update middlewares on inline function hot reload', async () => {
111
+ global.Odac.Route.buff = 'app'
112
+
113
+ const mw1 = jest.fn()
114
+ const handler = jest.fn()
115
+
116
+ route._pendingMiddlewares = [mw1]
117
+ route.set('get', '/mw-test', handler)
118
+ await Promise.all(route._pendingRouteLoads)
119
+ route._pendingRouteLoads = []
120
+ route._pendingMiddlewares = []
121
+
122
+ expect(route.routes.app.get['/mw-test'].middlewares).toEqual([mw1])
123
+
124
+ const mw2 = jest.fn()
125
+ route._pendingMiddlewares = [mw2]
126
+ const newHandler = jest.fn()
127
+ route.set('get', '/mw-test', newHandler)
128
+ await Promise.all(route._pendingRouteLoads)
129
+ route._pendingRouteLoads = []
130
+ route._pendingMiddlewares = []
131
+
132
+ expect(route.routes.app.get['/mw-test'].middlewares).toEqual([mw2])
133
+ expect(route.routes.app.get['/mw-test'].cache).toBe(newHandler)
134
+ })
135
+
136
+ it('should update token option on inline function hot reload', async () => {
137
+ global.Odac.Route.buff = 'app'
138
+
139
+ const handler = jest.fn()
140
+ route.set('get', '/token-test', handler, {token: false})
141
+ await Promise.all(route._pendingRouteLoads)
142
+ route._pendingRouteLoads = []
143
+
144
+ expect(route.routes.app.get['/token-test'].token).toBe(false)
145
+
146
+ const newHandler = jest.fn()
147
+ route.set('get', '/token-test', newHandler, {token: true})
148
+ await Promise.all(route._pendingRouteLoads)
149
+ route._pendingRouteLoads = []
150
+
151
+ expect(route.routes.app.get['/token-test'].token).toBe(true)
152
+ })
153
+ })
52
154
  })
@@ -0,0 +1,62 @@
1
+ const Image = require('../../../src/View/Image')
2
+
3
+ describe('Image.buildFilename()', () => {
4
+ test('should produce {name}-{width}-{hash}.{ext} format with width', () => {
5
+ const result = Image.buildFilename('/images/logo.jpg', {width: 250, format: 'webp'})
6
+ expect(result).toMatch(/^logo-250-[a-f0-9]{8}\.webp$/)
7
+ })
8
+
9
+ test('should use "o" for dimension when no width specified', () => {
10
+ const result = Image.buildFilename('/images/hero.png', {format: 'avif'})
11
+ expect(result).toMatch(/^hero-o-[a-f0-9]{8}\.avif$/)
12
+ })
13
+
14
+ test('should fall back to source extension when no format specified', () => {
15
+ const result = Image.buildFilename('/images/photo.png', {width: 800})
16
+ expect(result).toMatch(/^photo-800-[a-f0-9]{8}\.png$/)
17
+ })
18
+
19
+ test('should sanitize special characters in basename', () => {
20
+ const result = Image.buildFilename('/images/my photo (1).jpg', {width: 100, format: 'webp'})
21
+ expect(result).not.toContain(' ')
22
+ expect(result).not.toContain('(')
23
+ expect(result).toMatch(/^my_photo__1_-100-[a-f0-9]{8}\.webp$/)
24
+ })
25
+
26
+ test('should produce different filenames for same name in different directories', () => {
27
+ const a = Image.buildFilename('/images/blog/logo.jpg', {width: 250, format: 'webp'})
28
+ const b = Image.buildFilename('/images/brand/logo.jpg', {width: 250, format: 'webp'})
29
+ // Same basename but different hash due to different full src path
30
+ expect(a).not.toBe(b)
31
+ expect(a).toMatch(/^logo-250-/)
32
+ expect(b).toMatch(/^logo-250-/)
33
+ })
34
+
35
+ test('should produce different filenames for same file with different quality', () => {
36
+ const a = Image.buildFilename('/images/logo.jpg', {width: 250, quality: 80, format: 'webp'})
37
+ const b = Image.buildFilename('/images/logo.jpg', {width: 250, quality: 50, format: 'webp'})
38
+ expect(a).not.toBe(b)
39
+ })
40
+
41
+ test('should be deterministic for identical inputs', () => {
42
+ const opts = {width: 300, format: 'webp', quality: 80}
43
+ const a = Image.buildFilename('/images/hero.jpg', opts)
44
+ const b = Image.buildFilename('/images/hero.jpg', opts)
45
+ expect(a).toBe(b)
46
+ })
47
+
48
+ test('should handle nested paths correctly', () => {
49
+ const result = Image.buildFilename('/uploads/2024/03/cover.png', {width: 600, format: 'webp'})
50
+ expect(result).toMatch(/^cover-600-[a-f0-9]{8}\.webp$/)
51
+ })
52
+
53
+ test('should produce different filenames when mtime changes', () => {
54
+ const opts = {width: 250, format: 'webp'}
55
+ const a = Image.buildFilename('/images/logo.jpg', opts, 1000000)
56
+ const b = Image.buildFilename('/images/logo.jpg', opts, 2000000)
57
+ expect(a).not.toBe(b)
58
+ // Both should have same prefix, different hash
59
+ expect(a).toMatch(/^logo-250-/)
60
+ expect(b).toMatch(/^logo-250-/)
61
+ })
62
+ })
@@ -0,0 +1,59 @@
1
+ const Image = require('../../../src/View/Image')
2
+
3
+ describe('Image.hash()', () => {
4
+ test('should produce an 8-character hex string', () => {
5
+ const result = Image.hash('/images/hero.jpg', {width: 400, height: 300})
6
+ expect(result).toMatch(/^[a-f0-9]{8}$/)
7
+ })
8
+
9
+ test('should be deterministic for identical inputs', () => {
10
+ const options = {width: 800, format: 'webp', quality: 80}
11
+ const a = Image.hash('/images/photo.png', options)
12
+ const b = Image.hash('/images/photo.png', options)
13
+ expect(a).toBe(b)
14
+ })
15
+
16
+ test('should produce different hashes for different sources', () => {
17
+ const options = {width: 400}
18
+ const a = Image.hash('/images/a.jpg', options)
19
+ const b = Image.hash('/images/b.jpg', options)
20
+ expect(a).not.toBe(b)
21
+ })
22
+
23
+ test('should produce different hashes for different dimensions', () => {
24
+ const a = Image.hash('/images/hero.jpg', {width: 400})
25
+ const b = Image.hash('/images/hero.jpg', {width: 800})
26
+ expect(a).not.toBe(b)
27
+ })
28
+
29
+ test('should produce different hashes for different formats', () => {
30
+ const a = Image.hash('/images/hero.jpg', {format: 'webp'})
31
+ const b = Image.hash('/images/hero.jpg', {format: 'avif'})
32
+ expect(a).not.toBe(b)
33
+ })
34
+
35
+ test('should produce different hashes for different quality values', () => {
36
+ const a = Image.hash('/images/hero.jpg', {quality: 80})
37
+ const b = Image.hash('/images/hero.jpg', {quality: 50})
38
+ expect(a).not.toBe(b)
39
+ })
40
+
41
+ test('should handle empty options gracefully', () => {
42
+ const result = Image.hash('/images/hero.jpg')
43
+ expect(result).toMatch(/^[a-f0-9]{8}$/)
44
+ })
45
+
46
+ test('should produce different hashes when mtime changes', () => {
47
+ const opts = {width: 400, format: 'webp'}
48
+ const a = Image.hash('/images/hero.jpg', opts, 1000000)
49
+ const b = Image.hash('/images/hero.jpg', opts, 2000000)
50
+ expect(a).not.toBe(b)
51
+ })
52
+
53
+ test('should produce same hash for same mtime', () => {
54
+ const opts = {width: 400, format: 'webp'}
55
+ const a = Image.hash('/images/hero.jpg', opts, 1000000)
56
+ const b = Image.hash('/images/hero.jpg', opts, 1000000)
57
+ expect(a).toBe(b)
58
+ })
59
+ })
@@ -0,0 +1,15 @@
1
+ const Image = require('../../../src/View/Image')
2
+
3
+ describe('Image.isAvailable()', () => {
4
+ test('should return a boolean', () => {
5
+ // Reset memoized state by accessing the class fresh
6
+ const result = Image.isAvailable()
7
+ expect(typeof result).toBe('boolean')
8
+ })
9
+
10
+ test('should return consistent results on repeated calls', () => {
11
+ const first = Image.isAvailable()
12
+ const second = Image.isAvailable()
13
+ expect(first).toBe(second)
14
+ })
15
+ })
@@ -0,0 +1,83 @@
1
+ const Image = require('../../../src/View/Image')
2
+
3
+ describe('Image.parse()', () => {
4
+ test('should convert <odac:img> with static src to <script:odac> block', () => {
5
+ const input = '<odac:img src="/images/hero.jpg" width="200" />'
6
+ const result = Image.parse(input)
7
+
8
+ expect(result).toContain('<script:odac>')
9
+ expect(result).toContain('Odac.View.Image.render(')
10
+ expect(result).toContain('"src":"/images/hero.jpg"')
11
+ expect(result).toContain('"width":"200"')
12
+ expect(result).toContain('</script:odac>')
13
+ })
14
+
15
+ test('should convert {{ }} expressions to live JS with Odac.Var', () => {
16
+ const input = '<odac:img src="{{ post.cover }}" alt="{{ post.title }}" />'
17
+ const result = Image.parse(input)
18
+
19
+ expect(result).toContain('(await Odac.Var(await post.cover ).html())')
20
+ expect(result).toContain('(await Odac.Var(await post.title ).html())')
21
+ expect(result).not.toContain('{{')
22
+ expect(result).not.toContain('}}')
23
+ })
24
+
25
+ test('should convert {!! !!} expressions to raw JS', () => {
26
+ const input = '<odac:img src="{!! getImageUrl() !!}" />'
27
+ const result = Image.parse(input)
28
+
29
+ expect(result).toContain('(await getImageUrl() )')
30
+ expect(result).not.toContain('{!!')
31
+ expect(result).not.toContain('!!}')
32
+ })
33
+
34
+ test('should leave tag unchanged when src is missing', () => {
35
+ const input = '<odac:img alt="no source" />'
36
+ const result = Image.parse(input)
37
+
38
+ expect(result).toBe(input)
39
+ })
40
+
41
+ test('should not affect non-img odac tags', () => {
42
+ const input = '<odac var="title" /> <odac:img src="/a.jpg" />'
43
+ const result = Image.parse(input)
44
+
45
+ expect(result).toContain('<odac var="title" />')
46
+ expect(result).toContain('<script:odac>')
47
+ })
48
+
49
+ test('should handle self-closing tag without trailing slash', () => {
50
+ const input = '<odac:img src="/a.jpg">'
51
+ const result = Image.parse(input)
52
+
53
+ // Both <odac:img ... /> and <odac:img ...> are valid — parser handles both
54
+ expect(result).toContain('<script:odac>')
55
+ expect(result).toContain('"/a.jpg"')
56
+ })
57
+
58
+ test('should handle multiple img tags in one template', () => {
59
+ const input = ['<odac:img src="/a.jpg" width="100" />', '<p>text</p>', '<odac:img src="/b.png" format="avif" />'].join('\n')
60
+ const result = Image.parse(input)
61
+
62
+ const scriptCount = (result.match(/<script:odac>/g) || []).length
63
+ expect(scriptCount).toBe(2)
64
+ expect(result).toContain('"/a.jpg"')
65
+ expect(result).toContain('"/b.png"')
66
+ })
67
+
68
+ test('should preserve boolean attributes', () => {
69
+ const input = '<odac:img src="/a.jpg" loading />'
70
+ const result = Image.parse(input)
71
+
72
+ expect(result).toContain('"loading":true')
73
+ })
74
+
75
+ test('should handle mixed static and dynamic attributes', () => {
76
+ const input = '<odac:img src="{{ item.image }}" class="rounded" width="300" />'
77
+ const result = Image.parse(input)
78
+
79
+ expect(result).toContain('(await Odac.Var(await item.image ).html())')
80
+ expect(result).toContain('"class":"rounded"')
81
+ expect(result).toContain('"width":"300"')
82
+ })
83
+ })
@@ -0,0 +1,38 @@
1
+ const Image = require('../../../src/View/Image')
2
+
3
+ describe('Image.process()', () => {
4
+ test('should return null when sharp is unavailable', async () => {
5
+ const originalIsAvailable = Image.isAvailable
6
+ Image.isAvailable = () => false
7
+
8
+ try {
9
+ const result = await Image.process('/images/hero.jpg', {width: 400})
10
+ expect(result).toBeNull()
11
+ } finally {
12
+ Image.isAvailable = originalIsAvailable
13
+ }
14
+ })
15
+
16
+ test('should return null for unsupported source extensions', async () => {
17
+ if (!Image.isAvailable()) return
18
+
19
+ const result = await Image.process('/images/document.pdf', {width: 400})
20
+ expect(result).toBeNull()
21
+ })
22
+
23
+ test('should return null for non-existent source files', async () => {
24
+ if (!Image.isAvailable()) return
25
+
26
+ global.__dir = process.cwd()
27
+ const result = await Image.process('/nonexistent/image.jpg', {width: 400})
28
+ expect(result).toBeNull()
29
+ })
30
+
31
+ test('should return null for path traversal attempts', async () => {
32
+ if (!Image.isAvailable()) return
33
+
34
+ global.__dir = process.cwd()
35
+ const result = await Image.process('/../../../etc/passwd', {width: 400})
36
+ expect(result).toBeNull()
37
+ })
38
+ })
@@ -0,0 +1,117 @@
1
+ const Image = require('../../../src/View/Image')
2
+
3
+ describe('Image.render()', () => {
4
+ test('should return a standard <img> tag when sharp is unavailable', async () => {
5
+ const originalIsAvailable = Image.isAvailable
6
+ Image.isAvailable = () => false
7
+
8
+ try {
9
+ const html = await Image.render({
10
+ src: '/images/hero.jpg',
11
+ width: '400',
12
+ height: '300',
13
+ alt: 'Hero image',
14
+ class: 'rounded'
15
+ })
16
+
17
+ expect(html).toContain('<img src="/images/hero.jpg"')
18
+ expect(html).toContain('alt="Hero image"')
19
+ expect(html).toContain('class="rounded"')
20
+ expect(html).toContain('height="300"')
21
+ expect(html).toContain('width="400"')
22
+ expect(html).not.toContain('format=')
23
+ expect(html).not.toContain('quality=')
24
+ expect(html).toMatch(/>$/)
25
+ } finally {
26
+ Image.isAvailable = originalIsAvailable
27
+ }
28
+ })
29
+
30
+ test('should exclude processing attributes (format, quality) from HTML output', async () => {
31
+ const originalIsAvailable = Image.isAvailable
32
+ Image.isAvailable = () => false
33
+
34
+ try {
35
+ const html = await Image.render({
36
+ src: '/images/photo.png',
37
+ width: '800',
38
+ format: 'webp',
39
+ quality: '90',
40
+ alt: 'Photo'
41
+ })
42
+
43
+ expect(html).not.toContain('format=')
44
+ expect(html).not.toContain('quality=')
45
+ expect(html).toContain('alt="Photo"')
46
+ } finally {
47
+ Image.isAvailable = originalIsAvailable
48
+ }
49
+ })
50
+
51
+ test('should return fallback <img> when src is empty', async () => {
52
+ const html = await Image.render({src: '', alt: 'Empty'})
53
+ expect(html).toContain('<img src=""')
54
+ expect(html).toContain('alt="Empty"')
55
+ })
56
+
57
+ test('should render boolean attributes without value', async () => {
58
+ const originalIsAvailable = Image.isAvailable
59
+ Image.isAvailable = () => false
60
+
61
+ try {
62
+ const html = await Image.render({
63
+ src: '/images/hero.jpg',
64
+ loading: 'lazy',
65
+ decoding: 'async'
66
+ })
67
+
68
+ expect(html).toContain('loading="lazy"')
69
+ expect(html).toContain('decoding="async"')
70
+ } finally {
71
+ Image.isAvailable = originalIsAvailable
72
+ }
73
+ })
74
+
75
+ test('should output HTML attributes in alphabetical order', async () => {
76
+ const originalIsAvailable = Image.isAvailable
77
+ Image.isAvailable = () => false
78
+
79
+ try {
80
+ const html = await Image.render({
81
+ src: '/images/hero.jpg',
82
+ width: '400',
83
+ alt: 'Test',
84
+ class: 'img'
85
+ })
86
+
87
+ const altPos = html.indexOf('alt=')
88
+ const classPos = html.indexOf('class=')
89
+ const widthPos = html.indexOf('width=')
90
+
91
+ expect(altPos).toBeLessThan(classPos)
92
+ expect(classPos).toBeLessThan(widthPos)
93
+ } finally {
94
+ Image.isAvailable = originalIsAvailable
95
+ }
96
+ })
97
+
98
+ test('should escape HTML special characters in attribute values to prevent XSS', async () => {
99
+ const originalIsAvailable = Image.isAvailable
100
+ Image.isAvailable = () => false
101
+
102
+ try {
103
+ const html = await Image.render({
104
+ src: '/img.jpg" onload="alert(1)',
105
+ alt: '<script>xss</script>'
106
+ })
107
+
108
+ // Quotes are escaped so the injected onload never becomes a real attribute
109
+ expect(html).toContain('&quot; onload=&quot;')
110
+ expect(html).not.toContain('<script>')
111
+ expect(html).toContain('&quot;')
112
+ expect(html).toContain('&lt;script&gt;')
113
+ } finally {
114
+ Image.isAvailable = originalIsAvailable
115
+ }
116
+ })
117
+ })
@@ -0,0 +1,56 @@
1
+ const fs = require('fs')
2
+ const fsPromises = fs.promises
3
+ const path = require('path')
4
+ const Image = require('../../../src/View/Image')
5
+
6
+ const IMG_CACHE_DIR = './storage/.cache/img'
7
+
8
+ describe('Image.serve()', () => {
9
+ const testFilename = 'testserve1234567.webp'
10
+ const testFilePath = path.join(IMG_CACHE_DIR, testFilename)
11
+
12
+ beforeAll(async () => {
13
+ await fsPromises.mkdir(IMG_CACHE_DIR, {recursive: true})
14
+ await fsPromises.writeFile(testFilePath, Buffer.from('fake-image-data'))
15
+ })
16
+
17
+ afterAll(async () => {
18
+ await fsPromises.unlink(testFilePath).catch(() => {})
19
+ })
20
+
21
+ test('should return stream, type, and size for a cached file', async () => {
22
+ const result = await Image.serve(testFilename)
23
+
24
+ expect(result).not.toBeNull()
25
+ expect(result.type).toBe('image/webp')
26
+ expect(result.size).toBe(Buffer.from('fake-image-data').length)
27
+ expect(typeof result.stream.pipe).toBe('function')
28
+
29
+ result.stream.on('error', () => {})
30
+ result.stream.destroy()
31
+ })
32
+
33
+ test('should return null for non-existent files', async () => {
34
+ const result = await Image.serve('nonexistent00000.webp')
35
+ expect(result).toBeNull()
36
+ })
37
+
38
+ test('should block path traversal attempts', async () => {
39
+ const result = await Image.serve('../../../etc/passwd')
40
+ expect(result).toBeNull()
41
+ })
42
+
43
+ test('should resolve jpg extension to image/jpeg MIME type', async () => {
44
+ const jpgFilename = 'testservejpg0000.jpg'
45
+ const jpgPath = path.join(IMG_CACHE_DIR, jpgFilename)
46
+ await fsPromises.writeFile(jpgPath, Buffer.from('fake-jpg'))
47
+
48
+ const result = await Image.serve(jpgFilename)
49
+ expect(result).not.toBeNull()
50
+ expect(result.type).toBe('image/jpeg')
51
+ result.stream.on('error', () => {})
52
+ result.stream.destroy()
53
+
54
+ await fsPromises.unlink(jpgPath).catch(() => {})
55
+ })
56
+ })
@@ -0,0 +1,53 @@
1
+ const fs = require('fs')
2
+ const fsPromises = fs.promises
3
+ const path = require('path')
4
+ const Image = require('../../../src/View/Image')
5
+
6
+ describe('Image.url()', () => {
7
+ const publicDir = path.join(process.cwd(), 'public')
8
+ const testImageDir = path.join(publicDir, 'images')
9
+ const testImagePath = path.join(testImageDir, 'url-test.jpg')
10
+
11
+ beforeAll(async () => {
12
+ await fsPromises.mkdir(testImageDir, {recursive: true})
13
+ await fsPromises.writeFile(testImagePath, Buffer.from('fake-jpg-data'))
14
+ })
15
+
16
+ afterAll(async () => {
17
+ await fsPromises.unlink(testImagePath).catch(() => {})
18
+ })
19
+
20
+ test('should return empty string for empty src', async () => {
21
+ const result = await Image.url('')
22
+ expect(result).toBe('')
23
+ })
24
+
25
+ test('should return empty string for null/undefined src', async () => {
26
+ expect(await Image.url(null)).toBe('')
27
+ expect(await Image.url(undefined)).toBe('')
28
+ })
29
+
30
+ test('should return original src when sharp is unavailable', async () => {
31
+ // sharp is not installed in test env
32
+ const result = await Image.url('/images/url-test.jpg')
33
+ expect(result).toBe('/images/url-test.jpg')
34
+ })
35
+
36
+ test('should return original src when source file does not exist', async () => {
37
+ // Force isAvailable to true temporarily to test file-not-found path
38
+ const original = Image.isAvailable
39
+ Image.isAvailable = () => true
40
+
41
+ const result = await Image.url('/images/nonexistent.jpg')
42
+ expect(result).toBe('/images/nonexistent.jpg')
43
+
44
+ Image.isAvailable = original
45
+ })
46
+
47
+ test('should accept options parameter without error', async () => {
48
+ const result = await Image.url('/images/url-test.jpg', {width: 300, format: 'webp', quality: 80})
49
+ // Returns original src since sharp is unavailable in test
50
+ expect(typeof result).toBe('string')
51
+ expect(result.length).toBeGreaterThan(0)
52
+ })
53
+ })
@@ -19,4 +19,14 @@ describe('View.constructor()', () => {
19
19
  new View(mockOdac)
20
20
  expect(global.Odac.View.Form).toBeDefined()
21
21
  })
22
+
23
+ it('should set global.Odac.View.Image', () => {
24
+ new View(mockOdac)
25
+ expect(global.Odac.View.Image).toBeDefined()
26
+ })
27
+
28
+ it('should expose Image on the instance for compiled templates', () => {
29
+ const view = new View(mockOdac)
30
+ expect(view.Image).toBe(global.Odac.View.Image)
31
+ })
22
32
  })