decorator-dependency-injection 1.0.2 → 1.0.3

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.
@@ -1,4 +1,4 @@
1
- name: Create Release
1
+ name: Release
2
2
 
3
3
  on:
4
4
  push:
package/README.md CHANGED
@@ -1,11 +1,14 @@
1
1
  # Decorator Dependency Injection
2
- [![npm version](https://badge.fury.io/js/decorator-dependency-injection.svg)](http://badge.fury.io/js/decorator-dependency-injection)
3
- [![Build Status](https://github.com/mallocator/decorator-dependency-injection/actions/workflows/node.js.yml/badge.svg)](https://github.com/mallocator/decorator-dependency-injection/actions/workflows/node.js.yml)
4
2
 
3
+ [![npm version](https://badge.fury.io/js/decorator-dependency-injection.svg)](http://badge.fury.io/js/decorator-dependency-injection)
4
+ [![Build Status](https://github.com/mallocator/decorator-dependency-injection/actions/workflows/release.yml/badge.svg)](https://github.com/mallocator/decorator-dependency-injection/actions/workflows/release.yml)
5
5
 
6
6
  ## Description
7
7
 
8
- With [TC39](https://github.com/tc39/proposal-decorators) reaching stage 3 on the decorators proposal, it's time to start thinking about how we can use them in our projects. One of the most common patterns in JavaScript is dependency injection. This pattern is used to make our code more testable and maintainable. This library provides simple decorators to help you inject dependencies into your classes and mock them for testing.
8
+ With the [TC39 proposal-decorators](https://github.com/tc39/proposal-decorators) reaching stage 3, it's time to start
9
+ thinking about how we can use them in our projects. One of the most common patterns in JavaScript is dependency
10
+ injection. This pattern is used to make our code more testable and maintainable. This library provides simple decorators
11
+ to help you inject dependencies into your classes and mock them for testing.
9
12
 
10
13
  ## Installation
11
14
 
@@ -13,21 +16,26 @@ With [TC39](https://github.com/tc39/proposal-decorators) reaching stage 3 on the
13
16
  npm install decorator-dependency-injection
14
17
  ```
15
18
 
16
- Until we reach stage 4, you will need to enable the decorators proposal in your project. You can do this by adding the following babel transpiler options to your `.babelrc` file.
19
+ Until we reach stage 4, you will need to enable the decorators proposal in your project. You can do this by adding the
20
+ following babel transpiler options to your `.babelrc` file.
17
21
 
18
22
  ```json
19
23
  {
20
- "plugins": ["@babel/plugin-proposal-decorators"]
24
+ "plugins": [
25
+ "@babel/plugin-proposal-decorators"
26
+ ]
21
27
  }
22
28
  ```
23
29
 
24
- To run your project with decorators enabled you will need to use the babel transpiler. You can do this by running the following command in your project root.
30
+ To run your project with decorators enabled, you will need to use the babel transpiler. You can do this by running the
31
+ following command in your project root.
25
32
 
26
33
  ```bash
27
34
  npx babel-node index.js
28
35
  ```
29
36
 
30
- Finally, for running tests with decorators enabled you will need to use the babel-jest package. You can do this by adding the following configuration to your `package.json` file.
37
+ Finally, for running tests with decorators enabled, you will need to use the babel-jest package. You can do this by
38
+ adding the following configuration to your `package.json` file.
31
39
 
32
40
  ```json
33
41
  {
@@ -43,20 +51,21 @@ Other testing frameworks may require a different configuration.
43
51
 
44
52
  For a full example of how to set up a project with decorators, see this project's ```package.json``` file.
45
53
 
46
-
47
54
  ## Usage
48
55
 
49
- There are 2 ways of specifying injectable dependencies: ```@Singleton``` and ```@Factory```:
56
+ There are two ways of specifying injectable dependencies: ```@Singleton``` and ```@Factory```:
50
57
 
51
58
  ### Singleton
52
59
 
53
- The ```@Singleton``` decorator is used to inject a single instance of a dependency into a class. This is useful when you want to share the same instance of a class across multiple classes.
60
+ The ```@Singleton``` decorator is used to inject a single instance of a dependency into a class. This is useful when you
61
+ want to share the same instance of a class across multiple classes.
54
62
 
55
63
  ```javascript
56
- import { Singleton } from 'decorator-dependency-injection';
64
+ import {Singleton} from 'decorator-dependency-injection';
57
65
 
58
66
  @Singleton
59
- class Dependency {}
67
+ class Dependency {
68
+ }
60
69
 
61
70
  class Consumer {
62
71
  @Inject(Dependency) dependency // creates an instance only once
@@ -65,25 +74,48 @@ class Consumer {
65
74
 
66
75
  ### Factory
67
76
 
68
- The ```@Factory``` decorator is used to inject a new instance of a dependency into a class each time it is requested. This is useful when you want to create a new instance of a class each time it is injected.
77
+ The ```@Factory``` decorator is used to inject a new instance of a dependency into a class each time it is requested.
78
+ This is useful when you want to create a new instance of a class each time it is injected.
69
79
 
70
80
  ```javascript
71
- import { Factory } from 'decorator-dependency-injection';
81
+ import {Factory} from 'decorator-dependency-injection';
72
82
 
73
83
  @Factory
74
- class Dependency {}
84
+ class Dependency {
85
+ }
75
86
 
76
87
  class Consumer {
77
88
  @Inject(Dependency) dependency // creates a new instance each time a new Consumer is created
78
89
  }
79
90
  ```
80
91
 
92
+ ### LazyInject
93
+
94
+ ```@Inject``` annotated properties are evaluated during instance initialization. That means that all properties should
95
+ be accessible in the constructor. That also means that we're creating an instance no matter if you access the property
96
+ or not. If you want to only create an instance when you access the property, you can use the ```@LazyInject```
97
+ decorator. This will create the instance only when the property is accessed for the first time. Note that this also
98
+ works from the constructor, same as the regular ```@Inject```.
99
+
100
+ ```javascript
101
+ import {LazyInject} from 'decorator-dependency-injection';
102
+
103
+ @Singleton
104
+ class Dependency {
105
+ }
106
+
107
+ class Consumer {
108
+ @LazyInject(Dependency) dependency // creates an instance only when the property is accessed
109
+ }
110
+ ```
111
+
81
112
  ## Passing parameters to a dependency
82
113
 
83
- You can pass parameters to a dependency by using the ```@Inject``` decorator with a function that returns the dependency.
114
+ You can pass parameters to a dependency by using the ```@Inject``` decorator with a function that returns the
115
+ dependency.
84
116
 
85
117
  ```javascript
86
- import { Factory, Inject } from 'decorator-dependency-injection';
118
+ import {Factory, Inject} from 'decorator-dependency-injection';
87
119
 
88
120
  @Factory
89
121
  class Dependency {
@@ -98,14 +130,15 @@ class Consumer {
98
130
  }
99
131
  ```
100
132
 
101
- While this is most useful for Factory dependencies, it can also be used with Singleton dependencies. However, parameters will only be passed to the dependency the first time it is created.
133
+ While this is most useful for Factory dependencies, it can also be used with Singleton dependencies. However, parameters
134
+ will only be passed to the dependency the first time it is created.
102
135
 
103
136
  ## Mocking dependencies for testing
104
137
 
105
138
  You can mock dependencies by using the ```@Mock``` decorator with a function that returns the mock dependency.
106
139
 
107
140
  ```javascript
108
- import { Factory, Inject, Mock } from 'decorator-dependency-injection'
141
+ import {Factory, Inject, Mock} from 'decorator-dependency-injection'
109
142
 
110
143
  @Factory
111
144
  class Dependency {
@@ -138,10 +171,22 @@ resetMock(Dependency)
138
171
  const consumer = new Consumer() // prints 'real'
139
172
  ```
140
173
 
141
- You can also use the ```@Mock``` decorator as a proxy instead of a full mock. Any method calls not implemented in the mock will be passed to the real dependency.
174
+ ### Resetting Mocks
175
+
176
+ The `resetMock` utility function allows you to remove any active mock for a dependency and restore the original
177
+ implementation. This is useful for cleaning up after tests or switching between real and mock dependencies.
178
+
179
+ ```javascript
180
+ import {resetMock} from 'decorator-dependency-injection';
181
+
182
+ resetMock(Dependency); // Restores the original Dependency implementation
183
+ ```
184
+
185
+ You can also use the ```@Mock``` decorator as a proxy instead of a full mock. Any method calls not implemented in the
186
+ mock will be passed to the real dependency.
142
187
 
143
188
  ```javascript
144
- import { Factory, Inject, Mock } from 'decorator-dependency-injection'
189
+ import {Factory, Inject, Mock} from 'decorator-dependency-injection'
145
190
 
146
191
  @Factory
147
192
  class Dependency {
@@ -192,4 +237,5 @@ npm test
192
237
 
193
238
  - 1.0.0 - Initial release
194
239
  - 1.0.1 - Automated release with GitHub Actions
195
- - 1.0.2 - Added proxy option to @Mock decorator
240
+ - 1.0.2 - Added proxy option to @Mock decorator
241
+ - 1.0.3 - Added @LazyInject decorator
package/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * @typedef {Object} InstanceContext
3
- * @property {string} type The type of the instance, either 'singleton' or 'factory'
4
- * @property {Class} clazz The class of the instance
5
- * @property {Class} [originalClazz] The original class if it is a mock
6
- * @property {Object} [instance] The instance if it is a singleton
7
- * @property {Object} [originalInstance] The original instance if it is a mock
8
- * @property {boolean} [proxy=false] If true, the mock if the injection instance will be a proxy to the original class
3
+ * @property {'singleton'|'factory'} type - The type of the instance.
4
+ * @property {Function} clazz - The class constructor for the instance.
5
+ * @property {Function} [originalClazz] - The original class if this is a mock.
6
+ * @property {Object} [instance] - The singleton instance, if created.
7
+ * @property {Object} [originalInstance] - The original instance if this is a mock.
8
+ * @property {boolean} [proxy=false] - If true, the mock will proxy to the original class for undefined methods/properties.
9
9
  */
10
10
 
11
11
  /** @type {Map<string|Class, InstanceContext>} */
@@ -16,22 +16,26 @@ const instances = new Map()
16
16
  * Singleton instances only ever have one instance created via the @Inject decorator.
17
17
  *
18
18
  * @param {string} [name] The name of the singleton. If not provided, the class will be used as the key.
19
- * @return {(function(*, *): void)|*}
19
+ * @return {(function(Function, {kind: string}): void)}
20
20
  * @example @Singleton() class MySingleton {}
21
21
  * @example @Singleton('customName') class MySingleton {}
22
22
  * @throws {Error} If the injection target is not a class
23
- * @throws {Error} If a singleton with the same name is already defined
24
- * @throws {Error} If a factory with the same name is already defined
23
+ * @throws {Error} If a singleton or factory with the same name is already defined
24
+ * @throws {Error} If the target is not a class constructor
25
25
  */
26
26
  export function Singleton(name) {
27
27
  return function (clazz, context) {
28
- if (context.kind !== "class") {
28
+ if (context.kind !== 'class') {
29
29
  throw new Error('Invalid injection target')
30
30
  }
31
- if (instances.has(name ?? clazz)) {
32
- throw new Error('Instance with that name or class already instantiated')
31
+ if (typeof clazz !== 'function' || !clazz.prototype) {
32
+ throw new Error('Target must be a class constructor')
33
33
  }
34
- instances.set(name ?? clazz, { clazz, type: 'singleton' })
34
+ const key = name ?? clazz
35
+ if (instances.has(key)) {
36
+ throw new Error('A different class is already registered under this name. This may be possibly a circular dependency. Try using @InjectLazy')
37
+ }
38
+ instances.set(key, {clazz, type: 'singleton'})
35
39
  }
36
40
  }
37
41
 
@@ -40,22 +44,26 @@ export function Singleton(name) {
40
44
  * Factory instances are created via the @Inject decorator. Each call to the factory will create a new instance.
41
45
  *
42
46
  * @param {string} [name] The name of the factory. If not provided, the class will be used as the key.
43
- * @return {(function(*, *): void)|*}
47
+ * @return {(function(Function, {kind: string}): void)}
44
48
  * @example @Factory() class MyFactory {}
45
49
  * @example @Factory('customName') class MyFactory {}
46
50
  * @throws {Error} If the injection target is not a class
47
- * @throws {Error} If a factory with the same name is already defined
48
- * @throws {Error} If a singleton with the same name is already defined
51
+ * @throws {Error} If a factory or singleton with the same name is already defined
52
+ * @throws {Error} If the target is not a class constructor
49
53
  */
50
54
  export function Factory(name) {
51
55
  return function (clazz, context) {
52
- if (context.kind !== "class") {
56
+ if (context.kind !== 'class') {
53
57
  throw new Error('Invalid injection target')
54
58
  }
55
- if (instances.has(name ?? clazz)) {
56
- throw new Error('Instance with that name or class already instantiated')
59
+ if (typeof clazz !== 'function' || !clazz.prototype) {
60
+ throw new Error('Target must be a class constructor')
61
+ }
62
+ const key = name ?? clazz
63
+ if (instances.has(key)) {
64
+ throw new Error('A different class is already registered under this name, This may be possibly a circular dependency. Try using @InjectLazy')
57
65
  }
58
- instances.set(name ?? clazz, { clazz, type: 'factory' })
66
+ instances.set(key, {clazz, type: 'factory'})
59
67
  }
60
68
  }
61
69
 
@@ -65,52 +73,67 @@ export function Factory(name) {
65
73
  *
66
74
  * @param {string|Class} clazzOrName The singleton or factory class or name
67
75
  * @param {*} params Parameters to pass to the constructor. Recommended to use only with factories.
68
- * @return {(function(*): void)|*}
76
+ * @return {(function(*, {kind: string, name: string}): void)}
69
77
  * @example @Inject(MySingleton) mySingleton
70
78
  * @example @Inject("myCustomName") myFactory
71
79
  * @throws {Error} If the injection target is not a field
72
80
  * @throws {Error} If the injected field is assigned a value
73
81
  */
74
82
  export function Inject(clazzOrName, ...params) {
75
- return function(initialValue, context) {
76
- if (context.kind === "field") {
77
- return function(initialValue) {
78
- if (initialValue) {
79
- throw new Error('Cannot assign value to injected field')
80
- }
81
- const instanceContext = getContext(clazzOrName)
82
-
83
- if (instanceContext.instance) {
84
- return instanceContext.instance
85
- }
86
-
87
- const instance = new instanceContext.clazz(...params)
88
-
89
- if (instanceContext.type === 'singleton') {
90
- if (instanceContext.originalClazz && instanceContext.proxy) {
91
- instanceContext.instance = getProxy(instance, new instanceContext.originalClazz(...params))
92
- } else {
93
- instanceContext.instance = instance
94
- }
95
- return instanceContext.instance
96
- }
97
-
98
- if (instanceContext.type === 'factory') {
99
- if (instanceContext.originalClazz && instanceContext.proxy) {
100
- return getProxy(instance, new instanceContext.originalClazz(...params))
101
- } else {
102
- return instance
103
- }
104
- }
105
-
106
- throw new Error('Unexpected injection type')
83
+ return function (initialValue, context) {
84
+ if (context.kind !== 'field') {
85
+ throw new Error('Invalid injection target')
86
+ }
87
+ return function (initialValue) {
88
+ if (initialValue) {
89
+ throw new Error('Cannot assign value to injected field')
107
90
  }
108
- } else {
91
+ const instanceContext = getContext(clazzOrName)
92
+ return getInjectedInstance(instanceContext, params)
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Inject a singleton or factory instance lazily into a class field. You can also provide parameters to the constructor.
99
+ * If the instance is a singleton, it will only be created once with the first set of parameters it encounters.
100
+ * @param {string|Class} clazzOrName The singleton or factory class or name
101
+ * @param {*} params Parameters to pass to the constructor. Recommended to use only with factories.
102
+ * @return {(function(*, {kind: string, name: string, addInitializer: Function}): void)}
103
+ * @example @InjectLazy(MySingleton) mySingleton
104
+ * @example @InjectLazy("myCustomName") myFactory
105
+ * @throws {Error} If the injection target is not a field
106
+ * @throws {Error} If the injected field is assigned a value
107
+ */
108
+ export function InjectLazy(clazzOrName, ...params) {
109
+ const cache = new WeakMap()
110
+ return (initialValue, context) => {
111
+ if (context.kind !== 'field') {
109
112
  throw new Error('Invalid injection target')
110
113
  }
114
+ context.addInitializer(function () {
115
+ Object.defineProperty(this, context.name, {
116
+ get() {
117
+ if (!cache.has(this)) {
118
+ const instanceContext = getContext(clazzOrName)
119
+ const value = getInjectedInstance(instanceContext, params)
120
+ cache.set(this, value)
121
+ }
122
+ return cache.get(this)
123
+ },
124
+ configurable: true,
125
+ enumerable: true
126
+ })
127
+ })
111
128
  }
112
129
  }
113
130
 
131
+ /**
132
+ * Get a proxy for the mock instance. This allows the mock to call methods on the original class if they are not defined in the mock.
133
+ * @param {Object} mock The mock instance
134
+ * @param {Object} original The original class instance
135
+ * @return {*|object} The proxy instance
136
+ */
114
137
  function getProxy(mock, original) {
115
138
  return new Proxy(mock, {
116
139
  get(target, prop, receiver) {
@@ -126,20 +149,20 @@ function getProxy(mock, original) {
126
149
  * Mark a class as a mock. This will replace the class with a mock instance when injected.
127
150
  * @param {string|Class} mockedClazzOrName The singleton or factory class or name to be mocked
128
151
  * @param {boolean} [proxy=false] If true, the mock will be a proxy to the original class. Any methods not defined in the mock will be called on the original class.
129
- * @return {(function(*, *): void)|*}
152
+ * @return {(function(Function, {kind: string}): void)}
130
153
  * @example @Mock(MySingleton) class MyMock {}
131
154
  * @example @Mock("myCustomName") class MyMock {}
132
155
  * @throws {Error} If the injection target is not a class
133
156
  * @throws {Error} If the injection source is not found
134
157
  */
135
158
  export function Mock(mockedClazzOrName, proxy = false) {
136
- return function(clazz, context) {
137
- if (context.kind !== "class") {
159
+ return function (clazz, context) {
160
+ if (context.kind !== 'class') {
138
161
  throw new Error('Invalid injection target')
139
162
  }
140
163
  const instanceContext = getContext(mockedClazzOrName)
141
164
  if (instanceContext.originalClazz) {
142
- throw new Error('Mock already defined, reset before mocking again')
165
+ throw new Error('Mock already defined, reset before mocking again')
143
166
  }
144
167
  instanceContext.originalClazz = instanceContext.clazz
145
168
  instanceContext.proxy = proxy
@@ -147,11 +170,22 @@ export function Mock(mockedClazzOrName, proxy = false) {
147
170
  }
148
171
  }
149
172
 
173
+ /**
174
+ * Internal: Get the context for a given class or name.
175
+ *
176
+ * @param {string|Class} mockedClazzOrName - The class or name to look up.
177
+ * @returns {InstanceContext}
178
+ * @throws {Error} If the context is not found.
179
+ */
150
180
  function getContext(mockedClazzOrName) {
151
181
  if (instances.has(mockedClazzOrName)) {
152
182
  return instances.get(mockedClazzOrName)
153
183
  } else {
154
- throw new Error('Cannot find injection source with the provided name')
184
+ const available = Array.from(instances.keys()).map(k => typeof k === 'string' ? k : k.name).join(', ')
185
+ throw new Error(
186
+ `Cannot find injection source for "${mockedClazzOrName?.name || mockedClazzOrName}". ` +
187
+ `Available: [${available}]`
188
+ )
155
189
  }
156
190
  }
157
191
 
@@ -160,7 +194,7 @@ function getContext(mockedClazzOrName) {
160
194
  */
161
195
  export function resetMocks() {
162
196
  for (const instanceContext of instances.values()) {
163
- reset(instanceContext)
197
+ restoreOriginal(instanceContext)
164
198
  }
165
199
  }
166
200
 
@@ -169,7 +203,7 @@ export function resetMocks() {
169
203
  * @param {string|Class} clazzOrName The singleton or factory class or name to reset
170
204
  */
171
205
  export function resetMock(clazzOrName) {
172
- reset(getContext(clazzOrName))
206
+ restoreOriginal(getContext(clazzOrName))
173
207
  }
174
208
 
175
209
  /**
@@ -177,14 +211,46 @@ export function resetMock(clazzOrName) {
177
211
  * @param {InstanceContext} instanceContext The instance context to reset
178
212
  * @private
179
213
  */
180
- function reset(instanceContext) {
214
+ function restoreOriginal(instanceContext) {
181
215
  if (!instanceContext) {
182
216
  throw new Error('Cannot find injection source with the provided name')
183
217
  }
184
218
  if (instanceContext.originalClazz) {
185
219
  instanceContext.clazz = instanceContext.originalClazz
186
- instanceContext.instance = instanceContext.originalInstance
220
+ delete instanceContext.instance
187
221
  delete instanceContext.originalClazz
188
222
  delete instanceContext.originalInstance
189
223
  }
190
224
  }
225
+
226
+ /**
227
+ * Get the injected instance based on the context and parameters.
228
+ * @param {InstanceContext} instanceContext The instance context
229
+ * @param {Array} params The parameters to pass to the constructor
230
+ * @return {Object} The injected instance
231
+ */
232
+ function getInjectedInstance(instanceContext, params) {
233
+ if (instanceContext.type === 'singleton' && !instanceContext.originalClazz && instanceContext.instance) {
234
+ return instanceContext.instance
235
+ }
236
+ let instance
237
+ try {
238
+ instance = new instanceContext.clazz(...params)
239
+ } catch (err) {
240
+ if (err instanceof RangeError) {
241
+ throw new Error(
242
+ `Circular dependency detected for ${instanceContext.clazz.name || instanceContext.clazz}. ` +
243
+ `Use @InjectLazy to break the cycle.`
244
+ )
245
+ }
246
+ throw err
247
+ }
248
+ if (instanceContext.proxy && instanceContext.originalClazz) {
249
+ const originalInstance = new instanceContext.originalClazz(...params)
250
+ instance = getProxy(instance, originalInstance)
251
+ }
252
+ if (instanceContext.type === 'singleton') {
253
+ instanceContext.instance = instance
254
+ }
255
+ return instance
256
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decorator-dependency-injection",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "A simple library for dependency injection using decorators",
5
5
  "author": "Ravi Gairola <mallox@pyxzl.net>",
6
6
  "license": "Apache-2.0",
@@ -1,4 +1,4 @@
1
- import {Factory, Inject, Singleton} from '../index.js'
1
+ import {Factory, Inject, resetMocks, Singleton} from '../index.js'
2
2
 
3
3
  describe('Injection via fields', () => {
4
4
  @Singleton()
@@ -46,6 +46,12 @@ describe('Injection via fields', () => {
46
46
  }
47
47
  }
48
48
 
49
+ afterEach(() => {
50
+ TestFactory.calls = 0
51
+ TestSingleton.calls = 0
52
+ resetMocks()
53
+ })
54
+
49
55
  it('should inject factory', () => {
50
56
  class TestInjectionFactory {
51
57
  @Inject(TestFactory) testFactory
@@ -83,7 +89,53 @@ describe('Injection via fields', () => {
83
89
  new TestInjectionFactoryParams()
84
90
  })
85
91
 
86
- @Singleton("named")
92
+ it('should cache factory instance on repeated accesses', () => {
93
+ class TestRepeatedFactoryAccess {
94
+ @Inject(TestFactory) testFactory
95
+
96
+ constructor() {
97
+ const instance1 = this.testFactory
98
+ const instance2 = this.testFactory
99
+ expect(instance1).toBe(instance2)
100
+ }
101
+ }
102
+
103
+ new TestRepeatedFactoryAccess()
104
+ })
105
+
106
+ it('should create distinct factory instances for different fields in the same object', () => {
107
+ class TestMultipleFactoryInjection {
108
+ @Inject(TestFactory) testFactory1
109
+ @Inject(TestFactory) testFactory2
110
+
111
+ constructor() {
112
+ // Access both properties to trigger initialization.
113
+ const one = this.testFactory1
114
+ const two = this.testFactory2
115
+ expect(one).not.toBe(two)
116
+ }
117
+ }
118
+
119
+ new TestMultipleFactoryInjection()
120
+ })
121
+
122
+ it('should inject the same singleton instance for different fields in the same object', () => {
123
+ class TestMultipleSingletonInjection {
124
+ @Inject(TestSingleton) testSingleton1
125
+ @Inject(TestSingleton) testSingleton2
126
+
127
+ constructor() {
128
+ // Access both properties to trigger initialization.
129
+ const one = this.testSingleton1
130
+ const two = this.testSingleton2
131
+ expect(one).toBe(two)
132
+ }
133
+ }
134
+
135
+ new TestMultipleSingletonInjection()
136
+ })
137
+
138
+ @Singleton('named')
87
139
  class NamedSingleton {
88
140
  static calls = 0
89
141
 
@@ -94,7 +146,7 @@ describe('Injection via fields', () => {
94
146
 
95
147
  it('should inject named singleton', () => {
96
148
  class TestInjectionNamedSingleton {
97
- @Inject("named") namedSingleton
149
+ @Inject('named') namedSingleton
98
150
 
99
151
  constructor() {
100
152
  expect(this.namedSingleton).toBeInstanceOf(NamedSingleton)
@@ -103,7 +155,7 @@ describe('Injection via fields', () => {
103
155
  }
104
156
 
105
157
  class TestInjectionNamedSingleton2 {
106
- @Inject("named") namedSingleton
158
+ @Inject('named') namedSingleton
107
159
 
108
160
  constructor() {
109
161
  expect(this.namedSingleton).toBeInstanceOf(NamedSingleton)
@@ -115,7 +167,7 @@ describe('Injection via fields', () => {
115
167
  new TestInjectionNamedSingleton2()
116
168
  })
117
169
 
118
- @Factory("named2")
170
+ @Factory('named2')
119
171
  class NamedFactory {
120
172
  static calls = 0
121
173
  params
@@ -128,7 +180,7 @@ describe('Injection via fields', () => {
128
180
 
129
181
  it('should inject named factory', () => {
130
182
  class TestInjectionNamedFactory {
131
- @Inject("named2") namedFactory
183
+ @Inject('named2') namedFactory
132
184
 
133
185
  constructor() {
134
186
  expect(this.namedFactory).toBeInstanceOf(NamedFactory)
@@ -137,7 +189,7 @@ describe('Injection via fields', () => {
137
189
  }
138
190
 
139
191
  class TestInjectionNamedFactory2 {
140
- @Inject("named2") namedFactory
192
+ @Inject('named2') namedFactory
141
193
 
142
194
  constructor() {
143
195
  expect(this.namedFactory).toBeInstanceOf(NamedFactory)
@@ -149,4 +201,109 @@ describe('Injection via fields', () => {
149
201
  new TestInjectionNamedFactory2()
150
202
  expect(result.namedFactory.params).toEqual([])
151
203
  })
204
+
205
+ it('should cache named factory instance on repeated accesses', () => {
206
+ class TestRepeatedNamedFactoryAccess {
207
+ @Inject('named2') namedFactory
208
+
209
+ constructor() {
210
+ const instance1 = this.namedFactory
211
+ const instance2 = this.namedFactory
212
+ expect(instance1).toBe(instance2)
213
+ }
214
+ }
215
+
216
+ new TestRepeatedNamedFactoryAccess()
217
+ })
218
+
219
+ it('should throw if @Inject is applied to a method', () => {
220
+ expect(() => {
221
+ // noinspection JSUnusedLocalSymbols
222
+ class BadInjection {
223
+ @Inject('something')
224
+ someMethod() {
225
+ }
226
+ }
227
+ }).toThrow()
228
+ })
229
+
230
+ it('should handle circular dependencies gracefully', () => {
231
+ @Singleton()
232
+ class A {
233
+ @Inject('B') b
234
+ }
235
+
236
+ @Singleton('B')
237
+ class B {
238
+ @Inject(A) a
239
+ }
240
+
241
+ expect(() => new A()).toThrow(/Circular dependency detected.*@InjectLazy/)
242
+ })
243
+
244
+ it('should throw if decorator is used on non-class object', () => {
245
+ expect(() => {
246
+ const obj = {}
247
+ Inject('something')(obj, 'field')
248
+ }).toThrow()
249
+ })
250
+
251
+ it('should throw a helpful error for eager circular dependencies', () => {
252
+ @Factory()
253
+ class A2 {
254
+ @Inject('B2') b
255
+ }
256
+
257
+ @Singleton('B2')
258
+ class B2 {
259
+ @Inject(A2) a
260
+ }
261
+
262
+ expect(() => new A2()).toThrow(/Circular dependency detected.*@InjectLazy/)
263
+ })
264
+
265
+ it('should inject into symbol-named fields', () => {
266
+ const sym = Symbol('sym')
267
+
268
+ @Singleton()
269
+ class S {
270
+ }
271
+
272
+ class Test {
273
+ @Inject(S) [sym]
274
+ }
275
+
276
+ const t = new Test()
277
+ expect(t[sym]).toBeInstanceOf(S)
278
+ })
279
+
280
+ it('should not leak injected properties to prototype', () => {
281
+ @Singleton()
282
+ class S {
283
+ }
284
+
285
+ class Test {
286
+ @Inject(S) dep
287
+ }
288
+
289
+ // noinspection JSUnusedLocalSymbols
290
+ const t = new Test()
291
+ expect(Object.prototype.hasOwnProperty.call(Test.prototype, 'dep')).toBe(false)
292
+ })
293
+
294
+ it('should handle undefined/null/complex params in factory', () => {
295
+ @Factory()
296
+ class F {
297
+ constructor(...params) {
298
+ this.params = params
299
+ }
300
+ }
301
+
302
+ class Test {
303
+ @Inject(F, undefined, null, {a: 1}) dep
304
+ }
305
+
306
+ const t = new Test()
307
+ expect(t.dep.params).toEqual([undefined, null, {a: 1}])
308
+ })
152
309
  })
@@ -0,0 +1,249 @@
1
+ import {Factory, InjectLazy, resetMocks, Singleton} from '../index.js'
2
+
3
+ describe('Lazy Injection via fields', () => {
4
+ @Singleton()
5
+ class TestLazySingleton {
6
+ static calls = 0
7
+
8
+ constructor() {
9
+ TestLazySingleton.calls++
10
+ }
11
+ }
12
+
13
+ it('should lazily inject singleton', () => {
14
+ class TestLazySingletonInjection {
15
+ @InjectLazy(TestLazySingleton) testSingleton
16
+
17
+ constructor() {
18
+ }
19
+ }
20
+
21
+ const instance = new TestLazySingletonInjection()
22
+ expect(TestLazySingleton.calls).toBe(0) // Not constructed until access
23
+ const first = instance.testSingleton
24
+ expect(first).toBeInstanceOf(TestLazySingleton)
25
+ expect(TestLazySingleton.calls).toBe(1)
26
+ // Repeated access returns the same instance.
27
+ const second = instance.testSingleton
28
+ expect(first).toBe(second)
29
+ })
30
+
31
+ @Factory()
32
+ class TestLazyFactory {
33
+ static calls = 0
34
+ params
35
+
36
+ constructor(...params) {
37
+ TestLazyFactory.calls++
38
+ this.params = params
39
+ }
40
+ }
41
+
42
+ afterEach(() => {
43
+ TestLazyFactory.calls = 0
44
+ TestLazySingleton.calls = 0
45
+ resetMocks()
46
+ })
47
+
48
+ it('should lazily inject factory with caching on first access per field', () => {
49
+ class TestLazyFactoryInjection {
50
+ @InjectLazy(TestLazyFactory) testFactory
51
+
52
+ constructor() {
53
+ }
54
+ }
55
+
56
+ const inst = new TestLazyFactoryInjection()
57
+ expect(TestLazyFactory.calls).toBe(0)
58
+ const first = inst.testFactory
59
+ expect(first).toBeInstanceOf(TestLazyFactory)
60
+ expect(TestLazyFactory.calls).toBe(1)
61
+ expect(inst.testFactory).toBe(first)
62
+ })
63
+
64
+ it('should create distinct factory instances for different fields', () => {
65
+ class TestMultipleLazyFactoryInjection {
66
+ @InjectLazy(TestLazyFactory) testFactory1
67
+ @InjectLazy(TestLazyFactory) testFactory2
68
+
69
+ constructor() {
70
+ }
71
+ }
72
+
73
+ const instance = new TestMultipleLazyFactoryInjection()
74
+ expect(TestLazyFactory.calls).toBe(0)
75
+ const one = instance.testFactory1
76
+ const two = instance.testFactory2
77
+ expect(one).toBeInstanceOf(TestLazyFactory)
78
+ expect(two).toBeInstanceOf(TestLazyFactory)
79
+ expect(one).not.toBe(two)
80
+ })
81
+
82
+ it('should pass constructor parameters when using lazy injection', () => {
83
+ class TestLazyFactoryParamsInjection {
84
+ @InjectLazy(TestLazyFactory, 'param1', 'param2') testFactory
85
+
86
+ constructor() {
87
+ }
88
+ }
89
+
90
+ TestLazyFactory.calls = 0
91
+ const instance = new TestLazyFactoryParamsInjection()
92
+ expect(TestLazyFactory.calls).toBe(0)
93
+ const factoryInst = instance.testFactory
94
+ expect(factoryInst).toBeInstanceOf(TestLazyFactory)
95
+ expect(factoryInst.params).toEqual(['param1', 'param2'])
96
+ })
97
+
98
+ it('should not initialize dependency if property is never accessed', () => {
99
+ class TestNeverAccess {
100
+ @InjectLazy(TestLazySingleton) testSingleton
101
+
102
+ constructor() {
103
+ }
104
+ }
105
+
106
+ TestLazySingleton.calls = 0
107
+ new TestNeverAccess() // Do not access testSingleton.
108
+ expect(TestLazySingleton.calls).toBe(0)
109
+ })
110
+
111
+ @Singleton('lazyNamedSingleton')
112
+ class NamedLazySingleton {
113
+ static calls = 0
114
+
115
+ constructor() {
116
+ NamedLazySingleton.calls++
117
+ }
118
+ }
119
+
120
+ it('should lazily inject named singleton', () => {
121
+ class TestLazyNamedSingletonInjection {
122
+ @InjectLazy('lazyNamedSingleton') namedSingleton
123
+
124
+ constructor() {
125
+ }
126
+ }
127
+
128
+ const instance = new TestLazyNamedSingletonInjection()
129
+ expect(NamedLazySingleton.calls).toBe(0)
130
+ const first = instance.namedSingleton
131
+ expect(first).toBeInstanceOf(NamedLazySingleton)
132
+ expect(NamedLazySingleton.calls).toBe(1)
133
+ expect(instance.namedSingleton).toBe(first)
134
+ })
135
+
136
+ @Factory('lazyNamedFactory')
137
+ class NamedLazyFactory {
138
+ static calls = 0
139
+ params
140
+
141
+ constructor(...params) {
142
+ NamedLazyFactory.calls++
143
+ this.params = params
144
+ }
145
+ }
146
+
147
+ it('should lazily inject named factory', () => {
148
+ class TestLazyNamedFactoryInjection {
149
+ @InjectLazy('lazyNamedFactory') namedFactory
150
+
151
+ constructor() {
152
+ }
153
+ }
154
+
155
+ NamedLazyFactory.calls = 0
156
+ const instance = new TestLazyNamedFactoryInjection()
157
+ expect(NamedLazyFactory.calls).toBe(0)
158
+ const first = instance.namedFactory
159
+ expect(first).toBeInstanceOf(NamedLazyFactory)
160
+ expect(NamedLazyFactory.calls).toBe(1)
161
+ expect(instance.namedFactory).toBe(first)
162
+ })
163
+
164
+ it('should not expose any internal caching artifacts', () => {
165
+ class TestLazyEnum {
166
+ @InjectLazy(TestLazySingleton) lazyProp
167
+
168
+ constructor() {
169
+ }
170
+ }
171
+
172
+ const inst = new TestLazyEnum()
173
+ // noinspection JSUnusedLocalSymbols: Force lazy initialization.
174
+ const _ = inst.lazyProp
175
+ const keys = Object.keys(inst)
176
+ expect(keys).toContain('lazyProp')
177
+ expect(keys.length).toBe(1)
178
+ const symbols = Object.getOwnPropertySymbols(inst)
179
+ expect(symbols.length).toBe(0)
180
+ })
181
+
182
+ it('should throw if @InjectLazy is applied to a method', () => {
183
+ expect(() => {
184
+ // noinspection JSUnusedLocalSymbols
185
+ class BadLazy {
186
+ @InjectLazy('something')
187
+ someMethod() {
188
+ }
189
+ }
190
+ }).toThrow()
191
+ })
192
+
193
+ it('should handle circular dependencies (lazy)', () => {
194
+ @Singleton()
195
+ class A {
196
+ @InjectLazy('B') b
197
+ }
198
+
199
+ @Singleton('B')
200
+ class B {
201
+ @InjectLazy(A) a
202
+ }
203
+
204
+ expect(() => new A()).not.toThrow()
205
+ })
206
+
207
+ it('should inject into symbol-named fields (lazy)', () => {
208
+ const sym = Symbol('sym')
209
+
210
+ @Singleton()
211
+ class S {
212
+ }
213
+
214
+ class Test {
215
+ @InjectLazy(S) [sym]
216
+ }
217
+
218
+ const t = new Test()
219
+ expect(t[sym]).toBeInstanceOf(S)
220
+ })
221
+
222
+ it('should not leak lazy injected properties to prototype', () => {
223
+ @Singleton()
224
+ class S {
225
+ }
226
+
227
+ class Test {
228
+ @InjectLazy(S) dep
229
+ }
230
+
231
+ // noinspection JSUnusedLocalSymbols
232
+ const t = new Test()
233
+ expect(Object.prototype.hasOwnProperty.call(Test.prototype, 'dep')).toBe(false)
234
+ })
235
+
236
+ it('should allow circular dependencies with lazy injection', () => {
237
+ @Singleton()
238
+ class A1 {
239
+ @InjectLazy('B1') b
240
+ }
241
+
242
+ @Factory('B1')
243
+ class B1 {
244
+ @InjectLazy(A1) a
245
+ }
246
+
247
+ expect(() => new A1()).not.toThrow()
248
+ })
249
+ })
package/test/mock.test.js CHANGED
@@ -1,4 +1,4 @@
1
- import {resetMocks, Inject, Mock, Singleton, Factory} from '../index.js'
1
+ import {Factory, Inject, InjectLazy, Mock, resetMock, resetMocks, Singleton} from '../index.js'
2
2
 
3
3
  describe('Mocking', () => {
4
4
  @Singleton()
@@ -24,12 +24,12 @@ describe('Mocking', () => {
24
24
  @Mock(ToBeMockedSingleton)
25
25
  class MockedSingleton {
26
26
  op() {
27
- return 'mocked'
27
+ return 'mocked1'
28
28
  }
29
29
  }
30
30
 
31
31
  const result = new TestInjection()
32
- expect(result.toBeMockedSingleton.op()).toBe('mocked')
32
+ expect(result.toBeMockedSingleton.op()).toBe('mocked1')
33
33
  expect(result.toBeMockedSingleton.op2).toBe.undefined
34
34
 
35
35
  resetMocks()
@@ -38,6 +38,30 @@ describe('Mocking', () => {
38
38
  expect(result2.toBeMockedSingleton.op2()).toBe('original2')
39
39
  })
40
40
 
41
+ // New tests for lazy injection with mocks (non-proxied)
42
+ it('should inject a lazy mock singleton', () => {
43
+ @Mock(ToBeMockedSingleton)
44
+ class MockedSingletonLazy {
45
+ op() {
46
+ return 'mocked2'
47
+ }
48
+ }
49
+
50
+ class TestInjectionLazy {
51
+ @InjectLazy(ToBeMockedSingleton) lazyMockedSingleton
52
+ }
53
+
54
+ const instance = new TestInjectionLazy()
55
+ const result = instance.lazyMockedSingleton
56
+ expect(result.op()).toBe('mocked2')
57
+ expect(result.op2).toBe.undefined
58
+
59
+ resetMocks()
60
+ const instance2 = new TestInjectionLazy()
61
+ expect(instance2.lazyMockedSingleton.op()).toBe('original')
62
+ expect(instance2.lazyMockedSingleton.op2()).toBe('original2')
63
+ })
64
+
41
65
  @Factory()
42
66
  class ToBeMockedFactory {
43
67
  op() {
@@ -65,13 +89,207 @@ describe('Mocking', () => {
65
89
  expect(result2.toBeMockedFactory.op()).toBe('original')
66
90
  })
67
91
 
92
+ // New tests for lazy injection with mock factory
93
+ it('should inject a lazy mock factory', () => {
94
+ @Mock(ToBeMockedFactory)
95
+ class MockedFactoryLazy {
96
+ op() {
97
+ return 'mocked'
98
+ }
99
+ }
100
+
101
+ class TestInjectionFactoryLazy {
102
+ @InjectLazy(ToBeMockedFactory) lazyMockedFactory
103
+ }
104
+
105
+ const instance = new TestInjectionFactoryLazy()
106
+ const result = instance.lazyMockedFactory
107
+ expect(result.op()).toBe('mocked')
108
+
109
+ resetMocks()
110
+ const instance2 = new TestInjectionFactoryLazy()
111
+ expect(instance2.lazyMockedFactory.op()).toBe('original')
112
+ })
113
+
68
114
  it('should throw an error if a mock is not a singleton or factory', () => {
69
115
  expect(() => {
70
116
  @Mock(ToBeMockedFactory)
71
- class Mocked1 {}
117
+ class Mocked1 {
118
+ }
72
119
 
73
120
  @Mock(ToBeMockedFactory)
74
- class Mocked2 {}
121
+ class Mocked2 {
122
+ }
75
123
  }).toThrow('Mock already defined, reset before mocking again')
76
124
  })
77
- })
125
+
126
+ // Edge case: Resetting specific mocks
127
+ it('should reset only the specified mock', () => {
128
+ @Singleton()
129
+ class A {
130
+ value() {
131
+ return 'A'
132
+ }
133
+ }
134
+
135
+ @Singleton()
136
+ class B {
137
+ value() {
138
+ return 'B'
139
+ }
140
+ }
141
+
142
+ @Mock(A)
143
+ class MockA {
144
+ value() {
145
+ return 'mockA'
146
+ }
147
+ }
148
+
149
+ @Mock(B)
150
+ class MockB {
151
+ value() {
152
+ return 'mockB'
153
+ }
154
+ }
155
+
156
+ class Test {
157
+ @Inject(A) a
158
+ @Inject(B) b
159
+ }
160
+
161
+ const t = new Test()
162
+ expect(t.a.value()).toBe('mockA')
163
+ expect(t.b.value()).toBe('mockB')
164
+
165
+ resetMock(A)
166
+ const t2 = new Test()
167
+ expect(t2.a.value()).toBe('A')
168
+ expect(t2.b.value()).toBe('mockB')
169
+ })
170
+
171
+ it('should use the latest mock when multiple mocks are applied', () => {
172
+ @Singleton()
173
+ class Original {
174
+ }
175
+
176
+ @Mock(Original)
177
+ class Mock1 {
178
+ }
179
+
180
+ resetMock(Original)
181
+
182
+ @Mock(Original)
183
+ class Mock2 {
184
+ }
185
+
186
+ class Test {
187
+ @Inject(Original) dep
188
+ }
189
+
190
+ const t = new Test()
191
+ expect(t.dep).toBeInstanceOf(Mock2)
192
+ })
193
+
194
+ it('should restore the original after unmocking', () => {
195
+ @Singleton()
196
+ class Orig {
197
+ }
198
+
199
+ @Mock(Orig)
200
+ class Mocked {
201
+ }
202
+
203
+ resetMock(Orig)
204
+
205
+ class Test {
206
+ @Inject(Orig) dep
207
+ }
208
+
209
+ const t = new Test()
210
+ expect(t.dep).toBeInstanceOf(Orig)
211
+ })
212
+
213
+ it('should inject subclass correctly', () => {
214
+ @Singleton()
215
+ class Base {
216
+ }
217
+
218
+ class Sub extends Base {
219
+ }
220
+
221
+ @Mock(Base)
222
+ class SubMock extends Sub {
223
+ }
224
+
225
+ class Test {
226
+ @Inject(Base) dep
227
+ }
228
+
229
+ const t = new Test()
230
+ expect(t.dep).toBeInstanceOf(Sub)
231
+ })
232
+
233
+ it('should use latest mock for lazy injection', () => {
234
+ @Singleton()
235
+ class Orig {
236
+ }
237
+
238
+ @Mock(Orig)
239
+ class Mock1 {
240
+ }
241
+
242
+ resetMock(Orig)
243
+
244
+ @Mock(Orig)
245
+ class Mock2 {
246
+ }
247
+
248
+ class Test {
249
+ @InjectLazy(Orig) dep
250
+ }
251
+
252
+ const t = new Test()
253
+ expect(t.dep).toBeInstanceOf(Mock2)
254
+ })
255
+
256
+ it('should restore original after unmocking (lazy)', () => {
257
+ @Singleton()
258
+ class Orig {
259
+ }
260
+
261
+ @Mock(Orig)
262
+ class Mocked {
263
+ }
264
+
265
+ resetMock(Orig)
266
+
267
+ class Test {
268
+ @InjectLazy(Orig) dep
269
+ }
270
+
271
+ const t = new Test()
272
+ expect(t.dep).toBeInstanceOf(Orig)
273
+ })
274
+
275
+ it('should inject subclass correctly (lazy)', () => {
276
+ @Singleton()
277
+ class Base {
278
+ }
279
+
280
+ class Sub extends Base {
281
+ }
282
+
283
+ @Mock(Base)
284
+ class SubMock extends Sub {
285
+ }
286
+
287
+ class Test {
288
+ @InjectLazy(Base) dep
289
+ }
290
+
291
+ const t = new Test()
292
+ expect(t.dep).toBeInstanceOf(Sub)
293
+ })
294
+ })
295
+
@@ -1,4 +1,4 @@
1
- import {resetMocks, Inject, Mock, Singleton, Factory} from '../index.js'
1
+ import {Factory, Inject, InjectLazy, Mock, resetMocks, Singleton} from '../index.js'
2
2
 
3
3
  describe('Proxy Mocking', () => {
4
4
  @Singleton()
@@ -70,4 +70,61 @@ describe('Proxy Mocking', () => {
70
70
  expect(result2.toBeProxiedFactory.op()).toBe('original')
71
71
  expect(result2.toBeProxiedFactory.op2()).toBe('original2')
72
72
  })
73
- })
73
+
74
+ it('should inject a lazy proxy singleton', () => {
75
+ @Mock(ToBeProxiedSingleton, true)
76
+ class ProxiedSingletonLazy {
77
+ op() {
78
+ return 'mocked'
79
+ }
80
+ }
81
+
82
+ class TestInjectionLazy {
83
+ @InjectLazy(ToBeProxiedSingleton) lazyProxiedSingleton
84
+ }
85
+
86
+ const instance = new TestInjectionLazy()
87
+ const result = instance.lazyProxiedSingleton
88
+ expect(result.op()).toBe('mocked')
89
+ expect(result.op2()).toBe('original2')
90
+
91
+ resetMocks()
92
+ const instance2 = new TestInjectionLazy()
93
+ expect(instance2.lazyProxiedSingleton.op()).toBe('original')
94
+ expect(instance2.lazyProxiedSingleton.op2()).toBe('original2')
95
+ })
96
+
97
+ it('should inject a lazy proxy factory', () => {
98
+ @Factory()
99
+ class ToBeProxiedFactoryLazy {
100
+ op() {
101
+ return 'original'
102
+ }
103
+
104
+ op2() {
105
+ return 'original2'
106
+ }
107
+ }
108
+
109
+ class TestInjectionFactoryLazy {
110
+ @InjectLazy(ToBeProxiedFactoryLazy) lazyProxiedFactory
111
+ }
112
+
113
+ @Mock(ToBeProxiedFactoryLazy, true)
114
+ class ProxiedFactoryLazy {
115
+ op() {
116
+ return 'mocked'
117
+ }
118
+ }
119
+
120
+ const instance = new TestInjectionFactoryLazy()
121
+ const result = instance.lazyProxiedFactory
122
+ expect(result.op()).toBe('mocked')
123
+ expect(result.op2()).toBe('original2')
124
+
125
+ resetMocks()
126
+ const instance2 = new TestInjectionFactoryLazy()
127
+ expect(instance2.lazyProxiedFactory.op()).toBe('original')
128
+ expect(instance2.lazyProxiedFactory.op2()).toBe('original2')
129
+ })
130
+ })