odac 1.1.0 → 1.2.0

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 (113) hide show
  1. package/.agent/rules/coding.md +27 -0
  2. package/.agent/rules/memory.md +33 -0
  3. package/.agent/rules/project.md +30 -0
  4. package/.agent/rules/workflow.md +16 -0
  5. package/.github/workflows/release.yml +42 -1
  6. package/.github/workflows/test-coverage.yml +6 -5
  7. package/.github/workflows/test-publish.yml +36 -0
  8. package/.husky/pre-commit +10 -0
  9. package/.husky/pre-push +13 -0
  10. package/.releaserc.js +3 -3
  11. package/CHANGELOG.md +67 -0
  12. package/README.md +16 -0
  13. package/bin/odac.js +182 -40
  14. package/client/odac.js +10 -4
  15. package/docs/backend/01-overview/03-development-server.md +38 -45
  16. package/docs/backend/02-structure/01-typical-project-layout.md +59 -26
  17. package/docs/backend/03-config/00-configuration-overview.md +6 -6
  18. package/docs/backend/03-config/01-database-connection.md +2 -2
  19. package/docs/backend/03-config/02-static-route-mapping-optional.md +1 -1
  20. package/docs/backend/03-config/03-request-timeout.md +1 -1
  21. package/docs/backend/03-config/04-environment-variables.md +4 -4
  22. package/docs/backend/03-config/05-early-hints.md +2 -2
  23. package/docs/backend/04-routing/03-api-and-data-routes.md +18 -0
  24. package/docs/backend/04-routing/07-cron-jobs.md +17 -1
  25. package/docs/backend/05-controllers/01-how-to-build-a-controller.md +48 -3
  26. package/docs/backend/05-controllers/03-controller-classes.md +40 -20
  27. package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +17 -0
  28. package/docs/backend/07-views/10-styling-and-tailwind.md +93 -0
  29. package/docs/backend/08-database/01-getting-started.md +2 -2
  30. package/docs/backend/10-authentication/03-register.md +1 -1
  31. package/docs/backend/10-authentication/04-odac-register-forms.md +2 -2
  32. package/docs/backend/10-authentication/05-session-management.md +15 -1
  33. package/docs/backend/10-authentication/06-odac-login-forms.md +2 -2
  34. package/docs/backend/10-authentication/07-magic-links.md +1 -1
  35. package/docs/index.json +5 -1
  36. package/jest.config.js +1 -1
  37. package/package.json +9 -5
  38. package/src/Auth.js +58 -23
  39. package/src/Config.js +7 -7
  40. package/src/Env.js +3 -1
  41. package/src/Ipc.js +7 -0
  42. package/src/Lang.js +9 -2
  43. package/src/Odac.js +44 -35
  44. package/src/Request.js +1 -1
  45. package/src/Route/Cron.js +58 -17
  46. package/src/Route/Internal.js +1 -1
  47. package/src/Route.js +282 -99
  48. package/src/Server.js +40 -3
  49. package/src/Storage.js +4 -0
  50. package/src/Token.js +6 -4
  51. package/src/Validator.js +1 -1
  52. package/src/Var.js +22 -6
  53. package/src/View/EarlyHints.js +43 -33
  54. package/src/View/Form.js +17 -11
  55. package/src/View.js +62 -6
  56. package/template/package.json +3 -1
  57. package/template/view/content/home.html +3 -3
  58. package/template/view/head/main.html +2 -2
  59. package/test/Client.test.js +168 -0
  60. package/test/Config.test.js +112 -0
  61. package/test/Lang.test.js +92 -0
  62. package/test/Odac.test.js +86 -0
  63. package/test/{framework/middleware.test.js → Route/Middleware.test.js} +2 -2
  64. package/test/{framework/Route.test.js → Route.test.js} +1 -1
  65. package/test/{framework/View → View}/EarlyHints.test.js +1 -1
  66. package/test/{framework/WebSocket.test.js → WebSocket.test.js} +2 -2
  67. package/test/scripts/check-coverage.js +4 -4
  68. package/test/cli/Cli.test.js +0 -36
  69. package/test/core/Commands.test.js +0 -538
  70. package/test/core/Config.test.js +0 -1432
  71. package/test/core/Lang.test.js +0 -250
  72. package/test/core/Odac.test.js +0 -234
  73. package/test/core/Process.test.js +0 -156
  74. package/test/server/Api.test.js +0 -647
  75. package/test/server/DNS.test.js +0 -2050
  76. package/test/server/DNS.test.js.bak +0 -2084
  77. package/test/server/Hub.test.js +0 -497
  78. package/test/server/Log.test.js +0 -73
  79. package/test/server/Mail.account.test_.js +0 -460
  80. package/test/server/Mail.init.test_.js +0 -411
  81. package/test/server/Mail.test_.js +0 -1340
  82. package/test/server/SSL.test_.js +0 -1491
  83. package/test/server/Server.test.js +0 -765
  84. package/test/server/Service.test_.js +0 -1127
  85. package/test/server/Subdomain.test.js +0 -440
  86. package/test/server/Web/Firewall.test.js +0 -175
  87. package/test/server/Web/Proxy.test.js +0 -397
  88. package/test/server/Web.test.js +0 -1494
  89. package/test/server/__mocks__/acme-client.js +0 -17
  90. package/test/server/__mocks__/bcrypt.js +0 -50
  91. package/test/server/__mocks__/child_process.js +0 -389
  92. package/test/server/__mocks__/crypto.js +0 -432
  93. package/test/server/__mocks__/fs.js +0 -450
  94. package/test/server/__mocks__/globalOdac.js +0 -227
  95. package/test/server/__mocks__/http.js +0 -575
  96. package/test/server/__mocks__/https.js +0 -272
  97. package/test/server/__mocks__/index.js +0 -249
  98. package/test/server/__mocks__/mail/server.js +0 -100
  99. package/test/server/__mocks__/mail/smtp.js +0 -31
  100. package/test/server/__mocks__/mailparser.js +0 -81
  101. package/test/server/__mocks__/net.js +0 -369
  102. package/test/server/__mocks__/node-forge.js +0 -328
  103. package/test/server/__mocks__/os.js +0 -320
  104. package/test/server/__mocks__/path.js +0 -291
  105. package/test/server/__mocks__/selfsigned.js +0 -8
  106. package/test/server/__mocks__/server/src/mail/server.js +0 -100
  107. package/test/server/__mocks__/server/src/mail/smtp.js +0 -31
  108. package/test/server/__mocks__/smtp-server.js +0 -106
  109. package/test/server/__mocks__/sqlite3.js +0 -394
  110. package/test/server/__mocks__/testFactories.js +0 -299
  111. package/test/server/__mocks__/testHelpers.js +0 -363
  112. package/test/server/__mocks__/tls.js +0 -229
  113. /package/template/{config.json → odac.json} +0 -0
package/src/Storage.js CHANGED
@@ -22,6 +22,10 @@ class OdacStorage {
22
22
  this.db = open({
23
23
  path: dbPath,
24
24
  compression: true
25
+ // CLUSTER SAFETY NOTE:
26
+ // LMDB uses memory-mapped files with OS-level locking logic.
27
+ // Multiple workers can safely read/write to this DB simultaneously.
28
+ // Data committed by Worker A is immediately visible to Worker B.
25
29
  })
26
30
  this.ready = true
27
31
  } catch (error) {
package/src/Token.js CHANGED
@@ -1,6 +1,9 @@
1
1
  const nodeCrypto = require('crypto')
2
2
 
3
3
  class Token {
4
+ // CLUSTER SAFETY NOTE:
5
+ // This is a request-scoped local cache (debounce) for performance.
6
+ // Valid tokens represent state persisted in Session (LMDB), shared across all workers.
4
7
  confirmed = []
5
8
 
6
9
  constructor(Request) {
@@ -22,10 +25,9 @@ class Token {
22
25
 
23
26
  // - GENERATE TOKEN
24
27
  generate() {
25
- let token = nodeCrypto
26
- .createHash('md5')
27
- .update(this.Request.id + Date.now().toString() + Math.random().toString())
28
- .digest('hex')
28
+ // Enterprise Standard: Use CSPRNG (Cryptographically Secure Pseudo-Random Number Generator)
29
+ // Replaced weak MD5(Math.random) with randomBytes(32)
30
+ let token = nodeCrypto.randomBytes(32).toString('hex')
29
31
  let tokens = this.Request.session('_token') || []
30
32
  tokens.push(token)
31
33
  if (tokens.length > 50) tokens = tokens.slice(-50)
package/src/Validator.js CHANGED
@@ -278,7 +278,7 @@ class Validator {
278
278
  error = true
279
279
  } else {
280
280
  const userData = Odac.Auth.user(vars[1])
281
- if (Odac.Var(userData).is('bcrypt')) {
281
+ if (Odac.Var(userData).is('hash')) {
282
282
  error = !Odac.Var(userData).hashCheck(value)
283
283
  } else {
284
284
  error = value !== userData
package/src/Var.js CHANGED
@@ -1,6 +1,5 @@
1
1
  const fs = require('fs')
2
2
  const nodeCrypto = require('crypto')
3
- const bcrypt = require('bcrypt')
4
3
 
5
4
  class Var {
6
5
  #value = null
@@ -79,17 +78,34 @@ class Var {
79
78
  return encrypted.toString('base64')
80
79
  }
81
80
 
82
- hash(salt = 10) {
83
- return bcrypt.hashSync(this.#value, bcrypt.genSaltSync(salt))
81
+ hash() {
82
+ const salt = nodeCrypto.randomBytes(16).toString('hex')
83
+ const derivedKey = nodeCrypto.scryptSync(this.#value, salt, 64)
84
+ return `$scrypt$${salt}$${derivedKey.toString('hex')}`
84
85
  }
85
86
 
86
87
  hashCheck(check) {
87
- return bcrypt.compareSync(check, this.#value)
88
+ if (!this.#value.startsWith('$scrypt$')) return false
89
+ const parts = this.#value.split('$')
90
+ if (parts.length < 4) return false
91
+
92
+ const salt = parts[2]
93
+ const originalHash = Buffer.from(parts[3], 'hex')
94
+
95
+ const derivedKey = nodeCrypto.scryptSync(check, salt, 64)
96
+ return nodeCrypto.timingSafeEqual(originalHash, derivedKey)
88
97
  }
89
98
 
90
99
  html() {
91
100
  if (this.#value === null || this.#value === undefined) return ''
92
- return String(this.#value).replace(/</g, '&lt;').replace(/>/g, '&gt;')
101
+ const map = {
102
+ '&': '&amp;',
103
+ '<': '&lt;',
104
+ '>': '&gt;',
105
+ '"': '&quot;',
106
+ "'": '&#39;'
107
+ }
108
+ return String(this.#value).replace(/[&<>"']/g, m => map[m])
93
109
  }
94
110
 
95
111
  is(...args) {
@@ -102,7 +118,7 @@ class Var {
102
118
  if (args.includes('alphaspace')) result = (result || any) && ((any && result) || /^[A-Za-z\s]+$/.test(this.#value))
103
119
  if (args.includes('alphanumeric')) result = (result || any) && ((any && result) || /^[A-Za-z0-9]+$/.test(this.#value))
104
120
  if (args.includes('alphanumericspace')) result = (result || any) && ((any && result) || /^[A-Za-z0-9\s]+$/.test(this.#value))
105
- if (args.includes('bcrypt')) result = (result || any) && ((any && result) || /^\$2[ayb]\$.{56}$/.test(this.#value))
121
+ if (args.includes('hash')) result = (result || any) && ((any && result) || /^\$scrypt\$[a-f0-9]+\$[a-f0-9]+$/.test(this.#value))
106
122
  if (args.includes('date')) result = (result || any) && ((any && result) || !isNaN(Date.parse(this.#value)))
107
123
  if (args.includes('domain')) result = (result || any) && ((any && result) || /^([a-z0-9-]+\.){1,2}[a-z]{2,6}$/i.test(this.#value))
108
124
  if (args.includes('email'))
@@ -1,4 +1,4 @@
1
- const fs = require('fs')
1
+ const fs = require('fs').promises
2
2
  const path = require('path')
3
3
 
4
4
  class EarlyHints {
@@ -15,61 +15,71 @@ class EarlyHints {
15
15
  }
16
16
  }
17
17
 
18
- init() {
18
+ async init() {
19
19
  if (this.#initialized) return
20
20
  this.#initialized = true
21
21
 
22
22
  if (!this.#config.enabled) return
23
23
 
24
- this.#buildManifest()
24
+ await this.#buildManifest()
25
25
  }
26
26
 
27
- #buildManifest() {
27
+ async #buildManifest() {
28
28
  const viewDir = path.join(process.cwd(), 'view')
29
29
  const skeletonDir = path.join(process.cwd(), 'skeleton')
30
30
 
31
31
  try {
32
- if (fs.existsSync(viewDir)) {
33
- const files = this.#getAllViewFiles(viewDir)
34
- for (const file of files) {
35
- const html = fs.readFileSync(file, 'utf8')
36
- const resources = this.#extractResources(html)
37
-
38
- const relativePath = path.relative(viewDir, file)
39
- const viewName = 'view/' + relativePath.replace(/\.html$/, '').replace(/\\/g, '/')
40
-
41
- if (resources.length > 0) {
42
- this.#manifest[viewName] = resources
43
- }
44
- }
32
+ try {
33
+ await fs.access(viewDir)
34
+ const files = await this.#getAllViewFiles(viewDir)
35
+ await Promise.all(
36
+ files.map(async file => {
37
+ const html = await fs.readFile(file, 'utf8')
38
+ const resources = this.#extractResources(html)
39
+
40
+ const relativePath = path.relative(viewDir, file)
41
+ const viewName = 'view/' + relativePath.replace(/\.html$/, '').replace(/\\/g, '/')
42
+
43
+ if (resources.length > 0) {
44
+ this.#manifest[viewName] = resources
45
+ }
46
+ })
47
+ )
48
+ } catch {
49
+ // viewDir might not exist
45
50
  }
46
51
 
47
- if (fs.existsSync(skeletonDir)) {
48
- const files = this.#getAllViewFiles(skeletonDir)
49
- for (const file of files) {
50
- const html = fs.readFileSync(file, 'utf8')
51
- const resources = this.#extractResources(html)
52
-
53
- const relativePath = path.relative(skeletonDir, file)
54
- const viewName = 'skeleton/' + relativePath.replace(/\.html$/, '').replace(/\\/g, '/')
55
-
56
- if (resources.length > 0) {
57
- this.#manifest[viewName] = resources
58
- }
59
- }
52
+ try {
53
+ await fs.access(skeletonDir)
54
+ const files = await this.#getAllViewFiles(skeletonDir)
55
+ await Promise.all(
56
+ files.map(async file => {
57
+ const html = await fs.readFile(file, 'utf8')
58
+ const resources = this.#extractResources(html)
59
+
60
+ const relativePath = path.relative(skeletonDir, file)
61
+ const viewName = 'skeleton/' + relativePath.replace(/\.html$/, '').replace(/\\/g, '/')
62
+
63
+ if (resources.length > 0) {
64
+ this.#manifest[viewName] = resources
65
+ }
66
+ })
67
+ )
68
+ } catch {
69
+ // skeletonDir might not exist
60
70
  }
61
71
  } catch {
62
72
  // Silently fail, manifest building is optional
63
73
  }
64
74
  }
65
75
 
66
- #getAllViewFiles(dir, files = []) {
67
- const entries = fs.readdirSync(dir, {withFileTypes: true})
76
+ async #getAllViewFiles(dir, files = []) {
77
+ const entries = await fs.readdir(dir, {withFileTypes: true})
68
78
 
69
79
  for (const entry of entries) {
70
80
  const fullPath = path.join(dir, entry.name)
71
81
  if (entry.isDirectory()) {
72
- this.#getAllViewFiles(fullPath, files)
82
+ await this.#getAllViewFiles(fullPath, files)
73
83
  } else if (entry.isFile() && entry.name.endsWith('.html')) {
74
84
  files.push(fullPath)
75
85
  }
package/src/View/Form.js CHANGED
@@ -12,20 +12,26 @@ class Form {
12
12
 
13
13
  static parseFormType(content, Odac, type) {
14
14
  const regex = new RegExp(`<odac:${type}[\\s\\S]*?<\\/odac:${type}>`, 'g')
15
- const matches = content.match(regex)
16
- if (!matches) return content
15
+ return content.replace(regex, match => {
16
+ const formConfig = this.extractConfig(match, null, type)
17
+ const configStr = JSON.stringify(formConfig)
18
+ const matchStr = JSON.stringify(match)
19
+ return `<script:odac>html += await Odac.View.Form.runtime(Odac, '${type}', ${configStr}, ${matchStr});</script:odac>`
20
+ })
21
+ }
17
22
 
18
- for (const match of matches) {
19
- const formToken = nodeCrypto.randomBytes(32).toString('hex')
20
- const formConfig = this.extractConfig(match, formToken, type)
23
+ /**
24
+ * Generates the form at runtime to ensure a fresh token is created and stored
25
+ * in the current session for every request. This prevents "session expired"
26
+ * errors caused by caching the form token in the compiled view.
27
+ */
28
+ static async runtime(Odac, type, config, originalHtml) {
29
+ const token = nodeCrypto.randomBytes(32).toString('hex')
30
+ config.token = token
21
31
 
22
- this.storeConfig(formToken, formConfig, Odac, type)
32
+ this.storeConfig(token, config, Odac, type)
23
33
 
24
- const generatedForm = this.generateForm(match, formConfig, formToken, type)
25
- content = content.replace(match, generatedForm)
26
- }
27
-
28
- return content
34
+ return this.generateForm(originalHtml, config, token, type)
29
35
  }
30
36
 
31
37
  static extractConfig(html, formToken, type) {
package/src/View.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const nodeCrypto = require('crypto')
2
2
  const fs = require('fs')
3
+ const fsPromises = fs.promises
3
4
  const Form = require('./View/Form')
4
5
  const EarlyHints = require('./View/EarlyHints')
5
6
 
@@ -8,7 +9,6 @@ const TITLE_REGEX = /<title[^>]*>([^<]*)<\/title>/i
8
9
  const CACHE_DIR = './storage/.cache'
9
10
 
10
11
  class View {
11
- #cache = {}
12
12
  #earlyHints = null
13
13
  #functions = {
14
14
  '{!!': {
@@ -116,6 +116,8 @@ class View {
116
116
  } else {
117
117
  this.#earlyHints = global.Odac.View.EarlyHints
118
118
  }
119
+ global.Odac.View.Form = Form
120
+ this.Form = Form
119
121
  }
120
122
 
121
123
  all(name) {
@@ -205,8 +207,8 @@ class View {
205
207
 
206
208
  // Normal page rendering
207
209
  let result = ''
208
- if (this.#part.skeleton && fs.existsSync(`./skeleton/${this.#part.skeleton}.html`)) {
209
- result = fs.readFileSync(`./skeleton/${this.#part.skeleton}.html`, 'utf8')
210
+ if (this.#part.skeleton && (await this.#exists(`./skeleton/${this.#part.skeleton}.html`))) {
211
+ result = await this.#readSkeleton(`./skeleton/${this.#part.skeleton}.html`)
210
212
 
211
213
  // Add data-odac-navigate to content wrapper for auto-navigation
212
214
  result = this.#addNavigateAttribute(result)
@@ -363,10 +365,42 @@ class View {
363
365
  }
364
366
 
365
367
  async #render(file) {
366
- let mtime = fs.statSync(file).mtimeMs
367
- let content = fs.readFileSync(file, 'utf8')
368
+ if (!global.Odac.View) global.Odac.View = {}
369
+ if (!global.Odac.View.cache) global.Odac.View.cache = {}
370
+
371
+ // Performance: In Production, skip stat check if cached
372
+ if (!this.#odac.Config?.debug && global.Odac.View.cache[file]) {
373
+ try {
374
+ return await require(`${__dir}/${CACHE_DIR}/${global.Odac.View.cache[file].cache}`)(
375
+ this.#odac,
376
+ key => this.#odac.Request.get(key),
377
+ (...args) => this.#odac.Lang.get(...args)
378
+ )
379
+ } catch {
380
+ // Fallback if cache file missing
381
+ }
382
+ }
383
+
384
+ let mtime = 0
385
+ let content = null
386
+
387
+ try {
388
+ const handle = await fsPromises.open(file, 'r')
389
+ try {
390
+ const stats = await handle.stat()
391
+ mtime = stats.mtimeMs
392
+
393
+ if (global.Odac.View.cache[file]?.mtime !== mtime) {
394
+ content = await handle.readFile('utf8')
395
+ }
396
+ } finally {
397
+ await handle.close()
398
+ }
399
+ } catch {
400
+ return ''
401
+ }
368
402
 
369
- if (this.#cache[file]?.mtime !== mtime) {
403
+ if (content !== null) {
370
404
  content = Form.parse(content, this.#odac)
371
405
 
372
406
  const jsBlocks = []
@@ -550,6 +584,28 @@ class View {
550
584
  this.#odac.Request.setEarlyHints(hints)
551
585
  }
552
586
  }
587
+
588
+ async #exists(path) {
589
+ try {
590
+ await fsPromises.access(path)
591
+ return true
592
+ } catch {
593
+ return false
594
+ }
595
+ }
596
+
597
+ async #readSkeleton(path) {
598
+ if (!global.Odac.View.skeletons) global.Odac.View.skeletons = {}
599
+
600
+ // In production (debug=false), cache logic
601
+ if (!this.#odac.Config?.debug && global.Odac.View.skeletons[path]) {
602
+ return global.Odac.View.skeletons[path]
603
+ }
604
+
605
+ const content = await fsPromises.readFile(path, 'utf8')
606
+ global.Odac.View.skeletons[path] = content
607
+ return content
608
+ }
553
609
  }
554
610
 
555
611
  module.exports = View
@@ -3,7 +3,9 @@
3
3
  "version": "1.0.0",
4
4
  "description": "Website for {{domain_original}}",
5
5
  "scripts": {
6
- "dev": "odac dev"
6
+ "dev": "odac dev",
7
+ "build": "odac build",
8
+ "start": "odac start"
7
9
  },
8
10
  "dependencies": {
9
11
  "odac": "*"
@@ -60,9 +60,9 @@
60
60
 
61
61
  <div class="structure-item">
62
62
  <div class="structure-icon">⚙️</div>
63
- <h3 class="structure-title">config.json</h3>
63
+ <h3 class="structure-title">odac.json</h3>
64
64
  <p class="structure-description">Configuration file for database, routes, and other settings.</p>
65
- <code class="structure-path">config.json</code>
65
+ <code class="structure-path">odac.json</code>
66
66
  </div>
67
67
  </div>
68
68
  </div>
@@ -156,7 +156,7 @@
156
156
  <div class="feature-icon">🗄️</div>
157
157
  <div class="feature-content">
158
158
  <h3>Database Ready</h3>
159
- <p>MySQL and SQLite support built-in. Configure in <code>config.json</code></p>
159
+ <p>MySQL and SQLite support built-in. Configure in <code>odac.json</code></p>
160
160
  </div>
161
161
  </div>
162
162
 
@@ -1,5 +1,5 @@
1
1
  <meta charset="UTF-8" />
2
2
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
3
- <title>Odac</title>
3
+ <title>ODAC</title>
4
4
  <meta name="description" content="A next-generation server and framework toolkit for modern web development" />
5
- <link rel="stylesheet" href="/assets/css/style.css" />
5
+ <link rel="stylesheet" href="/assets/css/app.css" />
@@ -0,0 +1,168 @@
1
+ describe('Client (odac.js)', () => {
2
+ let mockXhr
3
+
4
+ beforeEach(() => {
5
+ jest.resetModules()
6
+
7
+ mockXhr = {
8
+ open: jest.fn(),
9
+ setRequestHeader: jest.fn(),
10
+ send: jest.fn(),
11
+ getResponseHeader: jest.fn(),
12
+ status: 200,
13
+ responseText: '{}',
14
+ response: '{}',
15
+ onload: null,
16
+ onerror: null
17
+ }
18
+
19
+ const mockDocument = {
20
+ getElementById: jest.fn(),
21
+ querySelectorAll: jest.fn(() => []),
22
+ querySelector: jest.fn(),
23
+ addEventListener: jest.fn(),
24
+ removeEventListener: jest.fn(),
25
+ dispatchEvent: jest.fn(),
26
+ documentElement: {dataset: {}},
27
+ cookie: '',
28
+ readyState: 'complete',
29
+ createElement: jest.fn(() => ({
30
+ setAttribute: jest.fn(),
31
+ style: {},
32
+ appendChild: jest.fn(),
33
+ parentNode: {insertBefore: jest.fn()}
34
+ }))
35
+ }
36
+
37
+ const mockWindow = {
38
+ location: {
39
+ protocol: 'http:',
40
+ host: 'localhost',
41
+ href: 'http://localhost/'
42
+ },
43
+ history: {
44
+ pushState: jest.fn()
45
+ },
46
+ scrollTo: jest.fn(),
47
+ addEventListener: jest.fn(),
48
+ XMLHttpRequest: jest.fn(() => mockXhr),
49
+ localStorage: {
50
+ getItem: jest.fn(),
51
+ setItem: jest.fn(),
52
+ removeItem: jest.fn()
53
+ },
54
+ CustomEvent: jest.fn((name, detail) => ({name, detail})),
55
+ setTimeout: jest.fn(),
56
+ clearTimeout: jest.fn(),
57
+ requestAnimationFrame: jest.fn(cb => cb(Date.now())),
58
+ WebSocket: jest.fn(() => ({
59
+ send: jest.fn(),
60
+ close: jest.fn(),
61
+ readyState: 1 // OPEN
62
+ })),
63
+ FormData: jest.fn()
64
+ }
65
+
66
+ mockWindow.window = mockWindow
67
+ mockWindow.document = mockDocument
68
+ mockWindow.WebSocket.OPEN = 1
69
+ mockWindow.WebSocket.CLOSED = 3
70
+
71
+ global.window = mockWindow
72
+ global.document = mockDocument
73
+ global.location = mockWindow.location
74
+ global.XMLHttpRequest = mockWindow.XMLHttpRequest
75
+ global.localStorage = mockWindow.localStorage
76
+ global.CustomEvent = mockWindow.CustomEvent
77
+ global.WebSocket = mockWindow.WebSocket
78
+ global.setTimeout = mockWindow.setTimeout
79
+ global.clearTimeout = mockWindow.clearTimeout
80
+ global.requestAnimationFrame = mockWindow.requestAnimationFrame
81
+ global.FormData = mockWindow.FormData
82
+
83
+ delete require.cache[require.resolve('../client/odac.js')]
84
+ require('../client/odac.js')
85
+ })
86
+
87
+ afterEach(() => {
88
+ delete global.window
89
+ delete global.document
90
+ delete global.location
91
+ delete global.XMLHttpRequest
92
+ delete global.localStorage
93
+ delete global.CustomEvent
94
+ delete global.WebSocket
95
+ delete global.setTimeout
96
+ delete global.clearTimeout
97
+ delete global.requestAnimationFrame
98
+ delete global.FormData
99
+ delete global.Odac
100
+ })
101
+
102
+ test('Odac should be initialized on window', () => {
103
+ expect(window.Odac).toBeDefined()
104
+ })
105
+
106
+ describe('data()', () => {
107
+ test('should retrieve data from odac-data script tag', () => {
108
+ const mockData = {user: 'emre'}
109
+ document.getElementById.mockReturnValue({
110
+ textContent: JSON.stringify(mockData)
111
+ })
112
+
113
+ const result = window.Odac.data()
114
+ expect(result).toEqual(mockData)
115
+ expect(document.getElementById).toHaveBeenCalledWith('odac-data')
116
+ })
117
+
118
+ test('should return specific key from data', () => {
119
+ const mockData = {user: 'emre', role: 'admin'}
120
+ document.getElementById.mockReturnValue({
121
+ textContent: JSON.stringify(mockData)
122
+ })
123
+
124
+ expect(window.Odac.data('user')).toBe('emre')
125
+ expect(window.Odac.data('role')).toBe('admin')
126
+ })
127
+ })
128
+
129
+ describe('storage()', () => {
130
+ test('should get item from localStorage', () => {
131
+ localStorage.getItem.mockReturnValue('val')
132
+ expect(window.Odac.storage('key')).toBe('val')
133
+ expect(localStorage.getItem).toHaveBeenCalledWith('key')
134
+ })
135
+
136
+ test('should set item in localStorage', () => {
137
+ window.Odac.storage('key', 'val')
138
+ expect(localStorage.setItem).toHaveBeenCalledWith('key', 'val')
139
+ })
140
+ })
141
+
142
+ describe('token()', () => {
143
+ test('should fetch token via sync XHR if hash is empty', () => {
144
+ mockXhr.response = JSON.stringify({token: 'new-token'})
145
+ document.cookie = 'odac_client=abc'
146
+
147
+ const token = window.Odac.token()
148
+
149
+ expect(window.XMLHttpRequest).toHaveBeenCalled()
150
+ expect(token).toBe('new-token')
151
+ })
152
+ })
153
+
154
+ describe('OdacWebSocket', () => {
155
+ test('should connect to WebSocket and handle events', () => {
156
+ const ws = window.Odac.ws('/test-ws', {token: false})
157
+ expect(window.WebSocket).toHaveBeenCalled()
158
+
159
+ const openHandler = jest.fn()
160
+ ws.on('open', openHandler)
161
+
162
+ const socketInstance = WebSocket.mock.results[0].value
163
+ socketInstance.onopen()
164
+
165
+ expect(openHandler).toHaveBeenCalled()
166
+ })
167
+ })
168
+ })
@@ -0,0 +1,112 @@
1
+ const fs = require('fs')
2
+ const os = require('os')
3
+
4
+ const Config = require('../src/Config')
5
+
6
+ jest.mock('fs')
7
+ jest.mock('os')
8
+
9
+ describe('Config', () => {
10
+ beforeEach(() => {
11
+ jest.clearAllMocks()
12
+ // Reset global.__dir which is used in Config.js
13
+ global.__dir = '/mock/project'
14
+
15
+ // Reset Config properties to defaults before each test
16
+ Config.system = undefined
17
+ Config.encrypt.key = 'odac'
18
+ })
19
+
20
+ describe('init', () => {
21
+ it('should load system config from home directory', () => {
22
+ os.homedir.mockReturnValue('/home/user')
23
+ fs.readFileSync.mockImplementation(path => {
24
+ if (path === '/home/user/.odac/config.json') {
25
+ return JSON.stringify({deviceId: '123'})
26
+ }
27
+ return '{}'
28
+ })
29
+ fs.existsSync.mockReturnValue(false)
30
+
31
+ Config.init()
32
+
33
+ expect(Config.system).toEqual({deviceId: '123'})
34
+ expect(fs.readFileSync).toHaveBeenCalledWith('/home/user/.odac/config.json')
35
+ })
36
+
37
+ it('should load project config and merge it', () => {
38
+ os.homedir.mockReturnValue('/home/user')
39
+ fs.existsSync.mockImplementation(path => {
40
+ if (path === '/mock/project/odac.json') return true
41
+ return false
42
+ })
43
+ fs.readFileSync.mockImplementation(path => {
44
+ if (path === '/mock/project/odac.json') {
45
+ return JSON.stringify({encrypt: {key: 'secret'}})
46
+ }
47
+ return '{}'
48
+ })
49
+
50
+ Config.init()
51
+
52
+ // The key gets hashed in init(), so it won't be 'secret' anymore
53
+ expect(Config.encrypt.key).not.toBe('secret')
54
+ expect(Config.encrypt.key).toBeInstanceOf(Buffer)
55
+ })
56
+
57
+ it('should interpolate variables in config', () => {
58
+ process.env.TEST_VAR = 'env_value'
59
+ os.homedir.mockReturnValue('/home/user')
60
+ fs.existsSync.mockReturnValue(true)
61
+ fs.readFileSync.mockReturnValue(
62
+ JSON.stringify({
63
+ custom: 'value-${TEST_VAR}'
64
+ })
65
+ )
66
+
67
+ Config.init()
68
+
69
+ expect(Config.custom).toBe('value-env_value')
70
+ })
71
+ })
72
+
73
+ describe('_interpolate', () => {
74
+ it('should replace ${VAR} with environment variables', () => {
75
+ process.env.FOO = 'bar'
76
+ const result = Config._interpolate('hello-${FOO}')
77
+ expect(result).toBe('hello-bar')
78
+ })
79
+
80
+ it('should replace ${odac} with client path', () => {
81
+ // __dirname in Config.js is /.../src, so it replaces /src with /client
82
+ const result = Config._interpolate('path-${odac}')
83
+ expect(result).toMatch(/\/client$/)
84
+ })
85
+
86
+ it('should handle nested objects and arrays', () => {
87
+ process.env.VAR = 'x'
88
+ const obj = {
89
+ a: ['${VAR}'],
90
+ b: {c: '${VAR}'}
91
+ }
92
+ const result = Config._interpolate(obj)
93
+ expect(result).toEqual({
94
+ a: ['x'],
95
+ b: {c: 'x'}
96
+ })
97
+ })
98
+ })
99
+
100
+ describe('_deepMerge', () => {
101
+ it('should merge objects deeply', () => {
102
+ const target = {a: {b: 1}, c: 2}
103
+ const source = {a: {d: 3}, e: 4}
104
+ Config._deepMerge(target, source)
105
+ expect(target).toEqual({
106
+ a: {b: 1, d: 3},
107
+ c: 2,
108
+ e: 4
109
+ })
110
+ })
111
+ })
112
+ })